前端事件循环和nextTick原理

发布时间 2023-04-25 00:47:05作者: 每天七点半

一、事件循环机制

概念原理这东西还是需要理解的,这样才能融通知识点。下面是浏览器进程和线程组成

上图中与前端关系比较大的是渲染线程,它主要负责将HTML、CSS、JS资源解析渲染还负责事件循环、异步请求等多个方面。

1、GUI渲染线程:负责页面的绘制和渲染,HTML、CSS资源解析、渲染树的生成、页面的绘制都是该线程负责的;

2、JS引擎线程:负责JS的解析以及所有同步异步任务的执行,维护一个执行栈,先逐个处理同步代码,当遇到异步任务,就会借助事件触发线程(可以理解为单线程处理所有js任务太繁重了,需要借助事件触发线程协助管理异步任务的执行时机);

需要注意的是,JS引擎和GUI线程是互斥的,JS引擎执行时,会导致GUI线程挂起,直到JS引擎执行结束。JS线程只能一个事件做一件事情,但是浏览器提供了webAPI,可以处理setTimeout、Ajax等事件

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

4、计时器线程:setTimeout和setInterval所在的线程,借助以下示例说明

//如果由JS引擎线程来计时的话,一旦它被阻塞,计时器也会停止
//思考,打印出hello一定是3秒后吗?
setTimeout(() => {
  console.log("hello!");
}, 3000)
//答案是否定的,这里的3秒是指:3S后把setTimeout里的代码放到对应的任务队列的队尾,所以前面还有其他任务需要处理的话,就需要等前面的任务处理完才能执行对应的代码

事实上,setTimeout要等待的时间比我们想象的要长,因为它是一个宏任务

JS的事件执行机制

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

常见的宏任务和微任务:
microtask(微任务):Promise.then、process.nextTick、Object.observe、MutationObserve;
macrotask(宏任务):script整体代码,setTimeout、setInterval等;

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

看到这里,上面那个案例就很好理解了,因为没次事件循环只取一个宏任务,而微任务是连续取知道微任务队列为空,所以setTimeout可能等待的时间会更长。
下面分析几个案例帮助我们理解;

二、案例

//例1
console.log('hi');
setTimeout(() => {
  console.log('there');
}, 0);
console.log('JSConf')
  • 首先代码整体是一个宏任务,在逐行扫描时输出hi
  • 当执行到setTimeout时,触发计时器线程,计时0S之后,将它放到宏任务队列的队尾,等待下一次事件循环
  • 输出JSConf
  • 一次事件循环结束,执行栈为空,从宏任务队首取出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');
  • 首先输出start
  • 遇到两个宏任务,按顺序放到宏任务队列的末尾
  • 输出end
  • 一次事件循环结束,从宏任务队列队首取出一个宏任务:执行该宏任务中的同步任务,输出timer1、promise start,执行该宏任务中的微任务,输出promise1,该宏任务执行结束
  • 执行栈再次为空,同样的,再娶一个宏任务,输出timer2,promise2
// 例3
async function async1(){
  console.log('async1 start');
  await async2();
  console.log(async1 end);
}

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

async1();

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

三、为什么vue需要$nextTick

代码先行

// vue对dom的更新是异步的
for(let i=0; i<n; i++){
  this.someData += i;
}

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

vue也考虑到这一点,watcher监听数据变化,并不是监听到就渲染,而是在内部维护了一个异步的缓冲队列,来缓冲当前对数据的修改,并进行去重操作,然后等到一个合适的时机再渲染到页面上。

vue是什么时候将缓冲数据更新到页面上呢?这就跟事件循环机制息息相关了。

要么选择在一次事件循环的末尾进行dom更新,要么选择在下一次事件循环中进行dom更新。相比之下前者性能更好,因为在下一个事件循环中更新的话,必须要等到当前的微任务全部执行结束,JS引擎再取下一个宏任务,才能进行dom更新,这样就慢了好多。

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

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

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