ARTICLE · 1090549
一套代码跑多端:uni-app 报表前端实战
后端写了几十篇,前端这块一直没碰。最近翻到 历史项目 看到这个 uni-app 项目(91 个 .vue 组件),正好拿来开个前端系列。它最香的一点是:一套 Vue 代码,编译到 H5 / 微信小程序 / App。
这篇文章就拆这个项目里最值得抄的三块:路由骨架、请求封装、OAuth2 PKCE 登录,顺便把源码里两个真实安全坑指出来。
一、为什么选 uni-app
报表系统这种内部工具,使用者可能在电脑浏览器、可能在手机微信里、可能装在平板上。如果每个端单独写一套,维护成本直接 ×3。uni-app 用 Vue 语法写,通过条件编译 // #ifdef MP-WEIXIN 处理端差异,绝大部分业务逻辑共享。
适合:内部系统、工具类 App、需要快速铺多端的业务。
不适合:对包体积 / 性能极致敏感、重度依赖某端原生能力的场景。
二、项目骨架:pages.json 路由 + tabBar
uni-app 没有 vue-router,页面路由写在 pages.json 里,数组第一项就是启动页。这是它和 Vue SPA 最大的区别,新手最容易踩:
{ "pages": [ { "path": "pages/login/loading" }, // 第一项 = 启动页 { "path": "pages/login/login" }, { "path": "pages/home/home" }, { "path": "pages/report/add-report" }, { "path": "pages/login/CallbackView" } ], "tabBar": { "list": [ { "pagePath": "pages/home/home", "text": "首页" }, { "pagePath": "pages/my/my", "text": "我的" } ] } }注意那个 pages/login/CallbackView——它是 OAuth2 登录回调页,后端授权服务器把浏览器跳回来时带的 code 就落在它的 onLoad(options) 里。这个下面第四节细讲。
三、请求封装:Token 检查 + 401 处理
项目里 utils/request.js 把 uni.request 包了一层,核心是前置 Token 校验和 401 跳转登录。先贴能跑的部分:
function checkToken() { const token = uni.getStorageSync('token') if (!token) { uni.reLaunch({ url: '/pages/login/login' }) // 没 token 直接踢回登录 return false } return true } export const request = (options) => { return new Promise((resolve, reject) => { if (!checkToken()) return reject('Token invalid') uni.request({ url: options.url, method: options.method || 'GET', header: { 'Authorization': `Bearer ${uni.getStorageSync('token')}` }, success: (res) => { if (res.statusCode === 401 || res.statusCode === 403) { // 401/403:跳转登录页 uni.reLaunch({ url: '/pages/login/login' }) return } resolve(res.data) }, fail: reject }) }) }但这里有个半成品的坑:作者本来想做"Token 过期自动刷新 + 刷新期间请求排队",代码写好了却整段注释掉了:
// if (!isRefreshing) { // isRefreshing = true // refreshToken().then(success => { // isRefreshing = false // if (success) { // requests.forEach(cb => cb()) // 刷新成功后重试所有挂起的请求 // requests = [] // } else { // uni.reLaunch({ url: '/pages/login/login' }) // } // }) // }正确做法:401 时应该用 isRefreshing 标志位挡住并发,用 requests 队列把过期期间的请求缓存起来,refresh 成功后再重放。上面那段注释就是标准答案,上线前把注释去掉、并在 refresh 失败时清 token 跳登录即可。否则现在的行为是:Token 一过期,同时在发的 N 个请求会 N 次跳登录页。
四、OAuth2 PKCE 登录全流程
这个项目接的是我之前写的 Spring Authorization Server(第 40 篇 / Claw 07 微信登录)。前端负责三件事:拼授权链接、拉微信 openid、拿 code 换 token。
① 拼授权链接(utils/auth.js)——用 PKCE 的 challenge,把微信 openid 也带上:
export function getAuthUrl() { const verifier = generateCodeVerifier(); uni.setStorageSync('pkce_verifier', verifier); // 先存挑战原文 const challenge = generateCodeChallenge(verifier); const openId = uni.getStorageSync("openId"); return `${SSO_BASE_URL}/oauth2/authorize?response_type=code` + `&client_id=${authConfig.clientId}` + `&redirect_uri=${encodeURIComponent(authConfig.redirectUri)}` + `&code_challenge=${challenge}` + `&code_challenge_method=S256` + `&wx_openid=${openId}`; }② 微信 openid——用 uni.login 拿 code 换 openid,存本地,拼链接时带上,后端据此做免密登录(呼应 Claw 07)。
③ 回调换 token(pages/login/CallbackView.vue)——授权服务器跳回时带 code,用之前存的 verifier 换 access_token:
// CallbackView.vue onLoad(options) const code = options.code || ''; if (code) { exchangeCodeForToken(code).then(response => { if (response.statusCode === 200) { uni.setStorageSync('token', response.data.access_token); uni.reLaunch({ url: '/pages/home/home' }); // 落地首页 } }); } // utils/auth.js —— 用 code_verifier 换 token async function getToken(code, verifier, authConfig) { return uni.request({ url: `${SSO_BASE_URL}/oauth2/token`, method: 'POST', header: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: Object.entries({ grant_type: 'authorization_code', code, redirect_uri: authConfig.redirectUri, client_id: authConfig.clientId, client_secret: authConfig.clientSecret, code_verifier: verifier // PKCE 核心:证明"我就是刚才发起授权那个" }).map(([k,v]) => `${k}=${encodeURIComponent(v)}`).join('&') }) }PKCE 的意义:公开客户端(小程序 / H5)没有安全的地方存 client_secret,所以用"挑战-应答"代替密码——即使授权链接被截获,没有 verifier 也换不到 token。
五、两个真实安全坑(重点)
坑 1:PKCE verifier 用了 Math.random()
utils/pkceUtils.js 里生成 verifier 的随机源是自定义函数,它用的是 Math.random(),不是密码学安全随机数:
export const generateCodeVerifier = () => { const array = new Uint8Array(32); getRandomValues(array); // ← 被下面自定义函数"劫持"了 return Array.from(array, b => b.toString(16).padStart(2,'0')).join(''); } var getRandomValues = function (array) { for (var i = 0, l = array.length; i < l; i++) { array[i] = Math.floor(Math.random() * 256); // ← 不安全! } return array; }修复:verifier 是 PKCE 安全性的根基,必须用 CSPRNG。小程序环境用 wx.getRandomValues 或 crypto.getRandomValues(注意别起个同名局部函数把它覆盖掉);H5 用 crypto.getRandomValues + base64url。Math.random() 可预测,等于 PKCE 形同虚设。
坑 2:client_secret 硬编码在前端 config
static/config/auth.js 里直接写了 clientId 和 clientSecret:
export const authConfig = { redirectUri: 'http://192.168.1.8/sso/api/auth/callback/app', clientId: '81351278-c240-4a13-bbeb-122551134f84', clientSecret: '75af2180-2022-4b28-b432-25f02718a5ee' // ← 打包进前端,谁都能看到 }既然已经用 PKCE 了,这个 client_secret 既没必要、也不该出现。公开客户端应注册成 public 类型(认证方法 none),只靠 PKCE 保护,把 secret 从前端删掉。另外 redirectUri 写死成局域网 IP,记得改成线上域名。
六、小结
这套 uni-app 报表前端,骨架(pages.json + tabBar)、请求封装、OAuth2 PKCE 登录这三块都是可以直接抄的。上线前务必改两个地方:verifier 换 CSPRNG、删掉前端 client_secret。下一篇写同系列的 Vue3 中后台前端(lion-admin-front),你会发现它用的是同一套 PKCE 思路,只是把 config 挪到了 import.meta.env。