用户说页面卡,你只会盯 Lighthouse——今天才发现真正的瓶颈在 JS 执行本身,浏览器自己会采样了

用户说页面卡,你盯了三年 Lighthouse,跑了无数次 profile,翻遍了所有可疑函数——最后发现真正的热点在一个你从来没怀疑过的地方。这就是 JS 执行时间看不见的盲区。

过去,我们只能用 performance.mark() 手动埋点,测自己提前想到的地方。但你没想到的地方,恰恰是出问题的地方。而且埋点本身也有开销,Facebook 在 2021 年的测试里发现,手动埋点会让页面整体性能变慢 5%-15%,测得越细,偏差越大。

今天 Chrome 把这件事彻底变了——JS Self-Profiling API,让浏览器自己采样 JS 调用栈,原生知道哪个函数在花时间。

你以前怎么测 JS 执行时间

最常见的做法是手动埋点:

performance.mark("start-genPrimes");
const primes = genPrimes();
performance.mark("end-genPrimes");
performance.measure("genPrimes", "start-genPrimes", "end-genPrimes");

这样做有三个根本问题:

第一,你只能测到你想得到的地方。 真正吃 CPU 的,往往是某个你根本没怀疑过的工具函数,或者是某个第三方库的内部逻辑,你没办法在所有地方都预先埋点。

第二,埋点本身就有性能损耗。 performance.mark 内部要写时间戳、记录调用栈,每执行一次,JS 引擎就要多做一件事。你埋点越密,测量本身就把结果扭曲了。

第三,无法在真实用户环境里采集数据。 你在本地开发时测的,和用户手里那台 CPU 打六折的旧 Android 手机,完全是两回事。

Facebook 在 2021 年的 origin trial 里真实测过:手动埋点方案让页面整体 JS 执行时间增加了 5%-15%,而且这个数字随着埋点密度增加而恶化。

浏览器原生会自己采样了

JS Self-Profiling API 是 Chrome 94 引入的采样分析器,从语言层拿到了浏览器层,让浏览器自己来采样 JS 调用栈。Facebook 是这个 API 的主要推动者,他们在 origin trial 期间用这个 API 排查了大量线上性能问题。

用法只有三行:

const profiler = new Profiler({
  sampleInterval: 10,   // 每 10ms 采样一次
  maxBufferSize: 10000 // 最多存 10000 个样本
});
const result = genPrimes(); // 你的业务代码,一行不用改
const profile = await profiler.stop();
console.log(JSON.stringify(profile));

采样是异步进行的,浏览器会在每个 sampleInterval(默认 10ms)自动记录当前 JS 调用栈的状态,然后你调用 stop() 拿回完整的采样数据。这个过程浏览器自己完成,不需要你在业务代码里加任何埋点。

API 返回的数据结构包含四个数组:frames 记录每个堆栈帧的函数名、所在脚本 URL 和行列号;stacks 用 trie 结构组织调用栈关系,同一个父路径下的子调用会复用节点;samples 记录每个采样点的时间戳和对应的栈 ID;resources 列出所有涉及的脚本 URL。这个格式用 trie 而非平铺数组,就是为了节省内存——同一个函数被调用一万次,堆栈信息只存一份。

Facebook 怎么用这个 API 排查线上问题

Facebook 在 origin trial 期间把这个 API 用在了真实生产环境。他们在页面加载完成后的几个关键时间窗口内启动采样(比如”加载后 5 秒内”),然后把采样数据压缩后回传到服务器。

拿到数据之后,Facebook 的工程师做的事很有参考价值:不是找一个热点函数去优化,而是看整体分布——哪个模块占的采样比例最高、哪个第三方脚本在偷 CPU 时间、有没有非预期的长调用链。这些信息在本地 DevTools 里很难拿到,因为本地环境和真实用户的设备差太远了。

Facebook 的测试数据显示,在生产环境用这个 API 做采样,性能开销控制在 1% 以内,相比手动埋点方案动不动 5%-15% 的损耗,这个数字几乎可以忽略不计。

四个实际落地的关键点

1. 采样间隔不是越小越好。

sampleInterval: 10 意味着每 10ms 采样一次,这个频率已经能抓到绝大多数性能问题。如果设成 1ms,数据确实更精确,但采样本身的 CPU 开销也会翻倍,反而引入新的测量噪声。Facebook 官方推荐的平衡点就是 10ms。

2. Buffer 满了会自动触发事件。

如果你的页面 JS 执行时间很长(单次超过 100 秒),maxBufferSize: 10000 可能会装不下,这时候 samplebufferfull 事件会触发。你可以先停止采样、把数据取走处理,然后再重新启动:

profiler.addEventListener("samplebufferfull", async () => {
  const trace = await profiler.stop();
  sendTraceToServer(trace); // 发送到你的服务端
  // 重新启动下一段采样
  newProfiler = new Profiler({ sampleInterval: 10, maxBufferSize: 10000 });
});

3. 生产环境记得带 Source Map。

你的生产代码大概率是压缩过的,genPrimes 变成了 a(),看采样数据你会发现一堆混淆后的函数名,没有可读性。你需要在客户端或服务端把 Source Map 应用到 profile 数据上,才能还原出原始函数名和行列号。

4. 可以在 Web Worker 里处理数据。

采集和传输本身不应该影响主线程性能。Facebook 的建议是把 profiler.stop() 返回的数据送到 Web Worker 里做聚合处理,然后把聚合后的结果回传,这样主线程几乎感知不到采样过程的存在。

浏览器兼容性:只有 Chrome

这是目前最需要知道的现实:Chrome 94+ 默认启用,Safari 和 Firefox 还没支持。 这是 WICG 的一个提案,Chrome 是唯一实现它的浏览器。

如果你想给 Safari/Firefox 用户也做 JS 执行时间的采集,目前只能走服务端这条路:用 WebDriver 或者类似 Playwright 的自动化工具在服务器上跑一批真实设备测试,把采样结果集中起来做横向对比。这个方案覆盖不了真实用户的设备差异,但至少能拿到跨浏览器的基准数据。

从路线图看,Chrome 靠这个 API 已经解决了很大一部分问题——尤其是那些只有线上用户才能碰到的设备差异。如果你的用户主要是 Chrome 用户,这个 API 现在就能直接用。

下一步:从哪开始

三个可以立刻做的事:

第一,在你现有的性能监控里加一段 5-10 秒的采样窗口,用来采集页面关键交互(比如搜索提交、列表加载)这段窗口内的 JS 执行热点。这个数据会和你的 Lighthouse 数据完全不同。

第二,把这个采样机制和你的错误追踪打通——当用户触发一个 ANR(应用无响应)错误时,自动采集前后各 5 秒的 JS 采样数据,帮助你定位是哪段代码卡住了主线程。

第三,用 Chrome DevTools 的 Performance 面板先跑一遍对比——打开 DevTools 跑一次 profile,同时在代码里用 JS Self-Profiling API 跑一次采样,两份数据对照着看,你就知道哪个更适合你的场景了。

评论区

0 条评论

登录后可评论。

阿速·性能优化 23 阅读