高频面试题:JavaScript事件循环机制解析

文章首次发表在 个人博客

前言

最近面试了很多家公司,这道题几乎是必被问到的一道题。之前总觉得自己了解得差不多,但是当第一次被问到的时候,却不知道该从哪里开始说起,涉及到的知识点很多。于是花时间整理了一下。并不仅仅是因为面试遇到了,而是理解JavaScript事件循环机制会让我们平常遇到的疑惑也得到解答。

一般面试官会这么问,出道题,让你说出打印结果。然后会问分别说说浏览器的node的事件循环,区别是什么,什么是宏任务和微任务,为什么要有这两种任务...

本篇文章参考了很多文章,同时加上自己的理解,如果有问题希望大家指出。

事件循环

  1. JavaScript是单线程,非阻塞的
  2. 浏览器的事件循环
    • 执行栈和事件队列
    • 宏任务和微任务
  3. node环境下的事件循环
    • 和浏览器环境有何不同
    • 事件循环模型
    • 宏任务和微任务
  4. 经典题目分析

1. JavaScript是单线程,非阻塞的

单线程:

JavaScript的主要用途是与用户互动,以及操作DOM。如果它是多线程的会有很多复杂的问题要处理,比如有两个线程同时操作DOM,一个线程删除了当前的DOM节点,一个线程是要操作当前的DOM阶段,最后以哪个线程的操作为准?为了避免这种,所以JS是单线程的。即使H5提出了web worker标准,它有很多限制,受主线程控制,是主线程的子线程。

非阻塞:通过 event loop 实现。

2. 浏览器的事件循环

执行栈和事件队列

为了更好地理解Event Loop,请看下图(转引自Philip Roberts的演讲 《Help, I'm stuck in an event-loop》)

Help, I'm stuck in an event-loop

执行栈: 同步代码的执行,按照顺序添加到执行栈中

function a() {
    b();
    console.log('a');
}
function b() {
    console.log('b')
}
a();

我们可以通过使用 Loupe(Loupe是一种可视化工具,可以帮助您了解JavaScript的调用堆栈/事件循环/回调队列如何相互影响)工具来了解上面代码的执行情况。

调用情况
  1. 执行函数 a()先入栈
  2. a()中先执行函数 b() 函数b() 入栈
  3. 执行函数b(), console.log('b') 入栈
  4. 输出 bconsole.log('b')出栈
  5. 函数b() 执行完成,出栈
  6. console.log('a') 入栈,执行,输出 a, 出栈
  7. 函数a 执行完成,出栈。

事件队列: 异步代码的执行,遇到异步事件不会等待它返回结果,而是将这个事件挂起,继续执行执行栈中的其他任务。当异步事件返回结果,将它放到事件队列中,被放入事件队列不会立刻执行起回调,而是等待当前执行栈中所有任务都执行完毕,主线程空闲状态,主线程会去查找事件队列中是否有任务,如果有,则取出排在第一位的事件,并把这个事件对应的回调放到执行栈中,然后执行其中的同步代码。

我们再上面代码的基础上添加异步事件,

function a() {
    b();
    console.log('a');
}
function b() {
    console.log('b')
    setTimeout(function() {
        console.log('c');
    }, 2000)
}
a();

此时的执行过程如下


img

我们同时再加上点击事件看一下运行的过程

$.on('button', 'click', function onClick() {
    setTimeout(function timer() {
        console.log('You clicked the button!');    
    }, 2000);
});

console.log("Hi!");

setTimeout(function timeout() {
    console.log("Click the button!");
}, 5000);

console.log("Welcome to loupe.");
img

简单用下面的图进行一下总结

执行栈和事件队列

宏任务和微任务

为什么要引入微任务,只有一种类型的任务不行么?

页面渲染事件,各种IO的完成事件等随时被添加到任务队列中,一直会保持先进先出的原则执行,我们不能准确地控制这些事件被添加到任务队列中的位置。但是这个时候突然有高优先级的任务需要尽快执行,那么一种类型的任务就不合适了,所以引入了微任务队列。

不同的异步任务被分为:宏任务和微任务
宏任务:

  • script(整体代码)
  • setTimeout()
  • setInterval()
  • postMessage
  • I/O
  • UI交互事件

微任务:

  • new Promise().then(回调)
  • MutationObserver(html5 新特性)

运行机制

异步任务的返回结果会被放到一个任务队列中,根据异步事件的类型,这个事件实际上会被放到对应的宏任务和微任务队列中去。

在当前执行栈为空时,主线程会查看微任务队列是否有事件存在

  • 存在,依次执行队列中的事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的事件,把当前的回调加到当前指向栈。
  • 如果不存在,那么再去宏任务队列中取出一个事件并把对应的回到加入当前执行栈;

当前执行栈执行完毕后时会立刻处理所有微任务队列中的事件,然后再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务之前执行。

在事件循环中,每进行一次循环操作称为 tick,每一次 tick 的任务处理模型是比较复杂的,但关键步骤如下:

  • 执行一个宏任务(栈中没有就从事件队列中获取)
  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  • 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
  • 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
  • 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)

简单总结一下执行的顺序:
执行宏任务,然后执行该宏任务产生的微任务,若微任务在执行过程中产生了新的微任务,则继续执行微任务,微任务执行完毕后,再回到宏任务中进行下一轮循环。

宏任务和微任务

深入理解js事件循环机制(浏览器篇) 这边文章中有个特别形象的动画,大家可以看着理解一下。

console.log('start')

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

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

console.log('end')
浏览器事件循环
  1. 全局代码压入执行栈执行,输出 start
  2. setTimeout压入 macrotask队列,promise.then 回调放入 microtask队列,最后执行 console.log('end'),输出 end
  3. 调用栈中的代码执行完成(全局代码属于宏任务),接下来开始执行微任务队列中的代码,执行promise回调,输出 promise1, promise回调函数默认返回 undefined, promise状态变成 fulfilled ,触发接下来的 then回调,继续压入 microtask队列,此时产生了新的微任务,会接着把当前的微任务队列执行完,此时执行第二个 promise.then回调,输出 promise2
  4. 此时,microtask队列 已清空,接下来会会执行 UI渲染工作(如果有的话),然后开始下一轮 event loop, 执行 setTimeout的回调,输出 setTimeout

最后的执行结果如下

  • start
  • end
  • promise1
  • promise2
  • setTimeout

node环境下的事件循环

和浏览器环境有何不同

表现出的状态与浏览器大致相同。不同的是 node 中有一套自己的模型。node 中事件循环的实现依赖 libuv 引擎。Node的事件循环存在几个阶段。

如果是node10及其之前版本,microtask会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行 microtask队列中的任务。

node版本更新到11之后,Event Loop运行原理发生了变化,一旦执行一个阶段里的一个宏任务(setTimeout,setInterval和setImmediate)就立刻执行微任务队列,跟浏览器趋于一致。下面例子中的代码是按照最新的去进行分析的。

事件循环模型

┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<──connections───     │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘

事件循环各阶段详解

node中事件循环的顺序

外部输入数据 --> 轮询阶段(poll) --> 检查阶段(check) --> 关闭事件回调阶段(close callback) --> 定时器检查阶段(timer) --> I/O 事件回调阶段(I/O callbacks) --> 闲置阶段(idle, prepare) --> 轮询阶段...

这些阶段大致的功能如下:

  • 定时器检测阶段(timers): 这个阶段执行定时器队列中的回调如 setTimeout() 和 setInterval()。
  • I/O事件回调阶段(I/O callbacks): 这个阶段执行几乎所有的回调。但是不包括close事件,定时器和setImmediate()的回调。
  • 闲置阶段(idle, prepare): 这个阶段仅在内部使用,可以不必理会
  • 轮询阶段(poll): 等待新的I/O事件,node在一些特殊情况下会阻塞在这里。
  • 检查阶段(check): setImmediate()的回调会在这个阶段执行。
  • 关闭事件回调阶段(close callbacks): 例如socket.on('close', ...)这种close事件的回调

poll:
这个阶段是轮询时间,用于等待还未返回的 I/O 事件,比如服务器的回应、用户移动鼠标等等。
这个阶段的时间会比较长。如果没有其他异步任务要处理(比如到期的定时器),会一直停留在这个阶段,等待 I/O 请求返回结果。
check:
该阶段执行setImmediate()的回调函数。

close:
该阶段执行关闭请求的回调函数,比如socket.on('close', ...)。

timer阶段:
这个是定时器阶段,处理setTimeout()和setInterval()的回调函数。进入这个阶段后,主线程会检查一下当前时间,是否满足定时器的条件。如果满足就执行回调函数,否则就离开这个阶段。

I/O callback阶段:
除了以下的回调函数,其他都在这个阶段执行:

  • setTimeout()和setInterval()的回调函数
  • setImmediate()的回调函数
  • 用于关闭请求的回调函数,比如socket.on('close', ...)

宏任务和微任务

宏任务:

  • setImmediate
  • setTimeout
  • setInterval
  • script(整体代码)
  • I/O 操作等。

微任务:

  • process.nextTick
  • new Promise().then(回调)

Promise.nextTick, setTimeout, setImmediate的使用场景和区别

Promise.nextTick
process.nextTick 是一个独立于 eventLoop 的任务队列。
在每一个 eventLoop 阶段完成后会去检查 nextTick 队列,如果里面有任务,会让这部分任务优先于微任务执行。
是所有异步任务中最快执行的。

setTimeout:
setTimeout()方法是定义一个回调,并且希望这个回调在我们所指定的时间间隔后第一时间去执行。

setImmediate:
setImmediate()方法从意义上将是立刻执行的意思,但是实际上它却是在一个固定的阶段才会执行回调,即poll阶段之后。

经典题目分析

一. 下面代码输出什么

async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}
async function async2() {
    console.log('async2');
}
console.log('script start');
setTimeout(function() {
    console.log('setTimeout');
}, 0)
async1();
new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});
console.log('script end');

先执行宏任务(当前代码块也算是宏任务),然后执行当前宏任务产生的微任务,然后接着执行宏任务

  1. 从上往下执行代码,先执行同步代码,输出 script start
  2. 遇到setTimeout,现把 setTimeout 的代码放到宏任务队列中
  3. 执行 async1(),输出 async1 start, 然后执行 async2(), 输出 async2,把 async2() 后面的代码 console.log('async1 end')放到微任务队列中
  4. 接着往下执行,输出 promise1,把 .then()放到微任务队列中;注意Promise本身是同步的立即执行函数,.then是异步执行函数
  5. 接着往下执行, 输出 script end。同步代码(同时也是宏任务)执行完成,接下来开始执行刚才放到微任务中的代码
  6. 依次执行微任务中的代码,依次输出 async1 endpromise2, 微任务中的代码执行完成后,开始执行宏任务中的代码,输出 setTimeout

最后的执行结果如下

  • script start
  • async1 start
  • async2
  • promise1
  • script end
  • async1 end
  • promise2
  • setTimeout

二. 下面代码输出什么

console.log('start');
setTimeout(() => {
    console.log('children2');
    Promise.resolve().then(() => {
        console.log('children3');
    })
}, 0);

new Promise(function(resolve, reject) {
    console.log('children4');
    setTimeout(function() {
        console.log('children5');
        resolve('children6')
    }, 0)
}).then((res) => {
    console.log('children7');
    setTimeout(() => {
        console.log(res);
    }, 0)
})

这道题跟上面题目不同之处在于,执行代码会产生很多个宏任务,每个宏任务中又会产生微任务

  1. 从上往下执行代码,先执行同步代码,输出 start
  2. 遇到setTimeout,先把 setTimeout 的代码放到宏任务队列①中
  3. 接着往下执行,输出 children4, 遇到setTimeout,先把 setTimeout 的代码放到宏任务队列②中,此时.then并不会被放到微任务队列中,因为 resolve是放到 setTimeout中执行的
  4. 代码执行完成之后,会查找微任务队列中的事件,发现并没有,于是开始执行宏任务①,即第一个 setTimeout, 输出 children2,此时,会把 Promise.resolve().then放到微任务队列中。
  5. 宏任务①中的代码执行完成后,会查找微任务队列,于是输出 children3;然后开始执行宏任务②,即第二个 setTimeout,输出 children5,此时将.then放到微任务队列中。
  6. 宏任务②中的代码执行完成后,会查找微任务队列,于是输出 children7,遇到 setTimeout,放到宏任务队列中。此时微任务执行完成,开始执行宏任务,输出 children6;

最后的执行结果如下

  • start
  • children4
  • children2
  • children3
  • children5
  • children7
  • children6

三. 下面代码输出什么

const p = function() {
    return new Promise((resolve, reject) => {
        const p1 = new Promise((resolve, reject) => {
            setTimeout(() => {
                resolve(1)
            }, 0)
            resolve(2)
        })
        p1.then((res) => {
            console.log(res);
        })
        console.log(3);
        resolve(4);
    })
}


p().then((res) => {
    console.log(res);
})
console.log('end');
  1. 执行代码,Promise本身是同步的立即执行函数,.then是异步执行函数。遇到setTimeout,先把其放入宏任务队列中,遇到p1.then会先放到微任务队列中,接着往下执行,输出 3
  2. 遇到 p().then 会先放到微任务队列中,接着往下执行,输出 end
  3. 同步代码块执行完成后,开始执行微任务队列中的任务,首先执行 p1.then,输出 2, 接着执行p().then, 输出 4
  4. 微任务执行完成后,开始执行宏任务,setTimeout, resolve(1),但是此时 p1.then已经执行完成,此时 1不会输出。

最后的执行结果如下

  • 3
  • end
  • 2
  • 4

你可以将上述代码中的 resolve(2)注释掉, 此时 1才会输出,输出结果为 3 end 4 1

const p = function() {
    return new Promise((resolve, reject) => {
        const p1 = new Promise((resolve, reject) => {
            setTimeout(() => {
                resolve(1)
            }, 0)
        })
        p1.then((res) => {
            console.log(res);
        })
        console.log(3);
        resolve(4);
    })
}


p().then((res) => {
    console.log(res);
})
console.log('end');
  • 3
  • end
  • 4
  • 1

最后强烈推荐几个非常好的讲解 event loop 的视频:

  • What the heck is the event loop anyway? | Philip Roberts | JSConf EU
  • Jake Archibald: In The Loop - JSConf.Asia

参考

  • 详解JavaScript中的Event Loop(事件循环)机制
  • JavaScript 运行机制详解:再谈Event Loop
  • Node 定时器详解
  • 面试题:说说事件循环机制(满分答案来了)
  • 极客浏览器工作原理与实践
  • 微任务、宏任务与Event-Loop
  • Node.js event loop 和 JS 浏览器环境下的事件循环的区别
  • 图解搞懂JavaScript引擎Event Loop
  • 浏览器与Node的事件循环(Event Loop)有何区别?
  • 深入理解js事件循环机制(Node.js篇)
  • 《一文看懂浏览器事件循环》
  • Loupe

你可能感兴趣的:(高频面试题:JavaScript事件循环机制解析)