uni-app H5 路由切 history 模式:编译时特化、三层 Base 与动态路径部署
把 H5 路由从 hash 切到 history,表面上是一行配置的事。但这个改动背后牵扯三个层面的机制:浏览器的导航模型、服务器的部署契约、构建工具的编译策略。任何一个层面理解有偏差,产出的就是"本地正常、线上白屏"或"配置写 A、产物跑 B"的缝合包。
这篇文章把这三层机制讲透,最后给一个进阶实践:一份产物部署到运行时才能确定的路径。
一、先讲本质:hash 和 history 的差异到底在哪
SPA 路由的一切差异,源于浏览器导航模型中的一个规定:
URL 的 fragment(
#及之后的部分)不会出现在 HTTP 请求中。
由此推导出两种路由模式的全部行为差异:
| 导航场景 | hash 模式 | history 模式 |
|---|---|---|
| 应用内跳转 | 改 fragment,pushState,不发请求 |
改 path,pushState,不发请求 |
| 用户刷新 / 直接输入 URL | 请求的永远是 /index.html |
把完整 path 发给服务器 |
| 服务器视角 | 永远只有一个真实文档 | 无穷多个"看起来存在"的路径 |
| 路由状态的归属 | 客户端私有 | 服务器可见的公共状态 |
切换到 history,本质上是把路由状态从"客户端私有"提升为"服务器可见"。收益是真实的:URL 即状态,分享、埋点、埋深链接都更干净;OAuth 回跳不再有 fragment 被截断的经典问题。代价也来自同一个地方:服务器必须参与兜底。
二、history 模式的部署契约
契约一:SPA fallback,注意 try_files 的语义
所有未命中真实静态文件的路径,都要返回 index.html 让前端路由接管。nginx 的标准写法:
nginx
location /app/ {
alias /path/to/deployed/; # 文件系统路径配在这里
try_files $uri $uri/ /app/index.html; # 最后一项是回退目标
}
这里有个高频配置错误值得单独强调:try_files 最后一项是 URI,不是文件系统路径 。它触发的是一次内部重定向------nginx 会拿着这个 URI 重新匹配 location。如果写成 try_files $uri /build/app/index.html(服务器上的真实文件路径),没有任何 location 匹配它,规则等于没配。
验证方式永远是状态码矩阵,而不是"页面能不能打开":
powershell
curl.exe -s -o NUL -w "%{http_code}`n" https://your-domain.com/app/ # 应 200
curl.exe -s -o NUL -w "%{http_code}`n" https://your-domain.com/app/pages/me # 应 200(fallback 生效的铁证)
另外提醒一个容易误判的点:hash URL 能访问不构成任何证据 。/app/index.html#/pages/me 这个请求发给服务器的只有 /app/index.html------一个真实存在的文件,跟 fallback 没有任何关系。
契约二:缓存策略是发版正确性问题,不只是性能问题
index.html:必须Cache-Control: no-cache。它引用的是带 hash 的资源文件名,一旦被缓存,发版后老用户会拿着旧 HTML 去请求已被覆盖删除的旧资源------白屏assets/*(文件名带 hash):放心immutable长缓存
三、SPA 的 Base 其实有三重身份
部署出问题时,多数人对"base"的理解是混成一团的。实际上一个 SPA 有三个互相独立的 base:
| 身份 | 控制什么 | 在 uni-app 里由谁决定 |
|---|---|---|
| 文档 Base | 用户访问的 URL 前缀,如 /app/ |
服务器部署位置决定,前端无法控制 |
| 资源 Base | index.html 里 JS/CSS 的引用前缀,可指向独立 CDN |
vite 的 base(生产可用 CDN 绝对地址) |
| 路由 Base | createWebHistory(base) 的参数,路由匹配的起点 |
manifest 的 h5.router.base |
三条约束关系:
- 资源 Base 可以与文档 Base 解耦------指向 CDN 绝对地址时,页面部署在哪个路径都不影响资源加载。这是很多部署方案的地基
- 路由 Base 必须等于文档 Base------vue-router 会拿 URL 剥掉 base 后去匹配路由表,两者不一致的表现是:页面能加载(资源 200)但路由失配、白屏
- 三者由两套独立配置控制(vite base 与 router base),最常见的错误是只改了一个
ts
// 资源 Base:vite.config.ts → index.html 里的引用前缀
base: VITE_H5_CDN_BASE || VITE_APP_PUBLIC_BASE
// 路由 Base:manifest.config.ts → 编译注入运行时
'h5': {
router: { base: VITE_APP_PUBLIC_BASE, mode: 'history' }
}
把这三重身份分开,"部署到子路径后白屏"这类问题的排查路径就清晰了:先看资源请求是否 404(资源 Base 错),再看路由是否匹配(路由 Base 错),最后才轮到服务器 fallback。
四、uni-app 的路由模式是编译时特化,不是运行时配置
这是整篇文章最值得记住的机制。
uni-app H5 产物里,运行时配置对象 __uniConfig.router.mode 看起来记录了路由模式------但路由初始化根本不读它 。读源码(@dcloudio/uni-h5):
js
// uni-h5 运行时
function initHistory() {
let { routerBase } = __uniConfig.router;
if (routerBase === "/") { routerBase = ""; }
const history2 = __UNI_FEATURE_ROUTER_MODE__ === "history"
? createWebHistory(routerBase)
: createWebHashHistory(routerBase);
}
路由模式由 __UNI_FEATURE_ROUTER_MODE__ 决定,而它是个编译时常量 。生成逻辑在 @dcloudio/uni-cli-shared:
js
function initManifestFeature({ manifestJson, ... }) {
const features = { routerMode: '"hash"', /* ... */ };
const webManifest = manifestJson.web || manifestJson.h5;
if (webManifest?.router?.mode === 'history') {
features.routerMode = '"history"'; // 编译为字面量
}
return features;
}
链路是:manifest.json → 特性分析 → vite define 注入 → 编译期字符串替换 → minify 死代码消除(常量比较,一个分支直接消失)。
最终产物里,三元表达式整个没有了,选中的构造器被直接焊死:
js
// history 产物(minify 后)
const history2 = createWebHistory(routerBase);
// hash 产物
const history2 = createWebHashHistory(routerBase);
为什么框架要这样设计
这叫编译时特化(compile-time specialization) :同一个框架源码,按项目配置裁剪出只含所需实现的产物。收益是确定的------vue-router 的两套 history 实现可以被 tree-shake 掉一份。同类设计在业界很常见:Vue 的 __VUE_OPTIONS_API__、React 的 __DEV__、Svelte 的各种编译开关,都是同一个思路。
对开发者的三条推论
- 改配置 = 改代码 。路由模式变更必须触发完整重新编译。任何构建缓存链路上的断层(CI 缓存了中间产物、manifest 生成与构建被拆散)都会造成"配置与产物不一致"------而且这种不一致极难发现,因为产物里的
__uniConfig.router.mode元信息还是对的 - 验证产物要看代码,不要看配置。判断一个包到底是哪种模式,搜构造器:
powershell
$js = Get-Item .\dist\build\h5\assets\index-*.js | Select-Object -First 1
Get-Content $js.FullName -Raw | Select-String 'initHistory|createWebHashHistory' -AllMatches
# initHistory 内为 createWebHistory 且 createWebHashHistory 出现 0 次 → 纯 history 产物
- 配置驱动编译的框架,调试行为不一致时优先怀疑编译链路。"源码里明明有分支,产物里怎么没有了"------先想 define,再想 minify
五、进阶实践:一份产物,部署到动态路径
需求与约束分析
需求:同一份 H5 产物,要部署到运行时才能确定的路径,如 /share/123/prod/、/share/366/prod/......构建时不可能预知所有前缀,也不可能每个路径构建一次。
用第三节的框架分析,三个 base 里:
- 资源 Base:已指向固定 CDN,与文档路径天然解耦------不用动
- 文档 Base:服务器决定------不受前端控制
- 路由 Base :唯一需要动态化的,且它是运行时 从全局对象读取的(
initHistory里let { routerBase } = __uniConfig.router)
问题从"不可能"变成了"找到改写 __uniConfig 的时机窗口"。
时机窗口分析
产物加载的时序:
arduino
① index.html 内联同步脚本:定义 __uniConfig(含编译期注入的 routerBase)
↓
② main.ts(module 脚天然 defer,晚于①执行)
↓
③ uni 初始化路由:initHistory 读 __uniConfig.router.routerBase
②和③之间就是窗口:在 main.ts 模块体顶部(所有 import 之后、createApp 之前)按当前 URL 改写即可,一定先于路由初始化。
实现
ts
// src/main.ts 顶部
// #ifdef H5
;(function adaptDynamicBase() {
// 部署在 /share/{id}/prod/ 等动态路径时,按当前 URL 改写路由 Base
const m = location.pathname.match(/^(\/share\/\d+\/prod)\//)
const cfg = (window as any).__uniConfig
if (m && cfg?.router) {
const base = `${m[1]}/`
cfg.router.base = base
cfg.router.routerBase = base
}
})()
// #endif
设计要点:
- 正则不匹配时不改写------回落到构建时的兜底值,本地开发和既有部署零影响
- 正则按实际路径形态收紧/扩展
- 应用内后续所有跳转(
uni.navigateTo等)都基于同一个 router 实例,base 改写对全链路生效
服务器侧:正则 location 一次配齐所有部署点
nginx
location ~ ^(/share/\d+/prod)/ {
root /path/to/deploy_root;
try_files $uri $uri/ $1/index.html; # $1 = 正则捕获的动态前缀,逐点回退
}
风险声明
__uniConfig 是 uni-h5 的内部约定(自 vue2 时代就存在,非常稳定),但不是公开 API 。框架升级时需要回归:构建后确认产物中 initHistory 仍从 __uniConfig.router 解构 routerBase。接受这个约束,方案就是稳的;不能接受,退回构建时注入方案(每个路径构建一次,cross-env VITE_APP_PUBLIC_BASE=/share/123/prod/ uni build),零 hack 但产物不可复用。
六、总结:history 部署的完整心智模型
lua
浏览器(导航模型) → fragment 不发请求 / path 发请求 → 决定服务器是否需要参与
服务器(部署契约) → fallback 回退 URI / 缓存二分法 → 决定刷新和发版是否正确
构建(三层 Base) → 文档 / 资源 / 路由 各自独立 → 决定部署到哪里不出错
框架(编译特化) → 路由模式是编译时常量 → 决定配置如何真正生效
最后给一份生产级 checklist,三个阶段各 30 秒:
构建后(产物自检)
powershell
# 路由模式:initHistory 内应是 createWebHistory,createWebHashHistory 出现 0 次
$js = Get-Item .\dist\build\h5\assets\index-*.js | Select-Object -First 1
Get-Content $js.FullName -Raw | Select-String 'createWebHistory|createWebHashHistory' -AllMatches
# 资源前缀:index.html 里的引用应符合预期(CDN 或部署路径)
Select-String -Path .\dist\build\h5\index.html -Pattern 'src="([^"]+)"' | Select-Object -First 5
部署后(服务验证)
powershell
# fallback 生效(全部应 200,路由路径 200 是铁证)
curl.exe -s -o NUL -w "%{http_code}`n" https://your-domain.com/app/
curl.exe -s -o NUL -w "%{http_code}`n" https://your-domain.com/app/pages/xxx
浏览器验证(无痕窗口)
打开入口 URL,确认两件事:地址栏不会 自动补 #/(它是 hash 产物的指纹);点切换页面,地址栏是纯路径 /app/pages/xxx。
三个阶段都过,history 部署才算真正闭环。