首页
/ Puppeteer CSSCoverage 深度解析:如何用 CDP 精确追踪页面中真正被使用的 CSS 代码

Puppeteer CSSCoverage 深度解析:如何用 CDP 精确追踪页面中真正被使用的 CSS 代码

2026-09-06 14:10:23作者:庞眉杨Will

本篇围绕 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 维度的命中区间,而 urltext 字段必须由 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):

  1. 状态校验与重置:先 assert(!this.#enabled, 'CSSCoverage is already enabled') 防止重复开启,然后清空 #stylesheetURLs / #stylesheetSources,把 #enabled 置为 true
  2. 订阅 CDP 事件:通过 DisposableStack 管理两个监听器——
    • CSS.styleSheetAdded:新样式表加入时触发 #onStyleSheet,用于记录 URL 并拉取全文;
    • Runtime.executionContextsCleared:导航导致执行上下文清空时触发,若 #resetOnNavigationtrue 则清空上面两个 Map;
  3. 发送 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-L278should ignore injected stylesheets)验证了这一点:注入样式并触发样式重算后,stopCSSCoverage() 返回长度为 0 的数组;
  • sourceURL 注释的匿名样式表可以正确归属:测试 should report sourceURLscoverage.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):

  1. assert(this.#enabled) 校验确实处于开启状态(否则会抛出 CSSCoverage is not enabled);
  2. 调用 CSS.stopRuleUsageTracking 拿到 ruleUsage 数组——每条记录包含 styleSheetIdstartOffsetendOffsetused 布尔值;
  3. 并行发送 CSS.disableDOM.disable 关闭域,并通过 this.#eventListeners?.dispose() 释放全部事件订阅;
  4. 先按 styleSheetId 聚合规则命中区间(usedtruecount: 1,否则 count: 0),再对每个已注册的样式表调用 convertToDisjointRanges 把嵌套的区间归并为互斥区间,最终 push({url, ranges, text})

convertToDisjointRangesCoverage.ts#L457-L516)是一个扫描线算法:把每条区间的起点/终点投影成有序点序列,用命中计数栈做区间求并,最后过滤掉空区间。这保证了 ranges 中的区间两两不重叠,直接方便按"字符位置切片"的方式计算使用率。

两个值得注意的边界行为(均有测试佐证):

  • 零覆盖样式表也会出现在报告中should report stylesheets that have no coverage 测试(coverage.test.ts#L224-L233)断言 unused.css 对应的 ranges 长度为 0——也就是说"整张表都没被用到"同样是一条有效报告;
  • 空样式表返回空 textshould work with empty stylesheets 测试断言 coverage[0].text === ''

五、resetOnNavigation:跨导航的覆盖率策略

resetOnNavigationCSSCoverageOptions 唯一的选项,默认 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.htmldiv { 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 时需要注意:

  1. 必须成对使用start()stop() 有状态守卫——未开启时调用 stop() 会抛 CSSCoverage is not enabled;已开启时重复 start() 会抛 CSSCoverage is already enabled
  2. 匿名样式表不计入:动态注入且无 sourceURL 的样式不会被报告,需要统计此类内容时应让页面通过 /*# sourceURL=xxx.css */ 声明归属;
  3. 仅依赖 CDP 的 CSS 域:实现完全基于 CSS.enable / CSS.startRuleUsageTracking / CSS.stopRuleUsageTracking 指令,属于 CDP 通道能力;
  4. stop() 会关闭 CDP 域并释放监听:一次 start/stop 构成完整生命周期,再次采集需重新 start()
  5. 报告粒度是"字符区间"ranges 给出的是命中的 CSS 文本区间,而非选择器或规则结构,如需更细粒度的"未使用规则"分析需基于 text + ranges 自行解析,或结合源码注释中提到的 Istanbul 生态工具做格式转换。

参考路径

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388