创见博客
Chrome Performance 面板实战:读懂一次点击事件的执行过程
七崽爱吃小饼干2026/09/23阅读 0专栏 前端调试技巧

Performance 是 Chrome 浏览器提供的一个强大的性能调试工具。它可以录制并回放一段时间内浏览器的事件执行情况,同时保留各个时间点下的页面快照,从而帮助我们快速定位性能问题。

这篇文章会用一个极简的 Demo,带你完整走一遍:录制、放大时间线、读懂 Main 面板,以及同步任务、微任务、宏任务在火焰图上的区别。

准备一个 Demo

这个 Demo 很简单:两个上下堆叠的 box,给 box1 绑一个点击事件,点击以后 box1 的高度发生变化。

html
<!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 用不同颜色区分了各类工作:

颜色类别含义
黄色ScriptingJS 脚本执行
紫色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 的事件循环过程。

改成宏任务会怎样

下面把点击事件里的逻辑改成宏任务,看看火焰图会有什么变化。

html
    <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。这就是一个带有宏任务的事件循环过程。

更完整的事件循环例子

下面我们采用一个更完整的事件循环例子:把同步任务、微任务、宏任务放进同一次点击里,方便在同一条时间线上对比三者的执行时机。

html
<!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 面板分析事件循环的第一步。

评论
0/100