做 uni-app 微信小程序这几年,登录页面是我见过最容易"看起来简单、做起来翻车"的页面。它结构小、元素少,但偏偏要同时扛住视觉观感、输入交互、键盘适配、授权流程、登录态管理这几件事。这篇接着上一篇的思路往下走,不再讲"怎么把两个输入框摆上去",而是把登录页面拆到能直接抄的程度:从配色和圆角体系,到 input 组件的隐藏坑,再到微信授权登录的取舍和真机上的样式偏差。适合已经能用 uni-app 跑通一个页面、但对细节把控还没底的同学,也适合做了一两年小程序、想把登录页从"能用"打磨到"好看又好用"的人。
1. 好看的登录页,本质是三层功夫的叠加
很多同学一上来就问"有没有现成的模板",我一般不建议直接拿模板改。模板能解决 60 分的问题,但从 60 分到 90 分那段,恰恰是最费时间也最能体现水平的地方。而这段差距,拆开看其实就三层:视觉层、交互层、结构层。任何一层缺了,页面都会给人一种"说不上哪里不对,就是不够精致"的感觉。
1.1 视觉层:配色、圆角、阴影、留白四件套
视觉层最容易犯的错是"元素太多"。登录页的面积本来就有限,再堆三四种颜色、两套圆角规则,视觉上立刻就散了。我的做法是先把规则定死,再往里填内容。
配色上,一个登录页最多用三种颜色:一个主色(按钮、链接、选中态)、一个中性色系(文字、边框、背景,通常黑白灰的 5 到 6 个层级)、一个点缀色(错误提示、警告)。主色我用得最多的是#3B6FFF这一类偏蓝的色调,原因是蓝色在浅色背景上对比度稳定,不会像紫色那样在不同屏幕色域下偏色明显。
圆角是最容易被忽略的细节。我的习惯是建一套三级圆角体系:大卡片32rpx、输入框和次级容器24rpx、按钮44rpx(接近胶囊形但不完全是)。为什么不干脆全部用胶囊?因为输入框如果是全胶囊,和内部左对齐的文字配合起来会显得文字"浮"在框里,留白不好控制。统一体系比统一数值更重要,读者眼睛会自动捕捉这种规律性。
阴影要克制。很多人喜欢用很重的阴影来"突出"卡片,结果在小程序的深色背景或者低端安卓屏幕上,阴影会变成一团脏兮兮的灰。我的参数是:
.form-card { background: #ffffff; border-radius: 32rpx; box-shadow: 0 8rpx 40rpx rgba(17, 24, 39, 0.06); }0.06这个透明度是关键,超过0.1就会显得脏。另外阴影的y偏移要大于blur的四分之一,否则会像"发光"而不是"投影"。
留白用 8 的倍数体系,也就是8 / 16 / 24 / 32 / 48 / 64 rpx,不要出现17rpx、23rpx这种数字。这一条听着像强迫症,但实际效果非常明显:页面上所有间距都落在同一个节奏上时,人眼会觉得"很顺"。
1.2 交互层:焦点态、按压态、加载态,一个都不能少
视觉做完了,页面是"静态好看"。真正让人感觉精致的,是手指碰上去之后的反馈。这里有三个状态必须处理。
焦点态。用户点进输入框时,边框颜色要变、可以配合一点点缩放。但注意,微信小程序里的:focus伪类支持并不完整,正确做法是绑定@focus和@blur事件,用一个数据字段控制类名:
<view class="field" :class="{ 'field--focus': focusField === 'phone' }"> <input v-model="form.phone" type="number" maxlength="11" placeholder="请输入手机号" placeholder-class="field__placeholder" @focus="focusField = 'phone'" @blur="focusField = ''" /> </view>按压态。小程序的view支持hover-class,这是最稳的方案。不建议依赖 CSS 的:active,因为它在不同基础库版本上表现不一致,有的版本根本不触发。给按钮加一个hover-class="btn--hover",在对应类里把背景色压暗 8% 到 10%,再加一个transform: scale(0.98),手感就出来了。
加载态。点击登录之后如果只弹一个全屏的加载提示,用户会觉得"卡住了"。我倾向于把 loading 内联到按钮里:按钮文字变淡、左侧出现一个旋转的小圈、按钮进入禁用状态。这样用户能明确看到"系统在动",而且视线不用离开按钮。
1.3 结构层:装饰区、表单区、底栏区三段式
结构上我把登录页固定切成三块:顶部的装饰区、中间的表单区、底部的协议与切换登录方式区。
装饰区用绝对定位 +pointer-events: none,这样它就不会拦截点击事件。表单区用 flex 布局垂直居中,但如果内容高度超过视口,要允许滚动而不是硬撑。底栏区贴底,并且必须带安全区 padding,否则在全面屏机型上协议文字会被底部横条挡住一半。
这三块的边界一定要清晰。我见过不少人把装饰元素和表单元素混在同一个 flex 容器里,结果键盘弹起、内容伸缩时,装饰元素跟着一起动,整个页面就乱了。
2. 骨架搭建:rpx 换算、背景实现与安全区处理
骨架搭得好不好,决定了后面改样式时是"微调"还是"推倒重来"。这一节讲三个最基础但最容易出问题的点。
2.1 rpx 的换算逻辑,以及什么时候不该用它
rpx的规则很简单:不管屏幕多宽,都按 750 份等分,750rpx永远等于视口宽度。所以如果设计稿是 750px 宽,标注多少就写多少,1:1 映射,不需要换算。这一条是 uni-app 在小程序端比原生开发舒服的地方。
但rpx有个陷阱:它会跟着屏幕宽度等比放大。在手机上没问题,在平板、折叠屏展开态、甚至某些小程序的分屏模式下,750rpx会变成非常夸张的物理尺寸,一个600rpx宽的登录卡片在平板上能占满半个屏幕,字大得像在放大镜里看。
我的处理方式是给登录卡片同时设一个max-width:
.form-card { width: 640rpx; max-width: 640px; margin: 0 auto; }max-width用px,因为它是"上限保护",不应该跟着屏幕放大。这样在手机上按rpx走,在超宽屏上被px卡住,两边都不难看。
还有一类不该用rpx的地方:图标和需要精确像素级的元素,比如细边框。1rpx在 2 倍屏上其实是 0.5 物理像素,渲染出来会有时有时无的现象。边框我一般直接写1px。
2.2 背景装饰:能用 CSS 就别用图片
登录页的背景装饰通常有两种做法,用图片或者用 CSS。我强烈建议用 CSS,原因有三个。
第一是体积。一张 750x1334 的背景图,即使压成 webp 也要 40KB 到 80KB,而小程序主包只有 2MB 的额度,登录页作为启动必经之路必须在主包,每一 KB 都要省。
第二是清晰度。背景图在不同分辨率、不同宽高比的机型上一定会拉伸或裁切,渐变的过渡带容易出现色阶断层,看起来像"马赛克"。
第三是可控性。CSS 写的背景可以跟着主题色改,可以做缓慢的位移动画,图片则要重新切。
具体实现上我用的是径向渐变叠加:
.login-page { position: relative; min-height: 100vh; background: linear-gradient(180deg, #f5f8ff 0%, #ffffff 60%); overflow: hidden; } .blob { position: absolute; border-radius: 50%; pointer-events: none; } .blob-1 { width: 520rpx; height: 520rpx; top: -180rpx; right: -140rpx; background: radial-gradient(circle, rgba(59, 111, 255, 0.18) 0%, rgba(59, 111, 255, 0) 70%); } .blob-2 { width: 420rpx; height: 420rpx; bottom: -120rpx; left: -160rpx; background: radial-gradient(circle, rgba(255, 138, 101, 0.16) 0%, rgba(255, 138, 101, 0) 70%); }提示:径向渐变本身已经带了柔和的边缘过渡,不需要再叠加
filter: blur()。很多人为了追求"毛玻璃"效果加上blur(80rpx),在中低端安卓上会直接掉帧,因为模糊是 GPU 逐像素计算,面积越大越吃性能。
如果用linear-gradient做整页背景,注意色标至少要给三段,两段渐变在大屏上会有明显的色带。三段以上、并且相邻色差控制在 5% 以内,看起来才是"奶油感"而不是"塑料感"。
2.3 安全区:底部协议区必须处理
底部协议文字被全面屏的横条挡住,是登录页最经典的低级错误。处理方式是给底部容器加安全区 padding:
.footer { position: fixed; left: 0; right: 0; bottom: 0; padding: 24rpx 48rpx; padding-bottom: calc(24rpx + constant(safe-area-inset-bottom)); padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); }写两行padding-bottom是为了兼容老版本 iOS,constant()是旧语法,新版本用env(),后者不生效时前者兜底,顺序不能反。
另外要注意,如果登录页用了自定义导航栏(navigationStyle: custom),顶部的状态栏高度也要自己处理。获取方式是用uni.getSystemInfoSync().statusBarHeight,拿到之后作为 padding-top。这个值在开发者工具里是模拟值,真机上才会拿到真实数值,这一点后面还会再提。
3. 表单区域:input 组件那些不写在文档里的坑
表单是登录页的核心,也是坑最密集的地方。微信小程序的input组件和浏览器的<input>完全是两套东西,很多在 H5 上理所当然的写法搬过来就不生效。
3.1 placeholder 样式、光标、键盘类型
第一个坑是placeholder-style。这个属性文档里写着可以直接写内联样式,但实测在部分安卓机型上不生效,稳妥做法是改用placeholder-class,在 CSS 里定义类:
.field__placeholder { color: #9aa4b8; font-size: 28rpx; font-weight: 400; }第二个坑是键盘类型。type="number"会调起纯数字键盘,但它的行为是"可以输入小数点、减号",也就是说用户能输入1.5或者-3。如果你要限制为纯整数,需要在@input里做正则过滤。手机号场景我一般用type="number"配合maxlength="11"再加一层正则校验,因为maxlength对number类型在某些安卓版本上会失效,这个得靠代码兜底。
第三个坑是密码框。input有个password属性(布尔值),比type="password"更推荐,因为前者在小程序里对小眼睛图标的支持更好。切换明文与密文的实现是绑一个布尔值:
<input v-model="form.password" :password="!showPwd" placeholder="请输入密码" placeholder-class="field__placeholder" /> <view class="field__eye" @click="showPwd = !showPwd"> <text class="iconfont">{{ showPwd ? '\ue63e' : '\ue63f' }}</text> </view>注意:切换
password属性会导致输入框重新渲染,在 iOS 上会让光标位置回到开头。如果你的密码框允许用户编辑中间内容,这个体验就很糟。实际的规避方式是在切换时不改变绑定的值,或者干脆只在输入为空时才允许切换。
第四个是cursor-spacing。这个属性控制光标与键盘的距离,默认值在某些机型上偏小,输入框紧贴键盘显得很挤。我一般设cursor-spacing="20",单位是 px 不是 rpx,这一点要注意。
confirm-type也值得配一下:手机号框设next,密码框设done,用户输完可以直接在键盘上点"下一项"和"完成",减少手指移动距离。这个细节用的人不多,但对体验的提升很直观。
3.2 校验策略:什么时候校验,比校验什么更重要
校验规则本身不复杂,手机号/^1[3-9]\d{9}$/,密码长度 8 到 20 位且必须包含字母和数字。真正需要设计的是校验时机。
常见的三种策略对比:
| 策略 | 触发时机 | 优点 | 缺点 |
|---|---|---|---|
| 实时校验 | 每次输入都校验 | 反馈最快 | 输入到一半就报错,用户烦躁 |
| 失焦校验 | 离开输入框时校验 | 打扰少,准确 | 反馈稍慢 |
| 提交校验 | 点登录时全量校验 | 实现简单 | 错误一次性全冒出来,体验差 |
我的做法是混合式:输入时只清错,不在输入过程中报新错;失焦时校验该字段;提交时全量校验并把第一个错误字段滚动到可视区。
具体实现上,表单数据里存两份:form放值,errors放错误信息。
const form = reactive({ phone: '', password: '' }); const errors = reactive({ phone: '', password: '' }); const rules = { phone: [ { test: v => !!v, msg: '请输入手机号' }, { test: v => /^1[3-9]\d{9}$/.test(v), msg: '手机号格式不正确' } ], password: [ { test: v => !!v, msg: '请输入密码' }, { test: v => /^(?=.*[a-zA-Z])(?=.*\d)[\w!@#$%^&*]{8,20}$/.test(v), msg: '密码需8-20位且包含字母和数字' } ] }; function validateField(key) { const rule = rules[key].find(r => !r.test(form[key])); errors[key] = rule ? rule.msg : ''; return !rule; }有个细节一定要做:错误提示的区域要预留固定高度。如果错误文案是动态插入的,输入框会被挤下去,整个卡片抖一下,观感非常差。做法是给提示文案一个min-height,没错误时占位但内容为空:
.field__error { min-height: 36rpx; line-height: 36rpx; font-size: 24rpx; color: #f04a4a; padding-left: 8rpx; }3.3 验证码倒计时与协议勾选的状态管理
如果登录方式里有短信验证码,倒计时逻辑几乎是必写。这段代码看着简单,但有三个坑。
第一个坑是setInterval没有清理。用户点完获取验证码,然后直接返回上一页,定时器还在后台跑,反复进入几次就会同时存在多个定时器,倒计时数字乱跳。必须在onUnload里清掉:
let timer = null; function startCountdown() { countdown.value = 60; timer = setInterval(() => { countdown.value -= 1; if (countdown.value <= 0) { clearInterval(timer); timer = null; } }, 1000); } onUnload(() => { if (timer) { clearInterval(timer); timer = null; } });第二个坑是后台切换导致计时不准。小程序切到后台后定时器会被降频甚至暂停,用户切回来一看还是 60 秒。更稳的做法是记录结束时间戳,每次 tick 用Date.now()反算剩余秒数:
const endAt = Date.now() + 60000; timer = setInterval(() => { const rest = Math.max(0, Math.ceil((endAt - Date.now()) / 1000)); countdown.value = rest; if (rest <= 0) { clearInterval(timer); timer = null; } }, 500);第三个是协议勾选。这里我不建议用小程序自带的checkbox组件,因为它的样式几乎改不动,选中态的圆角、颜色、大小都受系统控制。自己用view画一个更省事:
<view class="agree" @click="agreed = !agreed"> <view class="agree__box" :class="{ 'agree__box--on': agreed }"> <text v-if="agreed" class="agree__tick">✓</text> </view> <text class="agree__text">我已阅读并同意</text> <text class="agree__link" @click.stop="openProtocol('user')">《用户协议》</text> </view>注意@click.stop,不加的话点协议链接会同时触发勾选和跳转,用户会莫名其妙地看到勾选框被选中。
如果用户没勾协议就点登录,我的处理是给协议那一行加一个横向抖动动画,并弹一个轻提示。抖动比单纯弹 toast 更有效,因为用户视线通常在按钮区域,抖动能把注意力引到协议上。
@keyframes shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-12rpx); } 40% { transform: translateX(12rpx); } 60% { transform: translateX(-8rpx); } 80% { transform: translateX(8rpx); } } .agree--shake { animation: shake 0.4s ease-in-out; }4. 动效设计:让页面活起来,但别活过头
登录页加动效这件事,做对了是加分项,做过了就是减分项。我见过有人登录页上又是粒子又是打字机又是 3D 翻转,结果页面加载慢、掉帧、还显得很廉价。我的原则是:动效只服务于"引导注意力"和"提供反馈"两个目的,不为炫技。
4.1 入场动画:CSS 优于 JS 动画
uni-app 里做动画有两条路:CSS 的@keyframes,和uni.createAnimation。我基本上不用后者,原因是createAnimation是通过修改数据驱动视图的,每一次动画帧都要走一遍setData通信,在页面上同时跑三四个动画时就会明显卡顿。CSS 动画直接跑在渲染层,不占逻辑层的通信带宽,这是本质区别。
入场动画我的方案是分层延迟:logo 先出现,标题跟进,表单卡片最后滑入。
@keyframes fadeUp { from { opacity: 0; transform: translateY(24rpx); } to { opacity: 1; transform: translateY(0); } } .header__logo { animation: fadeUp 0.5s ease-out both; } .header__title { animation: fadeUp 0.5s ease-out 0.08s both; } .header__sub { animation: fadeUp 0.5s ease-out 0.16s both; } .form-card { animation: fadeUp 0.6s ease-out 0.24s both; }both是关键,它让元素在动画开始前就保持from状态,避免元素先闪一下再开始动画。延迟用0.08s这种小间隔就够了,总时长控制在 0.8 秒以内,超过 1 秒用户会觉得"这个页面怎么加载这么慢"。
要动就只动transform和opacity。这两个属性可以由合成层直接处理,不触发重排。动width、height、top、left、margin都会引发布局计算,在低端设备上就是掉帧的根源。
4.2 按钮反馈与内联 loading
登录按钮是页面上最重要的元素,它的反馈设计直接决定用户的操作信心。我给它做三层状态:默认态、按压态、加载态。
按压态用hover-class,加载态用数据驱动:
<view class="btn" :class="{ 'btn--disabled': loading }" hover-class="btn--hover" :hover-stay-time="80" @click="handleLogin" > <view v-if="loading" class="btn__spinner"></view> <text class="btn__text">{{ loading ? '登录中' : '登 录' }}</text> </view>.btn { height: 96rpx; border-radius: 48rpx; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #3b6fff 0%, #5a8bff 100%); transition: transform 0.15s ease, opacity 0.15s ease; } .btn--hover { opacity: 0.92; transform: scale(0.985); } .btn--disabled { opacity: 0.6; } .btn__spinner { width: 32rpx; height: 32rpx; margin-right: 12rpx; border: 4rpx solid rgba(255, 255, 255, 0.35); border-top-color: #ffffff; border-radius: 50%; animation: spin 0.7s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } }transition用transform和opacity,同样是为了避开重排。hover-stay-time设小一点(80ms 左右),按钮的按压感会更"脆",设大了会有种粘滞感。
4.3 键盘弹起适配:adjust-position的两难
这是登录页最麻烦的一个问题。小程序的input有个adjust-position属性,默认true,意思是键盘弹起时页面自动上推,保证输入框可见。
这个默认行为在普通文档流页面里很好用,但如果你的登录页用了position: fixed的布局,上推机制会失效或者错位——因为在fixed定位下,页面的滚动容器高度没有变化,系统不知道往哪儿推。
我的处理方式分两种情况:
如果页面是普通流式布局,保留adjust-position="true",同时给输入框设cursor-spacing,让输入框和键盘之间留出呼吸空间。
如果页面用了固定布局,就把adjust-position关掉,手动监听键盘高度:
const keyboardHeight = ref(0); onMounted(() => { uni.onKeyboardHeightChange(res => { keyboardHeight.value = res.height; }); }); onUnload(() => { uni.offKeyboardHeightChange(); });拿到高度之后,用translateY把整个表单卡片往上推,推的距离是键盘高度 - 卡片底部到屏幕底部的距离 + 一点余量。这个计算必须在真机上调,因为不同机型的键盘高度差异很大,iOS 的候选词栏高度也不一样。
提示:
uni.offKeyboardHeightChange()一定要在onUnload里调用。这个监听是全局的,不取消的话切到其他页面还会继续触发回调,在一些低内存机型上会引发奇怪的渲染问题。
5. 从点击按钮到拿到 token:登录流程的完整链路
UI 做完了不代表登录页做完了。真正的登录流程涉及校验、防重复提交、授权方式选择、token 存储和页面跳转,这一段出问题往往是"页面好看但用户登不进去"。
5.1 防重复提交:一个标志位不够
用户手快连点两下登录按钮,会产生两个请求。第一个请求成功拿到 token,第二个请求可能因为验证码已失效而返回错误,结果用户看到一个"登录失败"的提示,实际已经登录成功了。这种问题很难复现,但用户投诉里经常出现。
最基础的方案是加一个loading标志位:
async function handleLogin() { if (loading.value) return; if (!validateAll()) return; loading.value = true; try { const res = await loginApi({ ...form }); saveToken(res.token); uni.reLaunch({ url: '/pages/home/index' }); } catch (e) { uni.showToast({ title: e.message || '登录失败', icon: 'none' }); } finally { loading.value = false; } }finally里重置loading很重要。如果只写在try里,请求异常时按钮会永远卡在加载态,用户只能杀掉小程序。
但标志位有个漏洞:如果请求很快返回但页面跳转有延迟,用户在这段间隙里还是能点到。我的补充做法是加一个时间戳节流,两次点击间隔小于 800ms 直接忽略。另外在reLaunch之前把按钮保持在禁用状态,不要提前解锁。
请求本身也要设超时。小程序的uni.request默认超时 60 秒,对登录接口来说太长了,用户会以为小程序死了。我在登录接口上单独设timeout: 10000,10 秒没响应就给个明确提示。
5.2 三种授权方式的取舍
微信小程序里做登录,大致有三条路,各有各的适用场景。
| 方式 | 适用主体 | 用户体验 | 主要限制 |
|---|---|---|---|
| 账号密码登录 | 不限 | 需要输入、注册成本高 | 依赖自建账号体系 |
| 微信登录(code 换 openid) | 不限 | 一键完成、最轻 | 只能拿到 openid,拿不到手机号 |
| 手机号快捷登录 | 需企业主体 | 一键授权、拿到手机号 | 个人主体用不了 |
账号密码登录适合已有用户体系的业务,比如从 App 迁移过来的项目。它的实现最直接,但要做密码加密传输、登录失败次数限制、找回密码入口,配套工作不少。
微信登录是绝大多数小程序的首选。流程是:调用uni.login()拿到code,把code发给后端,后端用code去换openid和session_key,然后后端生成本业务的 token 返回给前端。
这里有两个必须记住的点。第一,code是一次性的,用过就失效,绝不能缓存在前端重复使用。第二,code有效期只有 5 分钟,而且用户每次调用uni.login()都可能拿到新的code,所以一定要"即时获取、即时使用"。
async function wxLogin() { const { code } = await uni.login({ provider: 'weixin' }); if (!code) throw new Error('获取登录凭证失败'); const res = await uni.request({ url: `${BASE_URL}/auth/wx-login`, method: 'POST', data: { code }, timeout: 10000 }); return res.data; }session_key绝对不能下发给前端,它是解密用户敏感数据的密钥,只能留在服务端。
手机号快捷登录需要用到button的open-type="getPhoneNumber"。注意这个能力的门槛:需要小程序主体为企业(或符合条件的组织),个人主体用不了。另外这个组件是按次计费的,个人练手项目上要留个心。
<button class="btn btn--wx" open-type="getPhoneNumber" @getphonenumber="onGetPhoneNumber" > 微信手机号一键登录 </button>回调里拿到的是加密的encryptedData和iv(新版可能是code),必须发给后端解密,前端解不了,也不该解。
还有一个容易踩的坑:早年的getUserProfile接口现在已经被回收了,拿不到真实的头像和昵称。现在的做法是用「头像昵称填写能力」,也就是button open-type="chooseAvatar"配合input type="nickname"。如果你的登录页还写着"授权获取头像昵称"然后调getUserProfile,用户看到的头像会是灰色的默认图。
5.3 token 存储与登录后的跳转策略
拿到 token 之后,存哪里?我的答案是uni.setStorageSync,不用全局变量。
原因是小程序随时可能被系统回收,全局变量一关页面就没了,用户下次进来又要重新登录。Storage是持久化的,除非用户手动清缓存或者卸载。当然,敏感度高的 token 建议加一层过期时间,在拦截器里判断有效期,过期就跳回登录页。
const TOKEN_KEY = 'app_token'; export function saveToken(token) { uni.setStorageSync(TOKEN_KEY, { value: token, expireAt: Date.now() + 7 * 24 * 3600 * 1000 }); } export function getToken() { const data = uni.getStorageSync(TOKEN_KEY); if (!data || !data.value) return ''; if (Date.now() > data.expireAt) { uni.removeStorageSync(TOKEN_KEY); return ''; } return data.value; }跳转用uni.reLaunch而不是uni.navigateTo。原因很直接:navigateTo会把登录页留在页面栈里,用户在新页面点返回键就回到了登录页,而这时他已经登录了,页面状态很尴尬。reLaunch会清空整个页面栈,是最干净的做法。
另外,登录页的onLoad里应该加一个判断:如果已经有有效 token,直接reLaunch到首页,不要等用户再点一次登录。这个逻辑在"登录态失效被踢回登录页"的场景下尤其有用。
onLoad(() => { if (getToken()) { uni.reLaunch({ url: '/pages/home/index' }); } });但要注意一点:不能无条件跳走。如果用户是主动点了"退出登录"回来的,那就应该正常展示登录页。我的做法是退出登录时先清 token,再跳登录页,同时带一个logout=1的参数,登录页看到这个参数就跳过自动跳转判断。
6. 踩坑记录:跑通了但真机上会出问题的那些细节
开发者工具里显示完美,真机上打开是另一回事。这是我做小程序这几年感受最深的一点。下面几个问题,我几乎每个项目都会遇到。
6.1 工具与真机的差异到底差在哪
差异主要来自三个地方:渲染引擎、字体、以及设备的 GPU 能力。
阴影和渐变。开发者工具用的是简化渲染,阴影的参数看起来会更"实"一些。真机上尤其是安卓,box-shadow的模糊半径超过 40rpx 后会有明显的带状分层。解决办法是把模糊半径压到 32rpx 以内,或者改用一层极浅的边框色来模拟。
字体。工具里用的是系统默认字体,iOS 上是苹方,安卓上各家都不一样。同一个字号在安卓上会显得比 iOS 大一圈,行高也会不同。所以我的建议是:不要用line-height: normal,全部显式指定行高;字号不要用奇数,28rpx比29rpx稳。
圆角溢出。iOS 上有个老问题:父元素设了border-radius和overflow: hidden,子元素还是可能溢出圆角。加一句transform: translateZ(0)触发合成层就能解决。但要注意,这会创建新的层叠上下文,可能会让内部绝对定位元素的层级表现发生变化。
滚动条和回弹。iOS 的页面回弹如果你不想要,得在pages.json里配置;安卓的scroll-view滚动条样式在真机和工具里完全不同,几乎没法统一,我的做法是直接隐藏它。
6.2 iOS 上的聚焦与光标问题
autofocus这个属性在小程序里非常不可靠。iOS 出于安全策略,不允许页面加载时自动聚焦输入框并唤起键盘,因为这会打断用户。所以你会看到工具里autofocus生效、真机上没反应。
正确的做法是"延后聚焦":页面加载后延迟一小段时间,再通过focus属性触发。但即使这样,iOS 上第一次也可能不弹键盘,需要用户先有一次页面交互。所以我的建议是:登录页不要执着于自动聚焦,把它当成一个锦上添花的效果,不要依赖它。
光标颜色用cursor-color属性设置,默认是系统蓝,和你的主题色不搭时很突兀。这个属性在安卓上支持良好,iOS 上偶尔不生效,属于可接受的降级。
还有就是密码框的password属性切换导致光标跳到开头的问题,前面提过。如果你要做"眼睛"图标切换明文,我的建议是加一个判断:只有当光标在输入框末尾时才允许切换,否则给一个提示。
6.3 包体积与首屏:登录页必须放在主包
小程序的规则是主包最大 2MB,总包(含所有分包)最大 20MB。登录页是小程序启动后用户看到的第一个或者第二个页面,绝对不能放在分包里,否则要等分包下载完才能显示,白屏时间会拉长。
省体积的几个实操点:
- 背景装饰全部用 CSS,不要图片。这一条前面讲过,收益最大。
- logo 如果一定要用图片,优先考虑 SVG(小程序支持 base64 形式的 svg 通过
image组件渲染)或者压到 10KB 以内的 webp。base64 内联到 CSS 里能减少一次请求,但会增大包体积,超过 4KB 就不划算了。 - 不要为了一个登录按钮引入完整的 UI 组件库。有些组件库单个组件也要几百 KB 的运行时依赖,如果项目里只有登录页用,可以直接手写。
- 首屏动画不要等数据。页面的入场动画应该是纯 CSS 的,不依赖接口返回,这样即使在弱网下用户也能立刻看到内容,感知上的"卡顿"会小很多。
另外提一句骨架屏。我自己在登录页是不用骨架屏的,因为登录页的结构是静态的,没有数据要等,直接渲染比骨架屏更快。骨架屏适合那些首屏要拉列表数据的页面。
提示:如果你用
uni.getSystemInfoSync()拿状态栏高度来算自定义导航栏,记得在真机上验证。这个接口在开发者工具返回的是模拟值(通常是 20),而在 iPhone 12 以后的机型上真实值是 44 或 47。用模拟值调样式,真机上一定错位。
我在实际项目里踩过最深的一个坑,是登录页的错误提示文案直接用了后端返回的message,结果某次后端返回了一段技术性的英文报错,用户看到一屏乱码。后来我改成前端统一映射错误码,后端文案只写进日志。这个习惯后来在很多页面都救了我——面向用户的文案,永远不要直接透传底层信息。