资讯详情

资讯详情

uni-app H5 hash路由配置与导航守卫实战

上周有个朋友拿着他们组的 H5 项目来找我说“守卫写了但拦不住”。我打开代码翻了十分钟就发现问题不在守卫他们为了绕开某些分享链接被截断的情况把 manifest.json 里的路由模式从 history 改回了 hash改完之后没重新编译只在浏览器里刷新了一下页面看效果。更麻烦的是他们的拦截逻辑一半写在 uni.addInterceptor 里一半写在自建的 router/index.js 里而 uni-app 里根本没有那个“自建 router 入口”的位置。这类问题在 uni-app 的 H5 端特别常见。很多人是带着纯 Vue 项目的经验过来的建 router 目录、写 beforeEach、挂到 main.js一条龙都很熟练。结果到 uni-app 里发现没有 router/index.js 这个入口pages.json 里没有 meta 字段main.js 里也拿不到 router 实例。于是开始怀疑“uni-app 不支持导航守卫”。其实它支持只是入口和普通 Vue 项目完全不同。这篇就把 hash 模式的配置位置、字段分工、三种拦截方式的适用边界以及我实际项目中遇到问题的排查过程完整梳理一遍。适合已经在写 uni-app、准备给 H5 端加登录态校验的人看也适合那些“守卫明明写了却不生效”的人对照检查。1. 先把“壳”摸清楚uni-app H5 端的路由到底是谁在干活1.1 编译产物里的 Vue2 vue-router 3.x很多人不知道的是uni-app 编译到 H5 之后底层用的就是标准的 Vue2 加 vue-router 3.x。你写的每一个页面最终都会变成 vue-router 路由表里的一条 record你调用的uni.navigateTo内部最终会落到router.push上uni.navigateBack对应router.back。打开编译后 dist 目录里的 JS能看到 vue-router 的源码片段。搞清楚这一层之后很多事情就通了。比如为什么to.path是/pages/detail/detail这种带前导斜杠的形式因为那是 vue-router 的 path 规范而不是 pages.json 里的pages/detail/detail。再比如为什么to.query能拿到参数因为跳转时参数被转成了 vue-router 的 query 对象。但反过来也要注意这层“壳”意味着你不应该把它当成完整的 vue-router 来用。pages.json 才是页面清单的唯一真相来源你在 router 上动态addRoutes加出来的页面uni-app 的页面栈管理是不认的跳过去会出现各种奇怪的回退问题。这个边界必须记牢。1.2 pages.json 管页面清单manifest.json 管 URL 形态这两个文件的分工经常被搞混我见过有人往 pages.json 里加mode: hash的那当然不会有任何效果。pages.json决定“应用里有哪些页面”包括路径、导航栏样式、是否 tabBar 页。它对应的概念是 vue-router 的路由表。manifest.json决定“这个应用编译到各端之后长什么样”其中h5.router这一段才管 H5 端的 URL 形态也就是 hash 还是 history、base 是多少。所以标题里说的“配置 hash 路由”动作只有一处改 manifest.json。pages.json 那边一根手指都不用动。还有一个容易被忽略的细节uni-app 的 manifest.json 支持注释语法上是 JSON with CommentsHBuilderX 里改完会自动格式化。但如果你用 CLI 创建的项目改完 manifest.json 之后开发服务器不一定会热更新配置必须重启npm run dev:h5。我遇到过好几次“改完没反应”都是因为只等热更新了。1.3 配置改了却没反应的三种情况排查“为什么我配了 hash 没生效”的时候我一般按这三步走第一种改错了文件。去 pages.json 或者 vue.config.js 里找路由配置这是最常见的。hash 模式只认 manifest.json 的h5.router.mode。第二种没重新编译。manifest.json 属于编译期配置改动之后必须重启编译进程。HBuilderX 里的表现是“运行到浏览器”需要重新点一次CLI 项目要 CtrlC 再起。第三种本来就是 hash你以为没生效。这一点很多人会忽略uni-app H5 端的默认路由模式就是 hashmode字段不写也是 hash。所以你看到 URL 里带#/pages/index/index不是配错了是它一直就这样。真正需要显式配置的场景通常是两个一是项目之前被改成了 history现在要改回来二是在团队协作里把默认值写死防止有人误改。我自己偏向显式写出来。虽然是默认值但写在 manifest.json 里等于给团队留了一份声明比“靠默认值”靠谱得多。2. hash 模式的落地点manifest.json 里那几个字段的真实分工2.1 mode、base、publicPath 各管一段这三个字段是最容易配错的组合我见过把它们当成一个东西来回试的。实际分工是这样的字段位置作用配错的典型症状router.modeh5.router.mode决定 URL 是#/pages/x还是/pages/x配错后刷新 404或分享链接被截断router.baseh5.router.base路由前缀影响路由解析和生成的链接部署到子目录后路径全错跳转 404publicPathh5.publicPath静态资源js/css/图片的加载前缀页面白屏控制台一片 404 的 chunk 请求publicPath和路由完全没关系它管的是 webpack 打包出来的资源引用路径。但这两个字段经常要一起改部署到https://example.com/app/这种二级目录下时publicPath不写/app/JS 加载不到页面直接白屏router.base不写/app/路由解析会从根开始算跳转全 404。关于 base 在 hash 模式下的行为可以这样理解它会被拼在域名和#之间。比如 base 是/app/最终 URL 长这样https://example.com/app/#/pages/index/index。而 history 模式下 base 是直接拼在 path 上的。两种模式下 base 的语义一致只是外观不同。2.2 二级目录部署的组合写法给一份我实际在用的配置直接抄就行{ h5: { publicPath: /app/, router: { mode: hash, base: /app/ } } }如果是部署在根目录两个值都写/就行这是默认情况一般不用动。这里有个坑要说清楚publicPath和router.base在写法上必须以/开头和结尾少了斜杠就会出现/app#/pages/index这种拼接错误。我见过写成base: app的结果跳转全指向根目录排查了半天以为是守卫的问题。还有一种更省心的做法如果你不确定最终部署路径publicPath可以写成相对路径./这样资源会相对于 index.html 加载部署到任何目录都不会白屏。但router.base不建议用相对路径路由前缀还是老老实实写绝对路径比较稳。2.3 hash 与 history 的取舍对照既然标题是“配置 hash”那为什么不是 history这个选择在不同项目里答案不一样我把实际对比列出来维度hash 模式history 模式URL 外观带#如/app/#/pages/index/index干净如/app/pages/index/index服务端要求不需要任何配置必须配置回退到 index.html否则刷新 404分享链接兼容性部分平台会截断#之后的内容无此问题第三方回调带参参数容易落在#前面需要在入口处处理参数直接在 search 上解析简单页面内锚点跳转和路由 hash 冲突基本用不了正常可用部署成本低扔上静态服务器就能跑需要运维配合选择 hash 最实在的理由就是部署成本低。一个纯静态托管的环境history 模式刷新页面必 404因为服务器找不到/pages/detail/detail这个真实文件。要么让运维配转发规则要么自己加一层 404 兜底都得多花功夫。如果项目里没有强 SEO 需求也不想折腾服务端hash 是性价比最高的选择。相对应的它的代价就是那两个分享链接可能被截断、第三方回调参数位置不对。这两个坑我在第 5 章会展开讲怎么处理都是可以解决的问题不是硬伤。3. 拦截入口不止一个addInterceptor、beforeEach、页面内判断的边界3.1 uni.addInterceptor 管的是 API 调用先把这个最容易误解的说清楚uni.addInterceptor拦截的是“你调用了某个 API”这个动作而不是“地址发生了变化”这个事实。const apis [navigateTo, redirectTo, reLaunch, switchTab] apis.forEach(api { uni.addInterceptor(api, { invoke(args) { // 返回 false 可以阻止本次调用 return true }, success(res) {}, fail(err) {} }) })invoke会在 API 执行前触发参数里带着原始的args你可以读取甚至改写args.url。返回false会直接阻止这次调用uni.navigateTo会走fail回调。这套机制的好处是同步、直接、不用等异步适合做“明显没登录就别发了”的粗筛。但它有两个明确的边界一是拦不住地址栏变化。用户在浏览器里手动改 hash、点浏览器的前进后退按钮、扫码直接打开某个页面这些都不经过uni.navigateTo拦截器根本不知道发生过什么。二是switchTab的表现不一致。tabBar 页的点击在 H5 端最终会走switchTab理论上能被拦。但如果你在invoke里return falsetabBar 的高亮状态可能已经切换过去了视觉上会出现“高亮在 A 页内容还是 B 页”的错位。所以 tabBar 场景我一般交给beforeEach处理不在这一层做拦截。3.2 从 App.vue 里取 $router 注册全局守卫这是真正能覆盖所有入口的方式。难点只有一个怎么拿到 router 实例。uni-app 不允许你自建 router 入口所以import router from ./router这条路是走不通的。可行的姿势是把守卫注册放到 App.vue 的生命周期里因为 App.vue 是根组件的上一级它的this.$router在 H5 端是可用的。export default { onLaunch() { // #ifdef H5 const router this.$router if (router) { router.beforeEach((to, from, next) { // ... 校验逻辑 }) } // #endif } }// #ifdef H5这段条件编译注释非常关键。App.vue 的代码会被编译到小程序、App 等多端那些端上没有$router不包起来会直接报错。如果某些场景下 App.vue 里取不到实例还有一条备用路径在页面组件里用this.$router或者用getApp().$router。但这两个都不适合注册全局守卫只适合读取当前路由信息。注册守卫还是应该集中在 App.vue 里只注册一次避免重复注册导致守卫被调用多次。3.3 页面级兜底为什么不能省即使有了全局守卫我还是建议在关键页面里留一道页面级判断。原因有两个第一个是时序问题。App.vue 的onLaunch和首次路由解析之间不同 uni-app 版本的执行顺序存在细微差异。如果你的首页在onLoad里就发起了跳转有可能抢在守卫注册之前。这种情况下守卫是空的页面直接就进去了。第二个是状态失效问题。用户把页面挂在后台放了几个小时回来点了一下token 早就过期了但这一下可能只是触发了页面的onShow没有发生路由跳转守卫根本不会被调用。这时候页面级判断是唯一的兜底。页面级的写法很轻onShow() { // #ifdef H5 if (!uni.getStorageSync(token) this.needAuth) { uni.reLaunch({ url: /pages/login/login }) } // #endif }别嫌它重复。全局守卫负责“进入这个页面的那一刻”页面级判断负责“回到这个页面的那一刻”两个时机不一样覆盖的是不同场景。3.4 三种方式的分工汇总一下我自己的用法场景用哪个原因未登录时阻止主动跳转uni.addInterceptor同步、开销小能在 API 层直接掐掉地址栏直输、扫码进入、前进后退router.beforeEach唯一能覆盖地址变化的入口从后台切回、长时间挂起后页面onShow不经过路由守卫不会触发tabBar 页的登录校验router.beforeEach拦截器在这一层有视觉副作用这三层叠起来才叫完整。只写一层就想覆盖所有场景一定会漏。4. 写一版能直接用的守卫白名单、异步鉴权、回跳参数4.1 路径比对的第一道坑前导斜杠这个问题我第一次遇到时查了快一个小时。现象是白名单里明明写了登录页但守卫还是把登录页拦下来了形成死循环。原因是白名单里的路径通常是从 pages.json 抄来的写成了pages/login/login不带前导斜杠。而to.path是 vue-router 的规范一定是/pages/login/login。字符串直接比永远不相等。所以要统一格式。我一般写一个归一化函数function normalize(path) { if (!path) return // 去掉 query 部分 const pure path.split(?)[0] // 补前导斜杠 const withSlash pure.startsWith(/) ? pure : / pure // 去掉末尾多余的斜杠 return withSlash.replace(/\/$/, ) || / }除了前导斜杠还要处理末尾斜杠和 query 混入。有人在白名单里写/pages/index/index?id1那更是永远匹配不上因为to.path里根本不带 query参数在to.query里。4.2 白名单与 token 的读取顺序白名单要放在 token 判断之前这个顺序反了就是死循环的根源。登录页本身必然是没有 token 的如果先判断 token 再判断白名单登录页会被重定向到登录页无限循环下去控制台会刷出一堆 “Redirected when going from ... to ...” 的警告。// #ifdef H5 const WHITE_LIST [ /pages/login/login, /pages/login/register, /pages/common/webview, /pages/error/404 ] function isWhite(path) { const p normalize(path) return WHITE_LIST.some(item normalize(item) p) } function getToken() { return uni.getStorageSync(token) || } // #endifgetToken这里刻意用的是uni.getStorageSync而不是从 Vuex 里读。原因在第 5 章会说简单讲就是刷新页面之后 Vuex 是空的而 storage 是持久的。白名单里我一般会额外放 webview 页和错误页。webview 页通常用于嵌入外部协议页带不了 token 也没关系错误页更不能拦否则一旦跳过去就出不来了。4.3 异步鉴权里 next 的调用纪律真正的业务里token 校验往往是异步的可能需要向服务端确认 token 是否有效、是否被顶下线、有没有续期。这时候next的调用最容易出问题。router.beforeEach(async (to, from, next) { const path normalize(to.path) if (isWhite(path)) { return next() } const token getToken() if (!token) { return next({ path: /pages/login/login, query: { redirect: encodeURIComponent(to.fullPath) } }) } try { const valid await checkTokenOnline(token) if (!valid) { uni.removeStorageSync(token) return next({ path: /pages/login/login, query: { redirect: encodeURIComponent(to.fullPath) } }) } next() } catch (e) { // 网络异常时放行还是拦截要按业务定 next() } })这里有几条纪律必须遵守第一next必须保证被调用且只调用一次。如果await中间抛了异常又没 catch 住next永远不会执行导航会永久挂起页面白屏而且控制台一点报错都没有。这是最难排查的一类问题因为它看起来像“卡住了”实际上是在等一个永远不会来的next。第二不要在next之后继续写逻辑。next调用之后控制权就交回路由了后面的代码可能在导航完成之后才执行容易产生竞态。第三网络异常时的兜底策略要想清楚。上面那个catch里我选择放行因为如果鉴权接口本身挂了把所有用户都踢回登录页是很糟糕的体验。但如果你的业务是强安全场景就应该选择拦截。这个没有标准答案取决于业务对“误放行”和“误拦截”哪个更敏感。4.4 回跳地址的拼接与解码登录成功之后要跳回原页面这个细节做不好体验会很差。存进去的时候要encodeURIComponent因为to.fullPath里可能带着 query直接拼在 URL 里会被二次解析。取出来的时候要decodeURIComponent一进一出必须配对。// 登录页拿到 redirect const pages getCurrentPages() const current pages[pages.length - 1] const redirect current.options current.options.redirect if (redirect) { const target decodeURIComponent(redirect) uni.reLaunch({ url: target }) } else { uni.reLaunch({ url: /pages/index/index }) }这里用reLaunch而不是redirectTo理由是reLaunch能处理 tabBar 页而redirectTo跳 tabBar 页会直接失败。回跳的目标页面是用户上次想去的地方你没法保证它一定不是 tabBar 页所以用reLaunch最保险。代价是reLaunch会关闭所有页面用户登录完成后按返回键会直接退出应用。如果这个体验不能接受可以加一层判断目标页在 tabBar 列表里就用reLaunch否则用redirectTo。5. 实战里真正会卡住人的五个场景5.1 登录页被自己的守卫拦下死循环的成因死循环的外观很好认页面疯狂闪烁控制台刷出一堆重定向警告浏览器甚至可能崩掉。根因就是白名单没生效。但白名单没生效的原因不止一种我按排查顺序列一下路径格式不一致前导斜杠、query、大小写这是最常见的白名单变量在守卫注册之后才初始化导致第一次判断时是undefined。这种情况在模块循环引用时会出现守卫被注册了多次。App.vue 里注册一次某个页面里又注册一次两个守卫互相打架。beforeEach是累加的不像有些框架会覆盖。第二种情况特别隐蔽。如果你把白名单放在单独的文件里导出而那个文件又反过来引用了某个工具函数很容易出现“守卫先跑起来白名单还没赋值”的情况。我的做法是把白名单直接写在守卫定义的作用域里不做跨文件引用。还有一个小技巧在守卫的第一行加一句判断防止自己在登录页的时候被重复调用if (path /pages/login/login isWhite(path)) { return next() }这种“提前返回”的写法能让逻辑更清晰也更容易排查。5.2 刷新即掉登录别在守卫里读 Vuex这个坑在 H5 端特别典型。原因是 hash 模式下刷新页面整个 JS 上下文重新执行Vuex 的状态全部丢失store.state.user.token读出来就是空字符串。守卫一看没 token直接把人踢回登录页。用户的感觉就是“随便刷新一下就要重新登录”。在小程序里不会遇到这个问题因为小程序的页面刷新是重新走一遍onLoad但应用实例没有销毁。H5 就成了重灾区。解决方法很直接守卫里读 token 一律走uni.getStorageSync不碰 Vuex。如果确实要在守卫里用用户信息从 storage 里恢复一份或者干脆只判断 token 是否存在具体信息等到页面里再取。顺带说一句uni.getStorageSync是同步的在守卫里调用完全没有性能问题。有些人担心同步读取本地存储会卡实测下来这点消耗可以忽略不计远不如一次网络请求。5.3 第三方回调参数落在 # 前面这个坑是 hash 模式特有的也是很多人切回 hash 之后才发现的。假设你的页面地址是https://example.com/app/#/pages/index/index某个第三方服务授权登录、支付回跳、短信短链要在你的页面上追加参数它拼接出来的地址通常是https://example.com/app/?codexxxstateyyy#/pages/index/index。注意参数落在了?后面、#前面。而 vue-router 在 hash 模式下解析的是#之后的部分to.query里自然什么都拿不到。代码里读to.query.code永远是undefined。处理方式有两种我推荐第一种方式一入口处抢救。在 App.vue 的onLaunch里先看window.location.search有没有内容有的话解析出来用history.replaceState把参数挪进 hash 里再让路由正常解析。// #ifdef H5 function rescueSearchParams() { const search window.location.search if (!search) return const query search.replace(/^\?/, ) const hash window.location.hash || #/pages/index/index const [hashPath, hashQuery] hash.split(?) const finalPath hashQuery ? ${hashPath}?${hashQuery}${query} : ${hashPath}?${query} window.history.replaceState(null, , window.location.pathname finalPath) } rescueSearchParams() // #endif执行完之后 URL 变成https://example.com/app/#/pages/index/index?codexxxstateyyyto.query就能正常读到了。注意这段代码要在路由解析之前执行放在onLaunch的最前面比较合适。方式二直接读原始参数。不走 vue-router在需要的页面里直接new URLSearchParams(window.location.search).get(code)。这种方式简单粗暴缺点是参数不会进入路由状态页面内其他逻辑拿不到而且一旦发生路由跳转参数就彻底丢了。如果你的回调只在一个页面里用一次方式二够用。如果回调参数需要流转到后续页面用方式一。5.4 switchTab 与 navigateTo 回调的行为差异这一点文档里没有明说但实际写代码时会撞上当守卫在beforeEach里做了重定向uni.navigateTo的success回调可能仍然会触发也可能走fail具体表现和 uni-app 版本有关。这意味着你不能依赖navigateTo的success回调来做后续逻辑。比如这样写uni.navigateTo({ url: /pages/order/detail?id1, success() { // 以为跳过去了其实被守卫重定向到了登录页 this.doSomething() } })如果守卫把这次跳转重定向到了登录页success里执行业务逻辑就是错的。我的处理原则是凡是可能被守卫拦截的跳转都不在回调里写业务逻辑。需要传数据就用 storage 或者 Vuex需要等页面加载完再做事情就放到目标页的onLoad里。还有switchTab的问题在addInterceptor里对switchTab返回falsetabBar 的高亮可能已经变了。这个视觉错位用户是能看到的所以 tabBar 页的校验一定要放在beforeEach里让它从根上就不允许导航发生。5.5 首次进入时守卫可能抢跑以及怎么验证前面提过onLaunch里注册守卫和首次路由解析之间的时序不同版本有细微差别。我遇到过一次首页直接进去没被拦的情况加了日志才发现首次导航发生时守卫还没注册上。比较稳的做法是双保险onLaunch() { // #ifdef H5 rescueSearchParams() const router this.$router if (router) { router.beforeEach(guard) } // #endif }然后在首页的onLoad里再做一次判断作为兜底。如果你的首页本身就是需要登录的页面这一道兜底不能省。验证守卫是否真的生效我一般用这三个手段第一在守卫里打日志输出to.path、from.path和判断结果。用浏览器控制台看每一次跳转的顺序一眼就能看出哪一次漏了。第二打开 vue-devtools 的路由面板看路由历史。被拦截的跳转不会出现在历史里通过这一点能区分“跳了但被重定向”和“压根没跳”。第三手动测试四个入口点击页面内按钮跳转、浏览器地址栏直接输 URL、按浏览器后退键、从外部链接比如微信里粘贴进入。四个都过了才算真的覆盖完整。6. 上线前值得花半小时做的检查我自己有个习惯H5 项目加完守卫之后上线前一定会过一遍这几个点。这些检查花不了多少时间但能省掉不少线上排查的功夫。检查项怎么查不通过的风险hash 模式是否真的生效地址栏应带#/pages/xxx刷新不 404配错成 history静态托管下刷新全挂base 与 publicPath 是否匹配部署路径打开任意二级页面看资源请求前缀白屏JS chunk 404白名单路径格式打印to.path与白名单逐条比对登录页死循环刷新后是否掉登录登录后按 F5看是否回到登录页Vuex 读 token用户天天重登第三方回调参数手动在 URL 上加?code1测试授权、支付回跳全失效tabBar 页面拦截效果未登录状态点每个 tab高亮错位或直接进页面回跳地址带 query 的参数页跳登录再回跳参数丢失页面显示异常这里面我最看重的是“刷新后是否掉登录”。因为这个问题的用户感知最强而且排查起来最像玄学——开发阶段你可能一直在同一个会话里测试根本发现不了。7. 最后一点个人的实操习惯我现在做 uni-app 的 H5 项目路由这块会先定三件事写在项目 README 的第一屏路由模式是什么、base 是什么、白名单在哪维护。这三件事定下来后面接手的人不用再去 manifest.json 里翻。守卫的代码我会单独放一个common/guard.js导出两个函数setupGuard(router)和checkPageAuth()。前者在 App.vue 里调一次后者在需要鉴权的页面onShow里调。这样全局和页面级两条线是分开的不至于一改就牵一发而动全身。条件编译的注释放的位置也有讲究。我一般把// #ifdef H5包在最外层整段 H5 专属逻辑都在里面包括window对象的操作、$router的访问、history.replaceState的调用。这样编译到小程序端的时候整块代码会被直接剔除不会因为一个window is not defined导致整个小程序起不来。还有一个习惯是每次改动路由配置之后一定用手机浏览器真机跑一遍别只在开发者工具里看。工具栏里的模拟器和真机的表现差异比想象中大尤其是涉及浏览器前进后退、从微信里打开、从后台切回这些场景模拟器根本模拟不出来。我吃过一次亏——工具里一切正常真机上从微信打开直接白屏原因是publicPath配了绝对路径但实际部署在子目录。这种事早发现半小时少熬一个通宵。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →