浏览器中的事件轮询

JavaScript 是一门单线程语言,之所以说是单线程,是因为在浏览器中,如果是多线程,并且两个线程同时操作了同一个 Dom 元素,那最后的结果会出现问题。所以,JavaScript 是单线程的,但是如果完全由上至下的一行一行执行代码,假如一个代码块执行了很长的时间,后面必须要等待当前执行完毕,这样的效率是非常低的,所以有了异步的概念,确切的说,JavaScript 的主线程是单线程的,但是也有其他的线程去帮我们实现异步操作,比如定时器线程、事件线程、Ajax 线程。

在浏览器中执行 JavaScript 有两个区域,一个是我们平时所说的同步代码执行,是在栈中执行,原则是先进后出,而在执行异步代码的时候分为两个队列,macro-task(宏任务)和 micro-task(微任务),遵循先进先出的原则。

/* 作用域链 */
function one() {
  console.log(1);
  function two() {
    console.log(2);
    function three() {
      console.log(3);
    }
    three();
  }
  two();
}
one();

// 1
// 2
// 3

上面的代码都是同步的代码,在执行的时候先将全局作用域放入栈中,执行全局作用域中的代码,解析了函数 one,当执行函数调用 one() 的时候将 one 的作用域放入栈中,执行 one 中的代码,打印了 1,解析了 two,执行 two(),将 two 放入栈中,执行 two,打印了 2,解析了 three,执行了 three(),将 three 放入栈中,执行 three,打印了 3

在函数执行完释放的过程中,因为全局作用域中有 one 正在执行,one 中有 two 正在执行,two 中有 three 正在执行,所以释放内存时必须由内层向外层释放,three 执行后释放,此时 three 不再占用 two 的执行环境,将 two 释放,two 不再占用 one 的执行环境,将 one 释放,one 不再占用全局作用域的执行环境,最后释放全局作用域,这就是在栈中执行同步代码时的先进后出原则,更像是一个杯子,先放进去的在最下面,需要最后取出。

而异步队列更像时一个管道,有两个口,从入口进,从出口出,所以是先进先出,在宏任务队列中代表的有 setTimeoutsetIntervalsetImmediateMessageChannel,微任务的代表为 Promise 的 then 方法、MutationObserve(已废弃)。

案例 1

const messageChannel = new MessageChannel();
const prot2 = messageChannel.port2;

messageChannel.port1.postMessage('I love you');
console.log(1);

prot2.onmessage = function (e) {
  console.log(e.data);
};
console.log(2);

// 1
// 2
// I love you

从上面案例中可以看出,MessageChannel 是宏任务,晚于同步代码执行。

案例 2

setTimeout(() => console.log(1), 2000);
setTimeout(() => console.log(2), 1000);
console.log(3);

// 3
// 2
// 1

上面代码可以看出其实 setTimeout 并不是在同步代码执行的时候就放入了异步队列,而是等待时间到达时才会放入异步队列,所以才会有了上面的结果。

案例 3

setImmediate(function () {
  console.log('setImmediate');
});

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

console.log(1);

// 1
// setTimeout
// setImmediate

同为宏任务,setImmediatesetTimeout 延迟时间为 0 时是晚于 setTimeout 被放入异步队列的,这里需要注意的是 setImmediate 在浏览器端,到目前为止只有 IE 实现了。

上面的案例都是关于宏任务,下面我们举一个有微任务的案例来看一看微任务和宏任务的执行机制,在浏览器端微任务的代表其实就是 Promise 的 then 方法。

案例 4

setTimeout(() => {
  console.log('setTimeout1');
  Promise.resolve().then(data => {
    console.log('Promise1');
  });
}, 0);

Promise.resolve().then(data => {
  console.log('Promise2');
  setTimeout(() => {
    console.log('setTimeout2');
  }, 0);
});

// Promise2
// setTimeout1
// Promise1
// setTimeout2

从上面的执行结果其实可以看出,同步代码在栈中执行完毕后会先去执行微任务队列,将微任务队列执行完毕后,会去执行宏任务队列,宏任务队列执行一个宏任务以后,会去看看有没有产生新的微任务,如果有则清空微任务队列后再执行下一个宏任务,依次轮询,直到清空整个异步队列。

Node 中的事件轮询

在 Node 中的事件轮询机制与浏览器相似又不同,相似的是,同样先在栈中执行同步代码,同样是先进后出,不同的是 Node 有自己的多个处理不同问题的阶段和对应的队列,也有自己内部实现的微任务 process.nextTick,Node 的整个事件轮询机制是 Libuv 库实现的。

Node 中事件轮询的流程如下图:


Node 事件环流程图
Node 事件环流程图


从图中可以看出,在 Node 中有多个队列,分别执行不同的操作,而每次在队列切换的时候都去执行一次微任务队列,反复的轮询。

案例 1

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

setImmediate(function () {
  console.log('setInmediate');
});

默认情况下 setTimeoutsetImmediate 是不知道哪一个先执行的,顺序不固定,Node 执行的时候有准备的时间,setTimeout 延迟时间设置为 0 其实是大概 4ms,假设 Node 准备时间在 4ms 之内,开始执行轮询,定时器没到时间,所以轮询到下一队列,此时要等再次循环到 timer 队列后执行定时器,所以会先执行 check 队列的 setImmediate

如果 Node 执行的准备时间大于了 4ms,因为执行同步代码后,定时器的回调已经被放入 timer 队列,所以会先执行 timer 队列。

案例 2

setTimeout(() => {
  console.log('setTimeout1');
  Promise.resolve().then(() => {
    console.log('Promise1');
  });
}, 0);

setTimeout(() => {
  console.log('setTimeout2');
}, 0);
console.log(1);

// 1
// setTimeout1
// setTimeout2
// Promise1

Node 事件轮询中,轮询到每一个队列时,都会将当前队列任务清空后,在切换下一队列之前清空一次微任务队列,这是与浏览器端不一样的。

浏览器端会在宏任务队列当中执行一个任务后插入执行微任务队列,清空微任务队列后,再回到宏任务队列执行下一个宏任务。

上面案例在 Node 事件轮询中,会将 timer 队列清空后,在轮询下一个队列之前执行微任务队列。

案例 3

setTimeout(() => {
  console.log('setTimeout1');
}, 0);

setTimeout(() => {
  console.log('setTimeout2');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise1');
});
console.log(1);

// 1
// Promise1
// setTimeout1
// setTimeout2

上面代码的执行过程是,先执行栈,栈执行时打印 1Promise.resolve() 产生微任务,栈执行完毕,从栈切换到 timer 队列之前,执行微任务队列,再去执行 timer 队列。

案例 4

setImmediate(() => {
  console.log('setImmediate1');
  setTimeout(() => {
    console.log('setTimeout1');
  }, 0);
});

setTimeout(() => {
  console.log('setTimeout2');
  setImmediate(() => {
    console.log('setImmediate2');
  });
}, 0);

//结果1
// setImmediate1
// setTimeout2
// setTimeout1
// setImmediate2

// 结果2
// setTimeout2
// setImmediate1
// setImmediate2
// setTimeout1

setImmediatesetTimeout 执行顺序不固定,假设 check 队列先执行,会执行 setImmediate 打印 setImmediate1,将遇到的定时器放入 timer 队列,轮询到 timer 队列,因为在栈中执行同步代码已经在 timer 队列放入了一个定时器,所以按先后顺序执行两个 setTimeout,执行第一个定时器打印 setTimeout2,将遇到的 setImmediate 放入 check 队列,执行第二个定时器打印 setTimeout1,再次轮询到 check 队列执行新加入的 setImmediate,打印 setImmediate2,产生结果 1

假设 timer 队列先执行,会执行 setTimeout 打印 setTimeout2,将遇到的 setImmediate 放入 check 队列,轮询到 check 队列,因为在栈中执行同步代码已经在 check 队列放入了一个 setImmediate,所以按先后顺序执行两个 setImmediate,执行第一个 setImmediate 打印 setImmediate1,将遇到的 setTimeout 放入 timer 队列,执行第二个 setImmediate 打印 setImmediate2,再次轮询到 timer 队列执行新加入的 setTimeout,打印 setTimeout1,产生结果 2

案例 5

setImmediate(() => {
  console.log('setImmediate1');
  setTimeout(() => {
    console.log('setTimeout1');
  }, 0);
});

setTimeout(() => {
  process.nextTick(() => console.log('nextTick'));
  console.log('setTimeout2');
  setImmediate(() => {
    console.log('setImmediate2');
  });
}, 0);

//结果1
// setImmediate1
// setTimeout2
// setTimeout1
// nextTick
// setImmediate2

// 结果2
// setTimeout2
// nextTick
// setImmediate1
// setImmediate2
// setTimeout1

这与上面一个案例类似,不同的是在 setTimeout 执行的时候产生了一个微任务 nextTick,我们只要知道,在 Node 事件轮询中,在切换队列时要先去执行微任务队列,无论是 check 队列先执行,还是 timer 队列先执行,都会很容易分析出上面的两个结果。

案例 6

const fs = require('fs');

fs.readFile('./.gitignore', 'utf8', function () {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(function () {
    console.log('setImmediate');
  });
});

// setImmediate
// timeout

上面案例的 setTimeoutsetImmediate 的执行顺序是固定的,前面都是不固定的,这是为什么?

因为前面的不固定是在栈中执行同步代码时就遇到了 setTimeoutsetImmediate,因为无法判断 Node 的准备时间,不确定准备结束定时器是否到时并加入 timer 队列。

而上面代码明显可以看出 Node 准备结束后会直接执行 poll 队列进行文件的读取,在回调中将 setTimeoutsetImmediate 分别加入 timer 队列和 check 队列,Node 队列的轮询是有顺序的,在 poll 队列后应该先切换到 check 队列,然后再重新轮询到 timer 队列,所以得到上面的结果。

案例 7

Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));

// nextTick
// Promise

在 Node 中有两个微任务,Promisethen 方法和 process.nextTick,从上面案例的结果我们可以看出,在微任务队列中 process.nextTick 是优先执行的。

上面内容就是浏览器与 Node 在事件轮询的规则,相信在读完以后应该已经彻底弄清了浏览器的事件轮询机制和 Node 的事件轮询机制,并深刻的体会到了他们之间的相同和不同。