博客迁移收官日,新前台上线不到一小时,我就发现顶栏点不动了。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 对不上」和「路由不切换视图」之间还差一整条因果链。为了看清链条,往根组件里临时塞了几个调试钩子:把 RouterChildrenOutletContexts 挂到 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 零报错

两个原因叠加:

  1. NG0500 只在 dev 构建输出,production 静默——线上排查永远看不到它;
  2. mismatch 之后应用继续正常运行,Router、事件监听一概正常,故障面看起来极小。

这也是这场悬案拖了一天的主要原因:所有常规观测手段都失效了。零报错不是「没有问题」的证据,恰恰是这类问题最阴险的特征。

一个反直觉的验证

「从纯 CSR 页面出发一切正常」这条线索当时就让我确信问题在 hydration——现在可以解释得更狠:纯 CSR 页面没有预渲染 DOM,hydration 无事可做,mismatch 根本不会发生,整条链自然完好。凶手的作案前提是「有服务端渲染的 HTML 可供认领」

定位工具箱(下次直接抄)

按性价比排序,前两步基本就能破这类案:

  1. hydration 疑难,一律先切 dev 构建。production 对 NG05xx 静默,别在压缩产物上白费时间。
  2. 给页面装错误收集器:hook window.onerror / unhandledrejection / console.error,把错误存进数组随时读——尤其在没法开 devtools 录制完整加载过程的时候。
  3. 一招判断「是不是整页跳转」:点击前设 window.__marker = 42,点击后还在 → SPA 导航(Router 在工作);消失 → 浏览器默认跳转(routerLink 根本没拦住)。两种情况修复方向完全不同。
  4. 核对 Router 事件流:把 Router 实例挂到 window,点击后看事件序列。NavigationEnd 完整出现而视图没换 → 问题在「激活到 DOM」的最后一公里。
  5. 验证 outlet 注册:hook ChildrenOutletContexts.onChildOutletCreated,全程无日志 = outlet 从未注册 = 视图初始化链条断在更上游。
  6. 检查 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> 了,并在这篇文章里获得了永生。