简单聊聊宏任务和微任务

最近看到一个很疑惑的问题:

const button1 = document.getElementById('btn1')

button1.addEventListener('click', () => {
     Promise.resolve().then(() => console.log('MicroTask 1'))
     console.log('Event 1')
})

button1.addEventListener('click', () => {
     Promise.resolve().then(() => console.log('MicroTask 2'))
     console.log('Event 2')
})

一个按钮,绑定两个事件,用户手动点击时和使用button1.click() 模拟点击会呈现不同的结果。

通过这个问题研究了一下宏任务和微任务的执行时机,记录一些结论

首先,简单回忆一下事件循环,由于js引擎和渲染引擎都工作在主线程上,事件循环的作用就是对两个引擎的执行进行调度,如下图所示:

一般情况下,浏览器刷新页面的频率为60fps(当然这个是可变的,比如说当你的显示器分辨率更高 或者当前存在太多的任务,浏览器会动态的改变刷新率)即16.7ms刷新一次。

也就是说,事件循环需要保证,每16.7ms调度一次渲染引擎,更新页面的变动,其中页面渲染的具体流程,可以了解一下浏览器的渲染原理,其中包含布局树的生成,绘制,分块 等等步骤

我们一般称一次页面渲染为一帧frame,也就大概是16.7ms的时间,在这个时间内,除了渲染以外剩余的时间,将会被拿来执行任务,如下图所示:

这里的任务包含 宏任务 和 微任务

我们写的同步代码,addEventListener中的事件处理回调,setTimeout,setInterval回调,以及MessageChannel中的onmessage回调,这些都属于宏任务

Promise在决定之后的回调函数属于微任务,比如Promise.resolve().then(microTaskFunc)

简单了解之后,我们就可以继续讨论了,首先记住一个结论,即:事件循环在一个循环内,必须把所有待执行的js代码(任务)都执行完,才会调度渲染引擎更新页面!

也就是说,如果你的任务代码中,包含同步阻塞代码,比如 while(true); 或者同步delay一段时间,就会导致当前的任务无法在一个帧的时间 16.7ms内完成,此时事件循环无法在准确的时间调度渲染引擎更新页面,导致浏览器被破降低帧率,让页面看上去卡顿!如下图所示:

那么宏任务和微任务的执行时机是什么呢?

当一帧的渲染完成后,事件循环会调度js引擎来执行宏任务队列中的任务,(这里需要强调,我们写的同步js代码,也会被封装成任务放到宏任务队列中)如果宏任务队列中存在任务,则将其加入到执行栈中执行,执行之后出栈,继续拿出来队列中的下一个任务执行!

那么微任务什么时候执行呢,答案是: 在调用栈为空的时候检查微任务队列并执行完所有的微任务 有的文章说的是在js代码执行完之后检查执行微任务,但是这样说容易引起误导,这里的js代码指的是我们写的同步代码? 还是计时器等放到宏任务队列中的任务代码?多个宏任务之间会执行微任务吗? 等等 

所以只需要记住,在执行栈为空时,检查并且执行微任务,我们再回顾上面的例子,在用户点击button时,addEventListener会将两个回调任务放到宏队列中,此时是没有同步代码在执行的,如图所示:

此时会去检查微队列,但是微队列为空,则将宏队列中第一个任务加入到执行栈

此任务将()=>{console.log('Microtask 1')} 放入微任务队列,并且输出"Event1",如图:

此时,第一个宏任务执行完成,弹出执行栈,执行栈为空,此时不会去取下一个宏任务,而是去检查微任务队列,执行微任务输出MicroTask1,执行后,微任务队列空,此时再去取下一个宏任务。

最终输出结果为:

通过这个例子你就能看出,微任务不是在我们写的同步代码执行之后执行的,准确的说,在每个宏任务执行之后,执行栈为空时,都会检查并且执行微任务,可能在一个帧frame 内,会检查并且执行多次微任务!

思考另外一个问题: 以下代码 会阻塞浏览器渲染吗

<body>
    <button id="btn1">宏任务阻塞</button>
    <button id="btn2">微任务阻塞</button>
    <script>
        const button1 = document.getElementById('btn1')
        const button2 = document.getElementById('btn2')

        button1.addEventListener('click', () => {
            function task1() {
                console.log('宏任务执行中!')
                setTimeout(task1);
            }
            setTimeout(task1);
        })

        button2.addEventListener('click', () => {
            function task2() {
                console.log('微任务执行中!')
                Promise.resolve().then(task2)
            }
            Promise.resolve().then(task2)
        })

        // 查看浏览器渲染状态
        function showRefresh(){
            console.log('浏览器渲染!!!')
            requestAnimationFrame(showRefresh)
        }
        requestAnimationFrame(showRefresh)

    </script>
</body>

点击两个按钮,分别会向宏任务队列和微任务队列中,加入任务,请问此时浏览器还能继续渲染吗?

我们使用requestAnimationFrame来标记每一次渲染,方便我们观察浏览器是否被"卡死"

在点击“微任务阻塞”按钮后,会发现页面完全被微任务堵死,不会再输出 "浏览器渲染!!!" 如图:

这个原因很简单,在每次微任务执行时,会再向微任务队列中加入新的微任务,当此次微任务执行结束后,会再去检查微队列,并且继续执行,一次类推,事件循环没有机会调度渲染引擎,导致死循环!

换成宏任务,会怎样呢? 可以看到,宏任务并没有阻塞浏览器渲染,在每执行3~4次宏任务后会执行渲染,为什么会这样?

其实并不是宏任务的特点,而是settimeout的特点,我们知道,在settimeout第二个参数不填时,会默认其最小时间大约为4.7ms 即: settimeout(func) = settimeout(func,4.7) 

 所以,在每个宏任务开启计时器之后,会大概等待4.7ms的时间,才会将任务加入到宏任务队列中,此时宏任务队列在执行完3~4个宏任务之后就会为空的状态,新的任务由于还没到4.7ms被加入进来,此时事件循环就可以进行调度,保证渲染不阻塞!

也许你会问,那有没有可以 立刻 马上将宏任务加入到宏任务队列中的办法呢? 还真有,就是MessageChannel

MessageChannel的回调函数会被立刻加入到宏任务队列中,不会有延迟,如下代码:

        const button1 = document.getElementById('btn1')
        const messageChannel = new MessageChannel()
        const port1 = messageChannel.port1
        const port2 = messageChannel.port2
        function fn(){
            console.log('宏任务执行!')
            port1.postMessage({})
        }
        port2.onmessage = ()=>{
           fn()
        }

        button1.addEventListener('click', () => {
            port1.postMessage({})
        })

        function showRefresh(){
            console.log('浏览器渲染!!!')
            requestAnimationFrame(showRefresh)
        }
        requestAnimationFrame(showRefresh)

此时你就会发现,在点击按钮之后,和微任务一样,渲染被阻塞,页面卡死!

所以,你也许可以理解,事件循环是一定会在所有js代码都执行完之后,才会去调度渲染的,这也就告诉我们需要控制js代码的规模,以保证页面的渲染流畅。 

比如React Fiber 就是采用requestIdleCallback 在空闲时间完成虚拟DOM的更新,保证页面不会被复杂的DIFF运算阻塞!

 最后,在看回上面的问题,那为什么使用button.click()的方式就会导致输出的顺序不一致呢?

这个问题的根本不在于宏微任务的顺序,而是如果使用js模拟点击事件,js引擎不会走宏任务队列这一步,而是直接在当前的执行上下问中调用该事件处理的回调函数。

chatGpt的回答如下:

 其过程大概如下:在点击click之后

此时的任务并不会被加入到宏任务队列中,而是继续在当前执行上下文中执行

 此时,输出Event1 并且将() => console.log('MicroTask 1') 加入微队列,执行完第一个回调后,回继续执行第二个回调,此时执行栈不为空,微任务队列不会检查

输出Event2 并且将() => console.log('MicroTask 2') 加入微任务队列,此时两个callback都执行完,弹出当前上下文,执行栈为空,检查微任务队列

 

依次输出 MicroTask1 和 MicroTask2 如图:

 

  • 17
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值