ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微信小程序登录界面制作:方案选型、代码实现与避坑指南

微信小程序登录界面制作:方案选型、代码实现与避坑指南

简介:这是一份微信小程序登录界面制作示例资源,面向小程序初学者或需要快速实现授权登录展示头像昵称的开发者。包内共10个文件,其中4个json用于项目与页面配置,2个js承载登录逻辑与生命周期处理,2个wxss控制界面样式,1个wxml定义页面结构,另有1张示例头像图片,整体压缩后仅7KB,体积轻量但结构完整,适合下载后直接打开分析。目前已有1597人学习下载。通过该资源可掌握wx.login、wx.getUserInfo授权流程,理解数据绑定与条件渲染方式,并对照清晰的目录组织方式搭建自己的小程序页面。从页面配置、样式设计到授权回调、数据绑定的完整链路均有体现,对想了解小程序基础架构与授权交互的入门开发者是一份实用参考。

1. 微信小程序登录界面:先想清楚“登录”这两个字,再开始写代码

做微信小程序,登录界面几乎是每个项目绕不开的第一道坎。它看起来只是几个输入框加一个按钮,但真正上线后你会发现,用户流失、投诉、评分下跌,很大一部分都发生在登录这一步。微信小程序登入界面制作这件事,难点往往不在按钮样式好不好看,而在“登录方案”本身:什么时候静默登录、什么时候要手机号、token 过期了怎么办。这篇笔记把这套流程拆开讲,从方案选型到页面搭建,再到后端换 token 和常见坑点,每一步给出可以直接抄走的代码和参数。

在我手里的项目里,登录模块返工率一直不低,但翻车原因通常不是语法问题,而是对登录态的边界没定义清楚。本文适合刚接触小程序的新手照着做,也适合熟手拿来对照自己的登录逻辑有没有漏洞。

2. 登录方案选型:wx.login、手机号授权与验证码的组合套路

微信小程序登录有三条主流路线,分别对应三种信息层级:wx.login换 openid 的静默登录、open-type="getPhoneNumber"的一键手机号授权、以及传统的短信验证码登录。大多数业务最终用的是这三者的组合,差别在于主次顺序。动手写页面之前,先把“我需要用户的什么信息”这个问题回答清楚,登录界面怎么做就自然有答案。

2.1 微信静默登录:wx.login 与 code2Session 的完整链路

静默登录的“静默”体现在用户无感知。前端调一次wx.login,拿到一个临时凭证 code;后端拿这个 code,加上小程序的 AppID 和 AppSecret,去微信服务端code2Session接口换数据,返回的是 openid 和 session_key。openid 是用户在当前小程序里的唯一身份标识,session_key 是微信侧会话密钥,涉及加密数据解密时才用得上。

这段流程不需要任何授权弹窗,可作为小程序启动时的自动注册通道。它的局限也很明显:拿不到手机号、头像、昵称等资料,只能识别“这个微信用户是谁”,不能识别“这个手机号是谁”。对工具类小程序够用,对电商、社区、需要做 CRM 的就不够。

核心代码放在onLaunch或需要触发登录的位置:

// app.js 片段:静默登录的触发时机 wx.login({ success: (res) => { if (res.code) { wx.request({ url: 'https://api.example.com/login', // 替换成你自己的后端登录接口 method: 'POST', data: { code: res.code }, success: (resp) => { wx.setStorageSync('access_token', resp.data.token); }, fail: () => { // 静默登录失败先不打断用户,后续接口 401 时会再处理 console.warn('静默登录请求失败'); }, }); } else { console.warn('wx.login 未返回 code', res); } }, });

这里有三个细节值得注意:一是res.code必须判空,开发工具模拟器偶尔会直接失败;二是静默登录失败不要弹 toast,它本身就是无感设计,把错误留给后续接口的 401 兜底;三是 code 有效期只有 5 分钟且只能用一次,前端不要缓存它,每次需要时重新调wx.login。

后端对应逻辑一般是这样的:

// Node.js 后端示意:code2Session 换 openid const axios = require('axios'); async function loginByCode(code) { const { data } = await axios.get( 'https://api.weixin.qq.com/sns/jscode2session', { params: { appid: process.env.WX_APPID, secret: process.env.WX_SECRET, js_code: code, grant_type: 'authorization_code', }, } ); if (data.errcode) { throw new Error(`code2session 失败: ${data.errcode} ${data.errmsg}`); } // data.openid 可用于查用户表,data.session_key 只在解密时使用 return data; }

拿到 openid 后,是新建用户还是走老用户登录,由你自己的用户表决定。登录成功后的凭证用 JWT 签发比较普遍,过期时间我一般设 30~60 分钟,配合后面的自动续期逻辑,比设一个超长 token 更安全,体验也不会差。

2.2 微信小程序登录获取手机号:一键授权按钮的正确用法

手机号是小程序业务里含金量最高的信息,也是最容易出现流程问题的点。当前主流做法是用按钮开放能力,前端代码只有一行:

<button open-type="getPhoneNumber" bindgetphonenumber="onWxLogin">微信一键登录</button>

用户点击后会弹出微信官方确认框,同意后事件回调里带回一个动态 code。注意,从基础库较新版本开始,回调参数里直接带的是e.detail.code,而不是加密数据;老版本则是encryptedData和iv。前端不需要区分这两者,最稳妥的写法是把整个e.detail传给后端,由后端根据自己接入方式处理。

后端拿这个 code 去换手机号,用的是微信的phonenumber.getPhoneNumber接口:

// Node.js 后端示意:用 code 换手机号 async function getPhoneNumber(code, accessToken) { const { data } = await axios.post( 'https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=' + accessToken, { code } ); if (data.errcode !== 0) { throw new Error(`获取手机号失败: ${data.errcode}`); } // data.phone_info.phoneNumber 就是明文手机号 return data.phone_info; }

这里必须强调一个边界:open-type="getPhoneNumber"要求小程序是非个人主体,且该功能已经开通。个人主体的小程序无法调用这个能力,只能退回短信验证码方案。很多团队在小程序前期用个人主体测试,结果真机一直弹不出手机号授权框,问题就出在这。

2.3 三种方案怎么选:先看业务诉求,再看开发成本

把三种方案放一起对比,选型思路会清楚很多:

方案能获得的信息用户打扰程度适用场景
wx.login 静默登录openid无感知工具类、内容类、游客模式
getPhoneNumber 一键授权手机号 + openid一次点击确认电商、社区、企业服务
短信验证码手机号 + 验证码输入手机号并等待短信绑定、换绑、敏感操作兜底

我的习惯是:凡是需要手机号的业务,把“微信一键登录”作为主按钮,把“手机号 + 验证码”放在折叠区域或底部作为兜底。这样用户愿意授权就走最短路,不愿意授权或按钮异常时,还有一条不用微信授权也能完成的路径。至于wx.login,无论哪种组合都要做,小程序启动后先静默登录拿 openid,把用户身份在后台认下来,再决定要不要弹出登录界面。

3. 搭建登录界面:WXML 表单、顶部导航栏高度与按钮状态

进入实际编码阶段。登录界面的构成比我之前提到的最小组合略复杂:品牌展示区、手机号输入、验证码输入、协议勾选、主登录按钮、微信一键按钮。这一节给出可直接复制的 WXML 与 WXSS,并把顶部导航栏的适配细节一并处理掉。

3.1 登录页 WXML 结构:从品牌区到协议勾选

先把页面骨架搭出来。这里我用了原生小程序语法,不依赖任何第三方组件库,复制到pages/login/index.wxml就能跑:

<view class="login-page"> <!-- 品牌区 --> <view class="brand" style="padding-top: {{navBarHeight}}px"> <image class="brand__logo" src="/assets/logo.png" mode="aspectFit"></image> <text class="brand__name">庭前小铺</text> <text class="brand__welcome">欢迎回来</text> </view> <!-- 手机号 + 验证码表单 --> <view class="login-form"> <view class="form-item"> <input type="number" maxlength="11" placeholder="请输入手机号" value="{{phone}}" bindinput="onPhoneInput" /> </view> <view class="form-item form-item--inline"> <input type="number" maxlength="6" placeholder="验证码" value="{{phoneCode}}" bindinput="onCodeInput" /> <button class="captcha-btn" disabled="{{countdown > 0}}" bindtap="sendCaptcha" > {{countdown > 0 ? countdown + 's' : '获取验证码'}} </button> </view> <!-- 协议勾选,用自定义单选框实现 --> <view class="agreement-row" bindtap="toggleAgreement"> <view class="checkbox {{agreed ? 'checkbox--checked' : ''}}"></view> <text>我已阅读并同意《用户协议》和《隐私政策》</text> </view> <button class="primary-btn" loading="{{submitting}}" disabled="{{!canSubmit || submitting}}" bindtap="loginByCode" > 登录 </button> </view> <!-- 分割线 --> <view class="divider"> <view class="divider__line"></view> <text class="divider__text">其他登录方式</text> <view class="divider__line"></view> </view> <!-- 微信手机号一键登录 --> <button class="wechat-btn" open-type="getPhoneNumber" bindgetphonenumber="onWxLogin" > 微信一键登录 </button> </view>

这段结构里有几个刻意的设计。协议勾选用的是自定义 checkbox 而不是原生组件,原因是原生 checkbox 在不同机型上样式差异大,且无法像这样整行绑定点击事件;form-item--inline让验证码输入框和按钮在同一行,避免用户视线上下跳动;主按钮的loading和disabled绑定在一起,防止重复提交。

3.2 顶部导航栏高度适配:别再让按钮被刘海挡住

很多人搜索“微信小程序顶部导航栏高度”,针对的正是自定义导航栏场景。一旦在app.json里设置了"navigationStyle": "custom",小程序就没了默认导航栏,页面顶部直接顶到状态栏。如果登录页的品牌区还按普通页面布局,logo 会被时间、信号等系统状态信息挡住一半。

正确的做法是运行时动态读取状态栏高度,再往上叠加自定义导航栏的内容高度:

// pages/login/index.js 的 onLoad 段 const { statusBarHeight } = wx.getWindowInfo(); this.setData({ navBarHeight: statusBarHeight + 44, // 44 是导航栏内容区的经验高度 });

44 这个值是对大多数机型导航栏高度的估算,iPhone 灵动岛的导航栏会略高,但加 44 后品牌区通常已经不会被遮挡。WXSS 侧把品牌区的内边距交给运行时变量,而不是写死某台机器的参数:

.brand { display: flex; flex-direction: column; align-items: center; padding-bottom: 40rpx; } .brand__logo { width: 128rpx; height: 128rpx; border-radius: 24rpx; } .brand__name { margin-top: 24rpx; font-size: 40rpx; font-weight: 600; } .brand__welcome { margin-top: 8rpx; font-size: 24rpx; color: #888; }

需要特别留意的是,wx.getWindowInfo()在老基础库版本里叫wx.getSystemInfoSync(),如果你还维护着老项目,要做一个版本兼容判断。我的习惯是统一封装成工具函数,逻辑里做能力检测,保证低版本微信打开页面不会白屏。

3.3 表单校验与按钮联动:手机号、验证码、协议勾选

登录页最容易出现“按钮能点但永远登录失败”的体验,根源在于没有做表单状态联动。手机号不满足 11 位、验证码不足 6 位、协议未勾选,这三个条件任意一个不满足,主按钮都应该保持灰色不可点。

Page({ data: { phone: '', phoneCode: '', agreed: false, countdown: 0, submitting: false, }, onPhoneInput(e) { this.setData({ phone: e.detail.value }); }, onCodeInput(e) { this.setData({ phoneCode: e.detail.value }); }, toggleAgreement() { this.setData({ agreed: !this.data.agreed }); }, sendCaptcha() { const { phone } = this.data; if (!/^1\d{10}$/.test(phone)) { wx.showToast({ title: '手机号格式不正确', icon: 'none' }); return; } // 请求后端发送验证码,成功后再启动倒计时 wx.request({ url: 'https://api.example.com/sms/send', method: 'POST', data: { phone }, success: () => { this.endTime = Date.now() + 60 * 1000; this.tick(); }, fail: () => { wx.showToast({ title: '发送失败,请稍后再试', icon: 'none' }); }, }); }, tick() { if (this.timer) clearInterval(this.timer); this.timer = setInterval(() => { const remaining = Math.ceil((this.endTime - Date.now()) / 1000); if (remaining <= 0) { this.setData({ countdown: 0 }); clearInterval(this.timer); return; } this.setData({ countdown: remaining }); }, 1000); }, onUnload() { if (this.timer) clearInterval(this.timer); }, loginByCode() { // ...省略具体请求逻辑,见第 4 章 }, onWxLogin(e) { // ...省略具体请求逻辑,见第 4 章 }, });

按钮可用状态canSubmit我没有单独存,而是利用disabled="{{!canSubmit || submitting}}"在 WXML 里直接联动,但canSubmit需要每次输入时重新计算。这里用最简单的方式:在输入和勾选的 setData 回调里同步执行一次this.updateCanSubmit()。小程序没有 Vue 的 computed 属性,靠 setData 联动反而最直观。

4. 登录逻辑落地:code 换 token、状态存储与自动续期

界面只是壳,登录真正的心脏在“code 换 token”这条链路。本章把前端调用、后端接口、token 存取和过期续期一次讲完。这样无论你用原生小程序、还是用 uni-app 打包微信小程序,核心逻辑都可以直接平移。

4.1 getPhoneNumber 回调与后端契约:前端只传 code,后端负责解密

点击“微信一键登录”后的前端逻辑并不复杂,关键是把e.detail完整交给后端,不要在前端尝试解析:

onWxLogin(e) { const detail = e.detail; // 老基础库 errMsg 可能为 getPhoneNumber:fail user deny,或 code 为空 if (detail.errMsg && detail.errMsg.indexOf('ok') === -1) { wx.showToast({ title: '已取消授权', icon: 'none' }); return; } if (!detail.code) { wx.showToast({ title: '授权失败,请使用验证码登录', icon: 'none' }); return; } wx.request({ url: 'https://api.example.com/login/phone', method: 'POST', data: { code: detail.code }, success: (resp) => { wx.setStorageSync('access_token', resp.data.token); this.jumpAfterLogin(); }, fail: () => { wx.showToast({ title: '登录失败,请稍后再试', icon: 'none' }); }, }); }

一段一个要点:jumpAfterLogin()单独抽出来,因为验证码登录成功后也要走同一个跳转逻辑。跳转逻辑放在utils/navigate.js里,统一处理“来自哪个页面、是否 tabBar 页面”这层差异,后续第 6 章会展开。

后端对应接口的职责只有一个——拿 code 换手机号、查用户、发 token。参考实现:

// Node.js 后端:/login/phone 伪实现 const jwt = require('jsonwebtoken'); router.post('/login/phone', async (req, res) => { const { code } = req.body; const accessToken = await getWechatAccessToken(); const phoneInfo = await getPhoneNumber(code, accessToken); const phone = phoneInfo.phoneNumber; let user = await findUserByPhone(phone); if (!user) { user = await createUser({ phone }); } const token = jwt.sign( { userId: user.id, phone }, process.env.JWT_SECRET, { expiresIn: '1h' } ); res.json({ token, user }); });

注意几点:getWechatAccessToken需要维护一个带缓存的获取逻辑,微信的 access_token 有效期是 2 小时,不要每次请求都重新拿;createUser之后要处理同一手机号多账号的情况;token 里不要放敏感信息,expiresIn建议 1 小时以内。

4.2 token 存储与全局注入:统一走封装的 request

直接在每个页面里调wx.request并在 success 里塞 token,是最容易埋雷的写法。我习惯在utils/auth.js里做一套 token 读写:

// utils/auth.js const TOKEN_KEY = 'access_token'; const getToken = () => wx.getStorageSync(TOKEN_KEY) || ''; const setToken = (token) => wx.setStorageSync(TOKEN_KEY, token); const clearToken = () => wx.removeStorageSync(TOKEN_KEY); const isLoggedIn = () => !!getToken(); module.exports = { getToken, setToken, clearToken, isLoggedIn };

然后所有请求统一走utils/request.js,这个封装的职责有两个:自动携带 token,以及拦截 401 做续期重试。先看第一个职责:

// utils/request.js const auth = require('./auth'); function request(options) { options.header = Object.assign({}, options.header); const token = auth.getToken(); if (token) { options.header.Authorization = 'Bearer ' + token; } wx.request( Object.assign({}, options, { success(res) { if (res.statusCode === 401) { handleUnauthorized(options); return; } typeof options.success === 'function' && options.success(res); }, fail(err) { typeof options.fail === 'function' && options.fail(err); }, }) ); }

这样写完后,登录成功后不需要每个页面读 token,页面里只需要调用这个request。更换 token 存储方案(比如从 Storage 迁到全局变量)时,也只需要改utils/auth.js和request.js两处。

4.3 401 后的自动续期:用 wx.login 静默换新 token

token 过期后用户如果还在操作,直接跳回登录页是很伤体验的。常见做法是捕获 401,先尝试用wx.login静默续期,成功则重放原请求,失败才引导用户重新登录。代码上在handleUnauthorized里做处理:

let refreshing = false; let refreshQueue = []; function handleUnauthorized(options) { if (refreshing) { // 已有续期请求在跑,把当前请求放进队列等结果 refreshQueue.push(options); return; } refreshing = true; wx.login({ success(res) { if (res.code) { wx.request({ url: 'https://api.example.com/login/refresh', method: 'POST', data: { code: res.code }, success: (resp) => { auth.setToken(resp.data.token); refreshQueue.forEach((q) => request(q)); refreshQueue = []; // 重放当前请求 request(options); }, fail: () => { redirectToLogin(); }, }); } else { redirectToLogin(); } }, fail: () => { redirectToLogin(); }, complete: () => { refreshing = false; }, }); } function redirectToLogin() { auth.clearToken(); wx.navigateTo({ url: '/pages/login/index' }); }

这段逻辑有个容易忽略的坑:刷新期间会堆积多个 401 请求,如果每个请求都独立触发wx.login,会造成 code 密集过期和数据错乱。所以必须有一个refreshing标志和一个请求队列,保证同一时间只发一次续期请求,其余请求等续期完成后集中重放。参数上还要给续期接口设置超时,一般 10 秒内没响应就按失败处理,避免用户长时间卡在等待中。

5. 登录功能避坑排查:五个高频翻车现场与解决路径

登录模块出错有个特点——平时不响,一响就全面爆发。这里整理五条我在项目里真实遇到过的翻车记录,每一条都按“现象 → 原因 → 解决”的顺序来写,方便你直接对照自己的代码。

5.1 现象:开发者工具里一切正常,真机上“微信一键登录”却毫无反应

原因有几个层面。最常见的是小程序主体为个人主体,open-type="getPhoneNumber"在真机上不会弹授权框,而开发者工具有时不会严格校验主体类型。另一个常见原因是页面里把 button 包进了form组件,导致bindgetphonenumber回调触发异常或触发两次,真机上行为不一致。

解决:先在小程序后台确认主体类型和接口权限,个人主体直接放弃这个方案,改用短信验证码。代码层面不要在任何form里使用open-type="getPhoneNumber"按钮,单独放在表单外层。另外,真机调试时打印e.detail的完整对象,看是errMsg错误还是code为空,能快速区分是用户取消还是接口权限问题。

5.2 现象:后端日志里频繁出现invalid code,用户重试也登录不进去

原因:同一个 code 被利用两次。code2Session的 code 是短时且一次性的,前端如果对同一个 code 发起了多个并发请求,或者页面重复执行了wx.login,就会导致后面的请求拿到已失效的 code。还有一种可能是用户从进入登录页到提交登录流程过长,code 超过 5 分钟有效期。

解决:请求登录接口前立刻执行wx.login获取新 code,不要复用页面缓存的值;后端对code2Session失败返回 401 或特定错误码,前端收到后主动重新走一遍wx.login,而不是直接弹“登录失败”。竞态条件容易出现在“静默登录”和“用户点击登录”同时展开的页面,确保两个入口共用同一个返回的 code 生成逻辑。

5.3 现象:验证码倒计时切后台再回来,变成了负数,按钮文案显示“-15s 后重发”

原因:小程序切后台后,setInterval会被系统挂起,回前台并没有自动把缺失的秒数补回来。如果倒计时依赖累减变量,必然出现负数。

解决:倒计时不依赖累减,而是记录一个绝对时间戳。前端在发送成功的回调里记录endTime = Date.now() + 60 * 1000,每次tick都去计算remaining,页面onShow时再同步一次。第 3 章给出的sendCaptcha+tick组合就是按这个思路写的,直接复用即可。

5.4 现象:真机预览时,登录请求报request:fail url not in domain list

原因:小程序正式环境只允许请求已配置在后台的合法域名。开发者工具默认勾选了“不校验合法域名”,所以工具里能通;真机没有这个豁免,请求还没发出就被拦了。

解决:把后端登录接口的 HTTPS 域名加到小程序后台的 “request 合法域名” 列表。上步骤要注意域名必须备案,且不能用 IP。开发阶段如果后端只有 IP 或本地服务,可以打开“真机调试”模式并勾选工具里的“不校验合法域名”,这种组合可以支持真机联调,但上线前务必改回来。

5.5 现象:token 过期后用户被突然踢回登录页,而且返回不了原来的页面

原因:页面在onShow里直接用isLoggedIn()判断,token 失效后立刻redirectTo到登录页;更糟的是某些页面用navigateTo跳转,导致登录页叠加了一层又一层。

解决:把跳转统一改掉。没有 token 时先尝试静默续期,只有续期失败才跳登录页;跳转用wx.redirectTo而不是wx.navigateTo,避免登录页被反复压栈。登录页面本身要处理“来自哪个页面”这个参数,登录成功后wx.navigateBack回到来源页,而不是固定跳首页。

6. 登录成功后的去向控制:回源跳转与降级体验

登录页面本身做完了,最后一步是把登录成功后的行为打磨好。很多用户在小程序里是带着“想看某个商品”的诉求来的,中间被登录拦住,登录成功后如果不能回到原来的商品页,而是被丢到首页,他可能就不再回来。

我的做法是一套回源逻辑:登录页onLoad时读取 URL 参数redirectUrl,登录成功后先判断这个目标页是不是 tabBar 页面。tabBar 页面用wx.switchTab,普通页面用wx.redirectTo,这样不会把登录页留在页面栈里。如果没有任何来源参数,默认跳首页。

// 登录成功后统一走这里 function jumpAfterLogin() { const pages = getCurrentPages(); const current = pages[pages.length - 1]; const redirect = current.options.redirect || '/pages/index/index'; if (redirect === '/pages/index/index') { wx.switchTab({ url: redirect }); } else { wx.redirectTo({ url: redirect }); } }

这段代码解决了“登录后去哪”的问题,但还有一个细节——登录按钮的防重复点击。我在loginByCode和onWxLogin里统一用一个成员变量this._loggingIn做互斥锁,进入后置为 true,请求回调完成后再复位。这样即使用户手速再快,也不会同时发出两个登录请求。

最后说一个我自己踩过的坑:登录页上线前,一定要清空本地缓存,用“全新用户”视角完整走一遍流程。很多团队在测试时因为本地已经有 token,跳过了登录环节,导致线上用户真正走进登录页时才发现按钮失效。我习惯每轮发版前都在开发者工具里执行一次“清缓存 → 重新编译”,然后真机上切不同的微信账号各登录一次,确认授权、验证码、回源跳转三个环节都正常。这个动作只要能坚持下来,登录界面的投诉率基本能压到最低。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进