ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

uni-app多端登录实例:登录态设计、token持久化与踩坑指南

uni-app多端登录实例:登录态设计、token持久化与踩坑指南

简介:一份基于uni-app与HBuilderX的登录功能实现实例资源,面向需要快速搭建跨平台登录模块的移动端开发者,也适合初学uni-app的Vue用户,无论是个人项目还是团队协作,均可作为登录模块的参考起点。包内共83个文件,以js逻辑脚本、html页面、json配置文件、vue和wxml组件模板为主,压缩包仅280KB,目录包含pages页面、store状态管理、components组件及unpackage构建输出,类型与功能划分清晰,便于按需查阅。全网已有20413人学习下载。实例从创建项目开始,完整演示登录页面的输入框与按钮设计、网络请求、服务器返回处理,并通过本地存储接口实现登录状态持久化,同时涵盖记住密码、自动填充、HTTPS传输与MD5哈希等安全措施,以及异常提示与反馈处理。参考其目录组织和代码写法,开发者能快速迁移登录模块到实际业务,减少从零探索的成本,并掌握跨端开发的典型工程实践。

1. 登录实例是 uni-app 项目里最常见的业务切面,也是最容易返工的模块

uni-app 的登录,表面上无非是“表单 + 接口 + 存 token”三步,真正动手后才会发现,H5、微信小程序、App 三端的行为差异会把一个看似简单的登录实例拖成持久战。标题里的这个实例要解决的核心问题,是用一套代码在三种环境里同时完成登录请求、登录态持久化、冷启动恢复、过期处理和退出清理,并且不出现“刚登录就掉线”的玄学问题。这篇文章适合两类人:刚上手 uni-app、想找一个完整登录流程照着写的新手,以及被多端登录态坑过几次、想回来对一遍方案的熟手。

登录实例的每一项选择都有代价,选错了不会立刻报错,而是会在某个端、某个场景下突然翻车。所以我会先讲登录态的设计,再给最小可复现的实现,最后把多端差异和踩坑记录单独拉出来,这些才是真正省时间的部分。

2. 登录态的本质:token、存储和多端策略

2.1 登录凭证与用户信息必须分开管理

uni-app 登录实例里最容易犯的第一个错,是把用户信息当成登录凭证。后端返回的 token 才是“你确实登录过”的唯一凭据,userInfo 只是展示用数据。两者生命周期完全不同:token 会过期、会被刷新、会被服务端吊销;userInfo 不会因为过期而自动失效,但也不能用它来证明登录状态。

一个相对健壮的登录态模型包含三个独立部分:

数据用途生命周期
token请求头里的鉴权凭证由后端控制,短则数小时,长则数天
refreshToken换取新 token 的凭证比 token 长,只在续期时使用
userInfo页面展示用随 token 一起更新,不作为登录判断依据

H5、小程序、App 三端共用这个模型,但存储方式和过期处理各不相同。token 的过期时间应以服务端返回为准,不要在前端写死。前端要做的事情简单得多:存下来、带上请求、发现失效时跳到登录页或静默续期。

2.2 H5、小程序、App 三端的 storage 差异

登录实例的代码量不大,存储却是整个项目里最容易出边界问题的地方。uni-app 的uni.setStorageSync和uni.getStorageSync是跨端统一的 API,但底层实现完全不同:

端底层实现特点
H5localStorage刷新后保留,清理浏览器缓存后丢失
微信小程序小程序 storage按 uid 隔离,卸载小程序后清空
Appplus.storage / 系统偏好应用重启不丢,卸载才清

三端都支持同步读写,这对登录实例非常友好,可以避免异步回调带来的启动竞态问题。但在 H5 端要留意:localStorage在部分隐私模式下可能不可用,写入时最好做 try catch。App 端不要混用plus.storage的异步接口和uni的同步接口,两套机制混用会导致“有时候存了读不到”的灵异现象。

2.3 为什么登录态不适合只放在内存里

只放在 Vuex 或 Pinia 里的登录态,页面切换时没问题,冷启动就全没了。用户杀掉小程序、重新打开 H5、或者 App 被系统回收后,内存里的 store 会被清空,但 storage 里的 token 还在。登录实例必须每次启动时从 storage 恢复登录态,再同步到内存状态。反过来,只在 storage 里维护登录态也不行,页面响应式更新跟不上,用户名在页面上不会自动出现。正确做法是 storage 做持久层,内存 store 做展示层,启动时恢复一次。

这个模型定下来,后面的代码都围绕它展开。

3. 最小可复现的登录闭环:请求封装、登录方法、启动恢复

3.1 统一封装 request:baseURL、header 注入与 401 拦截

uni-app 项目里直接写uni.request做登录不是不行,但登录态一旦涉及 token 注入、过期处理、统一错误提示,没有封装层会导致每个页面都堆重复代码。我一般会先做一个极简的 request 封装:

// utils/request.js const BASE_URL = 'https://api.example.com' export function request(options = {}) { const token = uni.getStorageSync('token') return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '' }, success: (res) => { // 后端约定的业务码 401 表示登录态失效 if (res.data.code === 401) { uni.removeStorageSync('token') uni.removeStorageSync('userInfo') uni.reLaunch({ url: '/pages/login/login' }) reject(new Error('登录已过期')) return } resolve(res.data) }, fail: (err) => reject(err) }) }) }

这段代码的逻辑是:每次请求自动从 storage 取 token 放到请求头,后端返回业务码 401 时清掉本地登录态并跳回登录页。注意这里判断的是res.data.code,不是 HTTP 状态码。有些端会强制把 HTTP 状态码改成 200,但业务码是后端透传的,不会变。

BASE_URL需要按环境切换,开发环境、测试环境、生产环境建议用条件编译或环境变量管理,不要写死在文件里。Authorization头的拼接方式要和后端对齐,有的是Bearer token,有的是直接裸 token,这块不一致会在联调时浪费大量时间。401 的跳转建议用reLaunch而不是navigateTo,避免页面栈里残留一堆业务页面,返回时又回到已过期的页面。

3.2 登录页与登录方法:从输入到拿到 token 的最小闭环

账号密码登录是登录实例的基础,其他登录方式都围绕同一套“拿到 token 后存 storage 并跳转”的逻辑:

<script setup> import { ref } from 'vue' import { request } from '../../utils/request' const username = ref('') const password = ref('') async function handleLogin() { if (!username.value || !password.value) { uni.showToast({ title: '账号和密码不能为空', icon: 'none' }) return } const res = await request({ url: '/user/login', method: 'POST', data: { username: username.value, password: password.value } }) if (res.code === 0) { uni.setStorageSync('token', res.data.token) uni.setStorageSync('userInfo', res.data.userInfo) uni.switchTab({ url: '/pages/index/index' }) } else { uni.showToast({ title: res.message || '登录失败', icon: 'none' }) } } </script>

登录按钮被点击后,先做本地非空校验,再发起请求。成功后写两个 key 到 storage:token 和 userInfo。token 用于后续请求鉴权,userInfo 用于页面展示头像和昵称。

res.code === 0是模拟后端约定的成功业务码,实际项目里以后端文档为准。登录成功后的跳转分两种情况:首页是 tabBar 页面就用switchTab,不是 tabBar 页面用reLaunch更稳妥,navigateTo会在页面栈里留下登录页,用户按返回键还能退回登录页,体验很差。登录按钮在请求期间要加 loading 状态,防止用户重复点击产生多个登录请求,这个习惯能避免后面避坑章节里 code 重复使用的问题。

3.3 启动时恢复登录态:onLaunch 里的一行判断

App.vue 的onLaunch是冷启动恢复登录态的唯一可靠入口:

// App.vue <script> export default { onLaunch() { const token = uni.getStorageSync('token') if (!token) return // 有 token 不代表有效,交给请求层去校验 uni.switchTab({ url: '/pages/index/index' }) } } </script>

这里的思路是:本地有 token 就认为用户“曾经登录过”,直接进首页。token 是否真的有效,不在启动时主动调接口验证,而是交给后续业务请求的 401 拦截去兜底。这样可以避免每次冷启动都白等一个校验接口的往返时间。

有些项目会让后端提供/user/check之类的验证接口,在启动时调一次。这种做法不是不行,但会带来两个问题:一是弱网环境下用户要等更久才能看到首页;二是校验接口本身也要带 token,如果它也挂在统一封装的 request 下,401 会触发跳转,把你从首页又踢回登录页,形成启动即跳转的循环。我建议把这个校验交给业务请求,登录实例的启动逻辑越轻越好。

4. 第三方登录的三端差异:微信小程序、App、H5 不能一套流程抄到底

4.1 微信小程序:uni.login 拿 code 交给后端换 token

小程序端最常见的登录方式是微信授权登录,核心是uni.login拿临时 code:

// pages/login/wechat-login.vue 的 script 部分 function handleWechatLogin() { uni.login({ provider: 'weixin', success: async (loginRes) => { // loginRes.code 只能用一次,拿到后立刻传给后端 const res = await request({ url: '/user/wechat-login', method: 'POST', data: { code: loginRes.code } }) if (res.code === 0) { uni.setStorageSync('token', res.data.token) uni.setStorageSync('userInfo', res.data.userInfo) uni.switchTab({ url: '/pages/index/index' }) } }, fail: () => { uni.showToast({ title: '微信登录失败', icon: 'none' }) } }) }

小程序的uni.login不弹出授权框,只在后台静默生成一个 code。这个 code 有效期很短,且只能用一次,前端拿到后要立即传给后端,由后端拿着 code 去换 openid 和 session_key。前端拿不到 openid,也不应该把 code 缓存复用。

provider在小程序里默认是weixin,在支付宝小程序里要改成alipp。在这套代码里,code 只是一个临时票据,后端会返回你们自己签发的 token 和用户信息,存 storage 的逻辑和账号密码登录完全一致。

4.2 App 端:OAuth 授权与原生登录的边界

App 端的微信登录和微信小程序是两套东西。App 端需要先在微信开放平台申请移动应用,拿到 appid,并在 manifest 里配置;然后调用uni.login时,返回值和小程序不完全一致:

// App 端:先唤起原生授权,再处理返回值 function handleAppLogin() { uni.login({ provider: 'weixin', success: (loginRes) => { // App 端 loginRes 里同时有 code 和 authResult // 部分版本 code 为空,要从 authResult 里取 const code = loginRes.code || loginRes.authResult.code if (!code) { uni.showToast({ title: '未获取到 code', icon: 'none' }) return } // 后续同样把 code 交给后端换 token } }) }

App 端有两个值得注意的边界:第一,uni.login返回结构在不同版本、不同平台的封装里不一致,不能假设loginRes.code一定存在,要有兜底取值;第二,如果用户设备没装微信,uni.login会直接 fail,登录前要捕获这个错误并提示用户。

App 端的登录成功回调里,有时还会拿到authResult里的用户敏感信息,但这不代表后端也认可这个登录。正规做法仍然是“code 换 token,userInfo 由后端返回”,不要在前端自行拼接用户数据。

4.3 H5 端:网页授权跳转与回调页的处理习惯

H5 是第三方登录差异最大的端,因为在浏览器环境里uni.login并不存在,必须走网页授权跳转。流程是:前端先拼出微信网页授权的 URL,跳转过去,用户授权后微信会带着 code 跳回你的回调地址,你再拿这个 code 去后端换 token:

// H5 端微信登录,不能调 uni.login,只能走页面跳转 const WECHAT_APP_ID = 'wx123456789' const REDIRECT_URI = encodeURIComponent(location.href.split('?')[0]) function handleH5WechatLogin() { const authUrl = 'https://open.weixin.qq.com/connect/qrconnect' + '?appid=' + WECHAT_APP_ID + '&redirect_uri=' + REDIRECT_URI + '&response_type=code' + '&scope=snsapi_login' + '#wechat_redirect' location.href = authUrl }

跳转之后,用户会在微信授权页完成确认,再回到你的回调页面,URL 上会多一个code参数,页面的 onLoad 里要处理这个参数:

onLoad(options) { if (options.code) { // 把 URL 上的 code 交给后端换 token,成功后走统一登录成功逻辑 } }

H5 端最常见的报错是把小程序的uni.login原封不动搬过来,结果浏览器里直接报 undefined 函数错误。这个不是运行环境问题,是流程没分开。另外,REDIRECT_URI必须和微信开放平台后台配置的域名完全一致,包括协议和路径,多一个斜杠都会导致授权失败。

5. 登录实例的 4 个高频雷区:现象、根因、解决办法

5.1 现象一:App 重启后登录态丢失

用户在手机上登录完,退出应用再打开,发现回到了登录页。检查 storage,token 确实没了。

原因是初始化过程中混用了两套存储机制。uni.setStorageSync和plus.storage在 App 端是两个底层实现,混用时写入的 key 不一定能被另一方读到。更隐蔽的情况是,有代码在内存 store 初始化完成后,用内存里的空值覆盖了 storage 里的真实 token,登录态就被“冲掉”了。

解决方式是统一用uni.setStorageSync/uni.getStorageSync,不要穿插调用原生 plus API;启动时先从 storage 读 token,再初始化内存 store,不要反过来。App 端 token 写入后可以验证一下卸载重装是否还在,这个测试能暴露大部分持久化问题。

5.2 现象二:H5 刷新一次就退回登录页

H5 端登录成功后一切正常,用户按 F5 刷新,页面直接跳到登录页。console 里能看到 request 报 401。

常见原因是登录成功后只把 token 放在了内存变量或 Vuex 里,没有同步到 localStorage;或者 storage 写入了,但启动恢复时读取时机不对,在页面还没挂载时就用空值覆盖了登录态。H5 端的uni.setStorageSync底层就是 localStorage,刷新不会丢,丢的一定是写入或恢复环节。

解决方式是登录成功后立即写 storage,启动时在 App.vue 的onLaunch里同步读取,并将读取结果写进内存 store。刷新后的页面跳转要等恢复函数完成后再执行,避免竞态。

5.3 现象三:拦截器里跳转登录页造成死循环

登录过期后,request 封装里的 401 逻辑跳转登录页,但登录页自己也在发请求,也会经过这个拦截器。某个接口返回 401,跳转登录页,登录页的某个校验接口也返回 401,又触发跳转,页面反复重定向。

原因是没有区分“当前已经在登录页”的情况。解决方式是跳转前加一个判断:

const pages = getCurrentPages() const currentPage = pages[pages.length - 1]?.route if (currentPage !== 'pages/login/login') { uni.reLaunch({ url: '/pages/login/login' }) }

另一种做法是给登录页的请求加一个跳过拦截的标记,比如在 options 里传skipAuth: true,拦截器检测到就放行。两种方案都可以,关键是不要无条件跳转。

5.4 现象四:code 重复使用导致登录失败

微信登录时,用户双击登录按钮,两个请求都带着同一个 code 发给后端。后端第一次用 code 换 session 成功,第二次再用同一个 code 会直接报错。

原因是前端没有控制按钮的重复提交状态。解决方式是登录按钮点击后立即置为 loading 状态,请求完成前不允许再次点击;同时记录一个isLogging标志位,在函数入口处判断:

let isLogging = false async function handleLogin() { if (isLogging) return isLogging = true try { // 登录逻辑 } finally { isLogging = false } }

微信登录的 code 只能生效一次,这个限制无法从前端绕开,只能靠前端控制提交频率。

6. 登录链路的自测清单与静默续期技巧

登录实例做完后,真正决定质量的是验证方式。我提测前会给前端登录链路跑一遍下面的自测清单:

场景操作预期
首次登录账号密码登录成功跳转首页,storage 里有 token
冷启动恢复登录后杀掉进程重进直接进首页,不需要重新登录
token 过期停留页面超过有效期,再点触发请求请求失败,自动跳转登录页
H5 刷新登录后按 F5保持登录态,不退回登录页
重复提交双击登录按钮只发出一个请求
退出再登录登出后重新走一遍登录旧 token 被清,新 token 正常写入

如果登录实例里接了 refreshToken,还可以把“过期深度操作”设计成静默续期而不是直接踢出。业务请求遇到 token 过期时,先调刷新接口换新 token,再重放原请求,用户无感知:

// 简化版:401 时先尝试刷新再重放 async function requestWithRetry(options) { const res = await request(options) if (res.code === 401 && !options._retry) { const refreshRes = await callRefreshToken() if (refreshRes.code === 0) { uni.setStorageSync('token', refreshRes.data.token) return request({ ...options, _retry: true }) } } return res }

注意刷新接口本身不能走 401 拦截的逻辑,否则会在续期时递归跳转。一般会给刷新接口单独走一个不带拦截的请求方法。我自己的习惯是:登录不复杂,但登录的边界场景能写满一页纸;每次改完登录相关代码,先把上面表格跑一遍再提交。希望帮到你。

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

RELATED READING

延伸阅读

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