Puppeteer Coverage.startJSCoverage() 详解:JS 代码覆盖率采集的完整机制与源码解析
【免费下载链接】puppeteerJavaScript API for Chrome and Firefox项目地址: https://gitcode.com/GitHub_Trending/puppeteer1/puppeteer
Puppeteer 的page.coverage.startJSCoverage()是采集浏览器中 JavaScript 实际执行情况(哪些代码被执行、哪些未执行)的核心入口,常用于统计站点首屏执行代码占比、检测未运行的 JS 死代码等场景。本文以官方 API 文档 puppeteer.coverage.startjscoverage 为主体,结合 Coverage.ts 的 CDP 实现与 coverage.test.ts 测试用例,完整讲清该方法的方法签名、JSCoverageOptions各选项的默认值与真实作用、底层Profiler/Debugger域调用链,以及结果条目JSCoverageEntry的结构和匿名脚本(eval/new Function)的处理规则,读完你可以直接复制可用的覆盖率统计代码并理解每一条 range 的来龙去脉。
一、方法签名与返回值
官方文档给出的签名如下(见 docs/api/puppeteer.coverage.startjscoverage.md):
class Coverage { startJSCoverage(options?: JSCoverageOptions): Promise<void>; }- 参数
options:类型为 JSCoverageOptions,可选。文档明确给出的默认值为:resetOnNavigation: true、reportAnonymousScripts: false、includeRawScriptCoverage: false、useBlockCoverage: true。 - 返回值:
Promise<void>,Promise 在覆盖率采集成功开启后 resolve。
在源码中,Coverage.startJSCoverage()是一个薄封装,直接把options透传给内部的JSCoverage类(见 Coverage.ts#L153-L155):
async startJSCoverage(options: JSCoverageOptions = {}): Promise<void> { return await this.#jsCoverage.start(options); }配套的停止方法是stopJSCoverage(): Promise<JSCoverageEntry[]>,它返回所有脚本的覆盖率报告数组;文档备注:JavaScript Coverage 默认不包含匿名脚本,但带有sourceURL的脚本会被报告。
二、JSCoverageOptions 四个选项逐项详解
JSCoverageOptions接口定义在 Coverage.ts#L51-L70,与官方文档 puppeteer.jscoverageoptions 一一对应。结合 JSCoverage.start() 中的默认值解构,四项的默认值与源码作用如下:
| 属性 | 类型 | 默认值 | 源码中的实际作用 |
|---|---|---|---|
resetOnNavigation | boolean | true | 监听Runtime.executionContextsCleared事件,导航时清空已记录的#scriptURLs/#scriptSources,即跨导航不累积脚本(Coverage.ts#L262-L268) |
reportAnonymousScripts | boolean | false | 在#onScriptParsed中过滤:if (!event.url && !this.#reportAnonymousScripts) return;,默认跳过无 URL 的脚本(Coverage.ts#L277-L280) |
includeRawScriptCoverage | boolean | false | 一方面作为Profiler.startPreciseCoverage的callCount参数(请求函数级调用计数),另一方面决定stop()结果条目是否附带rawScriptCoverage原始 V8 数据 |
useBlockCoverage | boolean | true | 作为Profiler.startPreciseCoverage的detailed参数:true时按块级(statement/block)收集,false时退化为函数级收集 |
注意assert(!this.#enabled, 'JSCoverage is already enabled')断言:覆盖率未stop之前重复调用startJSCoverage()会直接抛错,即同一页面同一时间只能有一轮 JS 覆盖率会话。
三、startJSCoverage() 底层执行了哪些 CDP 命令
这是理解该方法行为的关键。从 JSCoverage.start() 的实现看,开启采集共做三件事:
- 记录选项状态并清空旧数据:把四个选项写入私有字段,
clear()两个Map(scriptId → url / 脚本源码文本),防止上一轮残留。 - 订阅两个 CDP 事件(通过
DisposableStack托管,stop()时统一dispose()):Debugger.scriptParsed:每解析出一个新脚本就触发#onScriptParsed,回调里先用PuppeteerURL.isPuppeteerURL(event.url)排除 Puppeteer 自身注入的pptr:前缀脚本,再按reportAnonymousScripts决定是否跳过无 URL 脚本;随后发送Debugger.getScriptSource拉取脚本源码文本并缓存。若页面已经导航走,拉取失败只记错误日志、不抛异常。Runtime.executionContextsCleared:执行上下文被清空(通常是导航)时触发#onExecutionContextsCleared,按resetOnNavigation决定是否清空缓存。
- 并行发送四条 CDP 命令:
await Promise.all([ this.#client.send('Profiler.enable'), this.#client.send('Profiler.startPreciseCoverage', { callCount: this.#includeRawScriptCoverage, detailed: useBlockCoverage, }), this.#client.send('Debugger.enable'), this.#client.send('Debugger.setSkipAllPauses', {skip: true}), ]);其中Profiler.startPreciseCoverage是核心:detailed对应useBlockCoverage(块级 vs 函数级),callCount对应includeRawScriptCoverage。而Debugger.setSkipAllPauses保证页面上出现debugger语句或断点时不会挂起——这正是测试用例"should not hang when there is a debugger statement"所验证的行为。
四、stopJSCoverage():结果如何汇总为 JSCoverageEntry[]
stop() 先并行发送Profiler.takePreciseCoverage、Profiler.stopPreciseCoverage、Profiler.disable、Debugger.disable,然后对takePreciseCoverage返回的每条 V8 覆盖记录做如下处理:
- URL 解析:优先取
#onScriptParsed阶段缓存的 URL;若脚本没有 URL 且reportAnonymousScripts为true,则合成debugger://VM + scriptId作为占位 URL。 - 过滤:
url或源码文本任意为undefined的条目直接跳过(例如页面导航后源码没拉到的情况)。 - ranges 计算:把该脚本所有函数的
functions[].ranges扁平化后交给convertToDisjointRanges()(Coverage.ts#L457-L516)——这是一个扫描线算法:把每个嵌套区间拆成 start/end 事件点、排序后用命中计数栈扫描,输出互不重叠的已覆盖区间{start, end}数组,并过滤空区间。 - 是否附带原始数据:
includeRawScriptCoverage为false时推入{url, ranges, text};为true时额外附带rawScriptCoverage: entry(完整的Protocol.Profiler.ScriptCoverage)。
返回的条目类型JSCoverageEntry继承自CoverageEntry(url/text/ranges三个必填字段,见 Coverage.ts#L21-L45)。文档中“带 sourceURL 的匿名脚本会被报告”的行为,正对应#onScriptParsed里只要event.url存在就缓存的逻辑——//# sourceURL=xxx注释会让 V8 给脚本赋上该 URL。
五、实战示例:统计页面初始执行代码占比
官方 Coverage 文档给出的标准用法即“启用 → 导航 → 停止 → 统计字节占比”,可直接复制运行:
// 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}%`);要点:
startJSCoverage()必须在page.goto()之前调用,否则采集窗口漏掉了首次导航加载的脚本;stopJSCoverage()与stopCSSCoverage()可以Promise.all并行停止;- 若只想跨多个导航累计覆盖率,需
page.coverage.startJSCoverage({resetOnNavigation: false}); page.coverage属性由 Page 暴露,因此本方法属于 Page 级 API,通过 CDP 会话驱动,适用于 Chromium 系浏览器。
六、匿名脚本与 debugger://VM URL 的确切含义
原文档 Remarks 部分值得逐字理解:
匿名脚本是没有关联 URL 的脚本,即页面上通过
eval或new Function动态创建的脚本。当reportAnonymousScripts为true时,匿名脚本的 URL 会以debugger://VM开头(除非脚本中存在魔术注释//# sourceURL,此时该注释值即为 URL)。
对应源码逻辑:
- 过滤发生在
#onScriptParsed:event.url为空且未开启reportAnonymousScripts时不缓存(Coverage.ts#L277-L280); - URL 合成发生在
stop():url = 'debugger://VM' + entry.scriptId(Coverage.ts#L310-L313)。
测试用例对此有明确断言(见 coverage.test.ts):
"should ignore eval() scripts by default":默认配置下访问含eval的页面,结果只有 1 条(页面自身脚本),eval内容不出现;"should not ignore eval() scripts if reportAnonymousScripts is true":开启后,过滤掉debugger://前缀条目后仍剩 1 条,即eval脚本以debugger://VM...URL 形式被报告;"should ignore pptr internal scripts if reportAnonymousScripts is true":即便开启了匿名报告,page.evaluate()注入的脚本因pptr:前缀 URL 被排除,结果为 0 条——这是PuppeteerURL.isPuppeteerURL()(util.ts#L69-L71)保证的。
七、用测试用例验证四个选项的行为
test/src/coverage.test.ts 中JSCoverage一节的用例与本文各节一一对应,可作为行为基准:
- 块级 ranges(
useBlockCoverage: true):加载 test/assets/jscoverage/simple.html(内容是一个内联<script>),断言结果为 1 条且ranges精确等于[{start: 0, end: 17}, {start: 35, end: 61}]。 - 函数级 ranges(
useBlockCoverage: false):同一ranges.html页面下,函数级范围更大——console.log('unused!')所在的整个函数体被计入覆盖区间,与块级结果形成可验证的差异(coverage.test.ts#L99-L118)。 resetOnNavigation: true(默认):连续两次goto后stopJSCoverage()返回 0 条——第一次导航的脚本已随executionContextsCleared被清空。includeRawScriptCoverage:默认时entry.rawScriptCoverage为undefined;传true后字段存在且非空(coverage.test.ts#L161-L184)。sourceURL报告:加载sourceurl.html后条目的url为nicename.js,印证//# sourceURL注释会作为 URL 被采用。- 零覆盖脚本:
unused.html中脚本未被执行时仍会出现在结果里,只是ranges为空数组。 debugger语句不挂起:得益于Debugger.setSkipAllPauses({skip: true})。
八、小结与使用建议
startJSCoverage()本质是开启 V8 的Profiler.startPreciseCoverage精确覆盖采集,并借助Debugger域缓存每个脚本的 URL 与源码,最终把 V8 的嵌套区间归并成互不重叠的{start, end}覆盖区间;- 四个选项的默认值(
resetOnNavigation: true、reportAnonymousScripts: false、includeRawScriptCoverage: false、useBlockCoverage: true)覆盖了大多数“统计页面实际执行代码占比”的需求;需要调用计数或更细粒度分析时再开启includeRawScriptCoverage,需要评估eval/new Function代码时开启reportAnonymousScripts并自行识别debugger://VM前缀条目; - 覆盖率会话是独占的:再次
startJSCoverage()前必须先stopJSCoverage(); - 若需把结果交给 Istanbul 等工具链,官方文档在 Coverage 的 Remarks 中推荐配合
puppeteer-to-istanbul转换; - 本方法依赖 CDP 的
Profiler/Debugger域,属于 Chromium 系实现路径(实现位于 packages/puppeteer-core/src/cdp/Coverage.ts),使用前确认所用浏览器支持对应 CDP 命令即可。
【免费下载链接】puppeteerJavaScript API for Chrome and Firefox项目地址: https://gitcode.com/GitHub_Trending/puppeteer1/puppeteer
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考