跳到主要内容

复用真实 DevTools 引擎:性能 trace、Lighthouse、内存堆快照

30 秒导读: 前面几章讲的工具(点击、快照、输入)基本是薄薄一层包在 Puppeteer 上。这一章讲的三支——性能 trace、Lighthouse 审计、内存堆快照——完全不是那个量级:它们把 Chrome DevTools 前端(chrome-devtools-frontend)lighthouse 的真实实现整个搬进 Node 里直接调用。你从这些工具拿到的 LCP 拆解、Insight 建议、堆快照 dominator 树,和你在真 Chrome DevTools 面板里看到的是同一套引擎算出来的


1. 这是什么(零基础也能懂)

先建立一个直觉。假设你要给 AI 一个"帮我看看这个网页为什么慢"的能力,你有两条路:

  • 自己造轮子: 采集浏览器的原始 trace 事件(几万条 JSON),然后自己写代码去理解"哪段是渲染、哪段是脚本、LCP 是哪张图、有没有布局抖动"。这套逻辑 Chrome 团队写了很多年,你要重写一遍。
  • 借真引擎: Chrome DevTools 的"性能面板"背后那套 trace 分析引擎本身是 TypeScript 写的、开源在 chrome-devtools-frontend 里。直接把它当库 import,喂原始 trace,让它吐结论。

这一章讲的三支工具全走第二条路。它们是本项目里工程含量最高的一撮,妙就妙在"不重造"。

这三支各解决什么:

工具组解决的问题借的是谁的引擎
性能 trace录一段页面加载,算出 Core Web Vitals(LCP/INP/CLS)与可执行的性能 InsightDevTools trace engine + Insights
Lighthouse 审计跑无障碍 / SEO / 最佳实践 / agentic 审计,出分和报告lighthouse 库(DevTools 定制 bundle)
内存堆快照采集 JS 堆,看谁占内存、谁被谁引用、两次快照差异DevTools HeapSnapshotModel(含堆分析 worker)

一句话直觉/类比: 别的工具是"遥控器"(让 AI 操作浏览器);这三支是"把 Chrome 开发者面板的大脑拆下来,接到 AI 的嘴上"——面板能看懂的,AI 现在也能读到文字版。

一个重要前提: 这三支工具都是 page-scoped(作用于当前选中的页面)且非 read-only(录 trace 要导航、要重载;审计要改设备模拟)。它们不是"看一眼"那种安全操作,后面 §6 会展开。


2. 顶层全景(它大概怎么转)

怎么读这张图: 左边是三个工具入口,中间是本项目写的"胶水/适配层",右边是被复用的真实上游引擎。关键在中间那层——它让本该跑在浏览器里的 DevTools 代码,能在一个 Node 进程里跑起来。

工具入口(MCP tool) 本项目的胶水 被复用的真引擎(上游)
┌──────────────────────┐ ┌────────────────────────┐ ┌───────────────────────────┐
│ performance_* │───▶│ tools/performance.ts │───▶│ DevTools.TraceEngine │
│ (start/stop/analyze)│ │ trace-processing/parse │ │ DevTools.*Formatter │
│ │ │ │ │ DevTools.CrUXManager │
├──────────────────────┤ ├────────────────────────┤ ├───────────────────────────┤
│ lighthouse_audit │───▶│ tools/lighthouse.ts │───▶│ lighthouse bundle │
│ │ │ │ │ (snapshot/navigation) │
├──────────────────────┤ ├────────────────────────┤ ├───────────────────────────┤
│ take_heapsnapshot │───▶│ tools/memory.ts │───▶│ DevTools.HeapSnapshotModel│
│ get_heapsnapshot_* │ │ HeapSnapshotManager.ts │ │ (+ 堆分析 worker) │
│ compare_heapsnapshots│ │ HeapSnapshotFormatter │ │ │
└──────────────────────┘ └────────────────────────┘ └───────────────────────────┘


┌────────────────────────────────┐
│ third_party/index.ts │ ← 唯一的"入口闸门"
│ export * as DevTools from │ 所有上游引擎都从这里
│ chrome-devtools-frontend/mcp │ re-export 出去
└────────────────────────────────┘

┌────────────────────────────────┐
│ devtools/ 适配层 │ ← 让 DevTools 模块
│ overrideDevToolsGlobals / │ 误以为自己在浏览器里
│ createTargetUniverse │
└────────────────────────────────┘

部件职责一句话:

部件干什么在哪个文件
third_party/index.ts唯一收口:把 DevTools 命名空间、Puppeteer、MCP SDK、lighthouse bundle 全 re-exportsrc/third_party/index.ts
devtools/ 适配层给 DevTools 前端伪造浏览器环境(全局、host、target),让它在 Node 跑起来src/devtools/DevtoolsUtils.ts
tools/performance.ts录/停 trace、拉 CrUX、把结果交给引擎解析src/tools/performance.ts
trace-processing/parse.ts调 DevTools trace engine 解析、生成 summary 和 insight 文本src/trace-processing/parse.ts
tools/lighthouse.ts配 flags、调 lighthouse bundle、生成报告src/tools/lighthouse.ts
HeapSnapshotManager.ts加载 .heapsnapshot、经 DevTools 堆模型算聚合/引用/diffsrc/HeapSnapshotManager.ts

响应怎么组装成最终文本(McpResponse / attach* 系列)是 05-response-collectors 的主题,本章只讲这些工具往响应里塞了什么,不重复组装机制。


3. 核心机制一:一个入口闸门(third_party/index.ts)

它要解决的小问题: 上游 DevTools 前端是一大坨模块。如果全项目到处 import 它,一旦上游改结构就满地找。于是本项目立了一道唯一收口

最关键的一行在文件末尾——把整个 DevTools 命名空间 re-export 出去:

// src/third_party/index.ts:88 —— 全项目唯一从这里拿 DevTools
export * as DevTools from '../../node_modules/chrome-devtools-frontend/mcp/mcp.js';

之后 parse.tsHeapSnapshotManager.tsDevtoolsUtils.ts 全都写 import {DevTools} from '../third_party/index.js',谁也不直接碰 node_modules。上游变了,只改这一个文件。

同一个文件还收口了另外几样东西,各有讲究:

  • lighthouse bundle 的三个函数(snapshot / navigation / generateReport)——注意不是直接从 lighthouse 包拿,而是从一个定制 bundle 里拿,再手动套上类型(src/third_party/index.ts:68-86)。
  • 动态 import 的编码器:getToonEncode / getGcfEncodeawait import(...) 惰性加载 TOON / GCF 编码库(src/third_party/index.ts:59-66),用到才加载。
  • core-js polyfill:文件最顶上三行(src/third_party/index.ts:7-9)引入 Promise.withResolversSet.union、迭代器 helpers。为什么? 因为 DevTools 前端代码是给较新浏览器写的,用了这些新 API;搬到 Node 里得先把这些语言特性补齐,否则一 import 就崩。HeapSnapshotManager 里就真的用到了 Promise.withResolvers(见 §5)。

这一步的取舍(全章的核心主题):

复用真引擎 = 准确,但重。它把你死死绑在一个具体上游版本上——package.jsonchrome-devtools-frontendpin 到精确版本 1.0.1652307(package.json:68),lighthouse pin 到 13.4.0(package.json:75)。上游更新要走专门的 update-lighthouse 脚本,不能随手 ^ 放宽。换来的好处是:结论和真 DevTools 面板一字不差,不用自己维护一套解析器。


4. 核心机制二:性能 trace(录制 → 解析 → Insight)

这节讲最能体现"借真引擎"的一支:性能 trace。分三步走。

4.1 录制:CDP tracing + 清态 + autoStop

performance_start_trace(src/tools/performance.ts:28 startTrace)干的事其实是指挥 Puppeteer 走 CDP 的 Tracing.start,类别列表是从 Chromium 的 TimelineController 和 lighthouse 里抄来保持同步的:

// src/tools/performance.ts:74-91 —— 决定录哪些类别的 trace 事件
const categories = [
'-*', // 先全关
'blink.user_timing',
'devtools.timeline',
'disabled-by-default-devtools.timeline', // DevTools 时间线要的重头戏
'disabled-by-default-v8.cpu_profiler', // CPU 采样
'loading', 'latencyInfo', 'v8', /* …等 */
];
await page.pptrPage.tracing.start({categories});

录制前后有两个巧妙的细节:

  • 清态:reload=true,开录前先 goto('about:blank') 把上一页的状态清干净(src/tools/performance.ts:66),再导航回原 URL,保证录到的是一次干净的加载
  • autoStop: 默认录 5 秒自动停(src/tools/performance.ts:103,setTimeout(resolve, 5_000)),省得 AI 忘了停。停时走 stopTracingAndAppendOutput

4.2 保存 + 解析:把原始 buffer 交给真引擎

停录在 stopTracingAndAppendOutput(src/tools/performance.ts:184)里。它先(可选)把原始 trace 存盘——文件名以 .gz 结尾就 gzip 压缩(src/tools/performance.ts:194-209),因为原始 trace 很大。然后把 buffer 交给解析:

// src/tools/performance.ts:214 —— 原始 buffer 进,结构化结果出
const result = await parseRawTraceBuffer(traceEventsBuffer, {
cpuThrottling: page.cpuThrottlingRate,
networkThrottling: page.networkConditions ?? undefined,
});

解析这一步就是真引擎登场的地方。parse.ts 顶部创建了一个 DevTools trace 引擎实例:

// src/trace-processing/parse.ts:10 —— 这就是 DevTools 性能面板背后的引擎
const engine = DevTools.TraceEngine.TraceModel.Model.createWithAllHandlers();

parseRawTraceBuffer(src/trace-processing/parse.ts:27)把 buffer 解码成 JSON 事件数组,喂给 engine.parse(events, {metadata})(:54),再取 engine.parsedTrace()Insight(可执行建议)不是本项目算的——它直接读引擎产物 parsedTrace.insights(:62)。也就是说 "LCP 拆解""文档延迟""渲染阻塞资源"这些结论,是 DevTools 引擎自己算好的。

4.3 输出:summary 与单条 Insight 的文本化

解析成功后,工具往响应里挂两样东西(具体渲染在 05 章):

  • attachTraceSummary(result) → 最终由 getTraceSummary(src/trace-processing/parse.ts:83)产出。它用 DevTools 的 AgentFocus + PerformanceTraceFormatter 把 trace 格式化成给 agent 读的文字(含主线程调用帧、网络请求格式说明)。
  • performance_analyze_insight(src/tools/performance.ts:145 analyzeInsight)→ 走 getInsightOutput(src/trace-processing/parse.ts:101),用 PerformanceInsightFormatter某一条 Insight 展开成详细文字。InsightName 这个类型直接取自引擎的 InsightModels 的键(src/trace-processing/parse.ts:97),所以工具支持哪些 Insight 名字跟着上游引擎走,项目自己不维护清单。

4.4 CrUX:掺入真实用户数据(带隐私开关)

trace 是你这一次的加载。DevTools 面板还会叠加 CrUX(Chrome User Experience Report,真实用户体验报告)——即全球真实用户在这个 URL 上的 P75 体验。本项目也复用了这套:

// src/tools/performance.ts:236 populateCruxData —— 让 DevTools 的 CrUXManager 去拉真实用户数据
const cruxManager = DevTools.CrUXManager.CrUXManager.instance();
// … 收集 trace 里出现的 URL,逐个 getFieldDataForPage,塞回 metadata.cruxFieldData

它把 trace 里各 URL(含主帧 URL)去重后,让 CrUXManager 逐个拉字段数据(src/tools/performance.ts:249-272),供后续 formatter 使用。用哪台设备的口径(手机/桌面)由响应侧的 #deviceScope 决定(src/McpResponse.ts:274,按页面 viewport 判 PHONE/DESKTOP)。

隐私取舍(重要): CrUX 要把你 trace 里的 URL 发到 Google 的 CrUX API。所以:

  • 只有 context.isCruxEnabled() 为真才拉(src/tools/performance.ts:220;开关在 src/McpContext.ts:354)。
  • 启动时会主动打印告警并给出关掉的办法(src/index.ts:209-212):"Performance tools may send trace URLs to the Google CrUX API … To disable, run with --no-performance-crux."
  • 对应 CLI 开关 performanceCrux 默认 true(src/bin/chrome-devtools-mcp-cli-options.ts:261),--no-performance-crux 关掉。

5. 核心机制三:内存堆快照(采集 → DevTools 堆模型 → 分页呈现)

它要解决的小问题: 内存泄漏很难查——你需要知道"堆里有哪些对象、谁占大头、谁被谁引用着不放、跑一段后多了什么"。这套分析 Chrome DevTools 的"Memory 面板"做得极好,本项目整个借过来

5.1 采集:一行 Puppeteer

采集本身很轻——take_heapsnapshot(src/tools/memory.ts:19)直接调 Puppeteer 的 CDP 封装存盘:

// src/tools/memory.ts:40 —— 采集就一句,重头在后面的分析
await page.pptrPage.captureHeapSnapshot({path: snapshotPath});

5.2 分析:HeapSnapshotManager 挂上 DevTools 堆模型

真正的重活在 HeapSnapshotManager(src/HeapSnapshotManager.ts:46)。它加载 .heapsnapshot 文件的方式很能体现"复用引擎"的味道——起一个 DevTools 的堆分析 worker,像流一样把文件喂进去:

// src/HeapSnapshotManager.ts:317 —— 用 DevTools 自带的堆分析 worker 代理
const workerProxy = new DevTools.HeapSnapshotModel.HeapSnapshotProxy
.HeapSnapshotWorkerProxy(() => {}, import.meta.resolve(
'./third_party/devtools-heap-snapshot-worker.js')); // 上游打包出的 worker
// createLoader → 逐块 write 文件 → close → 拿到 HeapSnapshotProxy

拿到 proxy 后,一系列问题全是转手问 DevTools 堆模型,项目自己几乎不算:

MCP 工具Manager 方法底层 DevTools 调用
get_heapsnapshot_detailsgetAggregatessnapshot.aggregatesWithFilter(filter)(:91)
get_heapsnapshot_retainersgetRetainerscreateRetainingEdgesProvider(:166)
get_heapsnapshot_dominatorsgetDominatorsOfsnapshot.getDominatorsOf(:199)
get_heapsnapshot_retaining_pathsgetRetainingPathssnapshot.getRetainingPaths(:182)
compare_heapsnapshotsgetClassDiffscalculateSnapshotDiff(:289)

这里也用到了 §3 提到的 polyfill:加载 worker 时用 Promise.withResolvers()(src/HeapSnapshotManager.ts:325)等 worker 回调把快照 resolve 回来——正是 core-js 补的那个 API。

class diff 的一个细节坑: 两次快照的对比,baseSnapshot.uid 被当作 DevTools calculateSnapshotDiff缓存 key(src/HeapSnapshotManager.ts:283-289,代码里专门注释了这点),所以每份快照要有稳定唯一 uid。diff 结果还会过滤+按 sizeDelta 排序(:297-299),确保 summary 和 details 两个工具看到的索引一致——不然 AI 拿 summary 里的 index 去 details 会错位。

5.3 呈现:CSV 化 + 分页

堆快照数据量巨大,一股脑塞给模型会爆上下文。HeapSnapshotFormatter(src/formatters/HeapSnapshotFormatter.ts:48)把结果压成紧凑 CSV(带表头行),例如聚合视图:

// src/formatters/HeapSnapshotFormatter.ts:144 toString —— 输出成一行行 CSV,省 token
lines.push('id,name,count,selfSize,maxRetainedSize');
// 每个类一行:id,类名,实例数,自身大小(KB),最大保留大小(KB)

字节都经 DevTools 自己的 I18n.ByteUtilities.formatBytesToKb 格式化(:75),口径与面板一致。分页则在响应侧按 pageIdx/pageSize 切(工具 schema 里带这俩参数,见 src/tools/memory.ts:87-94),让 agent 一页一页翻,而不是一次吞下几万个对象。

门控: 这组读取类工具挂了 conditions: ['memoryDebugging'](如 src/tools/memory.ts:55),要显式开 --memoryDebugging=true 才可用(ToolHandlersrc/ToolHandler.ts:98-115 检查条件)——因为它们偏专家向,默认不亮出来。


6. 核心机制四:让 DevTools 在 Node 里"以为自己在浏览器"(适配层)

它要解决的小问题: DevTools 前端本是跑在浏览器里的代码,处处假设有 window、有 host binding、有一个"目标(target)"和一堆全局单例。搬进 Node 直接跑会到处 undefinedsrc/devtools/ 这层就是给它造一个足以骗过它的假环境

两个关键函数:

overrideDevToolsGlobals(src/devtools/DevtoolsUtils.ts:28)在 McpContext 构造时被调用一次(src/McpContext.ts:110),干几件"填坑"的事:

  • 装一个假的 host binding(McpHostBindingAdapter),让 DevTools 以为有前端宿主。
  • 抹掉 DevTools 的网络模拟命令:把 Network agent 的 invoke_emulateNetworkConditionsByRule 等一串方法 stub 成空(src/devtools/DevtoolsUtils.ts:47-107)。为什么? 因为网络节流是 Puppeteer 在管的,不能让 DevTools 前端在初始化时把 Puppeteer 的节流规则给清了。
  • 装 i18n locale、formatter worker 等其余全局。

createTargetUniverse(src/devtools/DevtoolsUtils.ts:135)则为每个页面建一个 DevTools "宇宙(Universe)"——一套隔离的 target/setting/model 环境,把 Puppeteer 的 CDP session 包成 DevTools 认得的连接(PuppeteerDevToolsConnection)。它在 McpPage 里按需创建(src/McpPage.ts:144)。里面还有两个务实的约定:永不在 DevTools 侧暂停调试器(SKIP_ALL_PAUSES)、不在 DevTools 侧记网络(DISABLE_NETWORK,网络由 Puppeteer 收集,见 05 章)。

还有个小而妙的东西:FakeIssuesManager(src/devtools/DevtoolsUtils.ts:21)——一个只实现被真正用到的那几个方法的假 issues 管理器,issues() 直接返回空数组。DevTools 的某些聚合器要一个 issues manager 才肯工作,项目就给它一个"够用就行"的壳(src/PageCollector.ts:169 在用)。

一句话看懂这层的哲学: 不去改上游一行代码,而是在外围造够假环境 + stub 掉会互相打架的部分,让上游模块原封不动地在 Node 里跑起来。这正是"复用真引擎"能成立的地基。


7. Lighthouse:同样的套路,换个引擎

Lighthouse 那支(src/tools/lighthouse.ts:23 lighthouseAudit)结构上和前面一脉相承,只是引擎换成了 lighthouse bundle。流程:

  1. 配 flags:审计类别写死为 accessibility / seo / best-practices / agentic-browsing(src/tools/lighthouse.ts:50-55)——注意刻意不含 performance(工具描述里明说性能请用 performance_start_trace),两支不重叠。再按 device 配桌面/移动的屏幕模拟(:70-88)。
  2. 跑审计:navigation 模式重载再审、snapshot 模式审当前态,分别调 §3 从 bundle re-export 出来的 navigation(...) / snapshot(...)(src/tools/lighthouse.ts:92-100)。跑完在 finallyrestoreEmulation() 还原设备模拟——因为它改了页面状态(这也是它非 read-only 的原因)。
  3. 出报告:generateReport(lhr, format)(:113)——同样是 bundle 里的真函数——生成 JSON + HTML 两份报告存盘,再把精简过的分数摘要 attachLighthouseResult(:171)挂进响应。

取舍同上: lighthouse 被 pin 在 13.4.0(package.json:75),升级要走 update-lighthouse 脚本;换来的是和官方 Lighthouse 完全一致的评分。


8. 巧妙之处(可带走的技术)

  • 唯一收口 re-export。 全项目只有 src/third_party/index.ts:88 一处碰 chrome-devtools-frontend,上游换版本改一行即可——把"依赖上游"这件危险事收敛到一个可控点。
  • 不重造解析器。 trace insight、堆 dominator、lighthouse 评分全是上游算的,项目只做"喂数据 + 文本化"。少写的每一行都是不用维护的正确性。
  • 造假环境而非改上游。 devtools/ 那层 stub/伪造得刚好够(FakeIssuesManager 只实现用到的方法、网络命令被抹平),既不 fork 上游也不被它的浏览器假设绊倒。
  • 给 agent 友好的输出。 堆快照压成 CSV + 分页、trace 用专门的 *Formatter 转成叙述文字——都是为了省 token、便于模型消化,而非直接倒原始 JSON。
  • 隐私默认可见、可关。 CrUX 会外发 URL,于是启动即告警并留 --no-performance-crux(src/index.ts:209-212)——把"数据出网"这件事摊开给用户。

9. 边界与局限(诚实)

  • 绑死上游版本。 chrome-devtools-frontend pin 到 1.0.1652307lighthouse13.4.0(package.json:68/:75)。想要新 Insight/新审计,只能等项目升级 bundle,不能自己放宽。
  • 重。 把整套 DevTools 前端 + lighthouse 拉进进程,启动与内存成本远高于一个薄工具;还需要 core-js polyfill、假 worker 等一堆脚手架。
  • 非 read-only + page-scoped。 录 trace 会 about:blank 清态并重载页面(src/tools/performance.ts:66)、Lighthouse 会改设备模拟——它们会动当前页面的状态,不是无副作用的观察。且只作用于当前选中页。
  • 只有一个 trace 能同时录。 performance_start_trace 若发现已在录会直接报错拒绝(src/tools/performance.ts:53-58)。
  • CrUX 依赖外部 API。 拿不到字段数据时(URL 无 CrUX 覆盖)相应部分就缺失,且需要出网。

10. 横向对比(本组其它章)

想了解去哪章
工具怎么定义、分类门控、请求生命周期01-tool-model
浏览器怎么接入、页面/路径沙箱02-browser-context-pages
文本快照与 uid 定位03-snapshot-uid
自动等待与输入动作04-autowait-input
McpResponse 怎么把这些 attach* 组装成最终文本、PageCollector 怎么采数据05-response-collectors

本章与 05 的分工:05 讲"响应怎么拼",06 讲"这三支重工具往响应里塞了什么、以及它们背后借了谁的引擎"


11. 代码地图(导航索引)

主题文件路径符号名
DevTools 命名空间收口 re-exportsrc/third_party/index.tsexport * as DevTools(:88)
lighthouse bundle 三函数src/third_party/index.tssnapshot / navigation / generateReport(:74-86)
动态 import 编码器src/third_party/index.tsgetToonEncode / getGcfEncode(:59-66)
core-js polyfillsrc/third_party/index.ts顶部 3 行 import(:7-9)
录制 trace(类别/清态/autoStop)src/tools/performance.tsstartTrace(:28)
停录 + 存盘(gz)+ 解析src/tools/performance.tsstopTracingAndAppendOutput(:184)
单条 Insight 分析src/tools/performance.tsanalyzeInsight(:145)
拉 CrUX 真实用户数据src/tools/performance.tspopulateCruxData(:236)
DevTools trace 引擎实例src/trace-processing/parse.tsengine = …createWithAllHandlers()(:10)
原始 buffer → 结构化src/trace-processing/parse.tsparseRawTraceBuffer(:27)
trace 摘要文本化src/trace-processing/parse.tsgetTraceSummary(:83)
单条 Insight 文本化 + 名字类型src/trace-processing/parse.tsgetInsightOutput / InsightName(:101/:97)
Lighthouse 审计工具src/tools/lighthouse.tslighthouseAudit(:23)
堆快照采集工具src/tools/memory.tstakeHeapSnapshot(:19)
堆快照加载 + 分析src/HeapSnapshotManager.tsHeapSnapshotManager(:46)
堆分析 worker 代理 + polyfillsrc/HeapSnapshotManager.ts#loadSnapshot(:310)
快照 diff(缓存 key/排序坑)src/HeapSnapshotManager.ts#getSortedRawClassDiffs(:273)
堆数据 CSV 化src/formatters/HeapSnapshotFormatter.tsHeapSnapshotFormatter(:48)
假环境注入src/devtools/DevtoolsUtils.tsoverrideDevToolsGlobals(:28)
每页 DevTools 宇宙src/devtools/DevtoolsUtils.tscreateTargetUniverse(:135)
假 issues 管理器src/devtools/DevtoolsUtils.tsFakeIssuesManager(:21)
CrUX 开关src/McpContext.ts / src/index.tsisCruxEnabled(:354) / 告警(:209)
上游版本 pinpackage.jsonchrome-devtools-frontend(:68) / lighthouse(:75)