# 路由最佳实践与常见坑(子路径部署 / History 模式 / CDN 回退) > 本篇是 [routing.md](./routing.md) 的**实战补充**:routing.md 讲「怎么做」,本篇讲 > 「为什么这么做、错了会怎样、如何一眼定位」。 > > 素材来自一次真实的多页看板交付(Lit 无构建、部署在 `///` 二级路径、 > History 干净 URL、走 OBS + CDN)。所有条目都对应一次真实故障。 > > 通篇只讲**网页应用**的通用规律(路由 / base / CDN / 部署 / 验收),不含任何具体业务。 ## 阅读方式 每条按 **症状 → 根因 → 最佳实践 / 规避** 组织。排查时按症状检索即可。 - 前缀缺失、深链接刷新 404:见 [坑 1](#坑-1任意的-new-urlpath-baseuri-会丢掉部署前缀) - 路由状态被第三方库污染:见 [坑 2](#坑-2lit-labsrouter-在子路径部署下污染路由状态) - 发版后看到旧页面:见 [坑 5](#坑-5cdn-刷新看似成功实则没刷) - 部署脚本「跑完了但东西没上去/地址不对」:见 [坑 8](#坑-8部署脚本的-static-cd-陷阱与硬编码-url) --- ## 零、一张图记住全部关系 ``` 浏览器地址栏 https://///students/s01 └────────┬─────────┘ 部署前缀(必须三处一致) = OBS 上传前缀 = = importmap 里业务/依赖的路径前缀 index.html ← 相对 import 的解析基准 utils/router 把「应用内路径 /students/s01」拼到 base 的 pathname 上 → 真实 URL CDN 回退 命中不了静态资源 → 200 返回 index.html(禁 302) ``` **一句话:应用内部只认「不含前缀的应用内路径」(`/students/s01`), 前缀只在三处出现一次(base href / 上传前缀 / importmap),且三者必须由同一个变量派生。** --- ## 坑 1:任意的 `new URL(path, baseURI)` 会丢掉部署前缀 **症状** - 子路径部署(如 `/dev/myapp/`)下,点导航跳到 `https:///students`(**域名根**,前缀没了)。 - 刷新该页 → **404**(OBS 上根目录没有这个文件,CDN 回退也没覆盖到)。 - 本地开发用根路径部署时一切正常,一上线换二级路径就崩 —— 典型的「环境相关」假象。 **根因** `new URL(input, base)` 的语义是 **RFC 3986 相对解析**:当 `input` **以 `/` 开头**时, 它是**绝对路径**,会**整体替换** base 的 path,**不保留 base 的目录前缀**。 ```js new URL('/students', 'https://cdn/dev/myapp/') // → https://cdn/students ❌ 前缀 /dev/myapp 被丢掉 new URL('students', 'https://cdn/dev/myapp/') // → https://cdn/dev/myapp/students ✅ 但依赖 base 以 / 结尾,脆弱 ``` 而应用内部路径(`/students`)天然带前导 `/`,于是「尊重 ``」这个直觉写法 在子路径部署下**必然**丢前缀。 **最佳实践** 先把应用内路径**拼到 base 的 pathname 上**,再交给 URL 解析(此时相对解析不会丢前缀): ```js /** base 路径(去尾斜杠),如 '/dev/myapp';根部署时为 '' */ export function basePath() { return new URL(document.baseURI).pathname.replace(/\/+$/, ''); } /** 应用内路径 → 保留部署前缀的绝对 URL(查询串 / hash 原样保留) */ export function resolveUrl(path) { const base = basePath(); const q = path.indexOf('?'); const h = path.indexOf('#'); let cut = path.length; if (q !== -1) cut = Math.min(cut, q); if (h !== -1) cut = Math.min(cut, h); let p = path.slice(0, cut); const rest = path.slice(cut); // ?query#hash 原样带过去 if (!p.startsWith('/')) p = '/' + p; // 容错:允许传 'students' return new URL(base + p + rest, location.origin); } ``` **红线(写进 code review 清单)**:业务代码里**禁止**出现 `new URL(path, document.baseURI)` 用在「导航 / 内部链接」上。一切经 `utils/router.js` 的 `resolveUrl()`。 --- ## 坑 2:`@lit-labs/router` 在子路径部署下污染路由状态 **症状** - 引入 `@lit-labs/router` 后,子路径部署的应用里路由「偶尔跳错 / 停在错误页 / 点内部链接整页刷新」。 - 根部署时完全正常,换二级路径后诡异。 **根因(两点叠加)** 1. 它在 `window` 上装了**全局 click 拦截器**,对**同源 `` 自行 `pushState`** —— 绕过你自己的导航函数(也就绕过了 base 前缀处理),且与「手动 `` + 原生刷新」的行为预期冲突。 2. 它在 `hostConnected` / `popstate` 时,用**未经剥离的 `location.pathname`**(带着 `/dev/myapp/`) 去匹配路由表 —— 而路由表里的 path 是**应用内路径**(`/students`),于是匹配不上 / 匹配错。 **最佳实践** - **子路径部署的项目,不要用 `@lit-labs/router`**。自写一个 ~60 行的 `utils/router.js`: - `navigate(path, {replace})`:`pushState` 后**手动派发 `popstate`**(History 原生不触发), 让订阅者感知;内部走 `resolveUrl()`(保留前缀)。 - `currentPath()`:从 `location.pathname` **剥掉 base 前缀**,返回应用内路径。 - `href(path)`:给**真实 ``** 用,返回保留前缀的完整 URL。 - 要不要界面路由框架?**不需要**。无构建项目里「路由表 + `currentPath()` + `popstate` 订阅」 就是最小的、可调试的路由。见 [routing.md 第二节](./routing.md)。 - 若坚持用第三方 router:必须在 `goto()`/`popstate` 前把 `pathname` 剥前缀,并 关掉/绕过它的全局 `` 拦截 —— 成本通常高于自写。 **配套:`currentPath()` 要稳** ```js export function currentPath() { const base = basePath(); const p = location.pathname; let path = p.startsWith(base) ? p.slice(base.length) : p; // 脏 URL 退回全路径 if (!path.startsWith('/')) path = '/' + path; return path.replace(/\/{2,}/g, '/') || '/'; // 折叠 '//students' } ``` - **剥前缀**:路由表只认应用内路径。 - **脏数据兜底**:万一 URL 落在前缀之外(历史书签 / 手工输入),**退回到 `/` 开头的全路径**, 交给路由表落到 404 页 —— 别抛异常、别白屏。 - **折叠重复斜杠**:`/dev/myapp//students` 不该变成两条不同路由。 --- ## 坑 3:深链接刷新 404 —— CDN 回退不是 200 **症状** - 站内点击正常(SPA 前端接管),但**在该 URL 上直接刷新 / 粘贴给别人打开 → 404**。 - 或:刷新后**地址栏变成了 `/index.html`**,路由参数全丢(这是 302 的特征)。 - 微信 OAuth 回调失败(`redirect_uri` 与实际落地 URL 不一致)。 **根因** History 模式下,`/dev/myapp/students/s01` 这个路径在 OBS 上**根本不存在**(OBS 里只有 `index.html` 和真实文件)。没有回退规则时,CDN 直接 404。 **最佳实践** CDN 配置**两条回退规则**,顺序不能反: | 顺序 | 匹配 | 动作 | |------|------|------| | 规则 1 | 命中真实静态资源(`.js` / `.css` / `.png` …) | 直接返回该文件 | | 规则 2 | 其余(路径不存在) | 返回 `index.html`,**状态码 200** | - **必须 200,禁止 301/302**:302 会由浏览器改写地址栏、丢失 `location.pathname`, 路由参数与 OAuth 回调全部破坏。回退「返回内容」而非「重定向」。 - `index.html` 设 `Cache-Control: no-cache`,保证回退永远拿到最新壳。 - 部署后**逐条实测深链接**(含一个不存在的路径,验证它会落到 404 页而不是报错)。 见 [坑 7 验收清单](#坑-7只-curl-首页不算验收)。 --- ## 坑 4:`` 与实际部署前缀不一致(三处必须同源) **症状** - 所有相对 import / 相对资源(`./app.js`、`./components/x.js`)**全部 404**, 被解析到**域名根**而不是项目目录。 - 或换个环境部署后,importmap 指向的依赖路径 404。 **根因** ``、OBS 上传前缀、importmap 里的路径前缀,是**同一个前缀的三个副本**。 只要有一处不一致(比如手工改了其中一处),就从那一处开始 404。 **最佳实践** 1. **三处同源**:在部署脚本里用**一个变量**派生全部前缀,禁止三处各写各的: ```bash OBS_PREFIX="dev/${PROJECT}" # OBS 上传前缀 BASE_HREF="/dev/${PROJECT}/" # 期望值 # importmap 里的业务/依赖路径 = /${OBS_PREFIX}/... ``` 2. **部署脚本里硬校验**(失败即中止,别等到线上白屏): ```bash grep -q "&2; exit 1; } ``` 3. **importmap 用内联**(``),不要用 `