routing-best-practices.md 21 KB

路由最佳实践与常见坑(子路径部署 / History 模式 / CDN 回退)

本篇是 routing.md 的实战补充:routing.md 讲「怎么做」,本篇讲 「为什么这么做、错了会怎样、如何一眼定位」。

素材来自一次真实的多页看板交付(Lit 无构建、部署在 /<env>/<project>/ 二级路径、 History 干净 URL、走 OBS + CDN)。所有条目都对应一次真实故障。

通篇只讲网页应用的通用规律(路由 / base / CDN / 部署 / 验收),不含任何具体业务。

阅读方式

每条按 症状 → 根因 → 最佳实践 / 规避 组织。排查时按症状检索即可。

  • 前缀缺失、深链接刷新 404:见 坑 1
  • 路由状态被第三方库污染:见 坑 2
  • 发版后看到旧页面:见 坑 5
  • 部署脚本「跑完了但东西没上去/地址不对」:见 坑 8

零、一张图记住全部关系

浏览器地址栏  https://<cdn>/<env>/<project>/students/s01
                         └────────┬─────────┘
                    部署前缀(必须三处一致)
                    = OBS 上传前缀
                    = <base href>
                    = importmap 里业务/依赖的路径前缀

index.html   <base href="/<env>/<project>/">     ← 相对 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://<cdn>/students(域名根,前缀没了)。
  • 刷新该页 → 404(OBS 上根目录没有这个文件,CDN 回退也没覆盖到)。
  • 本地开发用根路径部署时一切正常,一上线换二级路径就崩 —— 典型的「环境相关」假象。

根因

new URL(input, base) 的语义是 RFC 3986 相对解析:当 input 以 / 开头时, 它是绝对路径,会整体替换 base 的 path,不保留 base 的目录前缀。

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 href>」这个直觉写法 在子路径部署下必然丢前缀。

最佳实践

先把应用内路径拼到 base 的 pathname 上,再交给 URL 解析(此时相对解析不会丢前缀):

/** 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 拦截器,对同源 <a> 自行 pushState —— 绕过你自己的导航函数(也就绕过了 base 前缀处理),且与「手动 <a> + 原生刷新」的行为预期冲突。
  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):给真实 <a> 用,返回保留前缀的完整 URL。
  • 要不要界面路由框架?不需要。无构建项目里「路由表 + currentPath() + popstate 订阅」 就是最小的、可调试的路由。见 routing.md 第二节。
  • 若坚持用第三方 router:必须在 goto()/popstate 前把 pathname 剥前缀,并 关掉/绕过它的全局 <a> 拦截 —— 成本通常高于自写。

配套:currentPath() 要稳

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 验收清单。

坑 4:<base href> 与实际部署前缀不一致(三处必须同源)

症状

  • 所有相对 import / 相对资源(./app.js、./components/x.js)全部 404, 被解析到域名根而不是项目目录。
  • 或换个环境部署后,importmap 指向的依赖路径 404。

根因

<base href>、OBS 上传前缀、importmap 里的路径前缀,是同一个前缀的三个副本。 只要有一处不一致(比如手工改了其中一处),就从那一处开始 404。

最佳实践

  1. 三处同源:在部署脚本里用一个变量派生全部前缀,禁止三处各写各的:

    OBS_PREFIX="dev/${PROJECT}"       # OBS 上传前缀
    BASE_HREF="/dev/${PROJECT}/"      # <base href> 期望值
    # importmap 里的业务/依赖路径 = /${OBS_PREFIX}/...
    
  2. 部署脚本里硬校验(失败即中止,别等到线上白屏):

    grep -q "<base href=\"${BASE_HREF}\"" src/index.html \
     && echo "ok base href = ${BASE_HREF}" \
     || { echo "!! base href 与部署前缀 ${BASE_HREF} 不一致,相对 import 会 404" >&2; exit 1; }
    
  3. importmap 用内联(<script type="importmap">{...}</script>),不要用 <script type="importmap" src="..."> 外链 —— 外链 importmap 浏览器支持面窄, 内联是唯一 100% 可靠的形式。内联的那份同时托管一份到依赖仓供他项目复用。

  4. 依赖也带前缀:若依赖与业务同源(如都在 /<env>/npm/),importmap 里依赖路径 必须写成 /<env>/npm/lit/vX/index.js,与 <base href> 前缀一致; 同源即无 CORS 问题,是省事之选。

判定口诀:根部署(/)时它无所谓,子路径部署时它是所有 404 的总开关。


坑 5:CDN 刷新「看似成功实则没刷」

症状

  • 发布后线上仍是旧页面(甚至新旧混着:壳是新的、某个 js 是旧的)。
  • 刷新脚本日志打印 taskId: undefined 或干脆只刷了一个域名 —— 但退出码为 0,流水线绿灯。

根因(两类,都要防)

  1. 域名/凭证错配:脚本用的 domain_id(或 AK)只对某个加速域有效, 只刷了 a.example.com,没刷 app.example.com —— 而用户访问的是后者。
  2. SDK 返回结构读错:脚本读 result.refresh_task.id,而 SDK 实际返回的 refresh_task 是字符串 ID(不是对象)→ 恒得 undefined → 打印 taskId: undefined 后照样退出 0,形成假成功。

最佳实践

  • 刷新 type: 'directory'(URL 以 / 结尾),一次覆盖目录下新增/变更文件, 比逐个 file 刷新省事、不易漏。
  • 按域名对号入座:维护 host → domain_id 映射表,URL 里解析出 host 再取对应 domain_id + 凭证;未知域名直接报错退出,绝不静默跳过。
  • 把假成功变成真失败:对刷新结果做非空断言,任一失败则退出码非 0:

    const taskId = r.refresh_task;              // ← 字符串 ID,不是 r.refresh_task.id
    if (!taskId) { failed++; continue; }        // 没有 taskId = 没提交成功,不得算成功
    ...
    process.exit(failed ? 1 : 0);
    
  • 部署脚本调用刷新失败时不要吞掉(最多提示可手动重试,但要让流水线红)。


坑 6:index.html 被 sync 跳过 / 被长缓存

症状

  • 发了新版,index.html 还是旧的(但新增的文件都在)—— 典型的「壳没更新」。
  • 或者 CDN 回退拿到的 index.html 是缓存里的旧壳。

根因

  • obsutil sync 按 size + mtime 判定 Skip。index.html 名字固定、内容随发布变化, 若 size 恰好相同(或 mtime 未变),会被静默跳过。
  • CDN 若把 index.html 也长缓存,回退/更新都拿到旧壳。

最佳实践

  • 先删后传:obsutil rm <prefix>/index.html 再 obsutil cp ./src/index.html ..., 并排除它不参与上一步的 sync(-exclude="index.html")。
  • 传 index.html 时显式设头: -meta="Cache-Control:no-cache, no-store, must-revalidate#Content-Type:text/html; charset=utf-8"。
  • 带内容 hash 的静态资源可长缓存;index.html 永远 no-cache。
  • ⚠️ CDN 冷边缘:新上传的 key 首次被某边缘节点拉取时偶发 404(回源竞争), 重跑一次即消失。验收脚本遇到新 key 的首次 404 不要立刻判定为故障。

坑 7:只 curl 首页不算验收

症状

  • 部署脚本 curl -w '%{http_code}' 首页 → 200 → 认为发布成功。
  • 用户打开 → 白屏(某个模块 404 / MIME 错 / 回退没配 / 某个相对路径错一个字符)。

根因

curl 首页 200 只能证明 index.html 在。浏览器加载一个无构建 ESM 应用会走一条长链路, 任一环断裂都白屏,而 curl 一个都不覆盖:

index.html → 内联 importmap → 解析裸标识符 → 拉模块(MIME 必须 text/javascript)
           → 模块内相对 import(必须带 .js)→ 再拉… → 深链接回退(必须 200)

最佳实践(分层验收,越往下越真)

  1. 静态体检(部署前,node --check + 解析):语法/装饰器残留、相对 import 带 .js、 相对目标存在、裸标识符都在 importmap、自定义元素标签含连字符。
  2. 模块图爬取(部署后,HTTP 层):从入口 app.js 出发跟随相对 import 走完整图, 断言每个文件 200 + MIME 为 text/javascript;再断言 importmap 每条目标可达; 再断言深链接返回 200 且内容是 index.html(num_redirects=0)。 —— 这一步比人眼快,且能在 CI 里跑。
  3. 真实浏览器(最终验收):用 Playwright/真实浏览器点导航 + 刷新深链接, 确认不 404、无控制台报错。HTTP 层过了不代表渲染没问题(渲染报错、Signal 时机等)。
  4. 缓存层:核对 CDN 实际返回的内容(不只是状态码),确认是新版。

原则:能自动断言的都断言,别靠「我 curl 了一下 200」。 一个可复用的验收脚本应把上面 1–2 步固化成一条命令,纳入部署流程。


坑 8:部署脚本的 static cd 陷阱与硬编码 URL

症状

  • 脚本在某个目录下「卡住」或后续命令全部相对错位。
  • 文档里的交付链接换一个项目 / 换一个篇目就 404(把上一篇的 URL 抄到了新篇)。

根因

  • cd 相对路径:脚本用相对路径 cd,或在管道/子 shell 里改了 cwd, 后续 obsutil / 文件路径全部相对错位。
  • 硬编码 URL:把「某一次的线上地址」写死进文档/配置;换项目后就指向不存在的资源。

最佳实践

  • 脚本开头用以脚本自身位置推导的绝对路径,不依赖调用者的 cwd:

    set -euo pipefail
    REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
    cd "$REPO_ROOT"
    
  • 参数化:域名 / 前缀 / 桶都从变量或参数取(PROJECT、OBS_PREFIX、BASE_HREF), 绝不散落字面量;URL 用这些变量拼出来。

  • 交付物链接用统一的 URL 构造函数生成(https://<cdn>/<prefix>/<path>), 不要人肉抄。文档里若必须贴链接,注明它是哪个项目/前缀,避免跨项目误用。

  • 顺手加 --dry 只打印不执行的开关,方便核对将要跑的命令。


坑 9:裸 <a href> 与 SPA 导航冲突;href() 要留出「新标签打开」

症状

  • 点内部链接整页刷新:多标签 KeepAlive 全丢、状态重置、白屏一瞬。
  • 或:为了 SPA 全部改用 navigate(),结果中键/右键「在新标签打开」失效。

根因

  • 原生 <a href="/x"> 触发整页导航,不是 SPA 行为。
  • 但全部改成 JS click 又牺牲了浏览器原生能力(新标签、复制链接、无障碍)。

最佳实践

两套出口,按需选:

场景 用什么 说明
站内跳转(按钮/菜单项,语义是「切页面」) navigate(path) pushState + 派发 popstate,SPA 不刷新
真链接(要给用户「新标签打开/复制链接」) <a href=${href(path)}> href() 返回保留前缀的完整 URL,中间键/右键正常
在卡片/行内点击的链接(防止触发外层跳转) @click=${e => e.stopPropagation()} 见 坑 10
  • 内部导航一律经 utils/router.js,禁止裸 new URL(path, baseURI)(见坑 1) 与第三方全局拦截(见坑 2)。
  • 菜单项若用 <div> 模拟链接,记得补 role="link" tabindex="0" 与 @keydown 回车触发,保持键盘可达。

坑 10:可点击交付链接,点它不该触发外层卡片跳转

症状

  • 卡片整体可点(进详情),卡片里还嵌了「打开报告 / 打开仓库」链接 —— 点内层链接时外层跳转也触发了,或整页被导航走、新标签没打开。

根因

事件冒泡:内层 <a> 的 click 冒泡到外层卡片的 click 处理器。

最佳实践

  • 内层链接 @click=${(e) => e.stopPropagation()},切断冒泡;用真实 <a> + target="_blank" 让浏览器开新标签,别用 window.open 绕。
  • 交给数据的链接用统一结构(如 {kind, label, url} 数组),渲染层据 kind 决定 图标/文案,便于一致地展示「报告 / 看板 / 文档 / 仓库」等多种入口。
  • 进度/状态展示这类看板常见模式:
    • 不要编造数据:字段缺失就留空(如还没有的真实 URL 一律空), 比填假的强 —— 空会被发现,假的会被当成事实。
    • 筛选层统一剔除不该计入统计的条目(内部人员/测试数据), 在服务层做,不要在展示层各自打补丁。
    • 进度口径写清楚(「以输出报告为最终节点」这类),口径变了要在 UI/文档同步标注, 避免同一数字两种理解。

附:内部导航工具(可直接复制的最小实现)

// utils/router.js —— 子路径部署安全的导航(只用 History API,干净 URL)
export function basePath() {
  return new URL(document.baseURI).pathname.replace(/\/+$/, '');
}

export function resolveUrl(path) {
  const base = basePath();
  const q = path.indexOf('?'), 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);
  if (!p.startsWith('/')) p = '/' + p;
  return new URL(base + p + rest, location.origin);
}

export function navigate(path, { replace = false } = {}) {
  const url = resolveUrl(path);
  if (replace) history.replaceState({}, '', url);
  else history.pushState({}, '', url);
  window.dispatchEvent(new PopStateEvent('popstate'));  // History 原生不触发
}

export function currentPath() {
  const base = basePath();
  let path = location.pathname;
  if (base && path.startsWith(base)) path = path.slice(base.length);
  if (!path.startsWith('/')) path = '/' + path;
  return path.replace(/\/{2,}/g, '/') || '/';
}

export function currentQuery() {
  return Object.fromEntries(new URLSearchParams(location.search));
}

export function href(path) { return resolveUrl(path); }   // 供真实 <a href>

路由表 + 订阅(app-shell 里):监听 popstate,用 currentPath() 匹配路由表, 命中则「打开/激活」对应视图(多标签场景下不要整体重渲染,见 tabs-keepalive.md)。


附:上线前 checklist(路由 / base / CDN 维度)

  • <base href> == OBS 上传前缀 == importmap 路径前缀(同一变量派生,脚本里有硬校验)
  • importmap 内联在 index.html,依赖路径带正确的部署前缀
  • 业务代码无 new URL(path, baseURI) 用于导航;无 @lit-labs/router(子路径部署)
  • currentPath() 剥前缀 + 脏 URL 兜底 + 折叠 //
  • CDN 回退:命中真实资源直返;不存在路径 200 返回 index.html,禁 301/302
  • index.html 先删后传 + no-cache
  • CDN 刷新:type:directory,对号入座 domain_id,读 refresh_task(字符串),失败退出非 0
  • 静态体检(语法/装饰器/.js 后缀/importmap 覆盖/标签连字符)通过
  • 模块图爬取(200 + text/javascript)通过
  • 深链接刷新 200 且为 index.html(含一个不存在的路径 → 落 404 页)
  • 真实浏览器点导航 + 刷新深链接,无 404、无控制台报错
  • 交付链接 target=_blank 可点、不触发外层跳转;无数据不编造,留空

相关

  • routing.md —— 路由基本用法(History、守卫、OAuth、多标签交互)
  • deployment.md —— Repose 依赖托管 + OBS/CDN 部署 + 回退规则
  • pitfalls.md —— 无构建的通用坑(装饰器 / CORS / MIME / 多版本 lit)
  • tabs-keepalive.md —— 多标签 KeepAlive 与路由的配合
  • project-structure.md —— 子路径部署与 <base href>