Puppeteer CSSCoverage 深度解析:如何用 CDP 精确追踪页面中真正被使用的 CSS 代码
本篇围绕 Puppeteer 的 CSSCoverage 类展开,讲解如何通过 CDP(Chrome DevTools Protocol)的 CSS 域开启/停止 CSS 覆盖率采集、resetOnNavigation 选项的实际行为,以及 stop() 返回的 CoverageEntry[] 报告结构。读完后,你可以掌握定位"死 CSS"、统计初始 CSS 使用率、跨导航累积覆盖率等实战能力,并理解 Coverage.ts 中基于 CDP 事件的底层实现细节。
一、CSSCoverage 在 Puppeteer API 中的定位
CSSCoverage 是 Puppeteer 覆盖率采集体系(Coverage 类)中专管 CSS 部分的内核实现。官方文档中的 CSSCoverage class 给出了类的完整签名:
export declare class CSSCoverage
从源码结构看,CSSCoverage 并不是让用户直接 new 出来的类——它由 Coverage.ts 中的 Coverage 类在内部持有并统一管理:
export class Coverage {
#jsCoverage: JSCoverage;
#cssCoverage: CSSCoverage;
constructor(client: CDPSession) {
this.#jsCoverage = new JSCoverage(client);
this.#cssCoverage = new CSSCoverage(client);
}
}
用户侧的入口是 page.coverage 暴露的 startCSSCoverage() / stopCSSCoverage(),它们分别委托给内部的 CSSCoverage.start() 与 stop():
async startCSSCoverage(options: CSSCoverageOptions = {}): Promise<void> {
return await this.#cssCoverage.start(options);
}
async stopCSSCoverage(): Promise<CoverageEntry[]> {
return await this.#cssCoverage.stop();
}
因此,理解 CSSCoverage 的构造器与两个方法,就是理解 page.coverage 这套公共 API 全部 CSS 能力的关键。
二、构造器:绑定 CDPSession 与 Logger
CSSCoverage.(constructor) 的签名为:
class CSSCoverage {
constructor(client: CDPSession, logger?: Logger);
}
参数说明:
| 参数 | 类型 | 说明 |
|---|---|---|
client |
CDPSession | 与浏览器目标通信的 CDP 会话,所有 CSS.*、DOM.* 指令都经由此发送 |
logger |
Logger | 可选;用于输出错误日志(如样式表在页面导航后已失效导致的取文本失败) |
对照 Coverage.ts#L336-L349 的实现,构造器除了保存依赖,还初始化了一组内部状态,它们决定了整个采集生命周期:
export class CSSCoverage {
#client: CDPSession;
#enabled = false; // 防止重复 start()
#stylesheetURLs = new Map<string, string>(); // styleSheetId -> 样式表 URL
#stylesheetSources = new Map<string, string>(); // styleSheetId -> 样式表全文
#eventListeners?: DisposableStack; // 事件订阅的资源栈
#resetOnNavigation = false; // start() 时由选项写入
#logger?: Logger;
}
两个 Map 是理解后续 stop() 行为的钥匙:覆盖率追踪(CDP 的 rule usage tracking)只产出 styleSheetId 维度的命中区间,而 url 与 text 字段必须由 Puppeteer 自己通过 CSS.styleSheetAdded 事件补全。
构造器文档中还标注:构造器属于内部 API,第三方代码不应直接实例化或继承 CSSCoverage,统一从 page.coverage 入口使用。
三、start(options):开启采集的完整调用链
CSSCoverage.start 的签名:
class CSSCoverage {
start(options?: {resetOnNavigation?: boolean}): Promise<void>;
}
参数只有一个可选对象 options,其中唯一的字段来自 CSSCoverageOptions:
| 属性 | 类型 | 说明 | 默认值 |
|---|---|---|---|
resetOnNavigation |
boolean |
是否每次导航后重置覆盖率 | true(源码中 const {resetOnNavigation = true} = options 可确认) |
start() 内部做了三件事(见 Coverage.ts#L358-L380):
- 状态校验与重置:先
assert(!this.#enabled, 'CSSCoverage is already enabled')防止重复开启,然后清空#stylesheetURLs/#stylesheetSources,把#enabled置为true; - 订阅 CDP 事件:通过
DisposableStack管理两个监听器——CSS.styleSheetAdded:新样式表加入时触发#onStyleSheet,用于记录 URL 并拉取全文;Runtime.executionContextsCleared:导航导致执行上下文清空时触发,若#resetOnNavigation为true则清空上面两个 Map;
- 发送 CDP 指令,并行执行:
await Promise.all([
this.#client.send('DOM.enable'),
this.#client.send('CSS.enable'),
this.#client.send('CSS.startRuleUsageTracking'),
]);
其中 CSS.enable 打开 CSS 域后 Puppeteer 才会收到 styleSheetAdded 事件;CSS.startRuleUsageTracking 才是真正开始统计"哪些规则被样式计算(style recalc)命中"。这也解释了为什么 start() 是异步的:它必须等 CDP 指令回包后才能保证采集完整开启。
样式表注册逻辑:为什么动态注入的 style 不计入
#onStyleSheet 的处理逻辑(Coverage.ts#L390-L406):
async #onStyleSheet(event: Protocol.CSS.StyleSheetAddedEvent): Promise<void> {
const header = event.header;
// Ignore anonymous scripts
if (!header.sourceURL) {
return;
}
try {
const response = await this.#client.send('CSS.getStyleSheetText', {
styleSheetId: header.styleSheetId,
});
this.#stylesheetURLs.set(header.styleSheetId, header.sourceURL);
this.#stylesheetSources.set(header.styleSheetId, response.text);
} catch (error) {
// This might happen if the page has already navigated away.
this.#logger?.(DEBUG_PREFIXES.error)?.(error);
}
}
这里有两个关键边界行为,均与 Coverage.stopCSSCoverage 文档中的备注一致("CSS Coverage doesn't include dynamically injected style tags without sourceURLs"):
- 无
sourceURL的样式表直接被忽略——典型场景是page.addStyleTag({content: ...})注入的匿名<style>。测试用例 coverage.test.ts#L266-L278(should ignore injected stylesheets)验证了这一点:注入样式并触发样式重算后,stopCSSCoverage()返回长度为 0 的数组; - 带
sourceURL注释的匿名样式表可以正确归属:测试should report sourceURLs(coverage.test.ts#L202-L210)断言报告的 URL 为nicename.css,来自样式表内的/*# sourceURL=nicename.css */魔术注释; - 竞态保护:若事件触发时页面已导航走,
CSS.getStyleSheetText会失败,错误仅通过 logger 以error前缀记录,不会中断采集。
四、stop():生成 CoverageEntry[] 报告
CSSCoverage.stop 的签名:
class CSSCoverage {
stop(): Promise<CoverageEntry[]>;
}
返回 Promise<CoverageEntry[]>,每个 CoverageEntry 对应一张样式表:
| 字段 | 类型 | 说明 |
|---|---|---|
url |
string |
样式表的 URL(取自 sourceURL) |
text |
string |
样式表完整文本内容 |
ranges |
Array<{start: number; end: number}> |
被命中的区间集合,已归并为互不重叠的区间 |
stop() 的执行流程(Coverage.ts#L408-L454):
assert(this.#enabled)校验确实处于开启状态(否则会抛出CSSCoverage is not enabled);- 调用
CSS.stopRuleUsageTracking拿到ruleUsage数组——每条记录包含styleSheetId、startOffset、endOffset和used布尔值; - 并行发送
CSS.disable与DOM.disable关闭域,并通过this.#eventListeners?.dispose()释放全部事件订阅; - 先按
styleSheetId聚合规则命中区间(used为true记count: 1,否则count: 0),再对每个已注册的样式表调用convertToDisjointRanges把嵌套的区间归并为互斥区间,最终push({url, ranges, text})。
convertToDisjointRanges(Coverage.ts#L457-L516)是一个扫描线算法:把每条区间的起点/终点投影成有序点序列,用命中计数栈做区间求并,最后过滤掉空区间。这保证了 ranges 中的区间两两不重叠,直接方便按"字符位置切片"的方式计算使用率。
两个值得注意的边界行为(均有测试佐证):
- 零覆盖样式表也会出现在报告中:
should report stylesheets that have no coverage测试(coverage.test.ts#L224-L233)断言unused.css对应的ranges长度为 0——也就是说"整张表都没被用到"同样是一条有效报告; - 空样式表返回空 text:
should work with empty stylesheets测试断言coverage[0].text === ''。
五、resetOnNavigation:跨导航的覆盖率策略
resetOnNavigation 是 CSSCoverageOptions 唯一的选项,默认 true。它的生效点在 Runtime.executionContextsCleared 事件回调中:
#onExecutionContextsCleared(): void {
if (!this.#resetOnNavigation) {
return;
}
this.#stylesheetURLs.clear();
this.#stylesheetSources.clear();
}
即:导航后旧文档的样式表 Map 被清空,stop() 时就只报告"当前导航"加载的样式表;设为 false 时则跨导航累积。coverage.test.ts#L297-L316 中有一组对称的测试:
it('should report stylesheets across navigations', async () => {
await page.coverage.startCSSCoverage({resetOnNavigation: false});
await page.goto(server.PREFIX + '/csscoverage/multiple.html');
await page.goto(server.EMPTY_PAGE);
const coverage = await page.coverage.stopCSSCoverage();
expect(coverage).toHaveLength(2); // 两次导航的样式表都被保留
});
it('should NOT report scripts across navigations', async () => {
await page.coverage.startCSSCoverage(); // Enabled by default.
await page.goto(server.PREFIX + '/csscoverage/multiple.html');
await page.goto(server.EMPTY_PAGE);
const coverage = await page.coverage.stopCSSCoverage();
expect(coverage).toHaveLength(0); // 默认重置后旧样式表被丢弃
});
选择依据:想统计"整个会话生命周期内用过的全部 CSS"(例如单页应用的多路由)就用 false;想精确度量某次页面加载引入的 CSS 用量就用默认行为。
六、实战示例:统计初始 CSS 使用率
官方 Coverage 文档给出的标准用法是把 JS 与 CSS 覆盖率同时开启、并行停止,再按字符数计算使用率:
// Enable both JavaScript and CSS coverage
await Promise.all([
page.coverage.startJSCoverage(),
page.coverage.startCSSCoverage(),
]);
// Navigate to page
await page.goto('https://example.com');
// Disable both JavaScript and CSS coverage
const [jsCoverage, cssCoverage] = await Promise.all([
page.coverage.stopJSCoverage(),
page.coverage.stopCSSCoverage(),
]);
let totalBytes = 0;
let usedBytes = 0;
const coverage = [...jsCoverage, ...cssCoverage];
for (const entry of coverage) {
totalBytes += entry.text.length;
for (const range of entry.ranges) usedBytes += range.end - range.start - 1;
}
console.log(`Bytes used: ${(usedBytes / totalBytes) * 100}%`);
如果只关心 CSS(例如构建"死 CSS"报告),可以简化为:
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.coverage.startCSSCoverage(); // resetOnNavigation 默认 true
await page.goto('https://example.com');
// 视需要在这里模拟交互(点击、滚动),让惰性 CSS 有机会命中
const coverage = await page.coverage.stopCSSCoverage();
for (const entry of coverage) {
const used = entry.ranges.reduce((sum, r) => sum + (r.end - r.start), 0);
console.log(`${entry.url}: ${(used / entry.text.length * 100).toFixed(1)}% used`);
}
await browser.close();
由于 ranges 已被归并为互斥区间,r.end - r.start 直接相加即可得到"命中字符数",无需再去重。
七、测试体系如何验证 CSS 覆盖率
仓库的 test/src/coverage.test.ts#L187-L317 提供了 CSSCoverage 的完整行为基线,覆盖了主要功能路径:
| 测试用例 | 验证点 |
|---|---|
should work |
基础流程:simple.html 中 div { color: green; } 精确命中 ranges = [{start: 1, end: 22}],且 text.substring(start, end) 与规则文本一致 |
should report sourceURLs |
/*# sourceURL= */ 魔术注释决定报告 URL |
should report multiple stylesheets |
两张样式表分别产出两条 entry |
should report stylesheets that have no coverage |
零覆盖样式表 ranges 为空数组但仍在报告中 |
should work with media queries |
媒体查询嵌套结构的区间归并结果正确 |
should work with complicated usecases |
与黄金文件 csscoverage-involved.txt 对比,锁定复杂选择器场景的输出 |
should work with empty stylesheets |
空样式表 text 为空字符串 |
should ignore injected stylesheets |
匿名注入样式不计入 |
should work with a recently loaded stylesheet |
start() 之后运行时动态加载的 <link> 样式表也能被捕获 |
resetOnNavigation 两组用例 |
见上文第五节的跨导航对比 |
这些测试配合 TestSuites.json 中的测试套件配置运行,可以作为阅读 CSSCoverage 源码时逐条对照的"行为说明书"。
八、边界与限制小结
综合文档备注与源码断言,使用 CSSCoverage 时需要注意:
- 必须成对使用:
start()与stop()有状态守卫——未开启时调用stop()会抛CSSCoverage is not enabled;已开启时重复start()会抛CSSCoverage is already enabled; - 匿名样式表不计入:动态注入且无
sourceURL的样式不会被报告,需要统计此类内容时应让页面通过/*# sourceURL=xxx.css */声明归属; - 仅依赖 CDP 的 CSS 域:实现完全基于
CSS.enable/CSS.startRuleUsageTracking/CSS.stopRuleUsageTracking指令,属于 CDP 通道能力; stop()会关闭 CDP 域并释放监听:一次 start/stop 构成完整生命周期,再次采集需重新start();- 报告粒度是"字符区间":
ranges给出的是命中的 CSS 文本区间,而非选择器或规则结构,如需更细粒度的"未使用规则"分析需基于text+ranges自行解析,或结合源码注释中提到的 Istanbul 生态工具做格式转换。
参考路径
- CSSCoverage class 文档:本文的核心关联文档
- CSSCoverage.(constructor) / start / stop
- CSSCoverageOptions:
resetOnNavigation选项定义 - Coverage class / CoverageEntry:公共入口与报告结构
- packages/puppeteer-core/src/cdp/Coverage.ts:
CSSCoverage、JSCoverage、Coverage与区间归并算法的完整实现 - test/src/coverage.test.ts:CSS/JS 覆盖率的测试基线与行为验证
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00