上周有个朋友拿着他们组的 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 Comments),HBuilderX 里改完会自动格式化。但如果你用 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 项目要 Ctrl+C 再起。
第三种,本来就是 hash,你以为没生效。这一点很多人会忽略:uni-app H5 端的默认路由模式就是 hash,mode字段不写也是 hash。所以你看到 URL 里带#/pages/index/index,不是配错了,是它一直就这样。真正需要显式配置的场景通常是两个:一是项目之前被改成了 history,现在要改回来;二是在团队协作里把默认值写死,防止有人误改。
我自己偏向显式写出来。虽然是默认值,但写在 manifest.json 里等于给团队留了一份声明,比“靠默认值”靠谱得多。
2. hash 模式的落地点:manifest.json 里那几个字段的真实分工
2.1 mode、base、publicPath 各管一段
这三个字段是最容易配错的组合,我见过把它们当成一个东西来回试的。实际分工是这样的:
| 字段 | 位置 | 作用 | 配错的典型症状 |
|---|---|---|---|
router.mode | h5.router.mode | 决定 URL 是#/pages/x还是/pages/x | 配错后刷新 404,或分享链接被截断 |
router.base | h5.router.base | 路由前缀,影响路由解析和生成的链接 | 部署到子目录后路径全错,跳转 404 |
publicPath | h5.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 false,tabBar 的高亮状态可能已经切换过去了,视觉上会出现“高亮在 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?id=1,那更是永远匹配不上,因为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/?code=xxx&state=yyy#/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?code=xxx&state=yyy,to.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?id=1', success() { // 以为跳过去了,其实被守卫重定向到了登录页 this.doSomething() } })如果守卫把这次跳转重定向到了登录页,success里执行业务逻辑就是错的。
我的处理原则是:凡是可能被守卫拦截的跳转,都不在回调里写业务逻辑。需要传数据就用 storage 或者 Vuex,需要等页面加载完再做事情就放到目标页的onLoad里。
还有switchTab的问题:在addInterceptor里对switchTab返回false,tabBar 的高亮可能已经变了。这个视觉错位用户是能看到的,所以 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 上加?code=1测试 | 授权、支付回跳全失效 |
| 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配了绝对路径但实际部署在子目录。这种事,早发现半小时,少熬一个通宵。