从nextTick开始认识事件循环

发布时间 2023-07-12 17:12:08作者: 当下是吾

导读

在vue中,我们经常使用nextTick获取到最新的dom元素或者组件实例。至于原因,在于vue使用了异步DOM渲染更新机制,无论组件状态同步变化多少次,其相应的副作用总会被缓存在一个异步任务队列中,在下一次"tick"中才一起执行,也就是仅执行了一次更新。本文就是要探讨这样做的原因和其背后的大名鼎鼎的事件循环机制(Event Loop)。

认识浏览器中的线程

大多数人认识事件循环或介绍事件循环这个概念时都会直接从同步或者异步的区分开始。但为什么有同步和异步?从用户的操作到页面做出响应,内部的JS逻辑和页面的元素都发现了哪些变化?要更深入的理解,就让我们回到一切的原点:浏览器的线程。

 

  • GUI渲染线程

    浏览器的GUI渲染线程是指在浏览器中负责渲染页面,解析HTML、CSS,构建DOM树、CSSOM树、渲染树和绘制页面的线程。当页面重绘或者由于某些操作引起回流时,该线程就会执行。

  • JS引擎线程(web worker)

    浏览器的JS引擎线程是指在浏览器中负责处理JavaScript脚本程序的线程。它负责解析JavaScript代码,运行代码,处理异步任务等。浏览器的JS引擎线程是单线程的,也就是说,一个Tab页中无论什么时候都只有一个JS线程在运行JS程序。这是为了避免多线程带来的复杂性和危险性。需要注意的是,JS引擎线程与GUI渲染线程是互斥的,也就是说,当JS引擎线程在工作时,GUI渲染线程会被挂起,反之亦然。这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。

  • 浏览器事件线程

    浏览器的浏览器事件线程是指在浏览器中负责控制事件循环的线程。它会将用户的操作,如点击,移动等,或者其他线程产生的事件,如定时器,异步请求等,添加到任务队列的末尾,等待JS引擎线程的处理。由于JS引擎线程是单线程的,所以任务队列中的事件需要依次等待JS引擎线程执行。这就是浏览器中的事件循环机制(Event Loop)。

  • 定时器触发线程

    浏览器的定时器触发线程是指在浏览器中负责执行异步的定时器类事件的线程,如setTimeout和setInterval等。它会根据设定的时间间隔来计时,并在计时完毕后将回调函数添加到任务队列的末尾,等待JS引擎线程执行。由于JS引擎线程是单线程的,如果处于阻塞状态就会影响计时的准确性,所以浏览器中的定时器并不是由JS引擎线程来计数的,而是通过单独的线程来实现。

  • http异步线程

    浏览器的HTTP异步线程是指在浏览器中负责执行异步的HTTP请求的线程,如XMLHttpRequest等。它会在连接后通过浏览器新开一个线程发送请求,并在检测到状态变更时,将回调函数添加到任务队列的末尾,等待JS引擎线程执行。由于HTTP请求可能需要很长的时间,所以它被设计成异步API,可以让程序在请求进行的同时继续运行,而不是阻塞当前线程。

同步异步代码的执行流程

我们已经认识到了五大线程,可以了解到一个页面中只有一个JS引擎线程在执行我们的JS代码,为了简单化,我们将其称为主线程,同步的代码直接执行或存放在执行栈中,异步的代码分别由浏览器事件线程、定时器触发线程和http异步线程管理。

这些代码将按一个个异步事件(任务)对应一个或若干个回调函数的形式保存在**事件表(Event Table)中,并在相关的事件触发时将其回调函数添加到事件队列(Event Queue)也有称为任务队列(Task Queue)**中,并等待主线程取出并添加到执行栈中执行(有资料说直接执行,这是不正确的,从事件队列中取出回调还有特定的规则,将在下文说明)。

总的来说,主线程和GUI渲染线程交替运行时,其余三进程在各自的职责范围内将事件对应的回调注册到事件队列中(这里的回调称为任务),等待被取出放入到执行栈中。如下图所示。

 

宏任务和微任务

基本概念

从上一节中我们进一步认识了线程和同步异步代码的关系,并明确了同步异步代码的执行过程。对于异步代码来说,其存放在事件队列(为了契合接下来的内容,以下采用任务队列的说法)中,根据其任务划分,又可以分为宏任务微任务。其主要划分如下图所示。

 

执行顺序

主线程读取任务队列时,会先读取微任务队列中的所有微任务将他们放入到执行栈中,待执行完毕后再读取宏任务队列中的第一个任务,然后再执行该宏任务期间产生的所有微任务,最后读取下一个宏任务。必须要注意的一点是,宏任务之前会重新渲染页面,但此“重新渲染页面”并非将浏览器整个页面再渲染一次(重新渲染DOM),而是找出相关数据的差异,更新相关的真实DOM(React与Vue中的虚拟DOM diff的过程),但真实DOM不一定发生变化(不必要的更新)。(PS:我查阅资料时,重新两字造成了极大的误导,让我百思不得其解,真觉得要不要换一个说法。)


 

之所以这样做,也是为了保证一些重要的操作能够提前进行(插队),由于宏任务之前会重新渲染页面,如果只有宏任务,那么在本次宏任务异步修改数据的值将在所有的宏任务结束后才执行它,如果第二个宏任务重新渲染页面时需要使用这个数据,拿到的还是其更新之前的值。一旦有了微任务,异步微任务更新了数据,就能保证重新渲染页面之前数据已经是最新的了。分别使用宏任务和微任务更新后缀变量(macroDatamicroData)的值,宏任务更新数值在渲染之后执行,而微任务在首次宏任务之后渲染之前就执行,渲染前数值就已经更新,是符合现实逻辑的。于是,我们拥有了在即将执行的任务前插入其它任务,以改变页面的能力。

promise的整个过程并非都是异步的

必须要强调一点的是,对于Promise来说,其真正产生微任务的部分是resolvereject语句,我们可以将其理解为触发条件,其注册到任务队列的任务(回调函数)分别由.then()语句和.catch()语句提供,实例化Promise的过程依然是同步而不是异步。对应地,在实例化Promise时传入的Executor函数中,其除开resolve和reject语句,都先将其视为同步代码处理。

new Promise((resolve,reject)=>{
  console.log("1") //我是同步
  resolve()
}).then(()=>{
  console.log("2") //我是异步,是微任务
})
console.log("3")
//1 3 2

 

上述代码中的第一句输出语句是同步的,then里的回调中的输出语句是异步的。要想理解其实也很简单:承诺肯定是马上给出的,但是兑现承诺需要一个过程(pending),哪怕它很快,比牙签都快。而承诺最终的结局也只有两种:兑现(fulfilled)和不兑现(rejected),结果不可能向着过程再变化,这也是Promise A+规范的一部分。

拓展资料

如果你想要了解上图中的每个API,我附上了相关的参考链接,点击即可查看。如不想了解可继续阅读下文。

经典面试题

理清了宏任务微任务后我们就可以来看看一道经典的面试题了,原题如下:

async function async1() {
    console.log("A")
    await async2()
    console.log("B")
} 

async function async2() {
    console.log('C');
}

console.log('D')

setTimeout(function () { 
    console.log('E')
}, 0)

async1();

new Promise(function (resolve) {
    console.log('F')
    resolve()
}).then(function () {
    console.log('G')
})

console.log('H')

 

简单分析

我们可以看到这题有async/awaitPromisesetTimeout,其实同步异步和宏任务微任务的点。

将async/await还原

我们知道,async/await其实是Promise的同步写法语法糖,因此,可以将其转换为纯粹的Promise写法。于是以上代码等价于:


function async1() {
    console.log("A")
    new Promise((resolove) => {
        console.log("C")
        resolove(undefined)
    }).then((undefined) => {
        console.log("B")
    })
}

console.log('D')

setTimeout(function () { 
    console.log('E')
}, 0)

async1();

new Promise(function (resolve) {
    console.log('F')
    resolve(undefined)
}).then(function () {
    console.log('G')
})

console.log("H")

 

由于async异步函数会将返回值resolve,也就是以下代码

 

async function async2(){
  console.log("C")
}
//等价于
function async2(){
  console.log("C")
 return new Promise(resolve=>{
    resolve(undefined)
  })
}

 

await则先等待其后的Promise状态改变,并获取到fulfilledvalue或者rejectedreason,并将其后的代码推入到微任务中,让渡出主线程的控制权,因此await之后的代码就可以放入到new Promise().then()then回调之中。

同步代码执行

去掉asyncawait之后,我们就执行同步代码,上方的同步代码除了输出语句、async1函数还有Promise的实例化部分,因此得到输出:DACFH。而有两个微任务then()语句和一个宏任务setTimeout。

执行宏任务和微任务

此时两个微任务先执行,宏任务最后执行,先知道最后会输出:E。而这里由于Promise调用resolve时都是实例化后立刻调用,两个微任务将按先后顺序执行,输出:BG。

于是,输出结果为:DACFHBGE。

 


vue中nextTick的实现原理

vue内部的异步更新

由于vue内部DOM的更新采用异步更新机制,也就是等待所有数据的同步变动后再更新DOM。

如果没有异步更新机制,Vue中多个依赖某个响应式的组件也会多次渲染更新,这样无疑是对性能地极大损耗,以至于页面卡顿。因此,引入异步更新机制势在必行。

 

vue异步渲染和nextTick实现

那么,vue内部大致是如何实现这个异步渲染的呢?

首先,该过程是异步的,那我们就要考虑这是宏任务还是微任务。这个过程我们肯定希望在我们同步代码执行后立刻执行,那就只能是微任务了。其次,这些渲染任务需要我们事先将其缓冲在任务队列中,并在缓冲时去除不必要的重复更新。待数据同步更新完成后,我们再执行任务队列中的所有任务。

由于DOM是异步更新,我们在数据同步变化后肯定就不能取到最新的DOM和组件实例,我们必须等待渲染任务完成后执行,也就是在其后面插入新的微任务,于是我们几乎已经得到了nextTick的实现逻辑


const resolvePromise=Promise.resolve()
function nextTick(fn){
  return fn? resolvePromise.then(fn):resolvePromise
}

 

Promise.resolve()可以返回一个状态为fulfilledPromise,其调用then()会直接在当前微任务队列的末尾添加这个任务,保证nextTick声明的任务被及时执行。

因此,nextTick的作用细致来说就是声明一个微任务,以等待将来的执行。

手写简单的异步渲染队列和nextTick

为了加强理解,我还是将上述的例子使用异步渲染更新。

  1. 创建一个空的任务队列jobQueue
  2. 在proxy里将原有的渲染逻辑放入任务队列
  3. 使用Promise实现nextTick
  4. 将执行任务队列中所有任务的逻辑(大任务)调用nextTick添加到微任务队列。

同步代码执行完毕后将执行该微任务队列中的异步渲染任务。


总结

浏览器的JS引擎是单线程的,为了实现异步编程,引入了事件循环机制。

主线程总是先把当前执行栈清空后,去任务队列中取出一个任务再放入执行栈中执行。

任务队列有宏任务和微任务之分,先执行微任务后执行宏任务,且必须将当前的微任务全部执行后再执行下一个宏任务。

vue的DOM渲染机制为异步渲染机制,以队列的格式缓存,通过微任务异步执行。

nextTick的作用是声明一个微任务,其在数据更新后直接调用可以取到最新的DOM和组件实例。