
做小程序这几年登录页是我见过最容易被低估的页面。产品经理眼里它只是输入手机号点一下的小环节运营眼里它是转化漏斗的第一道闸门而真到了前端手上它同时要处理自定义导航栏、软键盘避让、玻璃拟态兼容、动画掉帧、表单校验时机、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 iosview classcard :classglassSupport ? 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 classfield view classfield__icon view classicon-phone/view /view input classfield__input typenumber maxlength11 v-modelform.phone placeholder请输入手机号 placeholder-classfield__ph :adjust-positionfalse focusonFocus(phone) bluronBlur / /view view classfield view classfield__icon view classicon-code/view /view input classfield__input typenumber maxlength6 v-modelform.code placeholder请输入验证码 placeholder-classfield__ph :adjust-positionfalse focusonFocus(code) bluronBlur / view classfield__code-btn :class{ is-disabled: countdown 0 || !phoneValid } taponSendCode 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 classslider :style{ width: sliderW px } view classslider__track :class{ is-ok: slideOk } text classslider__tip{{ slideOk ? 验证通过 : 按住滑块拖到最右侧 }}/text /view movable-view classslider__thumb :class{ is-ok: slideOk } directionhorizontal :xthumbX :disabledslideOk :damping30 changeonThumbChange touchendonThumbEnd view classthumb-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 classcard-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把动画放慢三倍观察全改为transformopacity深色渐变出现条纹色标太少产生色带真机看渐变区增加中间色标或叠噪点层倒计时在后台回来后跳秒定时器被系统节流切后台 30 秒再回来记录起始时间戳每次按差值计算安卓机上圆角有锯齿小尺寸元素上的大圆角真机看按钮边缘圆角用偶数 rpx必要时加overflow: hidden协议勾选框点不中点击热区只有图标本身大小真机反复点边缘用绝对定位把热区放大到 88rpx 以上首次进入白屏一下页面初始化逻辑太重真机冷启动观察计算逻辑放onReady首屏只渲染静态结构倒计时那一行我想补充一句。正确的做法是记录一个startAt Date.now()然后每次定时器触发时用60 - Math.floor((Date.now() - startAt) / 1000)计算剩余秒数这样即便定时器在后台被节流了回到前台后显示的数字也是准确的。4.4 上线前登录页的自检清单每次发版前我都会跑一遍这个清单看着琐碎但确实省了不少线上问题在 iPhone 的刘海屏机型上导航栏高度是否正确卡片是否被底部横条挡住。在一台安卓中低端机上装饰层动画是否流畅玻璃卡片是否正常降级。输入框聚焦时两个输入框分别在聚焦状态下卡片位移是否都不会顶到导航栏。快速双击登录按钮确认只发了一次请求可以在请求里打日志验证。弱网环境下开发者工具可以调成 3G 甚至离线点击按钮后是否有 loading 状态超时后按钮是否恢复可用。短信倒计时进行中切后台一分钟再回来倒计时数字是否准确。拒绝授权、关闭小程序再打开token 是否还在是否直接跳过了登录页。协议勾选框的点击热区是否足够大是不是只能点中那个小方块。页面卸载后定时器和键盘监听是否都已经清理干净。我个人在实际操作中的体会是登录页的难度从来不在写出来而在写得稳。把它当作一个独立的、要长期维护的模块来对待比当作一个临时页面来糊回报率高得多。这套结构我前后用在四个项目上每次只需要换配色和文案逻辑部分一行都不用改——这种可复用性才是分层设计真正的价值所在。