JS事件循环event loop:浏览器与Node环境以及vue.nexttick

秋招面试被问到这个问题,查了很多文章,比较零散,所以自己集百家之长,做个整理

什么是事件?

事件:事件就是由于某种外在或内在的信息状态发生的变化,从而导致出现了对应的反应。比如说用户点击了一个按钮,就是一个事件;HTML页面完成加载,也是一个事件。一个事件中会包含多个任务。
JavaScript引擎又称为JavaScript解释器,是JavaScript解释为机器码的工具,分别运行在浏览器和Node中。而根据上下文的不同,Event loop也有不同的实现:其中Node使用了libuv库来实现Event loop; 而在浏览器中,html规范定义了Event loop,具体的实现则交给不同的厂商去完成。

所以,浏览器的Event loop和Node的Event loop是两个概念,下面分别来看一下。

一、浏览器环境

在JavaScript中,任务被分为MacroTask(宏任务)和MicroTask(微任务)两种。它们分别包含以下内容:

MacroTask: script(整体代码), setTimeout, setInterval, setImmediate(node独有), I/O, UI rendering
MicroTask: process.nextTick(node独有), Promises, MutationObserver

由于js是单线程语言,只有一个主线程来处理所以任务,首次script压入执行栈,同步代码立即执行,异步代码放入事件队列,宏任务放入宏任务队列,微任务放入微任务队列。当当前执行栈空了会立刻处理所有的微任务,然后再去宏任务队列中取出一个事件,再执行所有的微任务…
JS事件循环event loop:浏览器与Node环境以及vue.nexttick_第1张图片

注意:浏览器中,一个事件循环里有很多个来自不同任务源的任务队列(task queues),每一个任务队列里的任务是严格按照先进先出的顺序执行的。但是,因为浏览器自己调度的关系,不同任务队列的任务的执行顺序是不确定的。

二、Node环境

nodejs的event loop分为6个阶段,它们会按照顺序反复运行,分别如下:

  1. timers:执行setTimeout() 和 setInterval()中到期的callback。
  2. I/O callbacks:上一轮循环中有少数的I/Ocallback会被延迟到这一轮的这一阶段执行
  3. idle, prepare:队列的移动,仅内部使用
  4. poll:最为重要的阶段,执行I/O callback,在适当的条件下会阻塞在这个阶段
  5. check:执行setImmediate的callback
  6. close callbacks:执行close事件的callback,例如socket.on(“close”,func)

如图所示是Node 11之前的事件循环机制:不同于浏览器的是,在每个阶段完成后,而不是MacroTask任务完成后,microTask队列就会被执行。

JS事件循环event loop:浏览器与Node环境以及vue.nexttick_第2张图片
注意:这个图是node11.0之前的机制,例如timers里面有两个settimeout,则两个settimeout都执行完之后,再执行微任务,但是node11.0之后,node修改了执行机制,和浏览器一样,执行完一个宏任务之后立刻执行所有微任务,例如下面示例3.1所示。

关于阶段,我从文档中摘录了比较重要的一段话:

轮询
轮询 阶段有两个重要的功能:
计算应该阻塞和轮询 I/O 的时间。
然后,处理 轮询 队列里的事件。
当事件循环进入 轮询 阶段且 没有被调度的计时器时 ,将发生以下两种情况之一:
如果 轮询 队列 不是空的 ,事件循环将循环访问回调队列并同步执行它们,直到队列已用尽,或者达到了与系统相关的硬性限制。
如果 轮询 队列 是空的 ,还有两件事发生:
如果脚本被 setImmediate() 调度,则事件循环将结束 轮询 阶段,并继续 检查 阶段以执行那些被调度的脚本。
如果脚本 未被 setImmediate()调度,则事件循环将等待回调被添加到队列中,然后立即执行。
一旦 轮询 队列为空,事件循环将检查 已达到时间阈值的计时器。如果一个或多个计时器已准备就绪,则事件循环将绕回计时器阶段以执行这些计时器的回调。
检查阶段
此阶段允许人员在轮询阶段完成后立即执行回调。如果轮询阶段变为空闲状态,并且脚本使用 setImmediate() 后被排列在队列中,则事件循环可能继续到 检查 阶段而不是等待。
setImmediate() 实际上是一个在事件循环的单独阶段运行的特殊计时器。它使用一个 libuv API 来安排回调在 轮询 阶段完成后执行。
通常,在执行代码时,事件循环最终会命中轮询阶段,在那等待传入连接、请求等。但是,如果回调已使用 setImmediate()调度过,并且轮询阶段变为空闲状态,则它将结束此阶段,并继续到检查阶段而不是继续等待轮询事件。

三、示例

3.1 浏览器与Node

setTimeout(()=>{
    console.log('timer1')

    Promise.resolve().then(function() {
        console.log('promise1')
    })
}, 0)

setTimeout(()=>{
    console.log('timer2')

    Promise.resolve().then(function() {
        console.log('promise2')
    })
}, 0)
//浏览器和Node输出均为:
timer1
promise1
timer2
promise2

3.2setImmediate() vs setTimeout()

setImmediate(() => {
  console.log('timer1')

  Promise.resolve().then(function () {
    console.log('promise1')
  })
})

setTimeout(() => {
  console.log('timer2')

  Promise.resolve().then(function () {
    console.log('promise2')
  })
}, 0)
//Node输出:
timer1               timer2
promise1    或者     promise2
timer2               timer1
promise2             promise1

按理说setTimeout(fn,0)应该比setImmediate(fn)快,应该只有第二种结果,为什么会出现两种结果呢?
这是因为Node 做不到0毫秒,最少也需要4毫秒。实际执行的时候,进入事件循环以后,有可能到了4毫秒,也可能还没到4毫秒,取决于系统当时的状况。如果没到4毫秒,那么 timers 阶段就会跳过,进入 check 阶段,先执行setImmediate的回调函数。
官网解释:if we run the following script which is not within an I/O cycle , the order in which the two timers are executed is non-deterministic, as it is bound by the performance of the process
但是,如果把settimeout和setimmediate放入I/O循环,那么setImmediate会比setTimeout更快,例如:

// timeout_vs_immediate.js
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
$ node timeout_vs_immediate.js
immediate
timeout

3.3process.nextTick()

先上代码,我亲自试的,果然百闻不如一试

setTimeout(() => {
        console.log('我是settimeout1')
        var p = new Promise((resolve) => {
          console.log('我是promise1')
          resolve()
        })
        p.then(() => {
          console.log('我是promise1成功的回调')
        })
        process.nextTick(() => {
          console.log('我是process1')
        })
      }, 0)
 setTimeout(() => {
    console.log('我是settimeout2')
    process.nextTick(() => {
      console.log('我是process2')
    })
    var p = new Promise((resolve) => {
      console.log('我是promise2')
      resolve()
    })
    p.then(() => {
      console.log('我是promise2成功的回调')
    })
  }, 0)
//node v12结果:
我是settimeout1
我是promise1
我是promise1成功的回调
我是settimeout2
我是promise2
我是promise2成功的回调
我是process1
我是process2

Node文档中说明,任何阶段都可以调用process.nextTick,它的回调将在事件循环继续之前解析(也就是执行完这一阶段所以宏任务,腰进入下一阶段之前执行),但它可能会造成一些糟糕的情况,因为它允许您通过递归 process.nextTick()调用来“饿死”您的 I/O,阻止事件循环到达poll阶段(process.nextTick递归调用的process.nextTick会立即放到微任务队列,并立即执行,也就是死循环,这点与setimmediate不同,递归调用的setimmediate会被放入下一次事件循环中)。

Vue.nextTick()

进程是cpu资源分配的最小单位(系统会给它分配内存)
线程是cpu调度的最小单位(一个进程中可以有多个线程)
js是一门单线程语言,JS可以操作DOM,如果JS同时有两个线程,一个删除dom,一个添加dom,会出问题,所以规定js是一门单线程的语言。
但是浏览器是多进程的
JS事件循环event loop:浏览器与Node环境以及vue.nexttick_第3张图片
GUI渲染线程和 JS 引擎线程是互斥的,当 JS 引擎线程在工作的时候,GUI 渲染线程会被挂起,当GUI 渲染线程在工作的时候,js线程会被挂起
JS事件循环event loop:浏览器与Node环境以及vue.nexttick_第4张图片

也就是在数据变化后,DOM并不会马上更新,而是在本轮事件循环结束后才执行更新。如果如果想要根据更新后 DOM 状态去做某些操作,就可以在数据变化之后立即使用 Vue.nextTick(callback) 。这样回调函数在 DOM 更新完成后就会调用。

JS事件循环event loop:浏览器与Node环境以及vue.nexttick_第5张图片

你可能感兴趣的:(JS,nodejs,js,javascript)