原文:https://dev.to/devteam/fixing-delicate-cache-mismatches-in-a-brownfield-spa-a-pragmatic-solution-dk9(作者 @ben)
公开开发(building in public)聊的常常是亮眼的新功能,但在成熟的生产应用里,最关键的工程工作通常是存量系统问题解决(brownfield problem solving)——在多年积累的代码逻辑与生产系统决策之上持续迭代。
在 DEV(由开源的 Forem 代码库驱动)上,我们采用了一套混合架构:Rails 服务端渲染、Fastly 边缘缓存,再叠加轻量的客户端导航(通过 InstantClick 实现)。这套组合能带来 100 毫秒以内的页面切换,但局部页面替换叠加激进的边缘缓存,也带来了微妙的部署难题。
我们合入了一个修复 PR #23789,它有助于解决 Web 导航中一个天生微妙的缓存错配问题。这个问题几乎从 DEV 诞生之初就存在,期间我们打过几次时好时坏的补丁,但我觉得这次是朝正确方向迈出的一步——尽管它绝不是完美完整的修复。
问题:跨部署的缓存错配
每当我们部署新的 CSS 更新,Rails 都会生成新的资源摘要哈希(例如 views-v2.css 取代 views-v1.css)。
- 完整页面加载: 用户打开更新后的首页。浏览器收到完整 HTML,并在
<head>中加载最新的v2样式表。 - 站内导航(局部页面替换): 用户点击一篇文章链接。InstantClick 不会触发整页刷新,而是在后台请求这篇文章,只把内部的
#page-content容器替换进现有 DOM。 - 边缘缓存陷阱: DEV 使用 surrogate keys 在 Fastly 上激进地缓存文章 HTML 片段。许多文章在最近一次部署之前就已缓存,这意味着缓存中的 HTML 片段仍然引用
v1样式表。
为什么旧方案很脆弱
为了防止页面用过期的 CSS 渲染,我们此前添加过客户端逻辑:检查传入页面所期望的样式表路径,然后动态替换 DOM 中的 <link rel="stylesheet"> 元素。
实践中,这套方案很脆弱:
- 从新版首页(
v2)导航到较早被边缘缓存的文章(v1)时,客户端脚本会比较currentHref (v2) !== expectedHref (v1)。 - 它假定传入页面代表「目标」状态,于是发起一次非自愿降级,把 DOM 换回
v1样式表。 - 如果
v1资源文件在部署后被清理掉了,浏览器就会报 404;就算加载成功,新 UI 组件也会突然坏掉,因为它们更新后的 CSS 在会话中途被剥掉了。 - 试图在
<head>和<body>两处异步替换多个样式表标签,还会引入 CSS 层叠竞态和无样式内容闪烁(FOUC)。
解决方案:复用内部导航参数
我们没有把它当作客户端 DOM 修改问题来处理,而是意识到这本质上是一个缓存分区问题。
多年来,Forem 一直在后台 AJAX 请求上悄悄携带一个内部查询参数 ?i=i,让 Rails 知道应渲染轻量的局部布局,而不是完整的 HTML 文档外壳。
在 PR #23789 中,我们给这个参数赋予了新的用途:
- 组合样式指纹: 首次完整页面加载时,Rails 会根据核心样式表(
minimal、views和crayons)的组合摘要计算出一个确定性的 10 字符哈希,并作为data-style-fingerprint放在<body>上:
`html
<body ... data-style-fingerprint="cc9ed033eb">
`
- 参数化内部导航: InstantClick 预加载或抓取链接时,会读取当前会话的指纹并附加到内部 URL 上:
`plaintext
https://dev.to/user/post?i=cc9ed033eb
`
(如果 `data-style-fingerprint` 缺失——比如部署切换期间保持打开的标签页——会优雅地回退到 `?i=i`。)
- Fastly 上的天然缓存分区: Fastly 的边缘配置早已把
i列入安全参数白名单。由于这个查询参数是缓存键的一部分:
- 处于 v2 会话的用户请求 /post?i=v2_fingerprint 时,Fastly 会查找已缓存的 v2 片段。
- 如果没有,Fastly 就向 Rails 回源取一个新的、用 v2 样式编译的片段。
- 过期的 v1 缓存片段直接被绕过,最终自然过期。
- 删除 DOM 替换逻辑: 有了缓存分区,传入的局部片段必然与当前 DOM 的样式表版本匹配。我们彻底删除了客户端的样式表替换脚本。在整个用户会话期间,浏览器中生效的
<link>标签保持静态不变。
存量系统开发的启示
- 在缓存层解决,而不是 DOM 层: 试图在客户端 JavaScript 里协调 DOM 修改、异步 CSS 加载和层叠优先级,几乎总是比让边缘缓存从一开始就返回正确版本的 HTML 更脆弱。
- 复用现有原语: 我们不需要新路由、边缘计算重写或自定义 HTTP 头。复用现有的
?i=...内部参数,就实现了精确的缓存键控制,基础设施零额外开销。 - 务实的改进胜过完美的重写: 这个修复并不能魔法般地挡住滚动部署期间每一个理论上的边角情况,但它切实解决了破坏性最大的问题。系统不再那么脆弱,我们也拥有了一个干净、可预期的模式,可以在此基础上继续构建。
原文:https://dev.to/devteam/fixing-delicate-cache-mismatches-in-a-brownfield-spa-a-pragmatic-solution-dk9(作者 @ben)



