☰
uni-app微信小程序登录页实战:纯CSS视觉与低端机性能优化
2026/9/30 3:24:39 网站建设 项目流程

1. 这套登录页到底解决什么问题

做 uni-app 微信小程序这两年,登录页面是我改动次数最多的一个页面。原因很实在:它既是用户进来的第一屏,又是整个鉴权链路的起点,视觉上要好看,逻辑上要稳,还得兼容各种机型。前两篇我分别写过卡片式和插画式的登录页,评论区反馈最多的两个问题,一个是"背景太素,跟别人的小程序撞脸",另一个是"在安卓低端机上滑动卡得厉害"。所以这一版 login-v3 我换了思路:视觉上做减法但加层次,交互上做加法但控成本,最终目标就是——一套能直接复制进项目的登录页面,视觉有记忆点,逻辑能跑通,低端机不掉帧。

这篇东西适合谁看?如果你正在用 uni-app 做微信小程序,手头需要一个能落地的登录页,又不想直接抄组件库那种一眼就看出出处的模板,那这套思路应该对你有用。我会把布局、配色、动画、表单校验、倒计时、错误提示、真机适配全部拆开讲,代码基本可以直接复制粘贴。涉及的关键词无非就是 uni-app、微信小程序、UI、登录页面这几样,但我会尽量把每个参数为什么这么写讲清楚,而不是甩一堆代码让你自己猜。

需要先说明一点:本文不是在讲某个万能模板,而是在讲一套我在实际项目里反复调整过的做法。你在自己项目里用的时候,根据品牌色和业务逻辑做替换就行,骨架不用动。

1.1 前两版留下的三个遗憾

第一版用的是纯色背景加居中卡片,开发十分钟就搞定了,但上线之后视觉上确实没什么记忆点,用户看一眼就忘。第二版加了插画和视差效果,好看是好看了,可插画资源打包进去之后主包体积涨了接近 400KB,而且在一些千元安卓机上滑起来有明显的掉帧感,首屏渲染时间从 600ms 左右拖到了 1.1s。第三个遗憾是逻辑层面的:前两版的校验都写在按钮点击事件里,用户输完手机号不移开焦点直接点登录,手机上很容易出现"最后一次输入没被读到"的诡异现象,本质上是blur和tap的事件顺序问题。

这三个遗憾基本就是第三版的改造方向。资源体积要靠纯 CSS 实现视觉层次,动画要控制在能用 transform 和 opacity 解决的范围内,校验逻辑要拆成独立函数并且在输入过程中就实时跑,不依赖提交那一刻的读取。

提示:登录页的资源体积是很多人忽视的点。小程序主包有 2MB 的限制,登录页又是用户必经路径,如果这里塞了大图,后面业务页面就没什么余量了。

1.2 第三版的取舍:视觉做减法,交互做加法

视觉减法的意思是,我把插画去掉了,换成渐变底色加两个模糊光斑,再加一层细网格纹理。这三个东西全部用 CSS 写,不占任何打包体积,还能跟着屏幕尺寸自适应。光斑用radial-gradient画,网格用linear-gradient平铺,配合filter: blur()做柔化,整体观感比单纯色块厚实不少。

交互加法的意思是,输入框的聚焦态、错误态、禁用态全部做区分;按钮有加载态和置灰态;验证码按钮带倒计时并且做了节流;错误提示用轻量 toast 加输入框下方红字双通道,兼顾即时性和可读性。这些东西写起来代码量不小,但都是纯逻辑,不增加渲染压力。

有一点要强调,动画我全程只用transform和opacity两个属性。原因是这两个属性可以被合成器单独处理,不触发重排重绘,在低端机上表现最稳。你用top、left、width、height做动画,在开发者工具里看不出问题,一到真机就露馅。

2. 动手前的准备工作与目录结构

开工之前先理清楚工程结构,不然写着写着文件就乱了。我习惯把登录页拆成三段:页面本体、可复用的输入框组件、样式变量文件。虽然多建了两个文件,但后面如果注册、找回密码页面也要用同样的输入框,直接引组件就行,不用复制粘贴一堆重复样式。

2.1 工程配置与页面注册

页面放在pages/login/login.vue,输入框组件放components/ui-input/ui-input.vue,全局样式变量放styles/variables.scss。在pages.json里注册页面的时候,我一般会把导航栏关掉,用自定义导航,这样整屏的控制权都在自己手里。

{ "path": "pages/login/login", "style": { "navigationStyle": "custom", "navigationBarTextStyle": "black", "backgroundColor": "#f5f6fa" } }

navigationStyle设为custom之后,页面顶部会一直顶到状态栏,这时候你需要自己处理状态栏高度,否则内容会被刘海屏的状态栏压住。状态栏高度通过uni.getSystemInfoSync().statusBarHeight获取,这个值在不同机型上不一样,iPhone 上是 44 或 47,很多安卓机是 24 左右,千万别写死。

如果你用的是 scss,记得在uni.scss里引入变量文件,这样每个页面都能直接用,不用重复 import。

@import '@/styles/variables.scss';

注意:navigationStyle: custom只在小程序和 App 端表现一致,H5 端的行为略有差异。如果你的项目要同时发 H5,建议在 H5 的样式分支里单独处理顶部间距。

2.2 样式变量的统一管理

我见过太多项目的登录页,颜色是直接写死的十六进制值,散落在十几个地方。等到品牌色一改,得全局搜索替换,还容易漏。所以从第三版开始,我把所有颜色、圆角、字号、间距全部抽成变量。

// styles/variables.scss $brand-primary: #4a6cf7; $brand-primary-light: #7b93ff; $brand-gradient: linear-gradient(135deg, #4a6cf7 0%, #7b93ff 100%); $text-main: #1a1a2e; $text-sub: #8a8fa3; $text-placeholder: #b8bccb; $bg-page: #f5f6fa; $bg-card: #ffffff; $border-color: #e8eaf0; $radius-card: 32rpx; $radius-input: 24rpx; $radius-btn: 48rpx; $space-sm: 16rpx; $space-md: 32rpx; $space-lg: 48rpx;

这么做还有个隐藏好处:设计稿换皮的时候,只要改变量文件里的几个值,整个页面风格就跟着变了,不用动业务代码一行。另外$brand-gradient这种复合值也能抽,后面按钮、图标背景都能复用。

字号我一般不用变量,因为小程序里的字号就那么几档,抽出来反而影响阅读代码时的直观判断,28rpx 就是 28rpx,比$font-md一眼看过去更快。

3. 视觉层的实现细节

视觉这部分我拆成三块讲:背景、卡片、输入框。背景决定了第一眼的观感,卡片决定了页面的层次,输入框决定了用户操作的舒适度。三块都是有坑的地方,尤其背景那块,很多人写出来的渐变在真机上是断层的。

3.1 顶部渐变与光斑背景怎么做才不糊

先说一个常见错误。很多人写渐变背景喜欢这样:

background: linear-gradient(to bottom, #4a6cf7, #f5f6fa 60%);

在开发者工具里看是平滑的,但部分安卓机在低色深屏幕下会出现明显的色带,也就是俗称的"断层"。原因是渐变跨越的色彩空间太大,8 位色深不够用。解决办法有两个:一是把渐变的结束位置提前,让颜色跨度变小;二是叠一层极轻微的噪点纹理打散色带。

我的做法是用两个模糊光斑来代替大面积渐变。具体就是在页面顶部放两个绝对定位的圆形,给他们加径向渐变和模糊,让它们自然晕开。

<view class="bg-wrap"> <view class="blob blob-1"></view> <view class="blob blob-2"></view> <view class="grid-mask"></view> </view>
.bg-wrap { position: fixed; inset: 0; overflow: hidden; background: #f5f6fa; z-index: -1; } .blob { position: absolute; border-radius: 50%; filter: blur(80rpx); opacity: 0.6; } .blob-1 { width: 520rpx; height: 520rpx; top: -180rpx; left: -140rpx; background: radial-gradient(circle, #7b93ff 0%, rgba(123, 147, 255, 0) 70%); } .blob-2 { width: 460rpx; height: 460rpx; top: 60rpx; right: -160rpx; background: radial-gradient(circle, #a5b8ff 0%, rgba(165, 184, 255, 0) 70%); }

这里有个关键点:filter: blur()在小程序里的性能开销跟模糊半径和元素面积正相关。上面这两个光斑面积不算小,模糊半径 80rpx 已经是比较克制的值了。如果你加到 200rpx 以上,低端机首屏渲染会明显变慢。实测下来,两个光斑加起来的渲染时间在 30ms 以内,属于可接受范围。

网格纹理用平铺的线性渐变实现,透明度压到 0.03 左右,肉眼几乎看不出,但能把大片纯色区域"激活",看起来不那么单薄。

.grid-mask { position: absolute; inset: 0; background-image: linear-gradient(rgba(74, 108, 247, 0.06) 1rpx, transparent 1rpx), linear-gradient(90deg, rgba(74, 108, 247, 0.06) 1rpx, transparent 1rpx); background-size: 60rpx 60rpx; }

提示:光斑和网格都用position: fixed加z-index: -1,这样页面滚动的时候背景不动,视觉上更稳定。但要注意 iOS 上 fixed 定位在键盘弹出时的表现,后面第 5 章会细说。

3.2 卡片式表单的层次感

卡片本身没什么难的,难的是怎么让它"浮"起来而不是"贴"在背景上。我的做法是三层叠加:一层白色底,一层极淡的边框,一层大范围低透明度的阴影。

.form-card { margin: 0 $space-lg; padding: 60rpx 48rpx 56rpx; background: #ffffff; border-radius: $radius-card; border: 1rpx solid rgba(74, 108, 247, 0.08); box-shadow: 0 8rpx 24rpx rgba(74, 108, 247, 0.06), 0 24rpx 64rpx rgba(74, 108, 247, 0.1); }

阴影这里我用了两层,近的那层负责边缘的清晰感,远的那层负责整体的悬浮感。如果你只用一层大阴影,卡片边缘会显得发虚;只用一层小阴影,又浮不起来。注意阴影颜色不要用纯黑,用品牌色的低透明度版本,整体会更协调。

圆角 32rpx 是个比较舒服的值。低于 24rpx 显得方正,高于 40rpx 在小屏手机上会让内容区域变窄,输入框跟着缩水。

3.3 输入框的三种状态与光标颜色

输入框是用户接触最多的元素,状态设计必须清晰。我给它定义了四种状态:默认、聚焦、错误、禁用。

默认态是浅灰底加无边框,聚焦态把底色变白并且加一圈品牌色的光晕边框,错误态把边框换成红色并且下方出提示文字,禁用态整体降透明度。

.ui-input { display: flex; align-items: center; height: 96rpx; padding: 0 28rpx; background: #f7f8fc; border-radius: $radius-input; border: 1rpx solid transparent; transition: background-color 0.2s, border-color 0.2s, box-shadow 0.2s; } .ui-input.is-focus { background: #ffffff; border-color: rgba(74, 108, 247, 0.5); box-shadow: 0 0 0 6rpx rgba(74, 108, 247, 0.08); } .ui-input.is-error { background: #fff5f5; border-color: rgba(245, 87, 87, 0.5); }

这里有个细节,box-shadow我用了0 0 0 6rpx这种写法,也就是只扩散不偏移,相当于给元素加了一圈外发光。相比border-width变化,这种写法不会引起布局抖动,因为边框宽度没变。

光标颜色在小程序里要单独设置,微信小程序支持cursor-color属性,但只在小程序端生效,H5 端要用 CSS 的caret-color。

<input class="input-inner" :value="modelValue" :password="password" :cursor-color="cursorColor" placeholder-class="input-placeholder" @input="onInput" @focus="onFocus" @blur="onBlur" />

placeholder-class是个容易踩的坑。很多人在 style 里直接写.input-inner::placeholder,在小程序里是不生效的,必须通过placeholder-class指定一个类名,然后在外层样式里写这个类。而placeholder-style可以写内联样式,适合只改颜色不改字号的场景。

4. 交互逻辑与数据流

视觉搞定之后,逻辑才是真正决定这个页面好不好用的地方。我把逻辑分成三块:校验、按钮状态、验证码倒计时。三块都围绕一个原则——用户每做一步操作,界面上必须有对应的即时反馈。

4.1 表单校验规则的写法

我不喜欢把校验写在提交函数里。第三版的做法是把每个字段的校验抽成一个纯函数,输入过程中实时调用,但错误提示延迟到失焦之后再展示。这样用户在输入的时候不会一直被红字骚扰,但一离开输入框就能看到结果。

const rules = { phone: { test: (v) => /^1[3-9]\d{9}$/.test(v), message: '请输入正确的手机号' }, code: { test: (v) => /^\d{4,6}$/.test(v), message: '验证码为4到6位数字' }, agree: { test: (v) => v === true, message: '请先阅读并同意相关协议' } }; const validateField = (key, value) => { const rule = rules[key]; if (!rule) return true; return rule.test(value); };

注意我这里的正则只用了最基础的规则。有些团队喜欢写特别复杂的手机号正则,把各种虚拟号段都排除掉,这在实践中反而是坑。原因很简单,号段是不断更新的,规则写太死,将来新号段放号用户就注册不了。基础校验只是拦截明显错误,真正的有效性交给后端短信验证。

关于"同意协议"这一项,我的建议是不要做成必须勾选才能点按钮的形式,用户体验很差。更好的是允许点击登录,点击后如果没勾选,弹一个轻提示并让勾选框闪一下。这种方式转化率明显更高。

4.2 登录按钮状态机

按钮我给它设计了三种状态,用一个计算属性推导出来:

const btnState = computed(() => { if (loading.value) return 'loading'; if (!canSubmit.value) return 'disabled'; return 'normal'; });

其中canSubmit的判断要小心,不能把"同意协议"算进去:

const canSubmit = computed(() => { return rules.phone.test(phone.value) && rules.code.test(code.value); });

按钮的视觉状态用类名切换,加载态显示转圈图标并且禁用点击:

.btn-login { height: 96rpx; border-radius: $radius-btn; background: $brand-gradient; color: #fff; font-size: 32rpx; letter-spacing: 4rpx; transition: transform 0.15s, opacity 0.2s; } .btn-login:active { transform: scale(0.98); } .btn-login.is-disabled { background: #d4d8e5; opacity: 0.9; }

transform: scale(0.98)这个按下反馈看起来不起眼,但实测对点击率的感知提升挺明显,用户会明确知道"我按到了"。注意用:active伪类在小程序里部分机型支持不完整,稳妥一点可以用@touchstart和@touchend手动加类名。

注意:加载态一定要禁用重复点击。我见过线上事故就是网络慢的时候用户连点七八次,后端收到一堆重复登录请求,直接触发了风控。

4.3 验证码倒计时与节流

验证码按钮的逻辑看着简单,坑其实最多。核心要求有三条:点击后立即禁用防止重复请求,倒计时期间显示剩余秒数,页面切到后台再回来时倒计时不能乱。

第一条用disabled或者自定义的if判断都行,我用的是一个counting标志位。第二条就是常规的setInterval。第三条很多人忽略,小程序切到后台之后定时器会被节流甚至暂停,回来时秒数会不准。解决办法是记录一个结束时间戳,每次 tick 的时候用当前时间和它做差。

const endTime = ref(0); let timer = null; const startCountdown = (seconds = 60) => { endTime.value = Date.now() + seconds * 1000; counting.value = true; tick(); timer = setInterval(tick, 1000); }; const tick = () => { const left = Math.ceil((endTime.value - Date.now()) / 1000); if (left <= 0) { clearInterval(timer); timer = null; counting.value = false; countdown.value = 0; return; } countdown.value = left; };

页面卸载的时候记得清理定时器,不然会内存泄漏:

onUnmounted(() => { if (timer) clearInterval(timer); });

发送请求本身也要做一层防抖,因为用户手快的话,在disabled生效之前可能已经点了第二下。我的做法是加一个sending标志,请求返回之前直接 return。

5. 小程序端的适配与真机验证

开发者工具里跑得好好的页面,真机上出问题,这是做小程序最常见的挫折。登录页尤其容易翻车,因为它是用户进来看的第一屏,各种刘海屏、折叠屏、大字号模式都会在这里暴露。这一章我讲三个最普遍的适配点。

5.1 导航栏高度、胶囊按钮与安全区

因为用了navigationStyle: custom,页面顶部需要自己撑开。我的做法是在页面根节点上加一个 padding,值等于状态栏高度。

const statusBarHeight = ref(0); onLoad(() => { const info = uni.getSystemInfoSync(); statusBarHeight.value = info.statusBarHeight || 20; });
<view class="page" :style="{ paddingTop: statusBarHeight + 'px' }">

注意这里用的是 px 不是 rpx。状态栏高度是物理无关像素,小程序返回的就是 px 单位的值,如果你乘了 2 换算成 rpx 就错了。

另外右上角有微信的胶囊按钮,高度一般是 32px,距离顶部有一个固定的偏移。如果你要在顶部放返回按钮或者标题,必须和胶囊按钮垂直居中对齐,否则看起来会错位。我的做法是拿胶囊按钮的位置信息来计算:

const menu = uni.getMenuButtonBoundingClientRect(); const navBarHeight = (menu.top - statusBarHeight.value) * 2 + menu.height;

这个公式算出来的是自定义导航栏的总高度。原理是胶囊按钮距离状态栏的距离,上下应该对称,所以顶部间距乘以 2 再加上胶囊高度,就是导航栏的完整高度。

底部安全区也别忘。iPhone 有底部横条,如果登录按钮离底部太近,会被横条挡住。用env(safe-area-inset-bottom)处理:

.page-footer { padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); }

5.2 键盘弹起、rpx 与 1px 边框

小程序里输入框聚焦时,键盘弹起会把页面顶上去。input组件默认有adjust-position属性,值为 true 时会自动上推页面。这在大部分情况下是好事,但如果你的登录按钮是固定在底部的,上推之后可能被键盘盖住。

我的处理是把整个表单放在可滚动区域里,adjust-position保持默认,同时给底部留出足够的 padding。这样键盘弹起时,用户能看到输入框,滚动一下也能点到按钮。

rpx 的换算要记牢:规定屏幕宽度为 750rpx,也就是说在 375px 宽的屏幕上,1rpx = 0.5px。所以在设计稿是 750 宽的情况下,量到多少就写多少,不用换算。但如果设计稿是 375 宽的,就要把量到的数值乘以 2。

1px 边框是经典问题。写border: 1px solid #eee在 2 倍屏上实际会渲染成 2 个物理像素,看起来偏粗。小程序里的常规做法是用伪元素加transform: scaleY(0.5)来做细线。不过说实话,登录页里的边框我一般用 1rpx 直接解决,视觉效果已经够细了,没必要为了极致去折腾伪元素方案。

5.3 真机调试的检查清单

每次改完登录页,我都会按照这份清单在真机上过一遍。列出来给你参考:

检查项关注点常见问题
状态栏内容不被刘海遮挡自定义导航没加 padding
胶囊避让顶部元素不与胶囊重叠标题位置偏左或偏右
键盘弹起输入框可见,按钮可点底部按钮被键盘盖住
输入法切换中文输入法下拼音不上屏用 @input 而非 @change 实时校验会误判
错误提示提示文字不被键盘遮挡提示位置写死在输入框下方固定距离
弱网按钮加载态正确显示请求没有超时处理,一直转圈
大字号模式布局不错乱固定高度导致文字被裁切
深色模式如果支持,配色对比度足够白底黑字硬编码

关于中文输入法那条,我要多说一句。用户用拼音输入时,@input事件会在拼音阶段就触发,这时候拿到的值可能是 "zhangsan" 这种中间态。如果你的实时校验逻辑比较严格,会在用户还没输完的时候就把输入框标红,体验很糟。解决办法是配合@compositionstart和@compositionend判断是否在组词状态,组词期间不触发校验。

6. 常见问题与排查技巧实录

前面讲的是怎么做,这一章讲做完了之后会碰到什么。我把这两年在这个页面上踩过的坑整理了一下,按出现频率排序。

6.1 问题速查表

现象可能原因处理方式
输入框里文字被光标挡住内边距不足左右 padding 至少 24rpx
焦点切换时页面跳动错误提示占位变化提示区域预留固定高度
安卓机背景模糊卡顿blur 半径过大降到 80rpx 以内,或改用多层径向渐变
iOS 上背景 fixed 抖动键盘弹起触发重排背景改用 absolute 加页面高度 100vh
倒计时秒数不准后台定时器被节流用时间戳差值计算剩余时间
按钮点击无反应被透明遮罩挡住检查 z-index 和 pointer-events
placeholder 样式不生效用了 ::placeholder改用 placeholder-class
提交后按钮一直转圈请求无超时兜底加 finally 复位 loading

倒计时那条我再展开说一点。小程序切后台之后,setInterval在 iOS 上会被限制到 1 秒以上甚至暂停,安卓上相对宽松。所以不管你用哪种方式实现倒计时,都别用"每次 tick 减一"的写法,一定要用目标时间戳做基准。这个习惯养成之后,不只是验证码,任何和时间相关的逻辑都会稳很多。

6.2 几个只有踩过才知道的细节

第一个细节,输入框的类型属性。手机号输入一定要设type="number",验证码如果只有数字也设type="number"。但要注意,type="number"在部分安卓机上键盘的"完成"按钮行为不一致,有的会直接收起键盘,有的会切到下一个输入框。如果你希望用户输完手机号直接跳到验证码框,可以在@confirm里手动focus下一个输入框,比依赖系统行为可靠。

第二个细节,错误提示的位置。我最初把提示文字放在输入框正下方,结果输入框一多,下面的元素就往下挤,整个卡片高度变化,视觉上很跳。后来改成在输入框内部右侧显示一个小图标,提示信息用顶部的轻提示条展示。这样布局高度恒定,不会被撑开。

第三个细节,关于登录接口的错误处理。后端返回的错误码要区分对待:手机号格式错误、验证码错误这类是用户可修复的,弹提示让用户改;token 过期、账号被锁这类需要跳转或者联系客服的,要给出明确的下一步指引。不要所有错误都弹一句"登录失败",那样用户完全不知道该怎么办。

第四个细节,也是我觉得最值得说的一个:登录成功之后的跳转。很多人直接uni.switchTab或者uni.reLaunch到首页,但没处理好页面栈。如果你的登录页是从某个业务页面跳转过来的,登录完之后把用户扔到首页,用户就得重新走一遍之前的路径,体验很割裂。更合理的做法是在跳转登录页的时候带上redirect参数,登录成功后优先回到这个地址,没有参数再去首页。

const redirect = options.redirect ? decodeURIComponent(options.redirect) : ''; const goAfterLogin = () => { if (redirect) { uni.reLaunch({ url: redirect }); } else { uni.switchTab({ url: '/pages/index/index' }); } };

这里用reLaunch而不是navigateTo,是为了清空页面栈。登录页不应该留在栈里,否则用户从首页返回还能回到登录页,逻辑上说不通。

最后一个心得,关于这套页面的复用。我在ui-input组件上暴露了v-model、type、password、error、disabled这几个属性,其余全部内部处理。这样在注册页、找回密码页、实名认证页都能直接复用,不用重复写样式。组件化这件事在登录页上收益特别高,因为表单元素高度重复,抽出来之后维护成本能降一大截。你如果现在手上正好有多个表单页面,建议先花半天把输入框组件抽出来,后面能省下好几天的调整时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询