
做小程序登录页这件事我前后推倒重来过至少七个版本。第一版是照着教程堆出来的深色背景配白色输入框自认为挺高级结果上线一周后后台数据显示登录页跳出率接近四成第二版换了配色数据没动直到第三版我把注册和忘记密码两个入口从按钮改成文字链接、把按钮从通栏改成留白转化才明显起来。这件事让我意识到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.scsspages.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 classlogin-page !-- 背景层 -- view classbg-layer view classblob blob-1/view view classblob blob-2/view view classdot-grid/view /view !-- 内容层 -- view classcontent :style{ paddingTop: statusBarHeight px } view classbrand view classlogoU/view text classbrand-title欢迎回来/text text classbrand-sub登录后同步你的全部数据/text /view view classform-card view classfield :class{ is-focus: focusKey account } text classfield-icon/text input classfield-input v-modelform.account typetext placeholder手机号 / 邮箱 placeholder-classph :maxlength30 :cursor-spacing24 focusfocusKey account blurfocusKey / view v-ifform.account classfield-clear tapform.account text classfield-clear-txt×/text /view /view view classfield :class{ is-focus: focusKey password } text classfield-icon#/text input classfield-input v-modelform.password :password!showPwd placeholder请输入密码 placeholder-classph :maxlength20 :cursor-spacing24 focusfocusKey password blurfocusKey / view classfield-eye tapshowPwd !showPwd text classfield-eye-txt{{ showPwd ? 隐藏 : 显示 }}/text /view /view view classform-assist text classassist-link tapgoForget忘记密码/text /view view classbtn-primary :class{ is-disabled: !canSubmit } hover-classbtn-primary--hover :hover-stay-time80 taphandleLogin text classbtn-primary-txt{{ loading ? 登录中… : 登 录 }}/text /view view classagree tapagreed !agreed view classagree-box :class{ is-checked: agreed } text v-ifagreed classagree-tick✓/text /view text classagree-txt 我已阅读并同意 text classagree-link tap.stopopenDoc(user)《用户协议》/text 与 text classagree-link tap.stopopenDoc(privacy)《隐私政策》/text /text /view /view view classthird view classthird-divider view classline/view text classthird-divider-txt其他方式登录/text view classline/view /view view classthird-row view classthird-item taploginByWeixin text classthird-item-txt微信/text /view view classthird-item taploginByPhone text classthird-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 包做加固处理要注意加固之后的包必须用原来的签名证书重新签名否则已经装过旧版本的用户在覆盖安装时会失败需要先卸载才能装上新版。这个坑挺常见的——加固工具大多会破坏原有签名很多人加固完直接发结果老用户更新不了还得单独发一次紧急版本。证书文件一定要留好备份别只存在某台电脑上。另外如果你同时在跑多个小程序项目基础库版本的设置要每个项目单独确认一遍。基础库版本是在开发者工具的项目配置里维护的换项目时不会自动继承很容易出现这个项目能跑那个项目跑不起来的困惑。最后说个我自己的习惯。每次改完登录页我会做一遍四项检查把手机字号调成系统最大看按钮文字会不会被截断把系统设为深色模式确认页面没被意外反色用一台老安卓机断网进入看错误提示是不是清晰连续点登录按钮十次确认只发了一次请求。这四件事花不到五分钟但能挡掉绝大部分上线后才会被投诉的问题。登录页这种每个用户都会经过一次的页面值得多花这五分钟。