夜雨聆风学习资料网

ARTICLE · 1090549

一套代码跑多端:uni-app 报表前端实战

一套代码跑多端: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。

相关学习资料