本篇是 routing.md 的实战补充:routing.md 讲「怎么做」,本篇讲 「为什么这么做、错了会怎样、如何一眼定位」。
素材来自一次真实的多页看板交付(Lit 无构建、部署在
/<env>/<project>/二级路径、 History 干净 URL、走 OBS + CDN)。所有条目都对应一次真实故障。通篇只讲网页应用的通用规律(路由 / base / CDN / 部署 / 验收),不含任何具体业务。
每条按 症状 → 根因 → 最佳实践 / 规避 组织。排查时按症状检索即可。
浏览器地址栏 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),且三者必须由同一个变量派生。
new URL(path, baseURI) 会丢掉部署前缀症状
/dev/myapp/)下,点导航跳到 https://<cdn>/students(域名根,前缀没了)。根因
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()。
@lit-labs/router 在子路径部署下污染路由状态症状
@lit-labs/router 后,子路径部署的应用里路由「偶尔跳错 / 停在错误页 / 点内部链接整页刷新」。根因(两点叠加)
window 上装了全局 click 拦截器,对同源 <a> 自行 pushState ——
绕过你自己的导航函数(也就绕过了 base 前缀处理),且与「手动 <a> + 原生刷新」的行为预期冲突。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 第二节。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'
}
/ 开头的全路径,
交给路由表落到 404 页 —— 别抛异常、别白屏。/dev/myapp//students 不该变成两条不同路由。症状
/index.html,路由参数全丢(这是 302 的特征)。redirect_uri 与实际落地 URL 不一致)。根因
History 模式下,/dev/myapp/students/s01 这个路径在 OBS 上根本不存在(OBS 里只有
index.html 和真实文件)。没有回退规则时,CDN 直接 404。
最佳实践
CDN 配置两条回退规则,顺序不能反:
| 顺序 | 匹配 | 动作 |
|---|---|---|
| 规则 1 | 命中真实静态资源(.js / .css / .png …) |
直接返回该文件 |
| 规则 2 | 其余(路径不存在) | 返回 index.html,状态码 200 |
location.pathname,
路由参数与 OAuth 回调全部破坏。回退「返回内容」而非「重定向」。index.html 设 Cache-Control: no-cache,保证回退永远拿到最新壳。<base href> 与实际部署前缀不一致(三处必须同源)症状
./app.js、./components/x.js)全部 404,
被解析到域名根而不是项目目录。根因
<base href>、OBS 上传前缀、importmap 里的路径前缀,是同一个前缀的三个副本。
只要有一处不一致(比如手工改了其中一处),就从那一处开始 404。
最佳实践
三处同源:在部署脚本里用一个变量派生全部前缀,禁止三处各写各的:
OBS_PREFIX="dev/${PROJECT}" # OBS 上传前缀
BASE_HREF="/dev/${PROJECT}/" # <base href> 期望值
# importmap 里的业务/依赖路径 = /${OBS_PREFIX}/...
部署脚本里硬校验(失败即中止,别等到线上白屏):
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; }
importmap 用内联(<script type="importmap">{...}</script>),不要用
<script type="importmap" src="..."> 外链 —— 外链 importmap 浏览器支持面窄,
内联是唯一 100% 可靠的形式。内联的那份同时托管一份到依赖仓供他项目复用。
依赖也带前缀:若依赖与业务同源(如都在 /<env>/npm/),importmap 里依赖路径
必须写成 /<env>/npm/lit/vX/index.js,与 <base href> 前缀一致;
同源即无 CORS 问题,是省事之选。
判定口诀:根部署(
/)时它无所谓,子路径部署时它是所有 404 的总开关。
症状
taskId: undefined 或干脆只刷了一个域名 —— 但退出码为 0,流水线绿灯。根因(两类,都要防)
a.example.com,没刷 app.example.com —— 而用户访问的是后者。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);
部署脚本调用刷新失败时不要吞掉(最多提示可手动重试,但要让流水线红)。
index.html 被 sync 跳过 / 被长缓存症状
index.html 还是旧的(但新增的文件都在)—— 典型的「壳没更新」。index.html 是缓存里的旧壳。根因
obsutil sync 按 size + mtime 判定 Skip。index.html 名字固定、内容随发布变化,
若 size 恰好相同(或 mtime 未变),会被静默跳过。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"。index.html 永远 no-cache。curl 首页不算验收症状
curl -w '%{http_code}' 首页 → 200 → 认为发布成功。根因
curl 首页 200 只能证明 index.html 在。浏览器加载一个无构建 ESM 应用会走一条长链路,
任一环断裂都白屏,而 curl 一个都不覆盖:
index.html → 内联 importmap → 解析裸标识符 → 拉模块(MIME 必须 text/javascript)
→ 模块内相对 import(必须带 .js)→ 再拉… → 深链接回退(必须 200)
最佳实践(分层验收,越往下越真)
node --check + 解析):语法/装饰器残留、相对 import 带 .js、
相对目标存在、裸标识符都在 importmap、自定义元素标签含连字符。app.js 出发跟随相对 import 走完整图,
断言每个文件 200 + MIME 为 text/javascript;再断言 importmap 每条目标可达;
再断言深链接返回 200 且内容是 index.html(num_redirects=0)。
—— 这一步比人眼快,且能在 CI 里跑。原则:能自动断言的都断言,别靠「我 curl 了一下 200」。 一个可复用的验收脚本应把上面 1–2 步固化成一条命令,纳入部署流程。
症状
根因
cd 相对路径:脚本用相对路径 cd,或在管道/子 shell 里改了 cwd,
后续 obsutil / 文件路径全部相对错位。最佳实践
脚本开头用以脚本自身位置推导的绝对路径,不依赖调用者的 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 只打印不执行的开关,方便核对将要跑的命令。
<a href> 与 SPA 导航冲突;href() 要留出「新标签打开」症状
navigate(),结果中键/右键「在新标签打开」失效。根因
<a href="/x"> 触发整页导航,不是 SPA 行为。最佳实践
两套出口,按需选:
| 场景 | 用什么 | 说明 |
|---|---|---|
| 站内跳转(按钮/菜单项,语义是「切页面」) | 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 回车触发,保持键盘可达。症状
根因
事件冒泡:内层 <a> 的 click 冒泡到外层卡片的 click 处理器。
最佳实践
@click=${(e) => e.stopPropagation()},切断冒泡;用真实 <a> + target="_blank"
让浏览器开新标签,别用 window.open 绕。{kind, label, url} 数组),渲染层据 kind 决定
图标/文案,便于一致地展示「报告 / 看板 / 文档 / 仓库」等多种入口。// 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)。
<base href> == OBS 上传前缀 == importmap 路径前缀(同一变量派生,脚本里有硬校验)index.html,依赖路径带正确的部署前缀new URL(path, baseURI) 用于导航;无 @lit-labs/router(子路径部署)currentPath() 剥前缀 + 脏 URL 兜底 + 折叠 //index.html,禁 301/302index.html 先删后传 + no-cachetype:directory,对号入座 domain_id,读 refresh_task(字符串),失败退出非 0.js 后缀/importmap 覆盖/标签连字符)通过text/javascript)通过index.html(含一个不存在的路径 → 落 404 页)target=_blank 可点、不触发外层跳转;无数据不编造,留空<base href>