做小程序这几年,登录页是我见过最容易被低估的页面。产品经理眼里它只是"输入手机号点一下"的小环节,运营眼里它是转化漏斗的第一道闸门,而真到了前端手上,它同时要处理自定义导航栏、软键盘避让、玻璃拟态兼容、动画掉帧、表单校验时机、token 落盘这一堆事。这次要聊的是 uni-app 微信小程序里一个视觉上比较讨喜的登录页 UI 方案,属于这个系列的第三篇,前两篇偏结构,这一篇我们把重点压到"好看"和"好用"同时成立上。核心围绕 uni-app、微信小程序、UI、登录页面这四个关键词展开:怎么用一套 rpx 单位撑起不同机型的视觉一致性,怎么用纯 CSS 画点线动态背景而不拖垮渲染,怎么让登录按钮在弱网下有明确反馈,以及怎么把登录态稳稳地存进本地缓存。无论你是刚开始接触 uni-app 的新手,还是已经发过几个小程序包的老手,这篇里的代码和踩坑记录都能直接抄走用。
1. 登录页方案怎么定:从需求到技术选型
1.1 为什么登录页值得单独花两天时间打磨
很多人做登录页的习惯是:从别的项目复制一份,改个 logo 和配色就上线。短期看没问题,长期看问题很大。小程序登录页是用户打开频率最高的页面之一,尤其是那种退出登录后要重新进的业务,登录页的加载速度直接影响第一印象。我做过一个统计,同一个包体下,把登录页的首屏渲染从 400ms 优化到 180ms,用户点"获取验证码"的完成率能提升三个点左右——这个数字听着不多,但在千万级 DAU 的场景下就是实打实的留存。
更关键的是,登录页是唯一一个"零信任"页面。用户在这里还没登录,拿不到任何用户态数据,所以页面的所有信息都必须来自静态资源和启动参数。这就决定了它的实现方式和内页完全不同:不能依赖接口返回的配置、不能依赖用户缓存、不能做太重的初始化逻辑。理解了这一点,后面的技术选型才有依据。
还有一层容易被忽略的价值:登录页是整个项目 UI 规范的"样板间"。按钮的圆角是多少、输入框的高度是多少、主色的十六进制是多少、错误提示用什么字号——这些如果不在登录页定死,后面几十个页面就会各写各的。我的习惯是先搭登录页,把变量抽到uni.scss里,后面所有页面都从这套变量里取值。
提示:登录页不要放任何需要登录才能拿到的数据。哪怕是一个"欢迎回来,XXX"的文案,也建议用骨架占位或者干脆不放,避免出现"先渲染成空白再跳变"的视觉抖动。
1.2 uni-app 与 Vue3 的边界:哪些能做,哪些别碰
选 uni-app 做微信小程序,最大的好处是一套代码能同时出小程序和 App,但代价是必须时刻清楚"哪些 API 是小程序独有的"。登录页这一块,踩坑最集中的是三个地方。
第一个是 CSS 能力边界。微信小程序支持绝大多数常用 CSS,但filter: blur()用在元素上会明显掉帧,backdrop-filter在 iOS 上表现良好、在相当一部分 Android 机型上直接失效。position: fixed在自定义导航栏场景下也容易出问题。我在登录页的做法是:主视觉尽量用背景色 + 半透明 + 阴影撑起来,把复杂的模糊效果压缩到一到两个小的装饰元素上,并且准备好降级样式。
第二个是 DOM 能力的缺失。小程序没有真正的document,拿不到元素的实际宽高。想要做"输入框自动聚焦并滚动到可视区"这种效果,只能靠uni.createSelectorQuery()异步查询后再做处理,而且要在onReady之后才能查到。这个异步特性决定了登录页的很多交互逻辑必须写成回调或者 Promise 形式。
第三个是条件编译。uni-app 的/* #ifdef MP-WEIXIN */注释写法在 CSS 和 JS 里都能用,但在模板里要用<template>标签包裹。登录页在不同端上的差异其实不小,比如小程序端有胶囊按钮,App 端没有,用条件编译分开处理比强行统一要省事得多。
1.3 三层结构:背景层、装饰层、表单层
好看的登录页,本质上是把"氛围"和"信息"分开。我一般把登录页拆成三层:
| 层级 | 作用 | 实现方式 | 是否会动 |
|---|---|---|---|
| 背景层 | 打底颜色,决定整体色调 | 纯色渐变,linear-gradient | 不动 |
| 装饰层 | 光斑、点阵、线条,负责氛围感 | 绝对定位的 view + CSS 绘制 | 缓慢动 |
| 表单层 | 输入框、按钮、协议勾选 | 常规布局 + 玻璃拟态卡片 | 不动 |
这么分层的原因是性能。装饰层是唯一会动的东西,把它单独拎出来,就能保证动画只影响一层,其他两层保持静止。如果背景色和点阵画在同一个元素上,一动画就会让整个背景重绘,低端机上立刻能感觉到拖影。
另外从维护角度讲,分层之后换肤也变得很简单。想换主色调,只改背景层的两个色值;想换装饰风格,只改装饰层的几个 view;表单层的代码一行都不用动。这个解耦在实际项目里救过我很多次——运营突然说要改成春节红色主题,我十分钟就改完了。
2. 页面骨架与视觉细节落地
2.1 自定义导航栏与状态栏高度的精确计算
登录页通常是全屏沉浸式的,必须把pages.json里的navigationStyle设成custom。这一步做完之后,页面顶部会直接顶到物理屏幕边缘,状态栏就压在内容上了,所以必须自己撑出一块安全距离。
{ "path": "pages/login/login", "style": { "navigationStyle": "custom", "navigationBarTextStyle": "white", "disableScroll": true } }注意disableScroll: true这个配置。登录页内容通常不多,不需要页面级滚动,关掉之后能避免小程序在软键盘弹起时把整个页面顶上去,也能防止在低端机上出现回弹白边。
接下来算导航栏高度。这里有个坑:安卓和 iOS 的导航栏高度是不一样的,iOS 通常是 44px,安卓常见的是 48px,直接写死某一个值,在另一端就会偏。正确做法是用胶囊按钮的位置反推:
// utils/navbar.js export function getNavBarInfo() { const sys = uni.getWindowInfo ? uni.getWindowInfo() : uni.getSystemInfoSync() const statusBarHeight = sys.statusBarHeight || 20 let navBarHeight = 44 // #ifdef MP-WEIXIN const menu = uni.getMenuButtonBoundingClientRect() if (menu && menu.height) { // 胶囊上下留白对称,所以导航栏高度 = 上留白 * 2 + 胶囊高度 navBarHeight = (menu.top - statusBarHeight) * 2 + menu.height } // #endif return { statusBarHeight, navBarHeight, totalHeight: statusBarHeight + navBarHeight } }这段逻辑的核心是那一行(menu.top - statusBarHeight) * 2 + menu.height。menu.top - statusBarHeight就是状态栏底部到胶囊顶部的留白,胶囊在导航栏里是垂直居中的,所以上下留白相等,乘以 2 再加上胶囊本身的高度,就是整个导航栏的高度。实测在几十款机型上都能对上,比自己判断平台靠谱得多。
拿到高度之后不要直接塞进style里做行内样式,那样每次渲染都要重新计算字符串。我习惯在onLoad里算一次,存到data里,模板里用:style="{ paddingTop: navbar.totalHeight + 'px' }"。
注意:
uni.getWindowInfo()在新版本基础库上才有,老版本会返回 undefined,所以上面用了三元判断做兜底。另外getMenuButtonBoundingClientRect在小程序端必须在页面渲染之后调用才准确,放在onLoad里偶尔会拿到 0,稳妥点放在onReady。
2.2 渐变背景与点线动态效果怎么写才不掉帧
这是整个页面最"炫"的部分,也是最容易翻车的地方。先说结论:渐变必须是静态的,动画只能做 transform 和 opacity。
背景主体做一个从深蓝紫到墨黑的斜向渐变就够了:
.bg-base { position: absolute; left: 0; top: 0; width: 100%; height: 100%; background: linear-gradient(160deg, #1B2A6B 0%, #131A3D 45%, #0A0E22 100%); }斜向 160 度这个角度是有讲究的。90 度是纯垂直渐变,会显得很"平",像一张糊了颜色的纸;160 度能形成从左下到右上的斜向过渡,视觉上更有流动感。同样,三个色标不是均匀分布的,中间那个放在 45% 而不是 50%,是为了让亮色区域更集中在上半部分,给下方的表单卡留出更暗的底。
点阵纹理用background-image配合background-size画出来,一行 CSS 就能搞定,完全不需要切图:
.dot-grid { position: absolute; left: -10%; top: -10%; width: 120%; height: 130%; background-image: radial-gradient(circle, rgba(255, 255, 255, 0.22) 2rpx, transparent 2rpx); background-size: 40rpx 40rpx; opacity: 0.5; animation: gridMove 24s linear infinite; will-change: transform; } @keyframes gridMove { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(0, -40rpx, 0); } }这里有几个细节值得说清楚。第一,元素尺寸故意比屏幕大(width: 120%、height: 130%),因为它在动,如果尺寸刚好等于屏幕,移动过程中边缘就会露出背景色,出现一条明显的接缝。第二,动画的位移量正好等于background-size的一个格子(40rpx),这样循环回起点的时候,点阵的相位是重合的,肉眼完全看不出"跳回去"的那一下——这是无限滚动动画的通用技巧。第三,translate3d而不是translateY,强制走合成层,能避开部分安卓机的重绘路径。
至于光斑,就是两个超大尺寸的圆形,用径向渐变做柔边:
.bg-blob { position: absolute; border-radius: 50%; will-change: transform; } .blob-a { width: 520rpx; height: 520rpx; left: -160rpx; top: 120rpx; background: radial-gradient(circle, rgba(96, 132, 255, 0.55) 0%, rgba(96, 132, 255, 0) 70%); animation: floatA 14s ease-in-out infinite; } @keyframes floatA { 0%, 100% { transform: translate3d(0, 0, 0) scale(1); } 50% { transform: translate3d(40rpx, -60rpx, 0) scale(1.08); } }径向渐变从中心到 70% 处就完全透明,剩下的 30% 留白,这样圆形边缘不会出现硬边。如果写成0%到100%全透明过渡,边缘会有一圈明显的色带(banding),在深色背景上特别明显。这个细节我在第一版里踩过,调了半天才反应过来是色带问题。
实操心得:装饰层的所有动画元素,建议统一加
pointer-events: none。小程序里虽然事件模型和浏览器不完全一样,但绝对定位的大面积元素盖在输入框上面,确实会拦住部分手势,尤其是movable-view这种需要拖动的组件。加了这个属性最省事。
2.3 玻璃拟态卡片的兼容性怎么处理
玻璃拟态好看,但坑在于backdrop-filter。iOS 的 WebView 对-webkit-backdrop-filter支持很好,安卓上则要看具体机型和系统版本,相当一部分中低端机是不支持的。不支持的时候,整个卡片会变成半透明色块,文字压在背景的点阵上,直接糊掉。
我的做法是运行时判断,给卡片加一个平台类名,然后在 WXSS 里写两套样式:
// 在 onLoad 里 const sys = uni.getWindowInfo ? uni.getWindowInfo() : uni.getSystemInfoSync() this.glassSupport = sys.platform === 'ios'<view class="card" :class="glassSupport ? 'card--glass' : 'card--solid'">.card { border-radius: 36rpx; padding: 56rpx 44rpx 48rpx; border: 1rpx solid rgba(255, 255, 255, 0.24); box-shadow: 0 24rpx 64rpx rgba(3, 8, 30, 0.45); } /* iOS:真正的毛玻璃 */ .card--glass { background: rgba(255, 255, 255, 0.10); -webkit-backdrop-filter: blur(30rpx) saturate(160%); backdrop-filter: blur(30rpx) saturate(160%); } /* 兜底:用高透明度白色 + 内阴影模拟质感 */ .card--solid { background: rgba(28, 38, 78, 0.92); box-shadow: 0 24rpx 64rpx rgba(3, 8, 30, 0.5), inset 0 1rpx 0 rgba(255, 255, 255, 0.18); }兜底方案里那个inset 0 1rpx 0是关键。它就是在卡片顶部内侧画一条 1rpx 高的半透明白线,模拟光线打到玻璃上边缘的高光。视觉上有没有这条线,质感的差距非常大——有它就像一块有厚度的玻璃,没它就是一张平贴的纸。这个小技巧我后来在所有深色卡片上都用了。
saturate(160%)这个参数是让穿过卡片的背景色饱和度提高一点,避免毛玻璃把下面的光斑滤成灰扑扑的一团。数值不要太高,超过 200% 会出现明显的颜色失真。
2.4 输入框、验证码与主按钮的组合实现
表单区我习惯用"整块输入框"而不是每个输入框单独一个圆角矩形。整块的好处是视觉上更整体,一行一个横线分隔,扫描效率高,也更符合现在主流的移动端设计语言。
<view class="field"> <view class="field__icon"> <view class="icon-phone"></view> </view> <input class="field__input" type="number" maxlength="11" v-model="form.phone" placeholder="请输入手机号" placeholder-class="field__ph" :adjust-position="false" @focus="onFocus('phone')" @blur="onBlur" /> </view> <view class="field"> <view class="field__icon"> <view class="icon-code"></view> </view> <input class="field__input" type="number" maxlength="6" v-model="form.code" placeholder="请输入验证码" placeholder-class="field__ph" :adjust-position="false" @focus="onFocus('code')" @blur="onBlur" /> <view class="field__code-btn" :class="{ 'is-disabled': countdown > 0 || !phoneValid }" @tap="onSendCode" > <text>{{ countdown > 0 ? countdown + 's 后重发' : '获取验证码' }}</text> </view> </view>图标这块我不建议用字体图标库,登录页一共就两三个图标,为了它们引入一个完整图标字体不划算,而且字体图标在小程序里首次加载有闪烁。用纯 CSS 画更稳。
.field { position: relative; display: flex; align-items: center; height: 104rpx; border-bottom: 1rpx solid rgba(255, 255, 255, 0.12); } .field__input { flex: 1; height: 100%; font-size: 30rpx; color: #FFFFFF; letter-spacing: 1rpx; } .field__ph { color: rgba(255, 255, 255, 0.38); font-size: 28rpx; } .field__code-btn { flex-shrink: 0; padding-left: 24rpx; font-size: 26rpx; color: #7EA2FF; } .field__code-btn.is-disabled { color: rgba(255, 255, 255, 0.28); }主按钮我一般是"胶囊 + 渐变 + 底部微阴影"的组合,高度给到 96rpx,也就是 48px,这个高度在单手拇指操作下命中率最高。低于 80rpx 就有点抠手了,超过 112rpx 又显得笨重。
.submit-btn { margin-top: 64rpx; height: 96rpx; border-radius: 48rpx; display: flex; align-items: center; justify-content: center; background: linear-gradient(90deg, #4E7BFF 0%, #7B5CFF 100%); box-shadow: 0 16rpx 36rpx rgba(78, 123, 255, 0.35); transition: transform 0.12s ease, opacity 0.12s ease; } .submit-btn:active { transform: scale(0.97); opacity: 0.9; } .submit-btn.is-disabled { background: rgba(255, 255, 255, 0.14); box-shadow: none; color: rgba(255, 255, 255, 0.4); }:active伪类在小程序里是支持的,按下去的缩放反馈非常重要。人的手指按在屏幕上如果 100ms 内没有任何视觉反馈,就会下意识地怀疑"是不是没点到",然后重复点击,这就是很多重复提交的源头。0.97 这个缩放比例是我反复试出来的,太小看不出来,太大又显得廉价。
注意:按钮禁用态千万不要只靠半透明处理。半透明在不同背景上的观感差别很大,在深色背景上可能看起来还是能点的。安全做法是禁用态同时改变背景色、去掉阴影、改文字颜色,三个维度一起变,用户一眼就能区分。
3. 交互逻辑与数据流落地
3.1 表单校验:正则、时机与提示方式
校验这块的逻辑不复杂,但时机选择很讲究。常见有三种:输入时实时校验、失焦时校验、提交时统一校验。我的方案是三者结合:
- 输入时只做"长度和字符类型"的粗筛,比如手机号框只允许输数字,且不能超过 11 位。不做格式判断,因为用户输入到第 6 位的时候,正则必然是不匹配的,这时候弹红字属于骚扰。
- 失焦时做完整格式校验,比如手机号是否符合中国大陆号段规则。这时候用户已经输完了,提示才有意义。
- 提交前再全量校验一次,作为最终闸门。
const PHONE_RE = /^1[3-9]\d{9}$/ const CODE_RE = /^\d{4,6}$/ checkPhone(phone, silent = false) { if (!phone) { if (!silent) this.toast('请输入手机号') return false } if (!PHONE_RE.test(phone)) { if (!silent) this.toast('手机号格式不正确') return false } return true }手机号正则我写的是^1[3-9]\d{9}$,没有去穷举 130 到 199 的每一段。原因是号段列表每年都在变,写死了过两年就要改,而 1 开头第二位是 3 到 9 这个范围,覆盖了当前所有在用的号段,容错性更好一点。真要严格,正确的做法是交给后端校验,前端只做"看起来像手机号"的判断。
提示方式上,我不太推荐用顶部弹窗 toast,因为登录页通常只有一个卡片,toast 出现的位置离用户的视线焦点很远。更好的做法是在对应输入框下面显示一行红字,输入框的下边框变成红色:
.field.is-error { border-bottom-color: #FF6B6B; } .field__error { margin-top: 12rpx; font-size: 24rpx; color: #FF6B6B; line-height: 1.5; }不过有个折中:如果是"手机号已注册"这种服务端返回的业务错误,用 toast 更合适,因为它不是针对某个输入框的格式问题。前端的格式错误用行内红字,服务端的业务错误用 toast,这个分工我一直沿用下来,用户的困惑最少。
3.2 登录请求与 token 落盘的正确姿势
请求链路本身不复杂,容易出问题的是三件事:重复提交、超时处理、token 存储。
重复提交的防护,光靠按钮禁用是不够的,因为快速双击的两次tap事件可能在同一个渲染帧里就都触发了,第二次进来的时候按钮的禁用状态还没渲染出来。所以除了 UI 禁用,还要加一个 JS 层面的锁:
async onLogin() { if (this.loading) return if (!this.checkPhone(this.form.phone)) return if (!CODE_RE.test(this.form.code)) return this.toast('验证码格式不正确') if (!this.agreed) return this.toast('请先阅读并同意用户协议') this.loading = true try { const res = await uni.request({ url: this.baseURL + '/api/v1/login', method: 'POST', timeout: 10000, data: { phone: this.form.phone, code: this.form.code, scene: 'mini_program' }, header: { 'content-type': 'application/json' } }) if (res.statusCode !== 200) { return this.toast('网络异常,请稍后重试') } const body = res.data if (body.code !== 0) { return this.toast(body.message || '登录失败') } uni.setStorageSync('token', body.data.token) uni.setStorageSync('tokenExpireAt', Date.now() + (body.data.expiresIn || 7200) * 1000) uni.setStorageSync('userInfo', body.data.userInfo || {}) uni.showToast({ title: '登录成功', icon: 'none', duration: 800 }) setTimeout(() => { uni.reLaunch({ url: '/pages/index/index' }) }, 800) } catch (e) { this.toast('请求超时,请检查网络') } finally { this.loading = false } }这里有几个点值得展开。第一,this.loading这个标志既控制按钮的禁用样式,也作为 JS 锁用,一份状态两处用,避免了两套状态不同步的问题。第二,finally里解锁,保证异常路径下按钮也能恢复——我见过不少项目把loading = false写在 try 的最后一行,一旦请求抛异常按钮就永久卡死了。第三,存了一个tokenExpireAt而不是只存 token 本身,这样每次带着 token 发请求前可以先本地判断是否过期,过期就直接跳登录页,省掉一次必然 401 的请求。
关于reLaunch和redirectTo的选择,登录成功后建议用reLaunch。原因是登录页在页面栈里已经没有存在的必要了,用redirectTo只能替换当前页,如果用户是从某个内页被踢回登录的,页面栈里还留着旧页面,后退一步又回到那个需要登录的页,体验很怪。reLaunch直接清空整个栈,最干净。
3.3 滑块验证与短信倒计时
带滑块验证的登录页现在越来越常见,主要是防机器批量刷验证码。思路不难,用movable-area和movable-view配合就能自己实现,不用引第三方库。
<movable-area class="slider" :style="{ width: sliderW + 'px' }"> <view class="slider__track" :class="{ 'is-ok': slideOk }"> <text class="slider__tip">{{ slideOk ? '验证通过' : '按住滑块,拖到最右侧' }}</text> </view> <movable-view class="slider__thumb" :class="{ 'is-ok': slideOk }" direction="horizontal" :x="thumbX" :disabled="slideOk" :damping="30" @change="onThumbChange" @touchend="onThumbEnd" > <view class="thumb-arrow"></view> </movable-view> </movable-area>关键在结束时的判定:
onThumbEnd() { if (this.slideOk) return const maxX = this.sliderW - this.thumbW // 允许 4px 的误差,手指滑到底部很难精确到像素 if (this.thumbX >= maxX - 4) { this.slideOk = true this.thumbX = maxX } else { this.thumbX = 0 } }那个 4px 的容差是必须的。movable-view的x值受父容器宽度和自身宽度影响,浮点计算下很难精确等于maxX,如果写成严格相等,用户滑到底也过不了,会被骂死。thumbX要设回maxX而不是保持原值,是为了让滑块在视觉上"吸"到最右边,这个吸附动作本身也是一种成功的反馈。
倒计时的实现没什么技术含量,但有个细节必须注意:setInterval在页面卸载的时候一定要清掉,否则用户登录成功后跳走了,这个定时器还在后台跑,每秒钟触发一次setData,虽然不报错,但会白白增加内存占用。
onSendCode() { if (this.countdown > 0) return if (!this.checkPhone(this.form.phone)) return if (!this.slideOk) return this.toast('请先完成滑块验证') uni.request({ url: this.baseURL + '/api/v1/sms/code', method: 'POST', data: { phone: this.form.phone, ticket: this.slideTicket }, success: (res) => { if (res.data.code !== 0) return this.toast(res.data.message) this.countdown = 60 this.toast('验证码已发送') this.timer = setInterval(() => { this.countdown-- if (this.countdown <= 0) { clearInterval(this.timer) this.timer = null } }, 1000) } }) }, onUnload() { if (this.timer) { clearInterval(this.timer) this.timer = null } }60 秒这个值不是随便定的,是短信服务商侧的接口限流策略和用户体验之间的一个常见平衡点。低于 30 秒容易被刷,高于 90 秒用户会觉得等待太久去点别的。另外要注意的是,倒计时的状态建议在切后台时也做一次校正——因为小程序切后台后定时器可能被系统节流,回来的时候秒数会跳变。
3.4 键盘弹起、软键盘避让与安全区适配
这是登录页体验上最容易扣分的地方。默认情况下,input组件的adjust-position是 true,聚焦时小程序会把整个页面往上顶,让输入框露出来。这在普通列表页是好事,但在登录页这种居中布局里就很灾难——整个卡片往上跳一大截,padding-top算好的导航栏也飞了。
所以我把adjust-position设成 false,自己控制避让:
onLoad() { this.keyboardHandler = (res) => { this.keyboardHeight = res.height } uni.onKeyboardHeightChange(this.keyboardHandler) }, onUnload() { if (this.keyboardHandler) { uni.offKeyboardHeightChange(this.keyboardHandler) this.keyboardHandler = null } }拿到键盘高度后,只对表单卡片做位移,不动背景:
.card-wrap { transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1); }<view class="card-wrap" :style="{ transform: cardTransform }">computed: { cardTransform() { if (this.keyboardHeight <= 0) return 'translate3d(0,0,0)' // 键盘高度一般是 px,需要避开键盘,但不要顶得太高 const offset = Math.min(this.keyboardHeight - 40, 240) return `translate3d(0, -${offset}px, 0)` } }Math.min那一步是防止在大屏机上位移过多导致卡片顶到导航栏。0.22 秒的缓动曲线用的是cubic-bezier(0.22, 1, 0.36, 1),这是一个"快出慢进"的曲线,键盘升起的时候卡片跟着快速上移,键盘收起时慢慢回位,比ease自然得多。
安全区这方面,底部要留env(safe-area-inset-bottom):
.login-footer { padding-bottom: calc(48rpx + constant(safe-area-inset-bottom)); padding-bottom: calc(48rpx + env(safe-area-inset-bottom)); }两行都要写,constant()是给已经淘汰的老版本 iOS 用的,env()是新标准。顺序不能反,反了老机型会覆盖掉,虽然现在老机型已经很少见了,但这两行基本没什么成本。
4. 常见问题排查与性能调优实录
4.1 开发者工具和真机表现不一致的几类原因
登录页最让人头大的就是"工具里好好的,真机上有问题"。我把遇到过的归类了一下,主要是三种。
第一种是渐变和色带。开发者工具用的是桌面浏览器的渲染引擎,色彩处理比手机 GPU 精细得多,很多在工具里看不出问题的渐变,到真机上会出现明显的条纹色带。尤其是深色背景上的大面积渐变,几乎必然出现。解决办法是在渐变上叠一层极低透明度的噪点,或者在色标之间多加几个中间色标,让过渡更平滑。我一般会加 5 个色标而不是 3 个。
第二种是动画和掉帧。工具的动画是理想帧率,真机上则会暴露卡顿。判断方法很简单:在真机上调出性能面板不太现实,我的土办法是把动画时长故意调慢三倍,如果慢速下能看到动画路径不自然(比如抖动、跳变),说明用的是会触发重排的属性,得改成 transform。
第三种是字体和行高。小程序在不同系统上的默认字体不一样,iOS 是苹方,安卓是思源黑体或系统默认,同样的字号在两端的实际渲染宽度会有几个像素的差异。如果某个按钮的文字刚好卡在换行边缘,就会出现一端单行、一端两行的情况。应对办法是给文字容器留出富余宽度,或者用white-space: nowrap强制不换行。
4.2 装饰层动起来的卡顿怎么定位
如果登录页出现明显掉帧,第一步是先做"二分法排除":把装饰层的动画全部注释掉,看是否流畅。如果流畅了,说明问题在动画;如果还是卡,那问题可能在别处,比如一次性渲染的节点太多,或者图片资源太大。
确认是动画问题后,逐个恢复,找出具体是哪个元素。我遇到过的几类原因和对应处理:
- 用了
filter: blur():这个在小程序里是性能杀手,尤其是在大面积元素上。改成径向渐变模拟柔边,视觉上差别不大,性能差别巨大。 - 动画了
top/left而不是transform:前者每帧都要重新计算布局,后者只走合成。全部换成translate3d。 - 动画了
box-shadow:阴影的模糊半径变化需要实时重新计算像素,非常耗。如果要做发光呼吸效果,改成叠一个带opacity动画的径向渐变层。 - 同时跑的动画太多:我一般控制在 3 个以内。两个光斑加一个点阵,足够了。再多视觉上也是乱的。
这里再分享一个判断节点数量的方法。登录页的节点总数建议控制在 150 个以内。装饰层的点阵如果不用 CSS 而是用一堆view拼出来,很容易就几百个节点,渲染和更新都会变慢。用background-image: radial-gradient一行搞定,只需要 1 个节点。
4.3 常见问题速查表
| 现象 | 可能原因 | 排查方式 | 处理方案 |
|---|---|---|---|
| 顶部内容被状态栏遮挡 | 未设navigationStyle: custom或未加paddingTop | 检查 pages.json | 用胶囊位置反推导航栏高度 |
| 卡片背景变灰、文字看不清 | 该机型不支持backdrop-filter | 判断getWindowInfo().platform | 降级到高透明度纯色 + 内阴影高光 |
| 聚焦输入框时整页上跳 | adjust-position为默认 true | 观察聚焦瞬间 | 设为 false,监听键盘高度自行位移 |
| 点按钮没反应,点了两次提交了两次 | 无双击锁 | 快速双击测试 | JS 加loading标志位做锁 |
| 动画有明显拖影 | 动画了布局属性或用了filter | 把动画放慢三倍观察 | 全改为transform+opacity |
| 深色渐变出现条纹 | 色标太少,产生色带 | 真机看渐变区 | 增加中间色标或叠噪点层 |
| 倒计时在后台回来后跳秒 | 定时器被系统节流 | 切后台 30 秒再回来 | 记录起始时间戳,每次按差值计算 |
| 安卓机上圆角有锯齿 | 小尺寸元素上的大圆角 | 真机看按钮边缘 | 圆角用偶数 rpx,必要时加overflow: hidden |
| 协议勾选框点不中 | 点击热区只有图标本身大小 | 真机反复点边缘 | 用绝对定位把热区放大到 88rpx 以上 |
| 首次进入白屏一下 | 页面初始化逻辑太重 | 真机冷启动观察 | 计算逻辑放onReady,首屏只渲染静态结构 |
倒计时那一行我想补充一句。正确的做法是记录一个startAt = Date.now(),然后每次定时器触发时用60 - Math.floor((Date.now() - startAt) / 1000)计算剩余秒数,这样即便定时器在后台被节流了,回到前台后显示的数字也是准确的。
4.4 上线前登录页的自检清单
每次发版前我都会跑一遍这个清单,看着琐碎,但确实省了不少线上问题:
- 在 iPhone 的刘海屏机型上,导航栏高度是否正确,卡片是否被底部横条挡住。
- 在一台安卓中低端机上,装饰层动画是否流畅,玻璃卡片是否正常降级。
- 输入框聚焦时,两个输入框分别在聚焦状态下,卡片位移是否都不会顶到导航栏。
- 快速双击登录按钮,确认只发了一次请求(可以在请求里打日志验证)。
- 弱网环境下(开发者工具可以调成 3G 甚至离线),点击按钮后是否有 loading 状态,超时后按钮是否恢复可用。
- 短信倒计时进行中切后台一分钟再回来,倒计时数字是否准确。
- 拒绝授权、关闭小程序再打开,token 是否还在,是否直接跳过了登录页。
- 协议勾选框的点击热区是否足够大,是不是只能点中那个小方块。
- 页面卸载后,定时器和键盘监听是否都已经清理干净。
我个人在实际操作中的体会是,登录页的难度从来不在"写出来",而在"写得稳"。把它当作一个独立的、要长期维护的模块来对待,比当作一个临时页面来糊,回报率高得多。这套结构我前后用在四个项目上,每次只需要换配色和文案,逻辑部分一行都不用改——这种可复用性才是分层设计真正的价值所在。