基于 Puppeteer 与 Chrome Trace 的前端性能自动化测量工具

发布于 | 分类于 前端/前端工程|本文包含AIGC内容|English version

复杂前端应用的性能问题,往往不发生在首屏加载阶段,而是发生在页面已经运行之后:大批量数据进入、组件持续更新、列表滚动、图表刷新或用户连续操作时,页面开始出现 CPU 占用升高、内存增长、掉帧和响应变慢。

Chrome DevTools 很适合定位一次具体问题,但手工录制难以稳定复现相同场景,也不方便比较两个版本。要让性能评测可以重复执行,需要把浏览器启动、场景操作、性能采集、指标解析和结果对比串成一条自动化链路。

本文整理一种基于 Puppeteer、Chrome Trace Event 和 Chrome DevTools Protocol(下文简称 CDP)的实现思路,重点讨论工具的边界、模块设计和指标提取规则,不涉及任何具体业务代码。

一、先明确测量对象 ​

“页面性能”不是一个单一指标。工具设计前,应先区分三类问题:

类型典型指标常用数据源
加载性能FCP、LCP、CLS、资源加载耗时Navigation Timing、Resource Timing、Web Vitals、Lighthouse
运行时性能主线程忙碌率、长任务、帧间隔、操作响应时间Chrome Trace、自定义埋点、Event Timing
系统资源浏览器进程 CPU、进程内存、GPU 使用操作系统进程监控、Chrome 任务管理器

本文关注第二类,即一个已经进入可操作状态的页面,在指定操作窗口内的运行表现。

这里有一个容易混淆的边界:从 Trace 中统计主线程任务耗时,得到的是“渲染主线程忙碌率”,不是操作系统意义上的 CPU 占用率。页面还可能使用 Worker、GPU 进程、网络进程和其他渲染线程,因此不能用这个数推导整台机器或整个浏览器使用了多少个 CPU 核心。

如果目标是系统级 CPU,需要先定位页面对应的渲染进程,再通过系统进程监控采样;它应当作为另一套指标单独记录,不能和主线程忙碌率混用。

二、工具的整体设计 ​

一套可重复使用的测量工具,可以拆成五层:

mermaid
flowchart LR
    A[场景定义] --> B[Puppeteer 执行器]
    B --> C[Trace / CDP / 自定义埋点]
    C --> D[事件解析与指标聚合]
    D --> E[本地测试记录]
    E --> F[详情查看与版本对比]

2.1 场景定义 ​

场景层只描述“如何把页面带到可测状态”和“执行什么操作”,不关心指标如何计算。通常需要提供几个扩展点:

  • install:页面加载前注入通用脚本、设置测试环境。
  • prepare:等待页面达到稳定、可操作状态。
  • actions:执行点击、滚动、输入或一段持续操作。
  • after:等待异步更新收敛并收集自定义埋点。

把场景和采集器分开有两个好处:同一套指标解析逻辑可以复用到不同页面;页面结构变化时,只需要调整场景,不需要修改底层采集代码。

2.2 Puppeteer 执行器 ​

执行器负责浏览器生命周期和测量窗口,主流程可以概括为:

  1. 使用固定版本的 Chrome 和固定窗口尺寸启动浏览器。
  2. 创建或复用一个页面,完成测试前初始化。
  3. 打开目标页面,等待明确的就绪条件。
  4. 开启 Trace 和其他采集器。
  5. 执行测试动作。
  6. 等待必要的收尾时间,停止采集。
  7. 解析数据、保存本地记录并关闭浏览器。

性能采集不应覆盖登录、页面跳转和不相关的等待时间。更可靠的方式是先让页面进入可测状态,再显式开启 Trace;动作完成并且界面稳定后立即停止。这样每次测量的时间窗口才具有可比性。

执行器还应使用 try/finally 保证异常情况下能够停止采集和关闭浏览器,避免留下损坏的 Trace 文件或后台进程。

2.3 三种互补的数据源 ​

单一数据源很难覆盖所有问题,因此工具同时使用三种采集方式:

数据源适合回答的问题特点
Chrome Trace主线程何时繁忙、是否有长任务、帧事件如何分布信息最完整,文件较大,解析成本高
CDP Performance.getMetricsDOM 节点、事件监听器、累计脚本和布局耗时适合低频采样,但多数计时字段是累计值
User Timing某个业务阶段从开始到可见完成用了多久语义明确,但需要在正确的完成时机埋点

三者的时间口径必须对齐到同一个测量窗口。否则 Trace 记录了 30 秒,CDP 采样了 60 秒,自定义埋点又跨越了页面初始化阶段,最后得到的数字无法相互解释。

2.4 本地结果模型 ​

每次测试可以在独立目录中生成三类文件:

  • trace.json:原始 Trace,供 DevTools 或 Perfetto 继续下钻。
  • summary.json:解析后的汇总指标和必要的时间序列。
  • records.json:测试记录索引,保存时间、场景、环境和结果文件的相对路径。

记录使用相对路径,整个结果目录就可以在本地移动。可视化层只读取这些静态 JSON,展示单次详情和两组结果的变化,不需要引入服务端。

三、理解 Trace Event Format ​

Puppeteer 的 tracing 接口最终生成 Chrome Trace Event Format。解析器主要关心以下字段:

字段含义
name事件名称,例如 RunTask、DrawFrame、UpdateCounters
cat事件所属分类
ph事件阶段或类型
ts事件开始时间,通常以微秒表示
dur完整事件持续时间,通常以微秒表示
pid进程 ID
tid线程 ID
args与事件相关的附加数据

常见的 ph 类型包括:

  • X:完整事件,开始时间和持续时间在同一条记录中。
  • B / E:一对开始、结束事件,需要按进程、线程和嵌套关系配对。
  • I:瞬时事件,只有发生时间。
  • M:元数据,例如线程名称。

指标解析前应先做两件事:

  1. 统一时间单位。Trace 通常使用微秒,而文章和报表一般使用毫秒。
  2. 锁定进程与线程。不同线程可能出现同名事件,只按 name 筛选会把不相关的数据混在一起。

Trace 的事件名称和类别不是稳定的 Web 标准 API,可能随 Chrome 版本变化。因此自动化基准必须固定浏览器版本,并保留原始 Trace;升级浏览器后,需要重新验证筛选规则。

四、确定测量时间范围与主线程 ​

4.1 时间范围 ​

最理想的时间范围来自显式的采集开始和结束时刻。若只能从事件反推,则可以取最早事件的 ts 作为开始,取最大的 ts + dur 作为结束。

但是,直接使用整个 Trace 的最小和最大时间戳可能包含初始化、元数据或停止采集时的额外事件。因此,解析器最好把测试动作对应的标记也写入 Trace,并以这对标记裁剪数据。

4.2 主线程识别 ​

主渲染线程通常可以从 thread_name 元数据中识别,其名称常见为 CrRendererMain。找到它的 pid 和 tid 后,主线程忙碌率和长任务都只在这个范围内统计。

当 Trace 中存在多个渲染进程时,仅取第一个同名线程并不可靠。更完整的实现还应结合目标页面的 frame、进程切换记录或导航事件,确认哪个渲染进程属于被测页面。

五、指标提取规则 ​

5.1 主线程忙碌率 ​

主线程忙碌率用于描述一个时间窗口内,主线程有多少时间在执行顶层任务。

首先把测量窗口切成固定长度的时间片,例如 200ms。对主线程上的完整 RunTask 事件,计算它与每个时间片的交集:

text
重叠时间 = max(0, min(任务结束, 分片结束) - max(任务开始, 分片开始))

分片忙碌率 = 分片内任务占用时间 / 分片实际长度 × 100%

一个 350ms 的任务可能跨越多个分片,因此不能只把它记到开始时间所在的分片中。

只统计顶层 RunTask,是为了避免重复累加它内部的 FunctionCall、Layout、Paint 等子事件。若事件本身可能重叠,应先对时间区间求并集再求和;简单相加后强制截断到 100%,会掩盖重复计数问题。

分片大小决定了结果的观察尺度:

  • 分片过小,峰值更明显,但噪声和数据量更大。
  • 分片过大,曲线更平滑,但短时间卡顿容易被平均掉。
  • 200ms 可以作为初始值,不应被视为通用标准。

汇总时建议至少保留平均值、中位数、P95 和最大值。平均值表示整体负载,中位数表示常态,P95 和最大值用于观察尾部峰值。

5.2 使用 CDP 采样主线程忙碌率 ​

Performance.getMetrics 中的 TaskDuration、ScriptDuration、LayoutDuration 和 RecalcStyleDuration 是自启用监控以来的累计秒数,不是某一时刻的比例。

因此不能直接用 TaskDuration × 100% 作为 CPU 指标。相邻两次采样之间的近似忙碌率应按增量计算:

text
忙碌率 = ΔTaskDuration / ΔTimestamp × 100%

累计字段还可以计算脚本、布局和样式重算在采样区间内的耗时增量。它们适合作为趋势监控和辅助解释,Trace 则更适合分析具体事件。

5.3 长任务 ​

浏览器主线程连续执行超过 50ms,会长期占据主线程并压缩处理输入和渲染的时间。基于 Trace 的一种实用近似规则是:

text
事件属于目标页面的主渲染线程
并且事件类型为完整事件
并且事件名称为顶层 RunTask
并且 dur >= 50ms

建议输出:

  • 长任务数量。
  • 总阻塞时长。
  • 平均、中位数、P95 和最大持续时间。
  • 每个长任务的开始时间和持续时间,便于回到 Trace 定位。

还可以计算近似的 Total Blocking Time:对每个长任务只累计超过 50ms 的部分。

text
TBT ≈ Σ max(0, 任务持续时间 - 50ms)

需要注意,这种 RunTask 筛选是工程上的近似口径,并不完全等价于 Long Tasks API 的任务归因和上下文定义。报告应明确写出提取规则,而不是只写“长任务来自 Chrome 标准”。

5.4 帧间隔、FPS 与掉帧 ​

一种简单做法是读取连续 DrawFrame 瞬时事件,用相邻时间戳之差计算帧间隔:

text
frameInterval[i] = drawFrame[i].ts - drawFrame[i - 1].ts
近似平均 FPS = 1000 / 平均帧间隔(毫秒)

但这个算法有明确适用条件:测试场景必须持续发生视觉更新。静态页面不会为了满足统计而持续产生有效绘制事件,此时较长的事件间隔并不表示页面一直在低帧率运行。

因此更稳妥的报告方式是优先输出帧间隔分布:平均值、P95、最大值,以及超过帧预算的次数。帧预算由测试环境的目标刷新率决定:

text
帧预算 = 1000 / 目标刷新率

60Hz 环境的帧预算约为 16.67ms,120Hz 环境约为 8.33ms。不能把 60 FPS 永久写死为所有设备的上限。

DrawFrame 也不能完整表达 Chrome DevTools 中的 dropped frame、partially presented frame 等状态。若目标是精确复现 DevTools Frames 轨道,需要解析更完整的 BeginFrame、提交、合成和呈现事件,而不是只根据两个 DrawFrame 的间隔下结论。

5.5 JS 堆内存 ​

Trace 中的 UpdateCounters 事件可能包含:

  • jsHeapSizeUsed:已使用的 JS 堆大小。
  • jsHeapSizeLimit:JS 堆上限或相关容量值。
  • DOM 节点等计数器。

这类事件通常是稀疏采样,不会在每个时间片出现。可以将最近一次有效值前向填充到后续分片,再计算最大值和基于采样窗口的平均值。

内存指标应至少保留三类结果:

  • 起始值、结束值以及净增长量。
  • 测量窗口内的最大值。
  • 分片序列,用于观察持续增长还是操作后的正常回落。

单次测试中的“结束值更大”不能直接证明内存泄漏。垃圾回收时间不可控,缓存也可能是预期行为。判断泄漏更适合重复执行同一操作,并在多轮操作后观察基线是否持续抬升;必要时结合 Heap Snapshot 验证对象是否仍被引用。

5.6 DOM、事件监听与渲染计数 ​

CDP 的 Performance.getMetrics 还可以提供一些结构性指标:

指标解释方式
Nodes当前 DOM 节点数量,适合观察是否持续增长
JSEventListeners已注册事件监听器数量,适合发现重复绑定趋势
LayoutCount累计布局次数,应使用相邻采样差值
RecalcStyleCount累计样式重算次数,应使用相邻采样差值
LayoutDuration累计布局耗时,应使用相邻采样差值
RecalcStyleDuration累计样式重算耗时,应使用相邻采样差值
ScriptDuration累计脚本执行耗时,应使用相邻采样差值

这些数字通常用于解释主指标,而不是单独作为性能结论。例如,一段操作的主线程忙碌率升高,同时布局次数和布局耗时大幅增加,才比较有理由继续调查布局抖动。

5.7 自定义阶段耗时 ​

Trace 能说明浏览器在忙什么,但不知道业务上“什么时候算完成”。例如一次操作可能在点击后先请求数据,再更新状态,最后等到 DOM 绘制完成才算对用户可见。

这类指标适合使用 User Timing API:

js
performance.mark(`${id}:start`)
// 执行被测操作
performance.mark(`${id}:end`)
performance.measure(name, `${id}:start`, `${id}:end`)

工具层应为每次调用生成唯一 ID,而不是只用名称和自增下标猜测配对关系。这样才能正确处理同名操作重入、并发执行、异常退出和未闭合测量。

结束点也必须对应用户真正感知到的完成时刻。只在 Promise 返回时结束,可能遗漏框架提交更新和浏览器绘制;只使用固定延迟,又会把无关等待算入结果。可以根据场景选择 DOM 状态、下一次绘制或应用提供的完成事件作为结束条件。

同名测量聚合时,建议记录 count、平均值、中位数、P95 和最大值,同时保留原始样本。样本数量太少时,不应只展示百分比变化。

六、为什么既保留汇总,也保留原始数据 ​

汇总指标便于建立基线和做版本对比,但会丢失时间上下文。两个版本的平均主线程忙碌率可能相同,其中一个版本却出现了单次 800ms 的长任务。

因此结果模型最好分成三层:

  1. 汇总层:用于列表展示和门禁判断。
  2. 时间序列层:用于定位峰值发生在哪个时间段。
  3. 原始 Trace:用于在 DevTools 或 Perfetto 中继续分析调用栈和事件关系。

对比界面中的百分比变化可以按下面的方式计算:

text
变化率 = (当前值 - 基线值) / |基线值| × 100%

当基线为 0、样本量不同或测试条件不一致时,变化率可能没有意义,界面应显示“不可比较”,而不是给出一个看似精确的百分比。

七、让测试结果可比较 ​

自动化只解决“能重复执行”,不自动保证“结果可信”。建立基线时至少要控制以下变量:

  • 固定 Chrome 大版本、操作系统和硬件。
  • 固定窗口尺寸、设备缩放和目标刷新率。
  • 固定数据集、操作顺序、操作次数和测量时长。
  • 明确缓存策略,并在所有轮次中保持一致。
  • 禁用会干扰结果的扩展,减少无关后台程序。
  • 保持页面位于前台,避免后台标签页的节流和冻结策略。
  • 预热后再测量,避免首次编译、缓存建立等一次性成本污染结果。
  • 同一版本运行多轮,使用中位数或分位数作为版本结果。

建议先完成若干轮预热,再执行至少 5~10 轮正式测量。性能门禁应同时设置绝对阈值和相对退化阈值,并要求退化在多轮结果中稳定出现,避免因为一次系统抖动误报。

八、实现时值得注意的问题 ​

8.1 Trace 文件体积 ​

记录类别越多、时间越长,Trace 文件越大。工具应只覆盖必要的测量窗口,并根据待分析指标选择 tracing categories。截图轨道对本文这些汇总指标不是必需项,关闭后可以明显减少文件体积。

8.2 解析复杂度 ​

如果为每个事件遍历全部时间片,复杂度会随着事件数和分片数相乘。更高效的方式是根据事件开始、结束时间直接计算它覆盖的分片索引,只处理相关分片。

当 Trace 很大时,也不宜一次性将完整 JSON 常驻内存。可以考虑流式解析、分阶段提取,或者借助 Trace Processor 查询所需事件。

8.3 数据类型 ​

报表展示可以格式化为 15.2 MB 或 23.4%,但持久化模型应保留原始数值和单位。否则可视化层只能从字符串中再次解析数字,容易出现比较和排序错误。

8.4 异常数据 ​

解析器需要显式处理:

  • 找不到目标主线程。
  • 测量窗口为空或结束时间早于开始时间。
  • 没有 DrawFrame 或内存采样事件。
  • Trace 中途被截断。
  • 自定义埋点未闭合。
  • CDP 两次采样时间戳相同。

缺失数据应标记为 N/A 并附带原因,不应默认为 0。0 表示确实测量到了零,和没有采集到数据是两种完全不同的情况。

九、工具的适用边界 ​

这套工具适合回答:

  • 某段固定交互在不同版本中的运行时性能是否退化。
  • 数据规模增加后,主线程、内存和渲染压力如何变化。
  • 一次优化是否减少了长任务或改善了尾部响应时间。
  • 性能峰值发生在哪个时间段,是否值得回到 Trace 深入定位。

它不适合直接替代:

  • Lighthouse 和 Web Vitals 对加载体验的评估。
  • 操作系统级进程 CPU、总内存和 GPU 监控。
  • Heap Snapshot 对内存泄漏引用链的分析。
  • Chrome DevTools 对调用栈、火焰图和具体源码位置的人工诊断。

自动化性能工具的价值,不是把所有性能问题压缩成一个分数,而是建立一套稳定、透明、可复现的测量口径:场景相同、窗口明确、规则可解释、原始数据可回溯。只有做到这些,不同版本之间的数字才真正具有比较价值。

十、用真实 API 串起完整流程 ​

下面使用 Puppeteer、CDP 和浏览器原生 Performance API 组成一段端到端伪代码。它省略了配置校验、异常恢复和完整的统计函数,但所有浏览器调用都对应真实 API,可以直观看到各模块如何衔接。

js
import fs from 'node:fs/promises'
import puppeteer from 'puppeteer'

async function runPerformanceTest(url) {
  const browser = await puppeteer.launch({
    headless: false,
    channel: 'chrome',
    args: ['--enable-precise-memory-info'],
  })

  const page = await browser.newPage()
  await page.setViewport({ width: 1440, height: 900 })

  // 在应用脚本执行前安装通用测量入口。
  await page.evaluateOnNewDocument(() => {
    window.__measure = {
      start(name, id) {
        performance.mark(`${name}:${id}:start`)
      },
      end(name, id) {
        const start = `${name}:${id}:start`
        const end = `${name}:${id}:end`
        performance.mark(end)
        performance.measure(name, start, end)
      },
    }
  })

  try {
    await page.goto(url, { waitUntil: 'networkidle2' })
    await page.waitForSelector('[data-page-ready="true"]')

    const cdp = await page.createCDPSession()
    await cdp.send('Performance.enable')

    // CDP 指标是累计值,测量窗口两端各采样一次。
    const metricsBefore = toMetricMap(
      await cdp.send('Performance.getMetrics'),
    )

    await page.tracing.start({
      path: './trace.json',
      screenshots: false,
      categories: [
        'devtools.timeline',
        'disabled-by-default-devtools.timeline',
        'v8.execute',
      ],
    })

    // 场景代码只负责描述被测操作和完成条件。
    const measureId = crypto.randomUUID()
    await page.evaluate((id) => window.__measure.start('interaction', id), measureId)
    await page.click('[data-test="action"]')
    await page.waitForFunction(() => {
      return document.querySelector('[data-test="result"]')?.dataset.ready === 'true'
    })
    await page.evaluate((id) => window.__measure.end('interaction', id), measureId)

    const userTimings = await page.evaluate(() => {
      return performance.getEntriesByType('measure').map((entry) => ({
        name: entry.name,
        startTime: entry.startTime,
        duration: entry.duration,
      }))
    })

    await page.tracing.stop()

    const metricsAfter = toMetricMap(
      await cdp.send('Performance.getMetrics'),
    )
    await cdp.detach()

    const trace = JSON.parse(await fs.readFile('./trace.json', 'utf8'))
    const traceMetrics = parseTrace(trace.traceEvents)
    const cdpMetrics = calculateMetricDelta(metricsBefore, metricsAfter)

    return {
      traceMetrics,
      cdpMetrics,
      userTimings: aggregateByName(userTimings),
    }
  } finally {
    await browser.close()
  }
}

function toMetricMap(result) {
  return Object.fromEntries(
    result.metrics.map(({ name, value }) => [name, value]),
  )
}

function calculateMetricDelta(before, after) {
  const elapsed = after.Timestamp - before.Timestamp

  return {
    mainThreadBusyRate:
      elapsed > 0
        ? ((after.TaskDuration - before.TaskDuration) / elapsed) * 100
        : null,
    scriptDuration: after.ScriptDuration - before.ScriptDuration,
    layoutDuration: after.LayoutDuration - before.LayoutDuration,
    recalcStyleDuration:
      after.RecalcStyleDuration - before.RecalcStyleDuration,
    layoutCount: after.LayoutCount - before.LayoutCount,
    recalcStyleCount: after.RecalcStyleCount - before.RecalcStyleCount,
    jsHeapUsedSize: after.JSHeapUsedSize,
    nodes: after.Nodes,
    eventListeners: after.JSEventListeners,
  }
}

function parseTrace(events) {
  const mainThread = events.find((event) => {
    return event.ph === 'M'
      && event.name === 'thread_name'
      && event.args?.name === 'CrRendererMain'
  })

  if (!mainThread) {
    return { available: false, reason: 'main thread not found' }
  }

  const runTasks = events.filter((event) => {
    return event.ph === 'X'
      && event.name === 'RunTask'
      && event.pid === mainThread.pid
      && event.tid === mainThread.tid
  })

  const longTasks = runTasks
    .filter((event) => event.dur >= 50_000)
    .map((event) => ({
      startTime: event.ts / 1000,
      duration: event.dur / 1000,
      blockingTime: Math.max(0, event.dur / 1000 - 50),
    }))

  const heapSamples = events
    .filter((event) => event.name === 'UpdateCounters')
    .map((event) => ({
      timestamp: event.ts / 1000,
      used: event.args?.data?.jsHeapSizeUsed,
    }))
    .filter((sample) => Number.isFinite(sample.used))

  const frameTimestamps = events
    .filter((event) => event.name === 'DrawFrame' && event.ph === 'I')
    .map((event) => event.ts / 1000)

  return {
    available: true,
    mainThreadBusyRate: calculateBusyRate(runTasks),
    longTasks: summarize(longTasks.map((task) => task.duration)),
    totalBlockingTime: sum(longTasks.map((task) => task.blockingTime)),
    jsHeap: summarize(heapSamples.map((sample) => sample.used)),
    frameIntervals: summarize(diff(frameTimestamps)),
  }
}

这段流程中,page.tracing 保存可下钻的原始事件,Performance.getMetrics 提供测量窗口两端的累计计数,User Timing 则补充业务语义。calculateBusyRate、summarize、aggregateByName、diff 和 sum 代表工具自身的纯计算函数,其规则分别对应前文的时间片、分位数、同名测量聚合和相邻帧间隔计算。

真实项目还应把开始 Trace、停止 Trace、关闭 CDP 会话和关闭浏览器分别放入可独立执行的清理逻辑中。上面的 finally 只保留了主干表达;如果点击或等待步骤抛出异常,仍应尝试停止正在进行的 Trace,以保证后续测试不会受到残留状态影响。

参考资料 ​

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。