CDP 截图抓到 FOUC:轮询计算样式才是正解
用裸 CDP 驱动 headless Chromium 给一个 Next.js App Router 页面截图,结果死活截出无样式的白板——衬线字体、蓝色下划线链接、本该双栏的布局竖着堆成一列。真实浏览器打开同一个 URL 完全正常,CSS 文件 GET /_next/static/chunks/xxx.css 返回 200,代码没有半点问题。排查了半天,最后发现是截图时机的锅,不是样式的锅。
现象
排查时最有价值的两个线索:
- 亮色截图和暗色截图字节数完全相同——
light.png132151 bytes,dark.png132151 bytes。两张一模一样意味着主题 token 根本没有应用,也就是说 CSS 整体没有生效。 - 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。
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 轮询只能作为第一道门,不能单独守住截图时机。