拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

uni-app微信小程序登录页第五版:Vue3 UI与多端适配实战

uni-app微信小程序登录页第五版:Vue3 UI与多端适配实战

一个登录页能看出一个团队的功底,这话不夸张。做 uni-app 微信小程序这两年,我手上过的登录页面少说也有二三十版,从最早那种“两个输入框加一个按钮”的裸奔版,到现在讲究层次、动效、多端一致性的版本,中间踩的坑基本能写一本小册子。这次要聊的是 uni-app 微信小程序里一个相对成熟的 UI 登录页面方案,属于我这一系列迭代里的第五版。它要解决的问题很具体:既要好看——渐变背景、层级卡片、状态反馈都要到位;又要好用——不同机型不错位、键盘弹起不遮挡、登录态流转不出岔子。适合正在做微信小程序、uni-app 跨端项目,或者被登录页样式折磨过的前端同学参考,新手能照着复现,有经验的也能挑几个细节回去改自己的项目。

1. 为什么登录页值得单独拿出来反复打磨

1.1 登录页是小程序的“第一印象页”

大部分小程序的流量入口是分享卡片、扫码、搜索关键词,用户点进来的第一个完整页面往往就是登录页——不是首页。这意味着用户对这个产品专业度的判断,在你还没展示任何核心功能之前就已经形成了。一个按钮歪着、输入框聚焦没反馈、软键盘弹出来把按钮盖住,用户心里的“这小程序靠谱吗”就会打上问号。

我做过一个粗略的统计,在同一个业务场景下,把登录页从“能用”改到“好看且顺手”,注册转化大概能提三到五个百分点。这个数字不算爆炸,但登录页的改造成本极低,两三天的工作量换这个收益,性价比在所有前端优化项里排得上前列。所以我把登录页单独拉出来做序列迭代,每一版聚焦不同的方向:第一版解决功能闭环,第二版解决多端适配,第三版处理动效,第四版收紧代码结构,这一版(第五版)主要解决视觉层次和交互反馈的精细度。

1.2 第五版和前四版的核心差异

前面几版的思路偏保守,背景是一张静态图或者纯色,卡片是白底加阴影,输入框就是系统默认样式套个边框。能跑,但没什么记忆点。第五版动了三个地方,这三个地方也是这篇文章的技术主线。

第一是把静态背景换成了纯 CSS 绘制的渐变加浮动光斑,不引入任何图片资源。小程序包体积对首屏加载时间的影响是直接的,一张看起来不错的背景图,压缩到不失真大概也要 80KB 到 150KB,而用 CSS 画同样的效果,代码体积不到 2KB。第二是把表单拆成独立的字段组件化结构,每个字段有默认态、聚焦态、错误态三种视觉状态,用户输入过程中的每一步都有明确的视觉回应。第三是把校验逻辑和视觉反馈绑在一起,错误提示不再是弹一个 toast 就完事,而是定位到具体字段上,让用户一眼看到问题在哪。

前四点想做但没做好的地方在于响应式尺度,第五版在这块用了设计令牌的思路统一间距和字号,后面会详细展开。

1.3 技术选型:为什么还是 uni-app 加 Vue3

有人会问,只做微信小程序,为什么不直接用原生小程序框架或者 Taro。我的判断标准是看项目未来半年的走向。如果确定只做微信一端,且团队没有跨端经验,原生确实是最省心的;但只要有一半概率要扩展到 App、H5 或者支付宝小程序,uni-app 的收益就非常明显了。

这一版我用的组合是 uni-app 加 Vue3 的<script setup>写法。选 Vue3 不是因为“新”,而是因为组合式 API 在登录这种表单密集场景下真的好用:校验逻辑、密码可见切换、倒计时这些互相独立的状态,可以各自封装成一个 composable,不用像 Options API 那样把data撑成一大坨。另外 uni-app 对 Vue3 的支持在最近几个版本已经很稳了,<script setup>加 TypeScript 的组合在我自己的项目里没遇到过阻塞性问题。

这里要提醒一句,如果你的项目里还混着 Vue2 的页面,别急着全量升级。uni-app 的 Vue2 和 Vue3 可以在同一个项目里共存,但页面级配置和部分 API 的行为有细微差别,混用阶段建议按页面逐步迁移,别一次性推倒。

2. 页面结构与视觉拆解:先定骨架再上色

2.1 三段式布局与安全区处理

整个页面的结构我固定为三段:顶部品牌区、中部表单卡片区、底部协议与辅助入口区。这个结构几乎是登录页的最优解,因为它的信息密度是自上而下递减的,用户视线自然从 logo 落到输入框,最后到按钮,符合从上到下的操作路径。

顶部的品牌区高度不是固定的,要留出状态栏。微信小程序在自定义导航栏模式下,uni.getSystemInfoSync()返回的statusBarHeight在 iOS 上通常是 44px 或 47px,安卓机型差异更大,从 24px 到 48px 都有。我的做法是在根容器里插一个占位 view,高度动态绑定这个值,而不是写死padding-top。

<view class="safe-top" :style="{ height: statusBarHeight + 'px' }"></view>

中部卡片区用flex: 1让它吃掉剩余高度,垂直居中。底部协议区要处理 iPhone 的底部安全区,最稳的写法是给容器加padding-bottom: calc(24rpx + constant(safe-area-inset-bottom)),同时保留env()那一行做兼容。这两行是历史包袱,别删,新机型上少写一行在某些系统版本里就可能被底部小黑条盖住。

2.2 渐变背景的取色逻辑

渐变背景好看不好看,八成取决于色相跨度和明度差,而不是取决于你会不会写linear-gradient。我的经验是:登录页背景用同色系邻近色,色相跨度控制在 20 到 40 度之间,明度差不要超过 25%。跨度太大就会像早期的 PPT 模板,俗气且廉价。

这一版用的是深蓝紫到靛蓝的方向,起点#5B5BF5到终点#2A2A72,色相跨度大概 240 度到 225 度,属于同色系。渐变方向用 160 度斜向,比正交方向更有动感,又不像 45 度那样过分张扬。光斑用三个绝对定位的圆形 div,分别设置不同的尺寸和透明度,配合filter: blur()做虚化。这里有个性能细节,filter: blur()在低端安卓机上开销不小,模糊半径超过 40px 时帧率会明显下滑。

所以我给光斑模糊值定在 30px 到 50px 之间,并且只用三个。如果你追求更极致的性能,可以把这三个光斑在构建时用一张极小的图代替,但就我的实测来看,三个光斑的纯 CSS 方案在中端以上的机器上跑 60 帧没问题,低端机上会有掉帧,但登录页本身不需要持续动画,用animation让它们缓慢漂移的好处只是氛围感,如果要做兼容降级,直接在低端机上关掉动画即可。

2.3 设计令牌:让间距和圆角有统一尺度

这是第五版改动最大也最容易被忽视的地方。前几版我写样式是随手来的,输入框圆角写 12rpx,按钮写 16rpx,卡片写 20rpx,看起来没什么问题,但整体会有一种“不整齐”的感觉,说不出来哪里怪。后来我把所有尺寸收敛成一套令牌,问题就消失了。

具体做法是在<style>里用 SCSS 变量定义一套尺度,所有组件只允许引用这套变量,不允许出现魔法数字。

$space-xs: 8rpx; $space-sm: 16rpx; $space-md: 24rpx; $space-lg: 40rpx; $space-xl: 64rpx; $radius-sm: 12rpx; $radius-md: 20rpx; $radius-lg: 32rpx; $radius-full: 999rpx; $font-xs: 24rpx; $font-sm: 28rpx; $font-md: 32rpx; $font-lg: 40rpx; $font-xl: 52rpx;

有了这套令牌之后,我在做设计还原时的效率提升非常明显。设计师给一个 24px 的间距,我直接换$space-md,不用再心算 rpx 和 px 的换算。更重要的是,当产品经理说“整体感觉有点挤”时,我只需要调整$space-*这一组值,全页面的呼吸感就跟着变,而不是去页面里逐个改数字。这套思路是从设计系统里借来的,用在小程序这种单体页面上同样成立。

3. 核心实现:从模板到交互的完整落地

3.1 模板结构:字段级组件化

先看模板的整体骨架。我把每个输入项抽象成统一的.field结构,靠一个focusField变量标记当前聚焦的字段,用类名切换视觉状态。这么写的好处是新增一个字段只需要复制一段结构,改v-model和 placeholder 就行,不用重新设计交互。

<template> <view class="login-page"> <view class="bg-decor" aria-hidden="true"> <view class="blob blob--1"></view> <view class="blob blob--2"></view> <view class="blob blob--3"></view> </view> <view class="safe-top" :style="{ height: statusBarHeight + 'px' }"></view> <view class="brand"> <view class="brand__logo">U</view> <text class="brand__title">欢迎回来</text> <text class="brand__desc">登录后即可同步你的全部数据</text> </view> <view class="card"> <view class="field" :class="{ 'is-focus': focusField === 'account', 'is-error': errors.account }" > <text class="field__icon">A</text> <input v-model="form.account" class="field__input" type="text" placeholder="手机号 / 邮箱" placeholder-class="field__ph" :maxlength="32" confirm-type="next" @focus="focusField = 'account'" @blur="onBlur('account')" /> </view> <text v-if="errors.account" class="field__err">{{ errors.account }}</text> <view class="field" :class="{ 'is-focus': focusField === 'password', 'is-error': errors.password }" > <text class="field__icon">P</text> <input v-model="form.password" class="field__input" type="text" :password="!showPwd" placeholder="请输入密码" placeholder-class="field__ph" :maxlength="24" confirm-type="done" @focus="focusField = 'password'" @blur="onBlur('password')" @confirm="handleLogin" /> <text class="field__toggle" @tap="showPwd = !showPwd"> {{ showPwd ? '隐藏' : '显示' }} </text> </view> <text v-if="errors.password" class="field__err">{{ errors.password }}</text> <view class="row"> <view class="agree" @tap="agreed = !agreed"> <view class="agree__box" :class="{ 'is-on': agreed }"> <text v-if="agreed" class="agree__tick">✓</text> </view> <text class="agree__text">我已阅读并同意用户协议</text> </view> <text v-if="countdown > 0" class="row__link">{{ countdown }}s 后重试</text> <text v-else class="row__link" @tap="handleSmsCode">获取验证码</text> </view> <view class="btn" :class="{ 'is-disabled': !canSubmit || loading }" @tap="handleLogin" > <text class="btn__text">{{ loading ? '登录中…' : '登 录' }}</text> </view> </view> <view class="foot"> <text class="foot__text">还没有账号?</text> <text class="foot__link" @tap="goRegister">立即注册</text> </view> <view class="safe-bottom"></view> </view> </template>

这里有几个地方是我特意这么写的,值得单独说。密码框用type="text"加:password属性,而不是type="password"。原因是微信小程序端type="password"在部分基础库版本上会带上系统原生的眼睛图标,跟自定义样式打架,用type="text"配合password属性更可控。

协议勾选框我没用checkbox组件,而是自己画了一个 32rpx 的方块。原生checkbox在小程序里的样式定制能力有限,各端渲染差异也大,尤其是 H5 和 App 端,改一个勾选样式要写一堆::before覆盖,还不如自己用 view 画。唯一的代价是要自己补role和aria-checked,无障碍这块在小程序里关注的人不多,但该补还是要补。

3.2 脚本逻辑:校验、倒计时与状态管理

脚本部分我按组合式 API 组织,把校验、倒计时、登录请求分成三块独立逻辑,互相之间只通过form和errors通信。

<script setup> import { ref, reactive, computed, onUnmounted } from 'vue' import { onLoad } from '@dcloudio/uni-app' const statusBarHeight = ref(20) const focusField = ref('') const showPwd = ref(false) const agreed = ref(false) const loading = ref(false) const countdown = ref(0) let timer = null const form = reactive({ account: '', password: '' }) const errors = reactive({ account: '', password: '' }) onLoad(() => { const info = uni.getSystemInfoSync() statusBarHeight.value = info.statusBarHeight || 20 }) const accountOk = computed(() => { const v = form.account.trim() const isPhone = /^1[3-9]\d{9}$/.test(v) const isMail = /^[\w.-]+@[\w-]+\.[a-zA-Z]{2,}$/.test(v) return isPhone || isMail }) const passwordOk = computed(() => form.password.length >= 8) const canSubmit = computed(() => accountOk.value && passwordOk.value && agreed.value) function onBlur(key) { focusField.value = '' if (key === 'account' && form.account && !accountOk.value) { errors.account = '请输入正确的手机号或邮箱' } if (key === 'password' && form.password && !passwordOk.value) { errors.password = '密码至少 8 位' } } </script>

校验策略上我做了一个取舍:只在失焦和提交两个时机校验,不做实时校验。实时校验看起来很“智能”,用户输第一个字符就提示“格式不对”,体验其实很糟。失焦校验是用户的输入动作已经完成,这时候给反馈是合理的;提交时全量校验是兜底,同时把第一个错误字段滚动到可视区域,减少用户的寻找成本。

倒计时这块要注意定时器的清理。页面onUnload或者用onUnmounted时一定要clearInterval,否则用户在倒计时期间返回上一页,这个定时器还在偷偷跑,反复进出几次就会出现内存占用上涨、倒计时数字跳变的问题。

function handleSmsCode() { if (countdown.value > 0) return if (!accountOk.value) { errors.account = '请先填写正确的手机号' return } countdown.value = 60 timer = setInterval(() => { countdown.value -= 1 if (countdown.value <= 0) { clearInterval(timer) timer = null } }, 1000) } onUnmounted(() => { if (timer) { clearInterval(timer) timer = null } })

3.3 登录请求与防重复提交

登录请求最容易出问题的地方是重复提交。用户点了一次没反应,又点一次,两个请求同时发出去,服务端那边的失败次数计数就被多算了一次。我的做法是loading状态加在按钮上,同时在handleLogin开头做一次短路判断。

async function handleLogin() { if (loading.value) return if (!validateAll()) return loading.value = true try { const res = await uni.request({ url: `${BASE_URL}/api/login`, method: 'POST', data: { account: form.account.trim(), password: form.password } }) if (res.data.code === 0) { uni.setStorageSync('token', res.data.token) uni.showToast({ title: '登录成功', icon: 'success' }) setTimeout(() => uni.reLaunch({ url: '/pages/index/index' }), 600) } else { uni.showToast({ title: res.data.msg || '登录失败', icon: 'none' }) } } catch (e) { uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' }) } finally { loading.value = false } }

跳转用的是uni.reLaunch而不是navigateTo。登录成功后要把登录页从页面栈里清掉,否则用户在新页面里按返回,又会回到登录页,这个体验非常割裂。reLaunch会关闭所有页面打开新页,正好符合“登录完成后进入主页”的语义。如果你希望在主页还能返回登录页做账号切换,那就用redirectTo,把当前页替换掉,语义上也算合理。

另外提一下,微信小程序端如果你要做手机号快速登录,button组件加open-type="getPhoneNumber"是可行路径,但它对企业主体和资质有要求,回调里的加密数据需要在服务端解密,前端拿不到明文手机号。这块的合规边界比较明确,不要在客户端尝试拼接或猜测,老老实实走服务端。

4. 多端适配与真机细节

4.1 rpx 和 px 的混合使用策略

uni-app 的rpx是按 750 设计稿宽度等比缩放的,这个问题在手机上是优势,到平板上就是灾难——在 iPad 上1rpx会变得非常大,字号看起来像放大镜。所以我给自己定了一条规则:字号、间距、圆角、图标这些视觉属性用rpx,状态栏高度、安全区、字体粗细这类跟设备物理特性相关的用px。

还有一个需要留意的点,rpx在微信小程序里的换算基准是屏幕宽度除以 750,在超宽屏或者小屏设备上,字号可能出现 0.5px 的取整误差,某些机型上两行文字的基线会看起来有轻微错位。规避方法是关键文字用偶数rpx值,比如 28rpx、32rpx,尽量避免 27rpx 这种奇数。

4.2 刘海屏与底部安全区的处理清单

这块我整理成一张表,按机型维度对照,实际开发中查起来快。

位置问题表现处理方案备注
顶部状态栏内容被灵动岛或刘海遮挡动态绑定statusBarHeight占位不要写死 44px
自定义导航栏胶囊按钮与标题重叠右侧留出胶囊宽度加边距用getMenuButtonBoundingClientRect
底部按钮区被系统手势条覆盖padding-bottom加env(safe-area-inset-bottom)同时保留constant()
键盘弹起输入框被顶到屏幕外输入框外层用adjust-position配合scroll-view或直接用页面自带的推起

4.3 软键盘遮挡的三种处理思路

这是一个高频问题,值得展开说。微信小程序里 input 聚焦时,系统默认会把页面整体上推,让输入框可见。大部分情况下这个默认行为够用,但在两种场景下会出问题:一是输入框在卡片中间,推起后卡片顶部被切掉,视觉上很突兀;二是页面里有position: fixed的底部按钮,推起时按钮会跟着跑到屏幕中间。

我试过三种方案。第一种是给 input 加:adjust-position="false",关掉自动推起,然后自己监听@focus事件里的键盘高度,手动给容器加transform: translateY()。这个方案控制最精准,但键盘高度在安卓端返回的值偶尔不准,需要做兜底。第二种是把整个表单放进scroll-view,设置scroll-y和固定的高度,靠滚动来保证可见性,实现简单,缺点是滚动区域的高度要算对,不然会出现双滚动条。第三种是干脆调整布局,把输入框放在页面上半部分,键盘弹起时天然不会被遮挡。这一版我用的是第一种加第三种组合,输入框位置整体上移,再配合自动推起,实测在各种机型上都比较稳。

注意:真机测试这一步不能省。开发者工具里的键盘模拟和真机行为差异很大,尤其是安卓端,不同厂商对键盘的处理逻辑都不一样,我遇到过同一份代码在小米上正常、在某品牌机型上推起高度多出 40px 的情况。

5. 常见问题与排查实录

5.1 样式不生效的几种典型情况

小程序里样式不生效,八成是这三个原因之一。第一是选择器不支持,微信小程序对 CSS 选择器的支持是有限的,*通配符、属性选择器、部分伪类都不支持,我见过有人写input[type="text"]结果在小程序端完全不生效。第二是组件样式隔离,自定义组件默认样式不外泄也不内透,父组件的样式改不到子组件内部的元素,需要显式设置styleIsolation。第三是scoped加深度选择器的问题,Vue3 里穿透要用:deep(),写/deep/在部分版本上会编译警告。

排查顺序我一般是:先在开发者工具的 WXML 面板看元素上的类名是不是真的加上了,确认类名正确之后再去看样式面板里这条规则有没有被划掉,被划掉就是优先级问题,没出现就是选择器没匹配上。这个流程比盲改样式快得多。

5.2 渐变与模糊导致的卡顿

前面提到过filter: blur()的性能问题,这里补充一个更隐蔽的情况:如果光斑元素参与了动画,并且模糊半径较大,浏览器或小程序渲染层每一帧都要重新计算模糊,掉帧会非常明显。我的处理方法有两个,一是给动画元素加will-change: transform,让渲染层提前把它提升为独立图层;二是把模糊的静态背景和动画的位移拆开,模糊只算一次,动画只做transform: translate3d这类合成层操作,不触发重绘。

如果这些做完还是卡,那就说明设备确实太老,这时候最实用的做法是给动画加一个开关,用uni.getSystemInfoSync()拿到benchmarkLevel,低于某个阈值就静态显示。宁可少一点氛围感,也不能让登录页卡成 PPT。

5.3 问题速查表

现象可能原因排查动作解决方案
输入框聚焦无边框变化类名绑定失败看 WXML 类名检查focusField赋值时机
密码框出现系统眼睛图标用了type="password"查看 input 属性改type="text"加:password
按钮点击无响应被父级遮挡或事件未绑定用调试工具查层级检查z-index与@tap
倒计时数字跳变定时器未清理检查onUnmounted补clearInterval
表单提交后重复请求无防重复机制看 Network 面板加loading短路判断
底部按钮被手势条盖住未处理安全区换全面屏机型测试补env(safe-area-inset-bottom)
平板端字号巨大rpx等比放大在 iPad 上预览关键字号改用 px 或加媒体查询

5.4 一个容易被忽略的协议勾选问题

协议勾选这个交互看起来简单,但有个细节很多项目都做错了:用户在未勾选协议时点击登录,正确的处理是把勾选框高亮、给一个轻震动反馈,并且把提示文字显示出来,而不是弹一个 toast 说“请先同意协议”。toast 一闪而过,用户还得回头找勾选框在哪。

我的做法是把错误状态直接绑在勾选框上,给它加一个持续 300ms 的抖动动画和红色描边,同时在勾选框旁边显示一行提示文字。这个改动很小,但能明显降低用户的挫败感。另一个细节是,如果勾选框在表单底部,键盘弹起时它可能被遮挡,所以协议区的定位要跟键盘状态联动,这个我在 4.3 节的方案里已经覆盖到了。

6. 实操心得与后续扩展方向

6.1 我踩过的几个坑

第一个坑是过早优化。我有一版花了两天时间做登录页的入场动画,logo 缩放、卡片滑入、按钮渐显,做出来确实好看,但真机上首屏白屏时间多出了将近 400ms。后来我把这些动画全部砍掉,只保留了背景光斑的缓慢漂移,用户感知反而更好。登录页的第一要务是快速可用,动效只是加分项,别本末倒置。

第二个坑是在onLoad里做太多事情。onLoad是页面加载的生命周期,这个阶段做网络请求、读缓存、初始化状态,会拖慢首屏渲染。我现在的做法是onLoad里只做同步的轻量初始化,比如拿状态栏高度、读本地 token 做登录态判断,其他异步操作放到onReady之后或者用setTimeout延后到渲染完成后执行。

第三个坑是忽略了input的maxlength。看起来是小事,但用户粘贴一段超长文本进去,如果不限制长度,后端校验直接返回错误,用户还不知道自己哪里填错了。我给账号框设 32,密码框设 24,都是够用又不至于出问题的值。

6.2 这套页面还能怎么扩展

如果你想在这个基础上继续往下做,我建议按三个方向推进。一是加第三方登录入口,微信小程序生态里通常会有手机号一键登录和微信授权登录两个入口,这块的关键是入口按钮的视觉权重不能盖过主登录按钮,一般是放在主按钮下方用分割线隔开,视觉层级要明确。

二是加多语言支持。uni-app 内置了 i18n 方案,登录页的文案相对固定,提前把文案抽出来成本很低,后期扩展海外版本时会省下大量重构时间。三是把校验规则抽成独立的配置对象,让账号、密码、验证码的校验逻辑都走同一套配置驱动,新增字段时只改配置不改逻辑代码。这一版的代码结构已经为这个方向留了口子,validateAll里现在是硬编码的两个字段,换成遍历配置对象就能扩展成任意字段数量。

最后分享一个小技巧,关于登录态的存储。uni.setStorageSync在小程序端上限是 10MB,存一个 token 绰绰有余,但要注意它存的是同步写入,在主线程执行,如果 token 很长或者写入频率很高,会阻塞渲染。我的习惯是把登录相关的数据统一放在一个auth对象里,一次写入,而不是拆成 token、过期时间、用户信息分别写四次。这个习惯在真机上带来的流畅度提升是能感觉到的。

返回列表