Javascript 事件循环

听说事件循环是面试必问的哦。

可视化

Loupe

JS 单线程,非阻塞

JavaScript 的主要用途是与用户互动,以及操作 DOM。
JS 所执行代码的线程称作 ** 主线程 **
多线程会使操作复杂起来,比如有两个线程同时操作 DOM,一个线程删除了当前的 DOM 节点,一个线程是要操作当前的 DOM 阶段,最后以哪个线程的操作为准?
** 所以 JS 是单线程的。**

非阻塞就是通过事件循环实现的。

任务(事件)队列

任务(事件)队列可以视作异步任务的缓冲区。

异步任务一般包含 IO 设备,键盘,网络 IO 等非 CPU 操作,异步任务直接被放到 “任务队列” 中,只有 “任务队列” 通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
异步的具体机制如下:

  1. 所有同步任务在主线程上执行,构成一个“执行栈(execution context stack)”
  2. 主线程外,存在一个“任务队列(task queue)”。存放异步事件。
  3. 一旦 “执行栈” 中的所有同步任务执行完毕,系统就会读取 “任务队列”,看看里面有哪些事件。某些异步任务就会进入执行栈来执行。

只要主线程空了,就会去读取 “任务队列”,这就是 JavaScript 的运行机制。这个过程会不断重复。

异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。

“任务队列” 是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,”任务队列” 上第一位的事件就自动进入主线程。

浏览器的事件循环

执行栈和事件队列

上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部 API,它们在 “任务队列” 中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取 “任务队列”,依次执行那些事件所对应的回调函数。

执行栈

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

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

可视化工具结果:

  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();

上图中,Web Apis 框框内,绿色圈圈转了 2s,就是 setTimeout 参数的 2000ms,由于之后还有同步代码,所以 setTimeout 的回调函数并没有在 2000ms 后立即执行。

再加上点击事件看一下:

$.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.");

总结一下:

宏任务和微任务

不同的异步任务可被再次分为:宏任务和微任务,只能属于其中一种。

宏任务:

  • script(整体代码)
  • setTimeout()
  • setInterval()
  • postMessage
  • I/O
  • UI 交互,渲染
  • XHR 回调

微任务:

  • new Promise().then(),catch(),基于的 promise 如 fetch
  • MutationObserver(html5 新特性)
  • Object.observe(已废弃;被 ES6 的 Proxy 替代)

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

两种异步任务的运行机制

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

存在,依次执行微任务队列中的事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的事件。
如果不存在,去宏任务队列中取出一个事件并把对应的回调加入当前执行栈去执行;
当前执行栈执行完毕后时会 ** 立刻 ** 处理所有微任务队列中的事件,然后才会去宏任务队列中取出一个事件。
** 一次事件循环中,微任务永远在宏任务之前执行。**

事件处理过程

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

  1. 检查 宏 任务队列是否为空,非空则到 2,为空则到 3
  2. 执行 宏 任务中的一个任务
  3. 检查 微 任务队列是否为空,若有则到 4,否则到 5
  4. 取出微 任务中的任务执行,执行完成返回到步骤 3
  5. 执行视图更新
  6. 检查是否有 Web Worker 任务,有则执行
  7. 执行下一个宏任务

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

示例演示

首先,全局代码(main())压入调用栈执行,打印 start;

接下来 setTimeout 压入宏任务队列,promise.then 回调放入微任务队列,最后执行 console.log,打印出 end;

至此,调用栈中的代码被执行完成,回顾宏任务的定义,我们知道全局代码属于宏任务,宏任务执行完,那接下来就是执行微任务队列的任务了,执行 promise 回调打印 promise1;

promise 回调函数默认返回 undefined,promise 状态变为 fullfill,触发接下来的 then 回调,继续压入微任务队列,事件循环会把当前的微 任务队列一直执行完,即执行第二个 promise.then 回调并打印出 promise2;

然后,微任务队列已经为空,从上面的流程图可以知道,接下来主线程会去做一些 UI 渲染工作(不一定会做,看浏览器的),然后开始下一轮事件循环 event loop,即检查执行宏任务列表,有 setTimeout 的回调则执行,打印出 setTimeout;

这个过程会不断重复,也就是所谓的事件循环。

事件循环与渲染的更新

回顾上面的事件循环示意图,update rendering(视图渲染)发生在本轮事件循环的 microtask 队列被执行完之后,也就是说执行任务的耗时会影响视图渲染的时机。通常浏览器以 60 帧 / S(60fps)的速率刷新页面,据说这个帧率最适合人眼交互,大概 1000ms / 60 = 16.7ms 渲染一帧,所以如果要让用户觉得顺畅,** 单个宏任务及它相关的所有微 任务最好能在内完成。**

但也不是每轮事件循环都会执行视图更新,浏览器有自己的优化策略,例如把几次的视图更新累积到一起重绘,重绘之前会通知 requestAnimationFrame 执行回调函数,也就是说 requestAnimationFrame 回调的执行时机是在一次或多次事件循环的 UI render 阶段。

验证代码:

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

requestAnimationFrame(function(){
    console.log('requestAnimationFrame')
})

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

new Promise(function executor(resolve) {
    console.log('promise 1')
    resolve()
    console.log('promise 2')
}).then(function() {
    console.log('promise then')
})

console.log('end')

可以看到,结果 1 中 requestAnimationFrame()是在一次事件循环后执行,而在结果 2,它的执行则是在三次事件循环结束后。

总结

  1. 事件循环是 js 实现异步的核心
  2. 每轮事件循环分为 3 个步骤:
    a) 执行 macrotask 队列的一个任务
    b) 执行完当前 microtask 队列的所有任务
    c) UI render
  3. 浏览器只保证 requestAnimationFrame 的回调在重绘之前执行,没有确定的时间,何时重绘由浏览器决定

测验

var date = new Date()

console.log(1, new Date() - date)

setTimeout(() => {
    console.log(2, new Date() - date)
}, 500)

// Promise.resolve().then(() => console.log(3, new Date() - date))
Promise.resolve().then(console.log(3, new Date() - date))

while (new Date() - date < 1000) { }

console.log(4, new Date() - date)

输出?

执行结果: 1 3 4 2。
代码从上往下执行,
先打印 1,看见 setTimeout 丢到宏任务里面,等待执行,
因为 promise.then()的参数是一个 console.log(注意:并不是一个函数),且 then 是立即执行的。
函数立即执行,会先走参数的逻辑,然后在去调用函数。

console.log(1);
let a = Promise.resolve()
a.then(console.log('2'))
console.log(3);
输出:
1
2
3

所以先打印 3,并且给 then 传了一个 undefined(console.log 的返回值是 undefined),再把 then 丢到微任务里面
while 循环是同步任务,等待 1s 后打印 4,
此时同步任务走完了,开始执行异步任务,先将 then 取出来执行,发现 then 的第一个参数是一个 undefined,promise 内部会判断,如果 then 的第一个参数,也就是成功回调函数,不是一个参数的话,会自动给他包装成一个函数,并且将 resolve 的 value 值透传到下一个 then 里面。
然后去执行 setTimeout,最后打印 2。

本文参考链接:

https://www.ruanyifeng.com/blog/2014/10/event-loop.html

https://segmentfault.com/a/1190000022805523

http://lynnelv.github.io/js-event-loop-browser

https://github.com/Advanced-Frontend/Daily-Interview-Question/issues/471


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论。
我的空间