Performance 是 Chrome 浏览器提供的一个强大的性能调试工具。它可以录制并回放一段时间内浏览器的事件执行情况,同时保留各个时间点下的页面快照,从而帮助我们快速定位性能问题。
这篇文章会用一个极简的 Demo,带你完整走一遍:录制、放大时间线、读懂 Main 面板,以及同步任务、微任务、宏任务在火焰图上的区别。
准备一个 Demo
这个 Demo 很简单:两个上下堆叠的 box,给 box1 绑一个点击事件,点击以后 box1 的高度发生变化。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Performance Demo</title>
<style>
.box{
display: inline-block;
width: 100px;
}
.box1{
height: 100px;
background-color: red;
}
.box2{
height: 100px;
background-color: blue;
}
</style>
</head>
<body>
<div class="box box1"></div>
<br>
<div class="box box2"></div>
</body>
<script>
const box1 = document.querySelector('.box1');
const box2 = document.querySelector('.box2');
const changeHeight = () => {
box1.style.height = '200px'
}
const clickEvent = () => {
changeHeight();
}
box1.addEventListener('click', clickEvent);
</script>
</html>
录制并定位到事件
开启 Performance 录制,点击 box1 触发它的点击事件,然后停止录制,看看浏览器捕捉到了哪些可以用来分析的内容。

从时间线上可以看到,浏览器在某一帧的快照里,box1 的高度发生了变化——这正是我们触发事件的时机。而具体的执行细节,就藏在下面的 Main 轨道里。

锁定大致范围后,点击放大镜就能进入这段时间的细节视图,最终可以把事件执行范围缩小到大约 1ms 之内。


读懂 Main 面板
面板下方的 Summary 用不同颜色区分了各类工作:
| 颜色 | 类别 | 含义 |
|---|---|---|
| 黄色 | Scripting | JS 脚本执行 |
| 紫色 | Rendering | 渲染相关(样式计算、布局等) |
| 绿色 | Painting | 绘制 |
| 灰色 | System | 浏览器系统级工作 |
(此外 Summary 里通常还会出现蓝色的 Loading 和空白的 Idle,这里 Demo 没有网络加载,所以看不到。)
具体看这张图,第一个 Task 就是我们整个点击事件的执行过程:先触发 Event: click,然后递归调用一次 Function call,再往下进入我们写的 clickEvent 函数,接着是 changeHeight——这是一条清晰的调用栈。
再往右是连续的三个紫色块,都属于渲染流程:第一个是 Recalculate Style,也就是重新计算样式;第二个是 Layout(布局,可以理解为重排);第三个是 Pre-paint(预绘制),为接下来的绘制做准备。
继续往右是另一个 Task,它包含两个绿色块和一个紫色块,顺序是 Paint(绿,把内容实际绘制到页面上)→ Layerize(紫,图层化)→ Commit(绿,提交给合成器)。注意 Layerize 是渲染类事件,颜色是紫色,不要和两侧的绿色绘制类事件混淆。
整体看下来,这就是一个只包含同步 JS 的事件循环过程。
改成宏任务会怎样
下面把点击事件里的逻辑改成宏任务,看看火焰图会有什么变化。
<script>
const box1 = document.querySelector('.box1');
const box2 = document.querySelector('.box2');
const changeHeight = () => {
box1.style.height = '200px'
}
const clickEvent = () => {
setTimeout(() => {
changeHeight();
}, 0);
}
box1.addEventListener('click', clickEvent);
</script>
再次触发后可以发现:clickEvent 下面是一个 setTimeout,而原本的两个 Task 之间多出来了一个新的 Task。这个新 Task 里,先触发 Timer Fired,然后由 Function call 调用一个匿名函数(也就是传给 setTimeout 的回调),接着再调用 changeHeight。这就是一个带有宏任务的事件循环过程。


更完整的事件循环例子
下面我们采用一个更完整的事件循环例子:把同步任务、微任务、宏任务放进同一次点击里,方便在同一条时间线上对比三者的执行时机。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Performance Demo</title>
<style>
.box{
display: inline-block;
width: 100px;
}
.box1{
height: 100px;
background-color: red;
}
.box2{
height: 100px;
background-color: blue;
}
.box3{
height: 100px;
background-color: green;
}
</style>
</head>
<body>
<div class="box box1"></div>
<br>
<div class="box box2"></div>
<br>
<div class="box box3"></div>
</body>
<script>
const box1 = document.querySelector('.box1');
const box2 = document.querySelector('.box2');
const box3 = document.querySelector('.box3');
const changeHeight = (box, height) => {
box.style.height = height;
}
const syncTask = () => {
changeHeight(box1, '200px');
}
const microTask = () => {
Promise.resolve().then(() => {
changeHeight(box2, '200px');
});
}
const macroTask = () => {
setTimeout(() => {
changeHeight(box3, '200px');
}, 0);
}
const clickEvent = () => {
syncTask();
microTask();
macroTask();
}
box1.addEventListener('click', clickEvent);
</script>
</html>
点击 box1 以后,三个 box 会依次以同步、微任务、宏任务的方式修改高度。
新的时间线可以看到:第一个 Function call 下依次调用了 clickEvent,然后同步代码都先执行了,还建立了一个 setTimeout。往后有一个黄色的 Run microtasks,但是看不见它的调用栈,这个就是我们创建的微任务。然后中间经历了一次 Task(重排、重绘,以及浏览器绘制页面),之后又有一个 Task 就是定时器触发的宏任务了。

为什么 clickEvent 下看不到 microTask?
仔细观察火焰图会发现一个反直觉的现象:clickEvent 的调用栈里只出现了 syncTask 和 macroTask,却没有 microTask。原因有两层。
一是 microTask 本身太短,而且会被 V8 内联。 它只做了一件事——Promise.resolve().then(cb),随即就返回了。这种极小的包装函数会被 V8 优化器内联,采样器几乎不会在它自己的栈帧上采到样本;即便不内联,它的执行时间也在亚微秒级,很容易被约 1ms 的采样间隔整个跳过。相比之下,syncTask 会真正触发渲染,macroTask 会调用原生 setTimeout,两者都“更重”、更容易被采样到。
二是真正改 box2 的代码不在 clickEvent 的调用栈里。 microTask 注册的回调是异步的,要等 clickEvent 的同步栈全部清空后,才在微任务检查点执行。所以那段逻辑不是 clickEvent 的子帧,而是同一 Task 内、同步代码之后的一个独立匿名调用(表现为 Run microtasks)。而 setTimeout 的回调更晚,跑到下一个 Task(Timer Fired)里去了。
一句话总结:微任务在当前 Task 的末尾执行,宏任务在下一个 Task执行;而负责注册回调的那个包装函数本身太轻,往往不会在火焰图上留下自己的名字。
想稳定看到微任务怎么办
- 把
microTask写大一点(加入循环或更多逻辑),防止被内联; - 或者在回调入口打一个
debugger,用 Debugger 的调用栈查看,而不是依赖采样的火焰图; - 或者在火焰图上直接找同步段之后那个匿名的
Function call帧——那才是微任务真正执行的位置。
小结
对比几次录制可以很直观地看到事件循环在火焰图上的形态:
- 同步任务:在同一个 Task 内从上到下执行完,调用栈完整可见;
- 微任务(
Promise.then):在当前 Task 的同步代码执行完后立即执行,表现为同一 Task 末尾的 Run microtasks; - 宏任务(
setTimeout):把回调推迟到下一个 Task,表现为独立的 Timer Fired。
理解了火焰图上的 Task 边界与调用栈,也就掌握了用 Performance 面板分析事件循环的第一步。