返回博客

CDP 截图抓到 FOUC:轮询计算样式才是正解

用裸 CDP 驱动 headless Chromium 给一个 Next.js App Router 页面截图,结果死活截出无样式的白板——衬线字体、蓝色下划线链接、本该双栏的布局竖着堆成一列。真实浏览器打开同一个 URL 完全正常,CSS 文件 GET /_next/static/chunks/xxx.css 返回 200,代码没有半点问题。排查了半天,最后发现是截图时机的锅,不是样式的锅。

现象

排查时最有价值的两个线索:

  1. 亮色截图和暗色截图字节数完全相同——light.png 132151 bytes,dark.png 132151 bytes。两张一模一样意味着主题 token 根本没有应用,也就是说 CSS 整体没有生效。
  2. CSS chunk 本身 200 且内容正确——直接 fetch('/_next/static/chunks/0_xhras~jf79v.css') 拿得到完整文件。文件存在,只是"还没 apply"。

根因

Next.js App Router 对每条路由做 CSS 代码分割:该路由专属的样式以 <link rel="stylesheet"> 的形式异步注入,并非打进初始 HTML 的 <style> 里。

SSR 吐出的 HTML 里已经包含了完整的文本内容。常见的"等待水合"写法是:

// 等页面文字出现就认为"准备好了"
await pollUntil(() =>
  document.body.innerText.includes("某个页面标题")
);
// ← 这里就截图了

问题在于:这段文字存在于 SSR HTML 里,在 CSS 异步 <link> 请求还在飞的时候 innerText 就已经命中。截图快门正好按在 FOUC(Flash of Unstyled Content)窗口里——浏览器收到 HTML、JS 执行完毕,但路由 CSS 还差几十毫秒才 apply。

坑:text/hydration 轮询比 CSS apply 快

document.body.innerText.includes(...) 检测的是 DOM 文本,而 DOM 文本来自 SSR HTML,在 CSS 加载完成之前就已经存在。用它作为"截图时机"的唯一门控,会稳定地截进 FOUC 窗口,得到无样式的截图。即使加 await sleep(1000) 也不能根治——慢网或 CI 环境下竞态依然存在。

正解

等 CSS 真正 apply 的唯一可靠方法是轮询某个已知元素的计算样式getComputedStyle),而不是文本内容:

// 第一关:页面文字出现(确认路由已加载)
await pollUntil(() =>
  document.body.innerText.includes("某个页面标题")
);
 
// 第二关:CSS 已 apply(检查计算样式)
for (let i = 0; i < 40; i++) {
  const r = await send("Runtime.evaluate", {
    expression: `(() => {
      const el = document.querySelector('.your-shell-selector');
      if (!el) return false;
      const bw = getComputedStyle(el).borderTopWidth;
      // Tailwind border 默认 0px,apply 后变为非零值
      return bw !== '0px' && bw !== '';
    })()`,
    returnByValue: true,
  }, sessionId);
  if (r.result?.value) break;
  await sleep(300);
}
await sleep(600); // 微小 settle,等渲染管线刷完
// ← 现在才截图

选择"CSS 信号"的原则:选一个默认值与应用后值截然不同的属性。borderTopWidth 是最稳健的选择——Tailwind border 类会把它从 0px 变成 1px,绝不会误判。其他可用信号:非默认 background-color、非衬线 fontFamily、固定宽度的侧边栏 width

正解:两道门控,缺一不可

text 轮询作为第一道门(确认路由存在),computed style 轮询作为第二道门(确认 CSS 已 apply)。两道都过才按快门,截图才能保证有样式、有主题、亮暗两色字节数不同。

附坑:stale next start 服务旧 manifest

如果在重新 pnpm build 之后没有重启 next start,进程内存里的 build manifest 还指向旧的 CSS chunk hash,而磁盘上已经是新 hash 的文件——浏览器请求旧 chunk 名拿到 404,整站无样式。遇到这种情况截出来的症状一模一样,但根因不同:重启 next start -p <port> 即可。

对照实验快速定位

截一张同服务器上另一个已发布页面作为对照。如果对照页有样式而目标页没有,是截图时机问题;如果两者都无样式,是构建/服务器问题。

一句话外卖

对任何路由级异步 CSS 的无头截图,必须用 getComputedStyle 轮询来确认 CSS 已 apply,text/hydration 轮询只能作为第一道门,不能单独守住截图时机。