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

资讯详情

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

uni-app微信小程序登录页:Vue3纯CSS高转化UI实战

uni-app微信小程序登录页:Vue3纯CSS高转化UI实战

做小程序登录页这件事,我前后推倒重来过至少七个版本。第一版是照着教程堆出来的深色背景配白色输入框,自认为挺"高级",结果上线一周后后台数据显示登录页跳出率接近四成;第二版换了配色,数据没动;直到第三版我把"注册"和"忘记密码"两个入口从按钮改成文字链接、把按钮从通栏改成留白,转化才明显起来。这件事让我意识到,UI 不是审美问题,是转化问题。这次要拆的是uni-app 微信小程序之好看的 UI 登录页面(二),也就是在第一版基础上重做的方案:Vue3 + script setup 语法,纯 CSS 实现渐变和点线背景,不引入任何第三方 UI 库,整套代码压缩后不到 12KB。它解决的问题很具体——让登录页在几十种机型上都不崩、不卡、不丑,同时把表单校验、验证码倒计时、协议勾选、token 落地这些"必要的脏活"一次性写干净。不管你是刚接触 uni-app 的新手,还是做了两三年小程序、想把手上的登录页再打磨一轮的老手,这套结构都能直接抄过去用。

1. 登录页设计思路与方案选型

动手写代码之前,先把两件事想清楚:一是什么样的视觉结构能被大多数人接受,二是用技术怎么把它稳定地还原出来。这两件事任何一件没想明白,写出来的东西都只能自己看着顺眼。

1.1 第一版为什么"不耐看"

回头看第一版的问题,其实不在配色,而在信息层级。那一版把 logo、欢迎语、输入框、按钮、第三方登录、注册链接全部塞进一个白色卡片里,卡片又贴边又高,视觉上是一整块,用户的眼睛没有落点,只能在页面里来回扫。更麻烦的是深色背景配纯白卡片,在小程序这种高频使用的场景里刺眼,用户每天点开五六次,三四天就腻了。

第二版我做了三件事,改动都不大但效果明显。第一是把内容拆成品牌层和表单层两块,品牌层只放 logo 和一句欢迎语,留出大约 240rpx 的呼吸空间;第二是卡片不再贴边,左右各留 48rpx 的边距,圆角从 16rpx 提到 40rpx,卡片内部 padding 给到 48rpx;第三是把主按钮的高度从 88rpx 提到 96rpx,字号从 30rpx 提到 32rpx,圆角用 48rpx 做成胶囊形。这几点加起来,页面从"能用"变成了"好看"。

还有一点经验值得单独说:登录页不需要创意,需要熟悉感。用户在登录页的心理状态是"我要快点进去",不是"我要欣赏设计"。所有需要用户思考的视觉元素都是负担。所以我最后的方案里,输入框位置、按钮位置、协议勾选位置,都跟微信、支付宝这类高频应用的登录页保持相近的肌肉记忆,只在背景和配色上做出自己的辨识度。

1.2 为什么不用 UI 库,而是手搓

uni-app 生态里成熟的 UI 库很多,但登录页这类页面我强烈建议手搓,理由有三条。

第一是包体积。小程序的包体积限制一直是硬约束,主包超额就得做分包。一个 UI 库即使按需引入,登录页用到的输入框、按钮、复选框三类组件,加上它们依赖的主题变量和图标字体,实打实要占几百 KB。而手搓一个登录页,模板加样式加起来不到 12KB,这个差距在启动速度上是能感知的。

第二是定制成本。UI 库的组件大多带默认样式和默认交互,你想要把输入框改成"下划线 + 聚焦高亮"的形态,往往要覆盖三四个类名,还要处理样式优先级问题。改到最后你会发现,覆盖样式的代码比从零写一遍还长,而且升级库版本的时候还得重新对一遍。

第三是行为可控。登录页有几个很细的交互:输入框聚焦时图标变色、密码可见性切换、验证码按钮的倒计时禁用、协议未勾选时按钮的半透明态。这些行为手写逻辑链路非常短,用组件库反而要绕一层 props 和事件,调试成本高。

当然这不是说 UI 库不能用。列表页、表单页、弹窗这类通用性极强的场景,用库省事得多。我的判断标准是:页面越核心、越需要定制、越在意首屏速度,就越应该手写。登录页三条全中。

1.3 设计稿到 rpx 的换算基准

这一步新手最容易出错。小程序里推荐用 rpx,规则是屏幕宽度恒等于 750rpx,跟实际像素无关。也就是说你用 750px 宽的设计稿最省事,设计稿上量出来多少像素,代码里就写多少 rpx,一比一映射,不用心算。

如果设计师给你的稿子是 375px 宽(也就是常见的移动端一倍图),那所有尺寸乘以 2 就对了。比如稿子上按钮高度是 48px,代码里写 96rpx。这个换算一定要在动手前统一,否则写到后面混着用,页面在不同机型上的比例就会乱掉。

还有两类值不建议用 rpx。一类是字体大小,小程序的字体在不同机型上的缩放策略和 rpx 不完全一致,实践中正文用 28rpx 到 32rpx 之间是安全的;另一类是边框宽度,1rpx 在部分安卓机上会被渲染成 0,看不见线,所以细边框我通常直接写 1px,宁可稍微粗一点也不出现"时有时无"。这一点我在下面样式那一节还会再提。

2. 页面结构拆解与核心视觉细节

结构定下来之后,剩下的就是每个区块怎么落地。这一节把页面拆成三层来讲,同时把点线背景这个"看起来花哨、实际上很轻"的实现方式说透。

2.1 三层结构:背景层、品牌层、表单层

整个页面的 DOM 结构我控制在三层,这个深度在渲染性能上是最舒服的。

背景层是一个绝对定位、铺满全屏的容器,内部再放两三个渐变光斑和一个点阵网格。它不参与任何布局计算,pointer-events等效于无,纯粹做视觉。之所以跟内容层完全分离,是因为背景里有动画,如果跟内容混在一起,动画每帧重排都会带上输入框和按钮,低端机上直接掉帧。

品牌层放 logo 和欢迎文案。logo 我建议不要用图片,用一个带渐变的圆角方块加一个文字,一来省一次网络请求,二来不会出现图片加载慢导致页面"闪一下"的情况。很多开发者习惯把 logo 放 CDN,结果弱网下登录页先白后跳,体验很糟。

表单层是一张卡片,内部从上到下依次是:账号输入框、密码输入框、忘记密码入口、主按钮、协议勾选、第三方登录分隔线、第三方登录按钮组。这个顺序是有讲究的——账号密码是必填项,往前放;忘记密码是补救路径,跟在密码后面;第三方登录是替代方案,放最后,不抢主流程的注意力。

2.2 渐变背景与点线动效的实现取舍

点线动态背景在小红书和各种前端社区上很火,但直接搬到小程序里要谨慎。原始实现通常是 Canvas 画点、画线、算距离,再按帧刷新。这个方案在 PC 浏览器上没问题,在小程序里有三个坑:Canvas 在部分安卓机的合成层处理不好,容易和页面其他元素互相干扰;每帧计算距离的循环是纯 JS 运算,机型差一点直接卡;页面切到后台再切回来,requestAnimationFrame 可能不再触发,需要手动恢复。

我的做法是用 CSS 降级实现同样的观感。点阵用radial-gradient配合background-size平铺出来,视觉上就是规整的小圆点;再加一个上下缓慢位移的动画,看起来像在流动。线条感则交给两个大尺寸的径向渐变光斑,它们边缘柔和、缓慢呼吸,配合点阵已经有了"科技感"的氛围,但全部由合成层承担,几乎不吃 CPU。

代价是它不如 Canvas 那样"点与点之间真的连线、鼠标靠近会聚合"。但登录页本来就不需要交互式背景,静态氛围到位就够了。能用 CSS 表达的效果,就不要用 Canvas,这是我做了几年小程序养成的一个条件反射。

2.3 输入框的体验细节,比好看更重要

一个输入框好不好用,跟它长什么样关系不大,跟这几件事关系很大。

第一是高度。小程序上输入框的可点区域建议不低于 88rpx,也就是 44px 左右。太矮了手指点不准,尤其是走路或者单手操作的时候。我的方案里输入框高度给到 100rpx,视觉上留白足够,点起来也从容。

第二是聚焦反馈。输入框获得焦点时,图标颜色从灰调变到主题色,同时底部有一条 2rpx 的高亮线滑出。这个反馈很轻,但它告诉用户"我点对了",尤其在安卓机上点击延迟稍高的场景下,这个立即的视觉响应能抵消掉一部分"卡"的错觉。

第三是清空按钮的位置。账号框内容非空时右侧出现一个清空按钮,这个按钮的点击热区要单独放大到 60rpx × 60rpx,视觉图标可以只有 28rpx。视觉小、热区大,是移动端的一个通用技巧。

第四是密码可见性切换。不要用眼睛图标,除非你有整套图标字体。用"显示 / 隐藏"两个汉字,字号 26rpx,颜色用次级文字色,成本低、识别度高,还省了图标资源。

3. 完整实操:从零把登录页跑起来

下面这一节是可以直接照着敲的。我会把目录结构、配置、模板、样式、逻辑完整走一遍,同时在关键处说明参数是怎么算出来的。

3.1 目录结构与页面注册

推荐的项目结构是这样的,登录页单独一个文件夹,样式和逻辑都不往外散:

src/ ├── pages/ │ └── login/ │ ├── login.vue │ └── login.scss ├── static/ ├── App.vue ├── main.js ├── pages.json └── uni.scss

pages.json里登录页的配置要注意三个字段:

{ "pages": [ { "path": "pages/login/login", "style": { "navigationStyle": "custom", "disableScroll": true, "backgroundColor": "#0F1226", "backgroundColorContent": "#0F1226" } } ], "globalStyle": { "navigationBarTextStyle": "white", "navigationBarTitleText": "uni-app", "navigationBarBackgroundColor": "#0F1226", "backgroundColor": "#0F1226" } }

navigationStyle: custom是必须的。只要你的页面背景是深色,默认导航栏的白色标题栏就会显得格格不入。设成 custom 之后导航栏完全由你自己控制,代价是你得自己处理状态栏高度。

disableScroll: true也很关键。登录页内容撑不满一屏,如果允许滚动,用户上下拖的时候页面会跟着动,配合键盘弹出特别容易乱。禁用页面滚动之后,所有的位移都由你自己控制。

状态栏高度这样拿:

const statusBarHeight = ref(20) try { const info = uni.getWindowInfo ? uni.getWindowInfo() : uni.getSystemInfoSync() statusBarHeight.value = info.statusBarHeight || 20 } catch (e) { statusBarHeight.value = 20 }

这里做了一个兼容处理。uni.getWindowInfo()是新版本 API,老版本基础库没有,所以要判断一下是否存在,不存在就回退到uni.getSystemInfoSync()。再从外面套一层 try/catch,是因为极少数环境下这两个 API 都可能拿不到值,抛异常会导致页面直接白屏,兜一个默认值 20px 至少能正常显示。

3.2 模板代码:结构和交互钩子

<template> <view class="login-page"> <!-- 背景层 --> <view class="bg-layer"> <view class="blob blob-1"></view> <view class="blob blob-2"></view> <view class="dot-grid"></view> </view> <!-- 内容层 --> <view class="content" :style="{ paddingTop: statusBarHeight + 'px' }"> <view class="brand"> <view class="logo">U</view> <text class="brand-title">欢迎回来</text> <text class="brand-sub">登录后同步你的全部数据</text> </view> <view class="form-card"> <view class="field" :class="{ 'is-focus': focusKey === 'account' }"> <text class="field-icon">@</text> <input class="field-input" v-model="form.account" type="text" placeholder="手机号 / 邮箱" placeholder-class="ph" :maxlength="30" :cursor-spacing="24" @focus="focusKey = 'account'" @blur="focusKey = ''" /> <view v-if="form.account" class="field-clear" @tap="form.account = ''"> <text class="field-clear-txt">×</text> </view> </view> <view class="field" :class="{ 'is-focus': focusKey === 'password' }"> <text class="field-icon">#</text> <input class="field-input" v-model="form.password" :password="!showPwd" placeholder="请输入密码" placeholder-class="ph" :maxlength="20" :cursor-spacing="24" @focus="focusKey = 'password'" @blur="focusKey = ''" /> <view class="field-eye" @tap="showPwd = !showPwd"> <text class="field-eye-txt">{{ showPwd ? '隐藏' : '显示' }}</text> </view> </view> <view class="form-assist"> <text class="assist-link" @tap="goForget">忘记密码</text> </view> <view class="btn-primary" :class="{ 'is-disabled': !canSubmit }" hover-class="btn-primary--hover" :hover-stay-time="80" @tap="handleLogin" > <text class="btn-primary-txt">{{ loading ? '登录中…' : '登 录' }}</text> </view> <view class="agree" @tap="agreed = !agreed"> <view class="agree-box" :class="{ 'is-checked': agreed }"> <text v-if="agreed" class="agree-tick">✓</text> </view> <text class="agree-txt"> 我已阅读并同意 <text class="agree-link" @tap.stop="openDoc('user')">《用户协议》</text> 与 <text class="agree-link" @tap.stop="openDoc('privacy')">《隐私政策》</text> </text> </view> </view> <view class="third"> <view class="third-divider"> <view class="line"></view> <text class="third-divider-txt">其他方式登录</text> <view class="line"></view> </view> <view class="third-row"> <view class="third-item" @tap="loginByWeixin"> <text class="third-item-txt">微信</text> </view> <view class="third-item" @tap="loginByPhone"> <text class="third-item-txt">手机号</text> </view> </view> </view> </view> </view> </template>

模板里有几个细节值得单独说。cursor-spacing设成 24 是为了让键盘弹出时输入框和键盘之间留出一点距离,不然光标会贴着键盘边缘,看着很挤。hover-class配合hover-stay-time给按钮一个按下变暗的反馈,这个反馈在小程序里比:active伪类可靠得多,安卓机上尤其明显。

协议那一行有个容易踩的坑:整行@tap切换勾选,但里面的《用户协议》也要能单独点开。如果直接写@tap,点协议文字会同时触发勾选和打开文档两个行为。解决办法就是给协议文字加.stop修饰符,阻止冒泡。这个小细节我见过很多人漏掉。

3.3 样式代码与关键参数

.login-page { position: relative; width: 100%; min-height: 100vh; background: linear-gradient(160deg, #0F1226 0%, #171C3D 55%, #0C0F22 100%); overflow: hidden; } .bg-layer { position: absolute; left: 0; top: 0; right: 0; bottom: 0; z-index: 0; } .blob { position: absolute; border-radius: 50%; } .blob-1 { width: 620rpx; height: 620rpx; top: -180rpx; left: -160rpx; background: radial-gradient(circle, rgba(88, 120, 255, 0.55) 0%, rgba(88, 120, 255, 0) 70%); animation: breathe 9s ease-in-out infinite; } .blob-2 { width: 520rpx; height: 520rpx; bottom: 60rpx; right: -180rpx; background: radial-gradient(circle, rgba(255, 108, 168, 0.42) 0%, rgba(255, 108, 168, 0) 70%); animation: breathe 11s ease-in-out infinite reverse; } @keyframes breathe { 0% { transform: scale(1) translate3d(0, 0, 0); opacity: 0.9; } 50% { transform: scale(1.12) translate3d(0, -20rpx, 0); opacity: 0.65; } 100% { transform: scale(1) translate3d(0, 0, 0); opacity: 0.9; } } .dot-grid { position: absolute; left: 0; top: -320rpx; right: 0; bottom: -320rpx; background-image: radial-gradient(rgba(255, 255, 255, 0.14) 1px, transparent 1px); background-size: 36rpx 36rpx; animation: gridMove 22s linear infinite; } @keyframes gridMove { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(0, 320rpx, 0); } }

这段样式里有几个"为什么"要讲清楚。

光斑为什么不用filter: blur()?因为在小程序里filter的支持度参差不齐,iOS 上勉强能用,部分安卓真机上要么不生效、要么直接把元素渲染成一块方形。用radial-gradient从中心色渐变到全透明,得到的柔和边缘是一样的,而且它是纯绘制,没有额外的模糊计算开销。这是我在真机上反复对比之后定下来的做法。

点阵为什么往下多铺 320rpx?因为动画只做位移不做重绘,如果容器高度刚好等于屏幕,位移过程中上下两端会露出空白。上下各多铺一块,位移范围在视觉上就是无缝的。同时动画加在transform上而不是background-position上,原因同样是性能——transform走合成层,background-position每次变化都会触发重绘,低端机上网格一动就掉帧。

.content的样式是:

.content { position: relative; z-index: 1; display: flex; flex-direction: column; min-height: 100vh; padding: 0 48rpx; box-sizing: border-box; padding-bottom: calc(48rpx + constant(safe-area-inset-bottom)); padding-bottom: calc(48rpx + env(safe-area-inset-bottom)); } .brand { padding: 120rpx 0 80rpx; display: flex; flex-direction: column; } .logo { width: 128rpx; height: 128rpx; border-radius: 36rpx; background: linear-gradient(135deg, #5B7CFF 0%, #9B6BFF 100%); color: #fff; font-size: 56rpx; font-weight: 600; display: flex; align-items: center; justify-content: center; margin-bottom: 40rpx; } .brand-title { font-size: 52rpx; color: #FFFFFF; font-weight: 600; letter-spacing: 2rpx; } .brand-sub { margin-top: 16rpx; font-size: 26rpx; color: rgba(255, 255, 255, 0.5); }

安全区的写法要注意,constant()和env()两行都要写,前者兼容旧版 iOS,后者是新标准。顺序不能反,否则新系统上会被旧写法覆盖成无效值。

输入框部分:

.field { position: relative; display: flex; align-items: center; height: 100rpx; border-bottom: 1px solid rgba(255, 255, 255, 0.1); transition: border-color 0.2s ease; } .field.is-focus { border-bottom-color: #5B7CFF; } .field-icon { width: 48rpx; font-size: 32rpx; color: rgba(255, 255, 255, 0.35); text-align: left; } .field.is-focus .field-icon { color: #5B7CFF; } .field-input { flex: 1; height: 100rpx; font-size: 30rpx; color: #FFFFFF; } .ph { color: rgba(255, 255, 255, 0.28); } .field-clear { width: 60rpx; height: 60rpx; display: flex; align-items: center; justify-content: center; margin-right: -12rpx; } .field-clear-txt { font-size: 40rpx; line-height: 1; color: rgba(255, 255, 255, 0.35); } .field-eye { padding-left: 16rpx; } .field-eye-txt { font-size: 26rpx; color: rgba(255, 255, 255, 0.45); }

这里border-bottom用的是1px而不是1rpx,前面提过原因——1rpx在部分安卓机型上会被渲染成零宽,线就消失了。这个坑我踩过一次,当时在开发者工具里看是有的,真机上完全没有,排查了半天才想起来是 rpx 精度问题。宁可稍微粗一点,也不要时有时无。

主按钮和协议勾选:

.btn-primary { margin-top: 56rpx; height: 96rpx; border-radius: 48rpx; background: linear-gradient(135deg, #5B7CFF 0%, #9B6BFF 100%); display: flex; align-items: center; justify-content: center; box-shadow: 0 16rpx 40rpx rgba(91, 124, 255, 0.35); transition: opacity 0.2s ease; } .btn-primary.is-disabled { opacity: 0.45; } .btn-primary--hover { opacity: 0.82; } .btn-primary-txt { font-size: 32rpx; color: #FFFFFF; font-weight: 500; letter-spacing: 8rpx; } .agree { margin-top: 36rpx; display: flex; align-items: flex-start; } .agree-box { width: 32rpx; height: 32rpx; border-radius: 50%; border: 2rpx solid rgba(255, 255, 255, 0.28); flex-shrink: 0; margin-top: 4rpx; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; } .agree-box.is-checked { background: #5B7CFF; border-color: #5B7CFF; } .agree-tick { font-size: 20rpx; color: #FFFFFF; line-height: 1; } .agree-txt { flex: 1; margin-left: 16rpx; font-size: 22rpx; line-height: 1.6; color: rgba(255, 255, 255, 0.42); } .agree-link { color: #7C93FF; }

按钮的letter-spacing: 8rpx是为了让"登 录"两个字中间有呼吸感。这个技巧在只有两三个字的按钮上很好用,比手动敲空格更可控。阴影用了主题色的半透明版本,看起来按钮像是"发光的",如果换成黑色阴影,整个界面会显脏。

3.4 交互逻辑:校验、倒计时、协议

<script setup> import { reactive, ref, computed, onUnmounted } from 'vue' const statusBarHeight = ref(20) try { const info = uni.getWindowInfo ? uni.getWindowInfo() : uni.getSystemInfoSync() statusBarHeight.value = info.statusBarHeight || 20 } catch (e) { statusBarHeight.value = 20 } const form = reactive({ account: '', password: '' }) const focusKey = ref('') const showPwd = ref(false) const agreed = ref(false) const loading = ref(false) const trimAccount = computed(() => form.account.trim()) const accountValid = computed(() => { const v = trimAccount.value return /^1[3-9]\d{9}$/.test(v) || /^[\w.-]+@[\w-]+\.[\w.]+$/.test(v) }) const canSubmit = computed(() => { return accountValid.value && form.password.length >= 6 && !loading.value }) function toast(msg) { uni.showToast({ title: msg, icon: 'none', duration: 1800 }) } async function handleLogin() { if (loading.value) return if (!trimAccount.value) return toast('请输入手机号或邮箱') if (!accountValid.value) return toast('账号格式不正确') if (form.password.length < 6) return toast('密码至少 6 位') if (!agreed.value) return toast('请先阅读并同意用户协议') loading.value = true try { uni.showLoading({ title: '登录中', mask: true }) const res = await request('/api/login', { account: trimAccount.value, password: form.password, platform: 'mini' }) uni.hideLoading() if (res && res.token) { uni.setStorageSync('token', res.token) uni.setStorageSync('userInfo', res.userInfo || {}) uni.reLaunch({ url: '/pages/index/index' }) } else { toast((res && res.message) || '登录失败,请重试') } } catch (err) { uni.hideLoading() toast(err && err.message ? err.message : '网络异常,请稍后重试') } finally { loading.value = false } } function loginByWeixin() { uni.login({ provider: 'weixin', success: (res) => { if (!res.code) return toast('授权失败,请重试') // 把 code 交给自己的后端,由后端完成后续换取登录凭证的步骤 request('/api/login/wechat', { code: res.code }) .then((data) => { uni.setStorageSync('token', data.token) uni.reLaunch({ url: '/pages/index/index' }) }) .catch(() => toast('微信登录失败')) }, fail: () => toast('已取消授权') }) } function goForget() { uni.navigateTo({ url: '/pages/login/forget' }) } function openDoc(type) { uni.navigateTo({ url: `/pages/doc/index?type=${type}` }) } onUnmounted(() => { uni.hideLoading() }) </script>

几个逻辑上的关键点。

校验放在提交时做,不放在输入时做。输入过程中就弹红字提示,用户会觉得被打断。我的做法是按钮通过canSubmit控制半透明态,用户能感知到"还没填完",但不具体告诉他哪里不对;点下去之后再按顺序逐条提示,一次只提示一条,改完一条提示下一条。这个节奏比一次性把所有错误列出来舒服得多。

loading状态一定要有,而且要早于请求设置。登录是个异步操作,用户点完按钮如果没有任何反馈,他会以为没点上,然后连续点三四次。这三四次请求打到后端,运气不好会触发风控。所以loading.value = true要放在发请求之前。

uni.reLaunch而不是uni.navigateTo。登录成功之后要把登录页从页面栈里清掉。如果用navigateTo,用户在新页面按返回键会回到登录页,体验很怪,而且页面栈最多十层,一直堆着也会出问题。reLaunch关掉所有页面再打开目标页,是跳首页的正确姿势。

第三方登录的code交给后端处理。小程序端拿到的只是临时的授权凭证,后续换登录态的步骤必须在自己的服务端完成,密钥不能出现在客户端代码里。这个流程前端只要把res.code传过去就行,剩下的交给后端。

3.5 请求封装与 token 落地

请求函数我习惯单独封一层,好处是统一处理基地址、超时、鉴权头和错误码:

const BASE_URL = 'https://your-domain.com' export function request(url, data = {}, method = 'POST') { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + url, data, method, timeout: 12000, header: { 'Content-Type': 'application/json', 'Authorization': uni.getStorageSync('token') || '' }, success: (res) => { const { statusCode, data: body } = res if (statusCode === 200 && body && body.code === 0) { resolve(body.data) return } if (statusCode === 401) { uni.removeStorageSync('token') uni.reLaunch({ url: '/pages/login/login' }) reject(new Error('登录已过期')) return } reject(new Error((body && body.message) || `请求失败(${statusCode})`)) }, fail: () => reject(new Error('网络异常,请检查网络连接')) }) }) }

这里timeout给 12 秒是有意为之。小于 8 秒在弱网下误判太多,大于 15 秒用户已经忍不住要退了。12 秒是个比较舒服的中间值。

Authorization头里直接带本地存的 token,是最简单也最通用的一种做法。如果对安全要求更高,可以换成签名的短期票据,但那需要后端配合,登录页这一版先用最简单的方案跑通。

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

登录页的问题有个共同特点:开发者工具里一切正常,真机上各种不对。这一节把我实际遇到过的几类问题整理出来,附上排查路径。

4.1 开发者工具正常、真机错乱

最典型的一类是背景渐变出现明显色带。开发者工具的渲染跟真机差距很大,工具里看着平滑的深色渐变,在中低端安卓机上会出现一条条的横向条纹。原因是很多安卓机的显示色深是 16 位,深色过渡区间的色值差异小于它的显示精度,就被量化成了一条条。

解决办法有两个。第一个是把渐变的角度改得更陡一点,减少大面积的低对比过渡;第二个是在渐变之上叠一层极淡的噪点纹理,噪点能打乱量化边界。我一般用第一个,成本最低。如果设计师坚持要那种平滑的大面积深色,那就在渐变层上盖一个 2% 透明度的点阵,效果能压下去七八成。

另一类是高版本 CSS 特性不生效。前面提到的filter: blur()、backdrop-filter、mix-blend-mode都属于这一类,工具里预览没问题,真机上直接当作不认识。我的原则是:非必要不用这些特性,实在要用必须准备降级方案。比如光斑,用radial-gradient就不需要降级,全平台一致。

还有一类是字体。部分安卓机型默认字体没有某些字重,你写font-weight: 600可能被渲染成 400,标题的层次感就没了。如果不接受这种差异,唯一的办法是用系统自带的字重并放大字号,靠大小而不是粗细来建立层次。

4.2 键盘顶起与安全区适配

键盘把页面顶得乱七八糟,这在登录页里非常常见。小程序默认在输入框聚焦时会自动上推页面,如果页面本身是position: fixed或者用了绝对定位铺满,上推之后布局就会错位。

我的处理方式是三点一起做。第一,pages.json里设disableScroll: true,把页面滚动关掉,只让内容层参与上推;第二,给输入框加cursor-spacing,控制光标和键盘之间的距离,默认值在小屏手机上会让输入框贴住键盘上沿,给个 24 到 32 比较舒服;第三,如果页面内容不多,直接把内容层也用position: absolute; bottom: 0固定在底部,上推的时候只推按钮,视觉上更稳。

刘海屏的适配主要在两处。顶部是状态栏高度,前面代码里已经处理了。底部是 Home Indicator 区域,按钮和协议文字不能贴太近,否则在小屏刘海机上会被横条压住。env(safe-area-inset-bottom)就是干这个的,记得constant()和env()都要写。

还有一个容易被忽略的地方:页面切后台再回来,输入框的焦点状态可能还留着,focusKey没被清掉,图标一直是高亮色。稳妥的做法是在onHide里把focusKey置空,或者在onShow里重新初始化一遍焦点状态。

4.3 常见问题速查表

现象大概率原因处理方式
深色渐变出现横向色带低色深屏幕的色值量化加大渐变角度,或叠加淡点阵纹理
细边框时有时无1rpx在部分安卓机上被舍入为 0细边框改用1px
输入框聚焦时页面跳动页面允许滚动 + 自动上推设disableScroll: true,加cursor-spacing
底部按钮被横条遮挡未处理安全区补env(safe-area-inset-bottom)
键盘弹出光标贴着键盘cursor-spacing默认值偏小调到 24 到 32
点协议文字时勾选被同步切换事件冒泡协议文字上加.stop
连续点击按钮发出多次请求缺少 loading 拦截loading提前置位并在请求前判断
登录成功后按返回又回到登录页用了navigateTo改用reLaunch
部分新样式不生效基础库版本低于特性要求在开发者工具"详情 - 本地设置"里调基础库版本复核

关于基础库版本这一条要多说一句。开发者工具里可以在"详情 - 本地设置"里切换基础库版本来复现问题,这是个非常实用的排查手段——你怀疑某个样式或 API 在老基础库上不生效,切成低版本跑一遍就能确认,不用真去借一台老手机。上线前记得把最低基础库版本在manifest.json里设置好,低于这个版本的会提示用户更新微信。

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

登录按钮的禁用态不要用disabled属性。button组件加disabled之后,部分安卓机上样式会被原生逻辑覆盖,你写的半透明渐变可能不生效,出来一个灰色块。改用普通view加自己控制的is-disabled类,同时在点击处理函数里再判断一次,行为完全可控。

v-model在部分输入法上会丢掉最后一个字符。这是中文输入法组合输入的老问题,用户打拼音还没上屏时,v-model拿到的值是中间态。如果你的校验是实时触发的,就会出现"明明输入对了却报格式错"。解决办法是校验只在失焦和提交时做,不要在input事件里做——这也正是我前面强调"校验放提交时"的另一层原因。

小程序进入登录页的时机要控制好。很多项目在onLoad里就调接口拿配置、拿图形验证码,结果用户还在看品牌区,底下已经在转圈了。更好的做法是把这些请求延后到页面渲染完成之后,或者干脆做成懒加载——用户点了发送验证码按钮,再去请求验证码资源。

5. 性能与多端差异补充

登录页只有一屏,看起来没什么性能可言,但实际上它是用户打开小程序看到的第一屏,慢 200 毫秒的感受差异比内页慢 500 毫秒还明显。

5.1 UI 卡顿的定位与优化思路

如果你发现登录页滑动或者动画时不顺,先按这个顺序排查。

第一,看有没有动画作用在width、height、top、left、background-position这些会触发重排或重绘的属性上。把它们全部换成transform和opacity,这是最有效的一步。我前面点阵用transform: translate3d()而不是background-position,原因就在这里。

第二,看阴影。box-shadow的模糊半径越大,绘制成本越高。登录页的主按钮用了 40rpx 的模糊半径,单看没问题,但如果页面上有七八个带阴影的元素,低端机就会开始吃力。控制在两三个以内是安全的。

第三,看层级。z-index堆得太多,浏览器和小程序的合成层管理都会变复杂。我整个页面只有三层,背景层z-index: 0,内容层z-index: 1,没有第三层。如果你发现某些元素需要用很高的z-index才能盖住别的元素,那说明结构该重构了。

第四,用真机而不是工具做判断。开发者工具的渲染管线和真机完全不同,工具流畅不代表真机流畅。找一台三四年前的中端安卓机测一遍,比在工具里看十遍都有用。

5.2 多端差异:从小程序到 App 的注意点

这套代码用的是 uni-app 的标准语法和组件,编译到 H5 和 App 端基本能直接跑,但有两处差异要提前知道。

一处是env(safe-area-inset-bottom)。H5 端在部分浏览器里这个值恒为 0,所以底部会显得偏空;App 端在安卓上也不一定返回有效值。如果你三端都要发,建议把底部间距做成一个可配置的变量,在小程序里用安全区、在其他端用固定值。

另一处是打包和发布的流程差异。小程序走的是微信开发者工具上传、平台审核那套;App 端走的是打包成安装包再分发的流程。如果你后续还要对 App 包做加固处理,要注意加固之后的包必须用原来的签名证书重新签名,否则已经装过旧版本的用户在覆盖安装时会失败,需要先卸载才能装上新版。这个坑挺常见的——加固工具大多会破坏原有签名,很多人加固完直接发,结果老用户更新不了,还得单独发一次紧急版本。证书文件一定要留好备份,别只存在某台电脑上。

另外,如果你同时在跑多个小程序项目,基础库版本的设置要每个项目单独确认一遍。基础库版本是在开发者工具的项目配置里维护的,换项目时不会自动继承,很容易出现"这个项目能跑那个项目跑不起来"的困惑。

最后说个我自己的习惯。每次改完登录页,我会做一遍四项检查:把手机字号调成系统最大,看按钮文字会不会被截断;把系统设为深色模式,确认页面没被意外反色;用一台老安卓机断网进入,看错误提示是不是清晰;连续点登录按钮十次,确认只发了一次请求。这四件事花不到五分钟,但能挡掉绝大部分上线后才会被投诉的问题。登录页这种"每个用户都会经过一次"的页面,值得多花这五分钟。

返回列表