前言:搜很多的讲事件循环机制的都是宏观从js主线程,微任务,宏任务切入,大概好像听明白了,也能应付一些promise,setTimeout,setInterval等一些的输出顺序判断,但真的精确到页面渲染的某一帧,某一个具体动画渲染去讲,似乎就云里雾里,本文主要参考Jake Archibald 的演讲和各个技术博客结合自己的理解讲述。
我的开发环境:mac+vscode+node(v15.14.0)
JavaScript是单线程的语言
JavaScript之所以设计为单线程语言,主要是因为它作为浏览器脚本语言,主要的用途就是与用户互动,操作
那么,单线程是如何执行语句的呢?
调用栈Call Stack(先进后出)
调用栈就是解释器,追踪函数执行流的一种机制。js解析的时候会将函数压入栈,并解析。就像下面例子:
function c 入栈 ->
这在同步的解析中可行,但是如果当一个语句也需要执行很长时间的话,比如请求数据、定时器、读取文件等等,后面的语句就得一直等着前面的语句执行结束后才会开始执行。
显然这是不可取的,于是
任务队列(先进先出)
同步任务就不多赘述,而异步任务又分为
- 宏任务主要包括:setTimeout、setInterval、setImmediate、I/O、UI 交互等
- 微任务主要包括:Promise(重点关注)、process.nextTick(Node.js)、MutaionObserver等
执行顺序:初始化,执行全局的js代码(同步任务消费完了) => 进入 Event Loop后(消费异步任务),是先执行 微任务,再执行宏任务
注意:
EventLoop
我们分三种情况讨论事件循环
- 空闲时:如果没事干的时候,事件循环就让cpu以经济的方式一直空转
- 加入任务队列:与
当有任务加入任务队列时, 任务执行的闸口就会打开,这是事件发生的地方。
setTimeout(callback1,1000)
setTimeout(callback1,1000)
——我们明确,这两个算法会并行,每个在1s后,往队列里加任务,回到主线程,后消费,消费完闸口又关闭了。
- 加入任务队列,加入GPU渲染,那么事情就变复杂了,渲染会兜另一个圈子,包含样式计算(S),生成渲染树(L),然后绘制(P),
当屏幕需要更新内容的时候, GPU渲染的闸口就会打开。
——有了初步的了解,我们来思考下执行一下代码会发生什么
情况一:
button.addEventListener('click', event => {
while(true)
})
结果:点击完页面就卡住了,点别的没反应
比如过几秒,浏览器告诉事件循环:“我们要更新gif了,或者有别的交互事件来啦,下次方便记得渲染”。
时间循环:“嗯,好的,等我手头上的事情忙完”
而实际,这事情永远忙不完,也永远没有交互,所以你点什么,页面都没反应,就卡死了。
情况二:
function loop(){
setTimeout(loop, 0)
}
loop()
结果:点击完没变化,点别的也正常
为什么啊,我们不是一直在产生任务吗?那么不应该一直消费任务,就卡死了吗?
我们回顾下之前注意里的话:
那么,
情况三:
function loop(){
Promise.resolve().then(loop)
}
loop()
结果:点击完页面就卡住了,点别的没反应
有了情况二的例子,这里估计就好理解了,Promise的then方法是微任务队列,执行完一个任务,又来一个任务,全部是当前轮要消费完的。
——都是异步任务,一个阻塞渲染,一个不阻塞渲染。但如果你希望执行的代码是跟渲染相关的,那就不适合放在事件循环左边的任务消费里,有没有什么能让代码在右边渲染步骤里执行啊?
浏览器给我们提供了requestAnimationFrame函数来实现(想先看该函数的可以先跳过情况四)
情况四(分人工点击,脚本执行两种情况):
button.addEventListener('click',()=>{
Promise.resolve().then(() => console.log('Microtask1'))
console.log('Listener 1')
})
button.addEventListener('click',()=>{
Promise.resolve().then(() => console.log('Microtask2'))
console.log('Listener 2')
})
//例子一:人工点击按钮触发事件
//例子二:button.click()
结果:例子一: Listener 1 Microtask1 Listener 2 Microtask2
例子二: Listener 1 Listener 2 Microtask1 Microtask2
因为人工合成(synthetic)的事件派发(dispatch)是同步执行的,包括执行
怎么理解 => JS stack调用栈清空了 => 微任务 queue => 本轮宏任务queue
情况一:
- 第一个监听器执行入JS stack => Promise.then(Microtask1)入微任务队列 =>
输出Listener 1 - 第一个监听器执行完毕出JS stack => 执行微任务队列Promise.then(Microtask1) =>
输出Microtask1 - 第二个监听器执行入JS stack => Promise.then(Microtask2)入微任务队列 =>
输出Listener 2 - 第二个监听器执行完毕出JS stack => 执行微任务队列Promise.then(Microtask2) =>
输出Microtask2
情况二:
- 首先js脚本button.click()入JS stack,调用click,同步派发事件
- 第一个监听器执行入JS stack => Promise.then(Microtask1)入微任务队列 =>
输出Listener 1 =>第一个监听器执行完毕出JS stack => 这时候js栈还没清空,轮不到微任务,继续Script任务 - 第二个监听器执行入JS stack => Promise.then(Microtask2)入微任务队列 =>
输出Listener 2 => 第二个监听器执行完毕出JS stack - 执行微任务队列Promise.then(Microtask1) =>
输出Microtask1 => 执行微任务队列Promise.then(Microtask2) =>输出Microtask2
requestAnimationFrame函数
requestAnimationFrame:后面简称raf
raf回调会在渲染步骤前执行(位置在于事件循环右边的计算(S)前),怎么理解?我们看个例子,每次移动1px的动画
function callback(){
moveBoxForwardOnePixel()
requestAnimationFrame(callback)
}
callback()
//写法二
function callback(){
moveBoxForwardOnePixel()
setTimeout(callback,0)
}
callback()
执行的动画效果大概如下
不是每次都移动1px吗,为什么一个快一个慢,为什么会这样?
EventLoop的时候说了,
这是由浏览器决定的,它的目标是尽量高效,必要时才会渲染,如果界面没有改变,就不会渲染,一般跟显示刷新频率同步,与显示器性能匹配,屏幕更新一次为一帧。
而且屏幕一般都有固定的刷新频率,常见的是60fps(每秒60次刷新),就是说你就算一秒钟改变一千次样式,也不会渲染一千次,通常是1秒60次,更快的渲染也没有意义,用户看不见。但setTimeout才不管你,我反正在任务队列那边,每次过来我就移动1px,可能是移动了几px才渲染一次 => 结果就是看起来移动更快,每一帧走的都是几px
所以,setTimeout压根就不是用来做动画的,因为他压根就不精确,他只是被放入宏任务队列里等下一轮事件循环执行它而已 => 由于它的不精确,会造成漂移现象(例如:在某一帧被微任务阻塞了啥也没干,在下一帧干了两倍的事情,这对用户来说视觉效果很差)
而使用requestAnimationFrame能让每次代码都在渲染前执行,就像这样
看起来就舒服多了,当然,你要是raf任务很长,远大于1帧(60fps里16.33ms),你又非要在主线程里执行,无论你放哪里,都没有意义。你要优化的思路无非两种:“让出主线程”或者“分解您的长任务(时间分片)”
那解决Long Task的方式有如下几种:
- 使用setTimeout分割任务
- 封装一个函数setZeroTimeout,与setTimeout不同的是,该函数的执行时机不会被延迟
- 使用web worker,处理逻辑复杂的计算
- 使用async/await分割任务
- 使用requestIdleCallback分割任务,但是注意。如果主线程长期拥塞,那么回调函数将无法执行
——到这里我们对微观下EventLoop怎么运行就有了一定的了解,那么我们来试着做一个动画,一个box从1000px处滑到500px
button.addEventListener('cllick',() => {
box.style.transform = 'translateX(1000px)'
box.style.transition = 'transform 1s ease-in-out'
box.style.transform = 'translateX(500px)'
})
执行完之后,我们发现不对,怎么是从0px处到500px处,我们来分析一下:
- 点击按钮,addEventListener的回调进入宏任务队列
- 事件循环到该任务,js从头到尾执行,根本不会考虑css样式这样,浏览器只要js执行到最后的结果
- 后面相同的属性覆盖前面的,那么该任务最终交出去的就是移动到500px的位置,然后供GPU渲染
那么我们改一下代码,乍一看好像是好500px放到GPU渲染的时候去了
button.addEventListener('cllick',() => {
box.style.transform = 'translateX(1000px)'
box.style.transition = 'transform 1s ease-in-out'
requestAnimationFrame(()=>{
box.style.transform = 'translateX(500px)'
})
})
然后效果还是从0-500px,到底发生了什么?
哦~那就是我们忽略了raf这一块在计算css(S)之前,GPU渲染里也有先后顺序的
于是我们再改下代码,让代码在下一轮raf的时候再修改到500px
button.addEventListener('cllick',() => {
box.style.transform = 'translateX(1000px)'
box.style.transition = 'transform 1s ease-in-out'
requestAnimationFrame(()=>{
requestAnimationFrame(()=>{
box.style.transform = 'translateX(500px)'
})
})
})
然后发现就可以啦~
raf的时机:俗称js 最后操作 dom 的机会。那么浏览器除了暴露这个时机的回调函数,还暴露了什么时机呢?
还给我们提供了requestIdleCallback函数来注册回调,会在浏览器空闲时执行
requestIdleCallback函数
requestIdleCallback:后面简称ric
当关注用户体验,不希望因为一些不重要的任务(如统计上报)导致用户感觉到卡顿的话,就应该考虑使用requestIdleCallback。因为requestIdleCallback回调的执行的前提条件是当前浏览器处于空闲状态。
——那么什么时候是浏览器空闲的时候呢?
首先我们先明确:我们所看到的网页,都是浏览器一帧一帧绘制出来的,那么在这一帧里浏览器干了什么呢?
包含了用户的交互、js的执行、以及requestAnimationFrame的调用,布局计算以及页面的重绘等工作。
假如某一帧里面要执行的任务不多,在不到16ms(1000/60)的时间内就完成了上述任务的话,那么这一帧就会有一定的空闲时间,这段时间就恰好可以用来执行requestIdleCallback的回调,如下图所示:
而当页面无需更新的时候,浏览器处于空闲状态,这时候留给requestIdleCallback执行的时间就可以适当拉长,最长可达到50ms,以防出现不可预测的任务(用户输入)来临时无法及时响应可能会引起用户感知到的延迟。
- 听起来好像很不错,但假如浏览器一直很忙,没有帧的空余时间,那是不是ric注册的回调就一直不执行了(例如上报丢失)
这时候可以设置一个超时时间,超时之后会执行注册的回调 => 传入第二个参数配置参数: { timeout: 2000 }
参考文章
Jake Archibald的演讲:https://www.youtube.com/watch?v=cCOL7MC4Pl0
你不知道的事件循环和渲染帧(上):https://juejin.cn/post/7215145804033818682
做一些动图,学习一下EventLoop:https://juejin.cn/post/6969028296893792286
前端如何优化Long Task性能:https://zhuanlan.zhihu.com/p/606276325
JavaScript的DOM事件回调不是宏任务吗,为什么在本次微任务队列触发:https://www.zhihu.com/question/362096226
你应该知道的requestIdleCallback:https://juejin.cn/post/6844903592831238157
文章评论