mpvue 写的微信小程序,登录到底应该写在哪?这个问题问得人一愣,因为做小程序开发的都知道登录要走 wx.login,但真到 mpvue 项目里,页面结构、生命周期、数据管理全是 Vue 的写法,很多人绕来绕去就不知道该把登录逻辑塞哪里了。我最近刚好接手一批老项目,里面清一色的 mpvue,用户登录功能的完整实现过程自然成了绕不开的功课。这篇就把整个链路从头到尾捋一遍,包含方案选型、代码落地、真实环境里踩过的坑,给同样在维护或者新写 mpvue 项目的朋友一份可以直接抄的作业。
1. 登录前后的完整逻辑链条(从wx.login到业务token)
1.1 小程序登录不是表单登录,是换身份凭证
很多人第一次做小程序登录,习惯性地想做一个账号密码页面。这是思维惯性害的。小程序跑在微信这个宿主环境里,用户本身已经是一个微信账号,前端根本不需要再收集账号密码。微信给出的方案是:临时授权码换用户身份。
拿 mpvue 项目来说,页面层虽然长着 Vue 的样子,但底层能力还是微信那一套。你在 mpvue 模板里可以正常写 WXML 语法,在 js 里照样可以调用 wx.login、wx.request、wx.getStorageSync。所以小程序登录的本质是利用微信提供的 openid 关联用户,再让后端签发一个属于你自己业务的 token。
openid 是啥?你可以理解为这个用户在你这个小程序里的身份证号。同一个用户在 A 小程序和 B 小程序里,openid 是不同的,微信用这种方式隔离不同小程序的数据。openid 本身不能直接拿来当登录凭证用,因为一旦泄露,任何人都能冒充这个用户。更关键的是,小程序前端根本没有办法直接调一个接口拿到 openid,拿到的只是临时 code。
1.2 一次完整登录经历的四个环节
第一步,前端调用 wx.login()。这个接口不需要任何授权弹窗,属于静默操作,只要用户打开小程序就能拿到一个临时 code。code 的有效期只有五分钟,而且只能用一次,用完了就作废。
第二步,前端把 code 通过 wx.request 发给自己的后端接口。后端拿着 code 去微信的服务端调 code2Session,微信服务端返回 openid 和 session_key。openid 用来标识用户,session_key 用来解密用户敏感数据,比如手机号、unionid。注意 session_key 不会明文出现在前端,后端拿到后要存起来。
第三步,后端拿着 openid 去自己的用户表里查,没有记录就创建一条新用户,然后生成你自己的登录 token 返回给前端。token 可以是自己签发的 JWT,也可以是一个随机字符串存 Redis。哪种都行,关键是要让前端后续请求都带上它。
第四步,前端把 token 存到本地缓存。mpvue 项目里一般存到 wx.setStorageSync,后面每次请求在 request header 里加一个字段带过去。
用户打开小程序 ↓ wx.login() 获取临时 code ↓ 前端 POST { code } 到后端接口 ↓ 后端用 code 换 openid + session_key ↓ 后端签发业务 token 返回前端 ↓ 前端缓存 token 并写入 Vuex这四个步骤里,front-end 真正复杂的地方在第三、四步的衔接:后端返回了 token 之后,前端怎么保证后续每个请求都带上它、登录过期了怎么处理、多个页面同时触发登录怎么办。后面我会用一整节讲代码实现。
1.3 session_key 和 wx.checkSession 的关系
session_key 是从 code2Session 里换出来的解密密钥,它的有效期微信官方没有明确承诺,实测一般不超过 24 小时。前端不能拿到这个值,但微信提供了一个判断接口 wx.checkSession。这个接口只能告诉我 session_key 在微信服务端有没有过期,它不负责验证你业务的 token 有没有过期。
很多新手会犯一个错:启动时只调用 wx.checkSession,发现 checkSession 成功了,就觉得登录态是好的。这是两码事。session_key 没过期,不代表你后端的 token 没过期,更不代表用户信息没被修改。所以正确姿势应该是双保险——业务 token 失效以接口 401 为准,session_key 失效以 wx.checkSession 或后端解密失败为准。
2. 登录方案设计和mpvue项目结构规划
2.1 静默登录 + 按需授权的组合拳
登录方案设计上,我把项目分成三种:
| 方案 | 做法 | 适合场景 |
|---|---|---|
| 纯静默登录 | wx.login 换 token,不拿头像昵称 | 数据不需要展示用户身份的浏览型应用 |
| 静默登录 + 按需授权 | 先静默登录,用户进到要身份信息的页面再引导授权 | 大多数电商、社区、工具类应用 |
| 强制绑定手机号 | 在静默登录基础上,用 open-type="getPhoneNumber" 让用户绑定手机 | 有强账号体系,需要手机号做客服或风控的业务 |
绝大多数项目采用第二套就够用了。一进来就弹授权框,用户大概率会点拒绝。先让用户正常浏览,等他真的用到收藏、下单、发布评论这类行为时,再引导授权,体验会好很多。
按需授权的“需”包含两层:第一层是头像昵称这类资料型数据,第二层是手机号这类隐私型数据。头像昵称现在微信改了规矩,不能直接用 getUserProfile 一把拿了,得用头像昵称填写能力。手机号则必须走 getPhoneNumber,后端拿到服务器返回的手机号再绑定账号。这两部分细节我在第三节展开。
2.2 Token生命周期和接口约定
业务 token 过期时间我一般建议后端设 7 到 30 天,存 Redis 可以随时拉黑。太短会让用户频繁重登,太长不安全。前端不管有效期,只认状态码。
前后端需要约定两个关键状态:401 代表 token 缺失或过期,需要重新登录;412 或者自定义错误码代表 session_key 过期,需要重新 wx.login。还有设备异常、账号被拉黑这类情况,一般用 403,前端给出文案提示就行。
我建议接口协议统一成这样:
{ "code": 0, "msg": "ok", "data": {} }code 为 0 表示成功,非 0 是业务错误码。HTTP status 只在 token 失效时返回 401,其他情况都返回 200,业务错误靠 code 区分。这样前端拦截逻辑会非常干净。
2.3 mpvue项目目录该怎么拆
mpvue 项目本身还是分页面开发的,app 只是外壳。登录功能涉及 api、store、utils、页面四块,我习惯这样组织:
src/ ├── api/ │ ├── login.js │ └── user.js ├── store/ │ ├── index.js │ └── modules/ │ └── user.js ├── utils/ │ ├── request.js │ └── auth.js ├── pages/ │ ├── index/ │ │ ├── index.vue │ │ └── main.js │ └── mine/ │ ├── index.vue │ └── main.js ├── App.vue └── main.jsapi 目录放接口定义,store 目录放 Vuex 状态,utils 下的 request.js 管请求拦截和登录重试,auth.js 管 token 读写。页面只干一件事:调 store 的 action,拿数据渲染。这个分层的好处是登录逻辑只写一次,任何页面需要登录时都是同一套动作。
2.4 为什么登录逻辑一定要收到Vuex里
有人会问,登录逻辑放在 App.vue 的 onLaunch 里不就行了?页面需要 token 时直接从缓存里取。这么做的问题在于:登录是一个异步过程,页面 onLoad 执行时登录可能还没完成,你拿不到 token,也不知道该不该跳转。App.vue 里没有页面栈的概念,出了错也没地方提示。
用 Vuex 管理的优势有三个。第一,登录状态是全局响应式的,页面里可以直接计算属性判断 isLogin。第二,action 可以返回 Promise,页面可以明确知道登录完成后再做下一步。第三,多个页面同时触发登录时,可以在 action 层面做并发控制。
3. mpvue登录模块代码落地:请求封装到Vuex状态
3.1 先从工具层做起:token读写与wx.login包装
做登录一定要先写工具函数,直接散写 wx.setStorageSync 会乱。我新建 utils/auth.js:
const TOKEN_KEY = 'auth_token' export function getToken() { return wx.getStorageSync(TOKEN_KEY) || '' } export function setToken(token) { wx.setStorageSync(TOKEN_KEY, token) } export function removeToken() { wx.removeStorageSync(TOKEN_KEY) }wx.login 本身是回调风格,在 mpvue 里必须包成 Promise 才好配合 async/await:
// api/login.js export function wxLogin() { return new Promise((resolve, reject) => { wx.login({ success: (res) => { if (res.code) { resolve(res.code) } else { reject(new Error('wx.login 未返回 code')) } }, fail: (err) => { reject(err) } }) }) }这里有个细节:wx.login 在个别情况下会失败,比如用户网络异常、微信服务端抽风。失败后不能闷声吞掉,要 reject 出去,让调用方决定是重试还是提示。
3.2 请求封装里实现“无token自动登录”
请求封装是整个登录模块的心脏。我的思路是:如果当前没有 token,并且这个请求需要登录,就先走一遍登录流程,登录成功后再发原请求。这里有一个很容易踩的坑:如果多个请求并发,会同时触发多次 wx.login,code 有可能互相覆盖。解决办法是缓存登录 Promise,保证并发时只有一个登录任务在跑。
// utils/request.js import { getToken, setToken, removeToken } from './auth' import { wxLogin } from '../api/login' const BASE_URL = 'https://your-api.example.com' let loginPromise = null function ensureLogin() { if (getToken()) { return Promise.resolve(getToken()) } if (loginPromise) { return loginPromise } loginPromise = wxLogin() .then((code) => { return new Promise((resolve, reject) => { wx.request({ url: `${BASE_URL}/api/login`, method: 'POST', data: { code }, success: (res) => { if (res.data && res.data.code === 0) { setToken(res.data.data.token) resolve(res.data.data.token) } else { reject(new Error('登录接口异常')) } }, fail: reject }) }) }) .finally(() => { loginPromise = null }) return loginPromise } export default function request(options) { const { url, method = 'GET', data = {}, needAuth = true } = options const send = (token) => { return new Promise((resolve, reject) => { wx.request({ url: `${BASE_URL}${url}`, method, data, header: token ? { Authorization: `Bearer ${token}` } : {}, success: (res) => { if (res.statusCode === 401) { removeToken() reject({ code: 401, msg: '登录已过期' }) return } if (res.data && res.data.code === 0) { resolve(res.data.data) } else { reject(res.data) } }, fail: reject }) }) } if (needAuth && !getToken()) { return ensureLogin().then((token) => send(token)) } return send(getToken()) }ensureLogin 里的 finally 很重要。不管登录成功还是失败,都要把这个全局 Promise 置空,否则下次登录请求会一直拿到上一次的 Promise。我见过有人漏了 finally,结果用户退出登录后再登录,拿到的永远是旧 token。
3.3 Vuex里打通登录Action和登录状态
有了工具层,Vuex 只是再包一层,顺便记住用户资料。store/modules/user.js 这样写:
import { getToken, setToken, removeToken } from '../../utils/auth' import { wxLogin } from '../../api/login' export default { namespaced: true, state: { token: getToken(), userInfo: null, isLogin: !!getToken() }, mutations: { SET_TOKEN(state, token) { state.token = token state.isLogin = !!token }, SET_USER_INFO(state, info) { state.userInfo = info }, CLEAR_LOGIN(state) { state.token = '' state.userInfo = null state.isLogin = false } }, actions: { async login({ commit, dispatch }) { const code = await wxLogin() const data = await new Promise((resolve, reject) => { wx.request({ url: 'https://your-api.example.com/api/login', method: 'POST', data: { code }, success: (res) => { if (res.data && res.data.code === 0) { resolve(res.data.data) } else { reject(res.data) } }, fail: reject }) }) setToken(data.token) commit('SET_TOKEN', data.token) commit('SET_USER_INFO', data.userInfo || null) return data }, async logout({ commit }) { // 这里可以调后端退出接口,清理 token 映射 removeToken() commit('CLEAR_LOGIN') } } }页面里调用登录就非常简单:
import { mapActions } from 'vuex' export default { methods: { ...mapActions('user', ['login']), async handleLogin() { try { await this.login() wx.switchTab({ url: '/pages/index/index' }) } catch (e) { wx.showToast({ title: '登录失败,请重试', icon: 'none' }) } } } }这里要注意,页面里千万不要直接调 wx.login 然后自己写请求逻辑,一定要走 store。否则每个页面都要处理 code、token、错误码,代码膨胀不说,逻辑还会到处不一致。
3.4 头像昵称引导:新版授权能力的正确姿势
微信官方已经不再推荐 getUserProfile 一把抓头像昵称。现在正确做法是引导用户主动填写。在 mpvue 页面里,用 open-type 能力可以这样写:
<template> <div class="profile-page"> <button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="onChooseAvatar"> <image v-if="avatar" :src="avatar" /> <text v-else>点击选择头像</text> </button> <input type="nickname" placeholder="请输入昵称" @blur="onNicknameBlur" /> <button @click="submitProfile">保存资料</button> </div> </template>选择头像的回调里拿临时路径,因为 form 提交和 button 回调拿到的头像路径都是本地临时文件,需要先调 wx.uploadFile 上传到自己的服务器,拿到一个 CDN 地址后再提交。不要直接把临时路径存到数据库,临时路径过段时间就失效了。
export default { data() { return { avatar: '', nickname: '' } }, methods: { onChooseAvatar(e) { // 临时头像路径 this.avatar = e.detail.avatarUrl }, onNicknameBlur(e) { this.nickname = e.detail.value }, async submitProfile() { // 先上传头像,拿到 url 后再调保存接口 } } }有个 mpvue 兼容细节建议留意:nickname 输入框上我用的是 @blur 手动取值,没有用 v-model。原因是 mpvue 对原生 input 的 v-model 在部分基础库版本上存在输入同步不及时的问题,blur 时再取值更稳。
3.5 用登录态控制页面跳转
很多业务页面要求必须登录才能看。判断不能用死逻辑,要考虑到用户进来时还没有 token 的情况。我的做法是在页面 onShow 里检查:
import { mapState } from 'vuex' export default { computed: { ...mapState('user', ['isLogin']) }, onShow() { if (!this.isLogin) { wx.redirectTo({ url: `/pages/login/index?redirect=${encodeURIComponent('/pages/mine/index')}` }) return } // 拉用户数据 } }登录成功后再回跳,登录页要读一下 redirect 参数:
methods: { async handleLogin() { await this.login() const pages = getCurrentPages() if (this.redirect) { wx.redirectTo({ url: decodeURIComponent(this.redirect) }) } else { wx.switchTab({ url: '/pages/index/index' }) } } }这种回跳方式比 wx.navigateBack 更可靠,因为登录页不一定是通过 navigateTo 进来的,栈里的页面状态不一定对。
4. 真实项目里反复踩到的登录坑
4.1 并发调用wx.login导致code失效
这个坑在请求封装里提过,但值得单独说,因为太经典了。小程序首页往往同时有好几个请求在发,第一个请求发现没有 token,就调 wx.login 拿 code,第二个请求也发现没有 token,又调了一次。微信服务端对同一个 session 生成的 code 有互斥逻辑,后拿到的 code 可能把前一个作废,最后两个请求都失败。
解决方式就是我前面写的缓存 Promise 思路。ensureLogin 返回同一个 Promise,让并发请求排队等同一个登录结果。这是一段看起来不起眼但极其关键的代码,建议直接抄。
4.2 token过期导致接口连环401
token 过期后,会有非常多的请求同时返回 401。如果每个请求都去跳登录页,用户会看到页面疯狂跳转,体验极差。我在 request.js 里的处理策略是:401 只清理本地 token,不跳转;真正需要跳登录页时调用一个专门的工具函数。
更稳的重试策略是在 request 函数里加一个 _retry 参数,401 后自动重新登录并重放请求,但只重试一次。如果重试后还是 401,再跳登录页。实现时注意不要对登录接口本身做重试,否则会死循环。
4.3 session_key过期导致解密失败
场景是这样的:后端用 session_key 解手机号,发现解密出来的数据格式不对,说明 session_key 已经过期。这时候后端没法自己刷新,只能返回特定错误码让前端重新走登录流程。
前端的处理是:识别到这个错误码,调用一次 ensureLogin,然后把原请求重新发一次。每次要发新 code 和后端拿新 session_key。这个问题在只登录一次、用户隔了很久再操作时才出现,但排查起来相当隐蔽。我建议后端要在日志里把“解密失败”和“其他失败”分开打点,不然前端很难判断根源。
4.4 开发者工具和真机的环境差异
mpvue 项目在开发者工具里能正常登录,一上真机就白屏或者请求失败,大概率是域名白名单问题。开发者工具有一个“不校验合法域名”的选项,逃过了很多问题,真机上这条路走不通。
排查顺序是这样:先看真机上 wx.request 的 fail 回调,提示“url not in domain list”就说明域名没配;然后在微信公众平台后台把 request 合法域名加进白名单,注意必须是 HTTPS,协议头和端口都要一致。还有一点,开发版和体验版用的是同一套白名单,改了后要等一小会儿才生效,不是立刻的。
另外基础库差异也要留意。chooseAvatar 和 type=nickname 需要基础库 2.21.2 以上,getUserProfile 需要 2.10.4 以上。老版本用户会直接没有授权入口,页面要有兜底提示。
4.5 用户拒绝授权后的兜底流程
有的用户永远不点授权。需要登录才能使用的功能,页面要给出明确提示,并且提供“去授权”按钮。如果用户已经拒绝了,wx.openSetting 可以重新打开设置页让用户手动开启。
不要每次进来都弹授权框,会被用户反感,微信也会在后台降低整体授权转化率。我的做法是:只在下单、评论这类高价值动作前触发授权,并且授权失败后给出一个可关闭的引导弹层。
5. 登录态检查与退出登录的无缝衔接
5.1 启动时的登录态检查
小程序冷启动时,本地可能存在旧的 token,但可能已经过期。在 App.vue 的 onLaunch 里做一次静默检查:
onLaunch() { if (getToken()) { wx.checkSession({ success: () => { // session_key 有效,token 是否有效要靠业务接口校验 }, fail: () => { // session_key 失效,重新登录 this.$store.dispatch('user/login') } }) } }wx.checkSession 成功不代表业务 token 有效,所以真正校验 token 的办法还是拉一次用户信息接口。如果这个接口返回 401,就走登录。
注意 mpvue 的 App.vue 里,Vuex 不能在 onLaunch 里用 this.$store 直接调用?实际上 App.vue 也是 Vue 实例的语法,this.$store 取决于 main.js 里是否挂在原型上。稳妥的做法是 import store 实例,然后 store.dispatch。代码示意里我就用 this.$store 是为了演示逻辑,真写的时候建议直接从 store 模块导入 dispatch。
5.2 退出登录要清理干净
退出登录的代码,不只是清 token。用户信息、本地缓存、第三方授权状态,都要理一遍。在 store 的 logout action 里:
async logout({ commit }) { try { // 通知后端清除 token 映射,服务端把 token 拉黑 await request({ url: '/api/logout', method: 'POST', needAuth: false }) } catch (e) { // 忽略网络错误,本地照常清理 } removeToken() commit('CLEAR_LOGIN') // 其他需要清理的业务缓存,逐个 removeStorageSync }页面里调用退出后,如果当前页面是登录态页面,要跳回首页。否则就会出现退出登录后还停留在“个人中心”的情况,用户以为是 bug。
多端同步退出也是值得注意的点。如果是自己的小程序体系,后端要支持把该用户所有端点的 token 一起拉黑。只清前端,用户在另一个手机上依然能正常操作,这不算真正退出。
写在最后的操作心得
折腾完这批 mpvue 老项目,我最大的体会是:登录功能不在于代码写得多花哨,而在于把并发、失效、跳转这几个边界情况按住。缓存登录 Promise、401 只清理不跳转、特定错误码重放请求,这三个处理点到位,登录就成功了八成。如果说要给后来者一个排错顺序的建议,我会说先抓 401 和 code 失效,这两个问题处理干净后,其他登录问题基本都能自己暴露出原因。如果是从零开始做新项目,我会在动手前先定好 token 存储 key、接口错误码规范、页面跳转策略三件事,后面写起来会顺很多。登录是每个小程序的第一道门,这道门做稳了,后面业务开发才谈得上体验。