【VUE】我看谁还不能理解事件循环机制+nextTick?

1,099 阅读7分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第3天,点击查看活动详情

前言

之前看过很多篇相关的文章,虽然比较深入,但总是原理多一些,例子少一些。这篇文章会以例子为主导,对事件循环机制和nextTick进行讲解。

了解事件循环机制

虽说概念原理很枯燥,但是要想真正掌握某个知识,对原理的理解是必不可少的,首先我们一起来了解事件循环机制叭~~~

先了解一下浏览器的进程和线程

其中和我们前端息息相关的就是渲染进程了,它主要负责将HTML、CSS、JS资源进行解析渲染,还负责事件循环、异步请求等多个方面。

  1. GUI渲染线程:负责页面的绘制和渲染。我们熟知的HTML、CSS资源的解析、渲染树的生成、页面的绘制都是该线程负责的。
  2. JS引擎线程:负责JS的解析以及所有同步异步任务的执行。维护一个执行栈,先逐个处理同步代码,当遇到异步任务,就会借助事件触发线程。 (可以理解为单线单线程的js处理所有的任务太吃力了,就需要借助事件触发线程帮他管理异步任务的实行时机)

需要注意的是,JS引擎和GUI线程是互斥的,JS引擎执行时,会导致GUI线程挂起,直到JS引擎执行结束。这就是为什么我们提倡将JS代码放到html的最后,或者使用deferasync来异步下载执行js代码。

js线程只能一个事件做一件事情,但是浏览器为我们提供了webAPI,可以处理setTimeout、Ajax等事件

  1. 事件触发线程:事件触发线程维护一个任务队列,其中又分为微任务队列和宏任务队列。任务队列里的异步任务被触发时,该任务就会被放到对应任务队列的队尾,等待js引擎线程的执行栈清空,再从任务队列中取任务进行处理。

  2. 计时器线程:setTimeoutsetInterval所在的线程,我们试想:如果由js引擎线程来计时的话,一旦他被阻塞,计时也会停止。

    接下来请思考下面这个例子,打印出hello一定是3s之后吗?

 setTimeout(()=>{
     console.log('hello~')
 },3000)

答案是否定的~这里的3s是指:3s过后把setTimeout里的代码放到对应的任务队列的队尾,所以如果前面还有别的任务需要处理的话,要等前面的任务处理完才能执行对应的代码。

事实上setTimeout要等待的事件比我们想象的还要长,因为他是一个宏任务。

宏任务、微任务和事件循环机制

为什么宏任务就会等待更长的时间呢?。让我们一起了解一下js的事件循环机制

  1. JS引擎逐行扫描js代码,遇到同步任务加入执行栈。
  2. 遇到异步任务如setTimeout发送ajax请求等的异步任务,就交给事件触发线程。当该异步任务被触发,就放到任务队列的末尾。该线程维护一个微任务队列和一个宏任务队列
  3. JS引擎将执行栈里的同步任务执行完之后,就去从宏任务队列队首取一个宏任务,和对应的微任务到执行栈里,直到微任务队列为空。注意宏任务是一次事件循环取一个,而为任务是一直取一直取直到任务队列为空
  4. 再取一个宏任务,重复3的过程。

常见的宏任务和微任务

microtast(微任务):Promise.then, process.nextTick, Object.observe, MutationObserver

macrotask(宏任务):script整体代码、setTimeout、 setInterval等

需要额外注意的是:await会阻塞后面的代码(把后面的代码作为微任务放到微任务队列里),表现为:先执行async外面的同步代码,同步代码执行完,再回到async内部,继续执行await后面的代码(微任务)。

到这里上面的问题就很好解释了,因为每次事件循环只取一个宏任务,而微任务是一直取一直取直到微任务队列为空,所以setTimeout可能等待的时间要更长。

说了这么多理论知识,你肯定觉得很枯燥吧,让我们来分析几个例子~~~~

实战分析

例1 先从简单的例子开始:

<script>
    console.log('hi')
    setTimeout(()=>{
        console.log('there')
    },0)
    console.log('JSConf')
</script>
  1. 首先script标签整体是一个宏任务,我们在逐行扫描的时候先输出hi
  2. 然后我们遇到了setTimeout,触发计时器线程,计时0s之后,我们把它放到宏任务队列的末尾,等待下一次事件循环。
  3. 输出JSConf
  4. 一次事件循环结束,执行栈为空,从宏任务队列队头取出setTimeout,放入JS执行栈中执行,输出there

看,不是很复杂吧,你对事件循环的理解是不是更清晰了一些?

例2

来看一个稍复杂的例子

console.log("start")

setTimeout(() => {
    console.log('timer1')
    new Promise(function (resolve) {
        console.log(" promise start ")
        resolve();
    }).then(function () {
        console.log('promise1')
    })
}, 0)

setTimeout(() => {
    console.log('timer2')
    Promise.resolve().then(function () {
        console.log('promise2')
    })

}, 0)

console.log("end")

试着自己分析一下,你心里的答案是什么呢?

  1. 首先输出start
  2. 遇到两个宏任务,按顺序放到宏任务队列的末尾
  3. 输出end
  4. 一次事件循环结束,从宏任务队列的队头取一个宏任务:执行该宏任务中的同步任务,输出timer1,promise start,执行该宏任务中的微任务,输出promise1,该宏任务执行结束
  5. 执行栈再次为空,同样地,再取一个宏任务,输出timer2,promise2

例3

看一个更复杂的例子

async function async1() {
    console.log('async1 start');
    await async2()
    console.log('async end');
}

async function async2() {
    return new Promise((resolve, reject) => {
        console.log('async2 start');
        resolve()
    }).then(res => {
        console.log('async2 end');
    })
}

async1()

new Promise(resolve => {
    console.log('Promise');
    resolve()
}).then(res => {
    console.log('Promise end');
})

console.log('script end');

是不是感觉复杂起来?跟我一起一步一步分析吧~

虽然过程看起来有点冗长,但是思路很明晰

  1. 先执行同步任务async1()(函数调用本身是一个同步任务),输出async1 start;然后执行async2,输出async2 start
  2. 向下扫描,遇到async2中的promise.then是微任务,把console.log('async2 end');放到微任务队列的末尾
  3. async2执行结束,继续逐行执因为await的存在,把后面的代码console.log('async2 end');放到微任务队列的末尾
  4. 遇到Promise声明中的同步代码console.log('Promise');,执行输出Promise
  5. 遇到Promise.then中的代码console.log('Promise end');,放到微任务队列的末尾
  6. 输出script end,至此同步任务执行结束。
  7. 我们知道,如果在逐行扫描的过程中遇到了宏任务,放到下一次事件循环的时候执行,如果遇到了微任务,就在本次事件循环中执行,直到微任务队列为空。所以接下来逐个从为微任务队列的队头中取出微任务执行,直到微任务队列为空,依次执行顺序是:async2 endasync endPromise end

是不是感觉豁然开朗呢?(没有的话也没事,多看几遍就好啦)

为什么vue需要$nextTick

不知道你有没有注意到:vue对dom的更新是异步的。

for(let i=0;i<n;i++){
    this.someData+=i
}

如上面的例子,如果我们多次修改someDate的值,并不是每次修改的结果都会渲染到页面上。因为我们只需要看到最终的值,对中间值的渲染是很消耗资源的。

vue也考虑到了这一点,watcher监听数据变化,并不是监听到一个就渲染一个,而是在内部维护了一个异步的缓冲队列,来缓冲当前对数据的修改,并进行去重操作,然后等到有一个合适的时机再渲染到页面上。类似于上面这种情况下,只有someData的最终结果会被推入该队列。

那么,vue是什么时候把缓冲的数据更改更新到页面上呢?这跟事件循环机制息息相关。

要么选择在一次事件循环的末尾进行dom更新,要么选择在下一次事件循环中进行dom更新。哪一个更优呢?肯定是前者。因为在下一个事件循环中更新的话,必须要等到当前的微任务全部执行结束,JS引擎再取下一个宏任务,才能进行dom更新,这样慢了很多。

那么问题来了,一次事件循环中,先执行同步任务,接下来才会执行异步任务,那么我们如果想要获取刚才更新的数据(dom)(这是同步的),这该怎么办呢?

vue已经为我们考虑到了这一点,他为我们提供了一个全局API,vm.$nextTick(callback),在下一次dom更新之后执行对应的回调,这样我们获取到的dom就是更新过的dom啦~

另外:理论上来说浏览器在每个宏任务执行之后才进行一次渲染,nextTick是一个微任务,他虽然可以拿到更新后的dom,但是此时修改还没有呈现到页面上。

总结

做个小总结叭~

本文首先对各个浏览器进程和线程做了简单的介绍,进而由JS引擎线程事件触发线程引出了js的事件循环机制,总的来说就是:

  1. script代码块整个是一个宏任务,在这个宏任务中,先执行同步任务;
  2. 遇到宏任务放到宏任务队列的末尾。等待下一次事件循环时处理;
  3. 遇到微任务放到微任务队列的末尾,等待同步任务执行完之后,取微任务队列中的任务,直到队列为空;
  4. 再取下一个宏任务,如此事件循环……

最后我们介绍了vue的dom更新策略,以及与事件循环机制密切相关的$nextTick

呼~不知不觉码了这么多字了,如果存在疏漏的地方,欢迎大家批评指正,如果你觉得写的还不错的话,欢迎给我一个赞呀~