跳到主要内容

数据截至 (上游 commit fd0b7e1d9ed9)

第 6 章:复用真 DevTools 前端

这章讲什么: 那些"LCP 3.2 秒,瓶颈在文档延迟"的结论、带 source map 的堆栈、堆快照的支配树分析,是从哪来的——答案是从真的 DevTools 里来的,不是重新实现的。


6.1 关键前提:DevTools 前端是一个可 import 的库

依赖形态

.gitmodules
[submodule "devtools-frontend"]
path = third_party/devtools-frontend
url = https://github.com/ChromeDevTools/devtools-frontend.git
branch = main, shallow = true

代码里的入口只有一行(src/third_party/index.ts:91):

export * as DevTools from '../../third_party/devtools-frontend/mcp/mcp.js';

上游专门为这个用途导出了一个 mcp/mcp.js 入口。 于是本项目全程用 DevTools.XXX 访问 trace 引擎、issue 模型、堆快照代理等等。

为什么值得单独讲

性能诊断、issue 分类、堆快照分析,每一个自己写都是数万行工程。直接用 DevTools 的实现,不但省了工作量,还保证了结论口径和用户在浏览器里看到的一致

代价是要把一个为浏览器写的前端跑在 Node 里——这就是本章其余部分在解决的事。


6.2 让 DevTools 在 Node 里跑起来

overrideDevToolsGlobals(src/devtools/DevtoolsUtils.ts:27)在 McpContext 构造时就被调用(src/McpContext.ts:122),做四件事:

① 装一个宿主适配器

DevTools.Host.InspectorFrontendHost.installInspectorFrontendHost(
new McpHostBindingAdapter(loadResource),
);

DevTools 前端本来跑在一个特殊的 devtools:// 页面里,靠 InspectorFrontendHost 这套绑定读文件、发请求。Node 里没有,项目自己实现了一个(src/devtools/McpHostBindingAdapter.ts)。

注意 loadResource 是从 McpContext 传进来的(src/McpContext.ts:123-125),所以 DevTools 内部想加载任何 URL,都会经过第 2 章那套黑白名单和路径沙箱。安全边界没有被绕过。

② 拦掉会和 Puppeteer 打架的 CDP 命令

这是最微妙的一处(src/devtools/DevtoolsUtils.ts:39-106)。DevTools 前端在建立会话时会主动调 Network.enableNetwork.setBlockedURLsNetwork.emulateNetworkConditionsByRule 等命令——而 Puppeteer 正用同一批命令维护节流和黑白名单规则。

解法是在 Network agent 原型上把这五个方法整个替换成"返回成功但什么都不做":

invoke_enable / invoke_disable / invoke_setBlockedURLs
invoke_emulateNetworkConditionsByRule / invoke_overrideNetworkState
→ 全部换成 () => Promise.resolve({getError: () => undefined})

注释写明动机:"prevents the DevTools Frontend from ever resetting/clearing Puppeteer's active network blocking/throttling rules"。

③ 固定语言环境

DevTools 会按浏览器语言本地化。Node 里没有这个概念,直接钉死 en-US(:108-117)——输出给模型的文本必须是确定的英文,否则同一份 trace 在不同机器上会产出不同措辞。

④ 指定 formatter worker 入口

DevTools.Formatter.FormatterWorkerPool.FormatterWorkerPool.instance({
forceNew: true,
entrypointURL: import.meta.resolve('../third_party/devtools-formatter-worker.js'),
});

浏览器里 worker 从 devtools:// 加载,Node 里换成本地文件。同样的手法在堆快照 worker 上再用一次(见 6.5)。


6.3 每个页面一个 DevTools "宇宙"

建立

createTargetUniverse(src/devtools/DevtoolsUtils.ts:134)在每个 McpPage.init() 时被调一次,先开一个二级 CDP 会话,再在它上面建一个 DevTools Universe(DevTools 内部的依赖注入容器 + 设置存储 + target 管理)。

McpPage
├── pptrPage ← Puppeteer 的主 CDP 会话
└── devtoolsUniverse
├── session ← createCDPSession() 开的第二条会话
├── universe ← DevTools 的容器
└── target ← DevTools 的 target 对象

为什么要第二条会话: 让 DevTools 的模型层独立收发 CDP 消息,不去污染 Puppeteer 那条。

三个刻意的设置

设置为什么
supportsEmulationfalse模拟归 Puppeteer 管
overrideAutoStartModels只有 DebuggerModel只启动做 source map 需要的那个模型
NetworkManager 观察者一加载就 networkAgent().invoke_disable()网络请求由 Puppeteer 收集,DevTools 侧不重复

最后一条配了注释(:186-190):"The network requests are collected through pptr and there isn't a use case for enabling devtools SDK's network domain."

还打开了两个开关:source map 懒加载、以及 skipAllPauses——绝不能让页面在断点处停住,那会挂死整个自动化流程。


6.4 性能:从原始 trace 到一句人话

链路

performance_start_trace
│ ① 若 reload: 先 goto about:blank 清状态
│ ② tracing.start({categories})
│ categories = ['-*', …TracingDefaultCategories,
│ …JsSampling, …Screenshot]
│ ③ 若 reload: goto 回原 URL
│ ④ 若 autoStop: 等 5 秒后自动停

performance_stop_trace → tracing.stop() → Uint8Array

├─ 有 filePath? 写盘(.gz 结尾就 gzip)


parseRawTraceBuffer() src/processors/PerformanceTrace.ts:27
│ DevTools.TraceEngine.TraceModel.Model.createWithAllHandlers()
│ 带上 cpuThrottling / networkThrottling 元数据

getTraceSummary() src/processors/PerformanceTrace.ts:83
│ DevTools.PerformanceTraceFormatter(focus, deviceScope).formatTraceSummary()

"## Summary of Performance trace findings: …"

关键在最后一步:PerformanceTraceFormatter 是 DevTools 自带的、专为 AI 消费设计的格式化器。 项目没有自己去解读 trace,只是把它的输出转发出去。

两个细节

分类里第一项是 '-*'(src/tools/performance.ts:76):先关掉所有默认分类,再显式加回需要的。这样 trace 体积可控。项目还额外加了 DevTools UI 里默认开、但在 API 里算可选的两组(JS 采样、截图)。

摘要末尾会追加格式说明(:77-81):把 callFrameDataFormatDescriptionnetworkDataFormatDescription 拼进去,告诉模型"接下来这些调用树和网络行怎么读"。数据和读法一起给。

深挖单个 Insight

performance_analyze_insight 拿最近一条 trace(context.recordedTraces().at(-1)),按 insightSetId + insightName 找到模型,交给 PerformanceInsightFormatter(src/processors/PerformanceTrace.ts:129)。

三种找不到的情况各有各的话术,其中一句直接教你怎么修:"Only use ids given in the 'Available insight sets' list."

状态机的自我修复

isRunningPerformanceTrace 是个布尔标志。startTrace 的 catch 块专门为它写了一段(src/tools/performance.ts:105-121):

If a setup step (navigation, tracing.start) throws before stopTracingAndAppendOutput runs, the running flag would otherwise stay stuck true for the rest of the session, blocking all future traces.

一个卡住的布尔值会让这个会话再也录不了 trace。 所以异常路径上要主动 unwind。

CrUX 真实用户数据

populateCruxData(:245)会去 Google 的 CrUX API 拉该 URL 的真实用户体验数据,和本地录的数据一起呈现。

可以用 --no-performance-crux 关掉,并且 server 启动时会打一句免责声明(src/index.ts:242-246)。源码里那句注释也挺坦率:"Yes, we're aware this API key is public. ;)"


6.5 内存:堆快照分析

分工

take_heapsnapshot → Puppeteer captureHeapSnapshot 直接写 .heapsnapshot 文件
(文件扩展名由 context.ensureExtension 强制)


get_heapsnapshot_summary / _class_nodes / _retainers /
_retaining_paths / _dominators / _duplicate_strings /
compare_heapsnapshots …


HeapSnapshotManager src/processors/HeapSnapshotManager.ts:52
│ DevTools 的 HeapSnapshotWorkerProxy + 本地 worker 文件

结构化结果 → HeapSnapshotFormatter → 文本 + JSON

快照走文件,不走内存也不走上下文。 一个几百 MB 的堆快照塞进 MCP 响应是不可能的,所以工具之间用文件路径传递。

加载

#loadSnapshot(:363)按 1MB 分块流式读文件,逐块 loaderProxy.write(chunk)。加载完的快照按绝对路径缓存(:76),同一个文件多次查询只解析一次。

失败路径写了注释(:400-406):worker 是在读文件之前创建的,而失败的加载永远进不了缓存表,所以 dispose() 清理不到它——必须在 catch 里手动 workerProxy.dispose(),否则每次路径写错都泄漏一个 worker。

类名 → 数字 id

getOrCreateIdForClassKey(:164)给每个类名分配一个递增数字。响应里给模型的是数字 id 而不是长类名,后续查询再用 id 反查(resolveClassKeyFromId)。又一次"省 token"的具体动作。

diff 的索引一致性

#getSortedRawClassDiffs(:326)最后的过滤 + 排序带了注释:

Return a filtered and sorted array here to ensure that compare_heapsnapshot_summary and compare_heapsnapshot_details agree on indices.

两个工具必须看到同一个顺序,否则模型按摘要里的第 3 项去查详情,拿到的是另一个类。


6.6 控制台:带 source map 的堆栈

难点

生产环境代码是压缩过的,原始堆栈里是 bundle.min.js:1:48210,对调试毫无用处。DevTools 会用 source map 还原,但它的还原是异步且事件驱动的:先给你一个未还原的堆栈,等 source map 到了再发更新事件。

MCP 只有一次响应机会,拿不到"后续更新"。

解法

createStackTrace(src/devtools/DevtoolsUtils.ts:393)把异步过程变成同步等待:

① 收集堆栈里所有 scriptId(含 async 父链)
② 对每个 scriptId:
waitForScript() ── 轮询 model.scriptForId,顺带监听 ParsedScriptSource
→ sourceMapForClientPromise() 等它的 source map 加载完
③ 整体套一个 AbortSignal.timeout(1000)
④ 全部就绪后才 createStackTraceFromProtocolRuntime

1 秒等不到就带着未还原的堆栈返回。 有总比卡住好。

错误的 cause 链

SymbolizedError(:207)还会顺着 error.cause 递归还原(#lookupCause,:345)——通过 CDP Runtime.getProperties 读远端对象的 cause 属性。现代 JS 大量使用 new Error(msg, {cause}),不展开就丢了根因。


6.7 Lighthouse:第三条复用路径

Lighthouse 不是 submodule,而是预打包进仓库的一个文件:src/third_party/lighthouse-devtools-mcp-bundle.js,由 scripts/update-lighthouse.ts 更新,许可证声明由 scripts/append-lighthouse-notices.ts 追加。

src/third_party/index.ts:71-89 只是给它套上类型:

export const snapshot = snapshotImpl as (page, options) => Promise<RunnerResult>;
export const navigation = navigationImpl as (page, url, options) => Promise<RunnerResult>;
export const generateReport = generateReportImpl as (lhr, format) => string;

对应工具 lighthouse_audit(src/tools/lighthouse.ts:23)。


6.8 小结:三种复用形态

复用对象形态更新方式
devtools-frontendgit submodule + mcp/mcp.js 入口git submodule update
Lighthouse预打包 js 文件进仓库npm run update-lighthouse
Puppeteer 内部工具puppeteer-core/internal/** 深引入跟随 puppeteer 版本

第三种也不少:MutexDisposableStackPipeTransportCdpPage 类型都是从 puppeteer-core/internal/ 拿的(src/third_party/index.ts:45-55)。所有第三方引入都集中在这一个文件里,这是一个很实用的约束——想知道项目依赖了什么内部 API,看一个文件就够。


6.9 代码地图

主题文件路径符号名
第三方统一出口src/third_party/index.tsDevToolspuppeteerMutexgetToonEncode
DevTools 环境适配src/devtools/DevtoolsUtils.tsoverrideDevToolsGlobalscreateTargetUniverseDISABLE_NETWORK
宿主绑定src/devtools/McpHostBindingAdapter.tsMcpHostBindingAdapter
堆栈还原src/devtools/DevtoolsUtils.tsSymbolizedErrorcreateStackTracewaitForScript
trace 解析与摘要src/processors/PerformanceTrace.tsparseRawTraceBuffergetTraceSummarygetInsightOutput
性能工具src/tools/performance.tsstartTracestopTracingAndAppendOutputpopulateCruxData
堆快照管理src/processors/HeapSnapshotManager.tsHeapSnapshotManager#loadSnapshot#getSortedRawClassDiffs
堆快照渲染src/formatters/HeapSnapshotFormatter.tsHeapSnapshotFormatter
issue 渲染src/formatters/IssueFormatter.tsIssueFormatter
Lighthousesrc/tools/lighthouse.tslighthouseAudit