一个 p 标签,弄哑了我的 SPA 导航
博客迁移收官日,新前台上线不到一小时,我就发现顶栏点不动了。URL 会变、标题会变、页面纹丝不动,console 干净得像什么都没发生过。这牵出了一场持续一整天的悬案:一度结案为「Angular 框架 bug」,最后翻案——凶手是一个不合法的
<p>标签,藏在我从 WordPress 主题里抄来的页脚里。
现场还原
先交代背景。这次博客大迁移的架构是:Angular 静态站点生成(SSG),构建期把全部内容预渲染成 HTML 和 JSON,访客直接读静态文件;同时开着 Angular 的 hydration,让首屏走服务端渲染的 HTML,后续点击走 SPA 导航。
上线后症状如下:
- 顶栏「归档」点下去,地址栏变成
/archives,<title>变成「归档 - sinon.top」; - 页面本身呢?纹丝不动,还停在首页;
- 反过来从归档点「首页」,一样:URL 变了,归档列表赖着不走;
- 打开 DevTools,console 一片干净。零报错,零警告。
最迷惑的就是这个组合:URL 和标题都变了,说明 Router 明明把导航跑完了;可视图死活不换。而且从首页点到归档、再点回首页、再点归档……每次都这样,稳定复现,不给任何线索。
唯一一条有用线索:从一个没有预渲染、纯客户端渲染的页面(比如 404 兜底壳)出发点导航,一切正常。
第一轮:直觉排除法,全灭
按直觉逐项排除,每一项都像真凶,每一项都被证伪:
怀疑懒加载? 路由组件是 loadComponent 懒加载的,改成直接 import——照坏。
怀疑异步数据流? 归档组件里有 rxResource 拉数据,换成常量数据流——照坏。
怀疑路由状态恢复? 当时记忆里 Angular 的 hydration 带「恢复 SSR 时路由状态」的机制,很容易让人联想到「恢复的状态污染了后续导航」。我把本地装的 Router 源码翻了一遍,搜 hydration、replay、restore——什么都没有。这个版本的 Router 里压根不存在路由状态恢复逻辑,顺带发现历史 API 早被移除了。这条路被源码堵死。
试试增量 hydration? withIncrementalHydration() 加上——无效。
排除法走到头,剩下的解释只剩一个:hydration 和 Router 的组合有框架级 bug。当时一天排下来实在没辙,做了个务实决定:关掉 hydration 上线。SEO 靠预渲染 HTML 不受影响,代价只是 SPA 导航时多拉几 KB 的 JSON,还有 CSR 启动时首屏重渲染一遍。能跑,就行。
结案陈词写下:「hydration 路由状态恢复的框架 bug,定案关闭 hydration」。——后来证明,这份结案报告两个错误:结论错了,连作案手法都描述错了。
翻案:换一双眼睛重看
第二天以「旁观者」的视角重新进场,从零开始。这次换了两个关键策略,事后看每一处都是破案关键。
第一步:让凶手自己开口
之前所有排查都在 production 产物上做,console 干净——但「干净」本身可疑。Angular 的 hydration 类错误(NG05xx)只在 dev 构建下输出详细诊断,production 构建一律静默处理。把构建改成不压缩重跑,刷新页面,console 里赫然躺着:
NG0500: During hydration Angular expected <p> but the node was not found.
Angular expected this DOM:
<a ...>
<img ...>
<p>...</p> <-- 期望这里有第二个 p
</a>
hydration mismatch。期望一个 <p>,实际没有。位置:页脚,备案信息那一行。
第二步:锁定破坏半径
报错有了,但「页脚 DOM 对不上」和「路由不切换视图」之间还差一整条因果链。为了看清链条,往根组件里临时塞了几个调试钩子:把 Router 和 ChildrenOutletContexts 挂到 window 上,再给 onChildOutletCreated(RouterOutlet 向父级注册自己的入口)包一层日志。
然后观察到一个惊人的事实:页面加载完成后,onChildOutletCreated 从未被调用过。也就是说——
<router-outlet>上的 RouterOutlet 指令,压根没有实例化。它不是「注册晚了」,是根本没出生。
而 RouterOutlet 的实例化发生在根视图初始化链条里,链条在页脚的 mismatch 处断了。后续所有机制都从这里推导出来。
完整机制链:一个 p 标签的连环作案
作案起点:浏览器不听 Angular 的
模板里那段 HTML 长这样(简化):
<p>
<a href="...">
<img src="badge.png">
<p>备案编号文案</p> <!-- 非法! -->
</a>
</p>
<p> 的内容模型只允许 phrasing content——<p> 套 <p>(哪怕隔着 <a>)是不合法的 HTML。这段代码来自 WordPress 主题,依赖浏览器解析器的容错才「看起来正常」。
关键在于:Angular 的模板编译器不执行浏览器的容错重排。编译器按字面结构照单全收,预渲染时也按字面结构输出 HTML 字符串。而浏览器拿到这个字符串再解析时,会按 HTML 规范自动闭合:
<!-- 浏览器里的实际 DOM -->
<p><a href="..."><img src="badge.png"></a></p>
<p>备案编号文案</p>
<p></p>
内层 <p> 被拆成了兄弟节点,还多出一个空 <p>。同一个模板,Angular 眼里的结构和浏览器眼里的结构,从这一刻起就不是同一棵树了。
hydration 认领失败
客户端 hydration 的本质是「按编译期结构逐个认领服务端渲染的 DOM」。Angular 拿着自己的结构图去核对 <img> 后面应该有个 <p>——浏览器说:没有。于是 NG0500,hydration mismatch。
破坏半径远超想象
如果 mismatch 只是「这一小块 DOM 放弃认领、客户端重渲染」,那不过是小性能损失。但实测结果是:根组件的整条视图初始化链在 mismatch 处中断了。受牵连的头号受害者就是 <router-outlet>:
- RouterOutlet 指令没有实例化;
- 没实例化就没机会执行注册(
ChildrenOutletContexts.onChildOutletCreated); - Router 的导航激活逻辑里有这样一段:激活新组件前要找到目标 outlet——
if (context.outlet) { outlet.activateWith(...) }。outlet 不在注册表里,激活被静默跳过。
于是每次点击导航:Router 完整跑完 NavigationStart → 守卫 → 解析 → 激活 → NavigationEnd(所以 URL、标题、事件流全部正常),最后一步「把新组件塞进视图」因为找不到 outlet 而无声跳过。旧 DOM 没人清理,新组件没人创建。
「URL 变了,页面没动」的全部真相,就藏在这个 if 的静默分支里。
为什么 console 零报错
两个原因叠加:
- NG0500 只在 dev 构建输出,production 静默——线上排查永远看不到它;
- mismatch 之后应用继续正常运行,Router、事件监听一概正常,故障面看起来极小。
这也是这场悬案拖了一天的主要原因:所有常规观测手段都失效了。零报错不是「没有问题」的证据,恰恰是这类问题最阴险的特征。
一个反直觉的验证
「从纯 CSR 页面出发一切正常」这条线索当时就让我确信问题在 hydration——现在可以解释得更狠:纯 CSR 页面没有预渲染 DOM,hydration 无事可做,mismatch 根本不会发生,整条链自然完好。凶手的作案前提是「有服务端渲染的 HTML 可供认领」。
定位工具箱(下次直接抄)
按性价比排序,前两步基本就能破这类案:
- hydration 疑难,一律先切 dev 构建。production 对 NG05xx 静默,别在压缩产物上白费时间。
- 给页面装错误收集器:hook
window.onerror/unhandledrejection/console.error,把错误存进数组随时读——尤其在没法开 devtools 录制完整加载过程的时候。 - 一招判断「是不是整页跳转」:点击前设
window.__marker = 42,点击后还在 → SPA 导航(Router 在工作);消失 → 浏览器默认跳转(routerLink 根本没拦住)。两种情况修复方向完全不同。 - 核对 Router 事件流:把
Router实例挂到 window,点击后看事件序列。NavigationEnd完整出现而视图没换 → 问题在「激活到 DOM」的最后一公里。 - 验证 outlet 注册:hook
ChildrenOutletContexts.onChildOutletCreated,全程无日志 = outlet 从未注册 = 视图初始化链条断在更上游。 - 检查 DOM 归属:预渲染元素上没有
__ngContext__,说明 Angular 压根没接管它。
修复与守则
修复本身朴实无华:把嵌套的 <p> 改成 <span>——文案本来就该是行内元素,主题作者当年大概是随手多敲了一个字母。改完之后 hydration 重新打开,导航矩阵全绿:首页和归档来回切、文章进进出出、分页跳转、强刷直链,全部正常。
修完这一天,沉淀下来几条守则:
- 模板 HTML 必须经得起浏览器解析。重点盯自带隐式闭合的标签:
<p>、<form>、<table>系、<li>、<option>——它们的嵌套限制会被浏览器静默「纠正」,而 Angular 眼里那棵树不会跟着变。 - 从旧系统搬 HTML 进现代框架前,先过一遍合法性检查。老主题的 HTML 活在浏览器容错解析的温床上,恰是 hydration 最怕的输入。
- production console 干净 ≠ 没有 hydration 问题,这类错误天生隐身。
ngSkipHydration不能救场——它只能加在组件宿主元素上,<router-outlet>这种指令宿主会直接报 NG0504。它是局部绕过,不是修复。
尾声
回头看,这场悬案最有意思的地方在于:真正的凶手(非法嵌套)从头到尾都写在源码里,但所有现代工具链都默认它无害——编译器照单全收,浏览器默默容错,两边各干各的,只有 hydration 需要两边严格一致时,裂缝才暴露出来。而暴露的那一刻,它还顺手掐断了报错的喇叭。
现在博客的前台带着 hydration 重新跑起来了,导航利索,首屏也不再重渲染。至于那个 p 标签——它已经变成 <span> 了,并在这篇文章里获得了永生。