Speculation Rules 预渲染:把下一个页面提前跑起来

要不要给一个页面接入预渲染,第一步不是写 <script type="speculationrules">,是算一笔账:预测错了要赔多少。预取一个用户根本不会点的链接,最多浪费几十 KB 流量;预渲染一整个页面就不一样了,浏览器要在后台把目标文档从请求、解析、执行 JS 到首屏渲染全部跑一遍——如果这个页面里有埋点上报、有购物车数量的接口调用、有表单提交后的重定向逻辑,这些副作用会在用户根本还没点开链接的时候就先跑了一遍。埋点平白多一条“访问”记录、库存接口被无谓地调用一次,都是这笔账里要算进去的成本。上周把公司的文章列表页接入 Speculation Rules 之前,我把这几种代价一条条列出来才敢动手。

传统的预加载手段是 <link rel="prefetch" href="/detail/123">,它做的事情很单一:告诉浏览器提前把这个 URL 对应的资源下载下来,放进缓存。等用户真的点进这个页面时,HTML 文档本身是有缓存的,但浏览器仍然要重新走一遍解析 HTML、构建 DOM、下载并执行页面里引用的 JS/CSS、执行 JS 里的初始化逻辑这一整套流程。省下来的只是网络往返时间,CPU 上该干的活一件没少。

对一个内容站的详情页来说,这意味着首字节时间(TTFB)能明显缩短,但从"点击"到"看到内容"之间的解析、渲染、脚本执行开销原封不动地留着。如果详情页里挂了一个体积不小的富文本编辑器或者图表组件,prefetch 帮不上这部分的忙。

悬停预加载库解决了半个问题

instant.page、quicklink 这类库这几年很流行,它们的思路是监听鼠标悬停或者视口内可见的链接,动态地插入 <link rel="prefetch"> 标签,本质上还是在做资源预取,只是把"预取哪些链接"这件事从写死的 HTML 变成了运行时判断。instant.page 用悬停停留超过 65 毫秒作为触发信号,quicklink 用 IntersectionObserver 判断链接是否进入视口,再配合 requestIdleCallback 在浏览器空闲时插入 prefetch。

这套思路好用,但天花板就是 prefetch 本身的天花板:它们能做的只是提前下载资源,没有办法命令浏览器把整个页面提前渲染出来。想要更进一步,只能自己写一套"隐藏 iframe 加载目标页面"的土办法,但这样做完全没有官方约束,iframe 里的页面该跑的 JS 全跑了,包括所有副作用,而且浏览器对 iframe 预加载的资源优先级、内存占用没有任何专门的调度策略,很容易导致同时预加载太多页面而拖慢当前页面。换句话说,这类库既控制不了预渲染的深度(要不要连 JS 都提前执行、执行到什么程度),也没有办法让浏览器按照页面访问概率去动态调整要不要继续预取,悬停这个信号本身也粗糙——用户划过链接不代表真的想点进去,一批链接密集排列的列表页很容易被误触发一堆没用的预取。

Speculation Rules 的语法

Speculation Rules API 用一段 JSON 声明"接下来可能要去哪",写在页面里的一个特殊 script 标签中:

1<script type="speculationrules">
2{
3  "prefetch": [
4    {
5      "urls": ["/product/1001", "/product/1002"]
6    }
7  ],
8  "prerender": [
9    {
10      "where": {
11        "and": [
12          { "href_matches": "/product/*" },
13          { "not": { "href_matches": "/product/*?*" } }
14        ]
15      },
16      "eagerness": "moderate"
17    }
18  ]
19}
20</script>

这段规则里有两种动作。prefetch<link rel="prefetch">类似,只下载资源不渲染;prerender 更进一步,浏览器会在后台开一个不可见的渲染进程,把目标页面完整加载、解析、执行 JS,甚至跑完首屏布局,用户点击链接的瞬间几乎是直接把已经渲染好的页面切换过来,观感上跟"秒开"没有区别。

规则的目标 URL 有两种写法。一种是像上面 prefetch 里那样直接列 urls 数组,适合列表页只有少数几个已知详情页链接的场景;另一种是 where 加选择器规则,配合 href_matches 这类匹配器动态圈定一批链接,不用逐条罗列。href_matches 支持类似 URL Pattern 的通配语法,上面例子里用 "/product/*" 匹配所有商品详情页,又用 not 把带查询参数的链接(多半是筛选后的列表页而不是详情页)排除掉。where 里还能写 document 字段,限定只在某个 base URL 下生效,多站点共用一份规则模板时会用到。

eagerness 这个参数控制的是浏览器要多"主动"地去执行预测。一共四档:immediate 是页面一加载完就立刻开始预取或预渲染,不等任何用户信号,通常只用在极少数几个几乎必然会被访问的链接上(比如搜索结果页第一条);eager 是比较激进的默认档,链接一进入视口附近浏览器就会考虑触发;moderate 要等用户对链接表现出明确的意图信号(比如鼠标悬停超过 200 毫秒、或者手指按下但还没松开)才触发,这也是列表页链接推荐用的档位,兼顾命中率和浪费;conservative 最保守,几乎要等到用户即将点击(pointerdown)才触发,节省带宽但预渲染来不及跑完的概率也更高。没有指定 eagerness 时,prefetch 默认是 moderateprerender 默认也是 moderate——这个默认值本身就体现了规范制定者对预渲染浪费成本的谨慎。

规则脚本可以动态插入,也可以有好几段

前面的例子都是把 <script type="speculationrules"> 直接写死在 HTML 里,实际项目中更常见的做法是运行时用 JS 动态插入,原因很直接:列表页上展示哪些详情页链接是随内容变化的,写死一份规则没法覆盖分页加载出来的新内容。规则脚本支持动态创建,插入之后立刻生效,不需要等下一次整页刷新:

1function addSpeculationRules(urls) {
2  const script = document.createElement('script');
3  script.type = 'speculationrules';
4  script.textContent = JSON.stringify({
5    prerender: [{ urls, eagerness: 'moderate' }],
6  });
7  document.body.append(script);
8}
9
10// 无限滚动加载出新一页文章后
11addSpeculationRules(newlyLoadedArticleUrls);

一个页面里可以同时存在好几个 speculationrules 脚本,它们的规则会被合并生效,不是后一个覆盖前一个。这对单页应用的路由场景很有用——路由切换时可以把上一屏不再相关的规则脚本移除掉(通过 script.remove()),避免规则列表随着用户翻页越滚越大,浏览器要维护的候选目标也不至于无限膨胀。

prefetch 动作预取的其实是整个文档

容易被 <link rel="prefetch"> 的经验带偏的一点是:以为 Speculation Rules 里的 prefetch 动作也只是泛泛地预取"资源"。实际上它预取的是目标页面的完整 HTML 文档响应,并且这份响应会被放进一个专门的投机性缓存里,等用户真的导航过去时,浏览器直接复用这份已经下载好的文档去解析渲染,省掉的是这次导航请求本身的网络往返,而不是像资源提示那样只是把某个静态资源丢进普通 HTTP 缓存等着被动命中。

这也是为什么用 Speculation Rules 的 prefetch 替代手写的 <link rel="prefetch" href="/article/123"> 通常收益更稳定——后者依赖 HTTP 缓存策略是否允许这份文档被缓存、缓存有效期设置得合不合理,命中与否有一定的不确定性;前者是浏览器自己维护的一份专用缓存,只服务于接下来这一次可能发生的导航,不受常规缓存头配置的影响,命中判断也更贴合"用户是不是马上要跳转"这个场景本身。

预测错一次到底要赔多少

把这笔账摊开算一遍,才知道值不值得为它专门写一段防御代码。一个中等复杂度的详情页,完整加载下来的资源体积大概在一两百 KB 到几 MB 不等,取决于图片和字体有多少。假设一个列表页展示 20 条链接,moderate 档位下大概三分之一到一半的悬停会被判定为足够明确的意图信号从而触发预渲染,如果这批预测里又有一半用户最终没有点击,等于每次打开列表页都要在背后多跑几个完整的详情页加载,流量成本是实打实的,尤其在按流量计费的 CDN 或者用户本身在用移动网络的场景下。

这也是为什么 immediate 这一档要非常克制地使用——它跳过了所有用户意图信号,页面一加载完就无条件预渲染,命中率完全取决于产品对用户行为的预判准不准。用在搜索结果第一条、或者一个几乎人人都会点的"继续阅读"按钮上,命中率可能高达八九成,代价划算;如果不假思索地把它用在一个内容参差不齐的推荐列表上,命中率可能连三成都不到,剩下七成全是白跑一趟。eagerness 的四档设计,本质上就是把"预测置信度"和"愿意承担的浪费成本"做了个映射,选档位之前先想清楚这条链接的点击率大概处在哪个区间,比照着示例代码抄一个默认值要靠谱得多。

副作用这一头的代价则不是流量能衡量的。一次错误的预渲染如果触发了写操作或者污染了统计数据,事后排查起来往往比性能问题更麻烦——性能数据一眼能看出异常,但"数据库里多了一条不该有的记录""画像模型被几次误触发的访问带偏了"这类问题,可能要等到运营或者算法同学发现结果不对劲,倒查回来才会想到是预渲染惹的祸。这也是为什么前面反复强调,涉及写操作的页面干脆别接入 prerender,这不是保守,是把一个排查成本很高的风险直接从源头上摘掉。

规则里还能声明一些额外的提示

除了 urlswhereeagerness 这几个核心字段,规则对象里还能加一些辅助信息,帮浏览器做更准确的判断,或者帮开发者表达更细的意图。

expects_no_vary_search 就是其中之一。前面提过服务端可以用 No-Vary-Search 响应头告诉浏览器哪些查询参数不影响页面内容,但这个头要等浏览器真的发起请求、拿到响应之后才能读到。如果规则声明阶段就已经知道目标页面会返回这样的头,可以提前把这个预期写进规则本身,让浏览器在还没发出请求之前就按这个预期做命中判断,减少一次"预渲染了但因为参数不同判定不命中"的浪费:

1{
2  "prefetch": [
3    {
4      "where": { "href_matches": "/article/*" },
5      "expects_no_vary_search": "params=(\"from\" \"ref\")"
6    }
7  ]
8}

target_hint 是另一个容易被忽略但实际有用的字段,它用来声明这条规则预期的目标链接会在哪种上下文里被激活。默认情况下预渲染只对当前标签页内的普通导航生效,如果页面里的链接带了 target="_blank",规则需要显式加上 "target_hint": "blank" 才能让浏览器知道这条预测也适用于新标签页打开的场景。忘记这个字段的常见后果是:明明规则和链接都写对了,用户按住 Ctrl 或者 Cmd 点开链接到新标签页,预渲染却完全没有命中,因为浏览器默认认为这条规则只对应同标签页跳转。

referrer_policy 也能在规则里单独覆盖,控制预渲染请求带过去的 referrer 信息级别,用来处理目标站点对 referrer 有额外校验、或者出于隐私考虑不想把完整来源路径透露给预渲染目标的场景。这几个字段平时用不上几次,但一旦踩中对应的场景,不知道有这个字段往往会把问题误判成"这个 API 不好用",其实只是少写了一行声明。

预渲染页面里怎么防止副作用被提前触发

这是接入 Speculation Rules 时最容易踩的一个坑:预渲染页面在后台执行的时候,页面里所有的 JS 都会正常跑,包括你写在页面加载时机的埋点上报、useEffect 里的接口调用、window.onload 里的第三方脚本初始化。如果不做处理,用户可能根本没点开这个商品详情页,埋点系统里却已经记了一条"访问",接口日志里也多了一次没有实际意义的请求。

页面可以用 document.prerendering 判断自己此刻是不是正处于预渲染状态,如果是,就先不跑那些有副作用的逻辑,等页面真正被激活(用户点击、浏览器把预渲染的文档切换成当前文档)时再补跑:

1function reportPageView() {
2  fetch('/api/track/pageview', {
3    method: 'POST',
4    body: JSON.stringify({ path: location.pathname }),
5    keepalive: true,
6  });
7}
8
9if (document.prerendering) {
10  document.addEventListener('prerenderingchange', () => {
11    reportPageView();
12  }, { once: true });
13} else {
14  reportPageView();
15}

prerenderingchange 事件在预渲染文档被激活的那一刻触发,只会发生一次,用 { once: true } 省得手动解绑。这套判断不只对埋点有用,任何"页面一加载就该发生"的逻辑都值得过一遍这个检查——比如往购物车接口发心跳、往消息队列注册 WebSocket 连接、弹出一个只该出现一次的引导浮层。特别要留意第三方脚本,很多分析 SDK、A/B 测试工具在自己的初始化代码里默认就会立刻上报一次页面浏览,如果这类脚本没有对 Speculation Rules 做适配,接入预渲染之前得先翻一下它的文档或者抓包确认它是不是也认 document.prerendering,不认的话就得自己包一层延迟触发。

另外有一类操作即使延迟到激活后触发也不该在预渲染阶段做准备工作——涉及写操作的表单、下单前的库存锁定这类接口,最稳妥的做法是干脆不要把这类页面纳入 prerender 规则,只用 prefetch,把预渲染留给纯展示型的详情页。

服务端也能识别出这是一次预测性请求

前面讲的 document.prerendering 是客户端的判断,其实服务端也有对应的手段能识别出一个请求是不是投机性的。浏览器发起 prefetch 或者 prerender 请求时,会在请求头里带上 Sec-Purpose,值是 prefetch 或者 prefetch;prerender,正常导航发出的请求没有这个头。

Sec-Purpose: prefetch;prerender

后端接口如果读到这个头,可以据此做出不同的处理:日志系统可以把这类请求单独打标、不计入正式的 PV 统计,避免预测命中率不高的时候,服务端统计出来的访问量比真实用户行为虚高一截;一些开销较大的接口(比如需要写审计日志、需要调用付费的第三方服务)也可以在识别到这是投机性请求时选择降级返回一个更轻量的响应,或者直接跳过那些只在真实访问下才需要执行的副作用。这道检查和前端的 document.prerendering 判断是同一件事的两个角度——前端管的是"页面里的 JS 该不该现在跑",Sec-Purpose 管的是"后端接口该不该把这次调用当真"。对副作用比较敏感的接口,两头都做一遍判断会更稳妥,不用完全依赖前端那一层。

需要注意的是 Sec-Purpose 在预渲染文档被激活之后不会重新发一次"正式"的请求来替换之前那次预测性请求——如果详情页在预渲染阶段就已经把接口调完了,激活后不会自动重放。所以对那些必须要在真实访问时才计一次数的接口,光是识别 Sec-Purpose 还不够,得配合前面 prerenderingchange 那套客户端延迟触发的逻辑一起用,两者分工不同,不能互相替代。

Chrome 很早就有过一版预渲染方案,写法是 <link rel="prerender" href="/next-page">,业内叫它 NoState Prefetch。它的实现方式是新开一个完整的渲染进程把目标页面整个跑起来,包括执行所有 JS、发起所有网络请求,但不会真的把页面绘制到屏幕上。这套方案带来过不少麻烦:内存和 CPU 开销大,一个页面同时预渲染好几个候选链接很容易把设备资源吃满;没有任何机制限制它触发的副作用,广告曝光、埋点全部被提前算了一遍,广告主投诉过很多次"曝光量对不上";而且它是 Chrome 单方面推的私有扩展,没有经过标准流程,其它浏览器谁都没有跟进的动力。这套旧方案已经被废弃,Chrome 自己也不再建议使用 rel="prerender"

Speculation Rules 是吸取了这些教训之后重新设计的标准提案,好几处都是针对性的修补:一是引入了 eagerness 分级,不再是"要么全速跑要么不跑",把资源浪费的风险摊薄;二是提供了 document.prerendering 这套开发者可以主动介入的钩子,把副作用的触发时机的控制权交还给页面自己,而不是浏览器闷头跑完再说;三是这次是走的正经 W3C 提案流程,有公开的讨论和测试套件,不是某一家浏览器厂商关起门来定的私有扩展。

查询参数和跨域:两个容易忽略的限制

规则圈定链接时,query string 是个绕不开的细节。浏览器默认把 /article/1001/article/1001?ref=home 当成两个不同的地址,即使查询参数根本不影响页面内容,预渲染命中判断时也会按完整 URL 精确匹配,参数稍有不同就算不命中。列表页给详情页链接挂了统计用的 ?from=list 之类参数时,这个问题尤其容易发生,规则明明写对了、prerender 也确实跑了,但用户实际点击的链接因为参数不一样,命中判断失败,白白浪费了那次预渲染。

服务端可以用一个 HTTP 响应头告诉浏览器哪些查询参数不影响页面内容,从而放宽这个精确匹配:

No-Vary-Search: params=("from" "ref")

带上这个头之后,fromref 这两个参数的差异会被忽略,/article/1001/article/1001?from=list 会被当成同一个预渲染目标。这个头要写在详情页自己的响应里,不是写在列表页,浏览器是在拿到目标文档的响应之后才应用这条规则的。

跨域预渲染受到的限制更严格。同源页面之间预渲染是默认允许的,但如果 prerender 规则指向另一个域名(比如从内容站跳到一个独立部署的活动页域名),目标站点必须在自己的响应头里显式同意,否则浏览器会拒绝执行这条规则:

Supports-Loading-Mode: credentialed-prerender

这个限制是刻意设计的:如果没有这道门槛,任何网站都能在自己页面里声明"预渲染某个陌生域名",被预渲染的一方完全无法拒绝,等于把自己的访问统计和服务器资源暴露给别人随意消耗。要求目标站点主动同意,把这道许可权交还给了被预渲染的一方。多数站内跳转不会碰到这个限制,只有涉及第三方域名或者多域名架构时才需要额外协调对方加这个响应头。

内容站这次接入的场景全部是同域跳转,没碰到这道许可门槛,但评估阶段确实认真查过——公司还有一个独立部署在别的域名下的活动落地页,最初设想过把列表页到活动页的跳转也纳入预渲染范围,一算才发现活动页那边的团队得先配合加响应头,权衡下来这次先不做,留到后面有精力协调跨团队配置的时候再看。

调试时去哪看预渲染有没有真的发生

规则写对了不代表浏览器一定会执行,Chrome 会根据当前设备状态动态决定要不要兑现这些预测——省流量模式(Data Saver)开启时预渲染会被直接跳过,内存紧张、CPU 占用过高、同时并发的预渲染数量超过内部限制(每个标签页同时只允许少量并发预渲染),这些情况都可能让某条规则被悄悄忽略,页面上不会有任何报错提示。

Chrome DevTools 里有专门的面板能看到这些细节。Application 面板的 Speculative loads 一栏会列出当前页面声明的每条规则,以及它的状态——正在执行、已经完成、还是因为什么原因被拒绝,拒绝的原因通常也会给出(比如内存不足、命中了并发上限、目标 URL 被 robots 规则挡住)。排查"规则写了但好像没生效",第一步就该去这个面板看状态,而不是先怀疑语法写错了。

预渲染涉及跨进程渲染,Chrome 的隐私沙盒策略也会限制预渲染页面里第三方 Cookie 的读写行为,一些依赖第三方 Cookie 做登录态同步的场景,预渲染阶段可能读不到预期的登录信息,激活之后才恢复正常,这也是排查"预渲染页面里某个功能表现异常,但用户正常访问又没事"时值得想到的一个方向。

现状:只有 Chromium 系跟了

这也是接入之前必须如实面对的一点——目前只有 Chrome 和 Edge 这类基于 Chromium 内核的浏览器实现了 Speculation Rules,Firefox 和 Safari 都没有跟进,Mozilla 和 WebKit 的标准立场里也没看到明确的实现承诺。这意味着不能把它当成一个"写了就全站生效"的通用优化,得当成一层可选的增强去接。

这套 API 走过了一段不短的稳定期。Chrome 最早以 Origin Trial 的形式让部分站点试用是几年前的事,prefetch 动作先落地,prerender 动作因为涉及的副作用问题更复杂,稳定得晚一些,中间也经历过好几轮字段调整——No-Vary-Searchtarget_hint 这些辅助字段都是后补进规范的。现在这些能力都已经在 Chrome 稳定版里默认可用,不需要开发者去申请试用资格或者翻实验旗标,这也是这次愿意认真评估接入的原因之一:早两年这东西还在实验阶段,字段随时可能改,不太敢往正式业务里放。

Mozilla 这边公开的立场文件里提到过顾虑,主要集中在跨站预渲染可能被滥用来做用户行为追踪、以及移动设备上后台跑一个完整渲染进程对电量和流量的额外消耗,尤其是不清楚具体流量计费方式的用户。Chromium 这边通过 eagerness 分级、跨域需要目标站点显式同意这些设计缓解了一部分顾虑,但显然还没有说服另外两家现在就跟进实现。这个分歧短期内不会消失,接入这个特性的时候得把它当成一个还在演化中的、覆盖面有限的能力,而不是像 fetch 或者 Promise 那样的基础设施。

好在这件事本身对渐进增强很友好:不支持的浏览器看到 <script type="speculationrules"> 这个标签会直接忽略,不会报错也不会有任何副作用,该怎么加载还怎么加载,等同于没写过这段代码。如果想在 JS 里做特性检测,可以用:

1if (HTMLScriptElement.supports?.('speculationrules')) {
2  const script = document.createElement('script');
3  script.type = 'speculationrules';
4  script.textContent = JSON.stringify({
5    prerender: [{ where: { href_matches: '/product/*' }, eagerness: 'moderate' }],
6  });
7  document.body.append(script);
8} else {
9  // 退回到基础的 <link rel="prefetch">,至少省下网络往返
10  document.querySelectorAll('a[data-prefetch]').forEach((a) => {
11    const link = document.createElement('link');
12    link.rel = 'prefetch';
13    link.href = a.href;
14    document.head.append(link);
15  });
16}

这样 Chromium 系用户拿到完整的预渲染体验,Firefox/Safari 用户至少还能吃到 prefetch 这一层最基础的优化,两边都不会比不做任何处理更差。也正因为覆盖面有限,我倾向于先在流量里 Chromium 系占比高、且业务对首屏速度敏感的页面上试点,而不是一上来就要求全站接入。

落地:一个内容站的列表页到详情页

真正接进去的是一个内容站的文章列表页,用户从列表点进详情页是全站访问量最大的一条路径。规则写得很克制,只圈定详情页链接、用 moderate 档位:

1<script type="speculationrules">
2{
3  "prerender": [
4    {
5      "where": { "href_matches": "/article/*" },
6      "eagerness": "moderate"
7    }
8  ]
9}
10</script>

详情页里已经按上面的方式包了一层 document.prerendering 判断,埋点、评论区未读数拉取这些都延迟到 prerenderingchange 之后再跑。上线前还核对了一遍详情页里第三方脚本的清单——评论组件的 SDK 默认会在加载时自动上报一次曝光,联系过对方文档确认它没有对预渲染做适配,最后是把这段 SDK 的加载本身也挪到了 prerenderingchange 之后,而不是只延迟它触发的接口调用。

上线没有直接推全站,先在一个流量占比不高的频道页做了两周灰度。灰度期间用 HTMLScriptElement.supports?.('speculationrules') 判断,只给命中特性检测为真的用户注入规则脚本,没命中的用户走原来的加载路径,这样出问题时影响面天然就限制在一小部分人身上。灰度头几天就发现了一个没预料到的情况:详情页底部有个"猜你喜欢"模块,会在页面加载完成后立刻请求个性化推荐接口,这个接口会按访问行为写一条用户画像更新记录。预渲染阶段这个模块的初始化代码没有做 document.prerendering 判断,于是用户在列表页上悬停了几个从没点开过的标题,画像数据里却多了几条不存在的"访问",推荐效果因此有点跑偏。这个模块后来也补上了延迟触发的判断,才继续扩大灰度范围。

判断这件事有没有效果,看的不是主观感受上"点进去感觉快了",而是几个具体指标的前后对比。核心看两个:一个是详情页的 LCP(最大内容绘制),预渲染命中的情况下,用户点击那一刻页面已经渲染完毕,LCP 时间点会被大幅提前,理论上限是接近于 0(因为最大内容元素在预渲染阶段就已经绘制完成);另一个是 TTFB,虽然预渲染不改变服务器处理请求的速度,但用户感知到的"首字节"是文档切换的时刻,而不是网络请求发出的时刻,实际观测到的数值会显著优于真实网络往返。

具体测的时候要把"预渲染命中"和"没命中"的样本分开看,不然平均数没有意义——命中预渲染的用户体验是接近瞬时的,没命中的用户体验和优化前几乎一样,两者混在一起算平均反而会低估收益。Chrome 在 Navigation Timing 里暴露了 PerformanceNavigationTiming.activationStart 这个字段,能用来判断当前这次导航是不是由预渲染激活而来,以及预渲染实际提前跑了多久:

1const [entry] = performance.getEntriesByType('navigation');
2if (entry.activationStart > 0) {
3  console.log('本次导航命中了预渲染,提前量(ms):', entry.activationStart);
4}

用这个字段把命中和未命中的用户分组,再分别看两组的 LCP 分布,才能拿到干净的对比数字。这个内容站接入之后,命中预渲染的那部分详情页访问,LCP 中位数从原来的 1.3 秒左右压到了不到 200 毫秒,基本就是"点击即所得";命中率跟 moderate 档位设置的悬停/触摸阈值有关,观察下来大概三分之一的详情页点击能吃到预渲染的收益,其余走的还是原来的加载路径,属于预期之内——moderate 本来就是故意放弃掉一部分"点得太快来不及预渲染"的场景,换取不浪费太多资源。

预渲染带来的带宽成本也没有想象中夸张。因为规则限定了只匹配 /article/* 且用了 moderate 档,实际触发的预渲染次数远小于列表页展示的链接总数,CDN 的流量监控里能看到多出来的请求量,但和详情页原本的正常访问量比起来是个小尾巴,不是什么需要额外担心的开销。这笔账值不值,最终还是要看命中率能不能覆盖这点多花的带宽——对这个内容站来说,答案是划得来。

灰度扩到全量之前,还给列表页加了一个粗糙但有用的安全阀:如果某一批详情页因为内容运营的临时调整(比如批量下线一部分文章)导致大量预渲染目标变成 404,规则会照样按配置执行,只是白白浪费带宽,不会有任何报错提示浏览器"这批链接已经失效了"。为了不让这种运营层面的变化悄悄拖慢自己,列表页渲染时会过滤掉最近标记下线的文章 ID,不把它们放进 href_matches 能匹配到的范围。这类清理不属于 Speculation Rules 本身的机制,是接入之后才想起来要单独处理的一处衔接。

现在这条规则跑了将近一个月,命中率和收益基本符合预期,暂时没有再往其它页面扩。下一步想尝试的是把它接到搜索结果页——用户在搜索结果里点第一条的概率明显偏高,immediate 档也许适合用在这一个位置上,但那又是另一笔要重新算的账。

整个接入过程里,大部分时间不是花在写规则本身——那段 JSON 前后改动没几次——而是花在把页面里所有可能被预渲染阶段提前触发的副作用一条条揪出来。prerender 这个动作本身很简单,麻烦的是原来那套页面代码从来没考虑过自己可能在用户看不见的时候被完整地跑一遍,得把这个假设重新过一遍才敢放心接进去。

这笔账算下来,花掉的排查时间比写规则本身多出好几倍,但换来的是三分之一详情页访问接近瞬时打开,这个比例在我看来是划算的。