avatar

【重识前端】一次搞定JavaScript的执行机制

最近在写【重拾前端】系列,下面有几个快速通道,大家自取

【重识前端】原型/原型链和继承

【重识前端】闭包与模块

【重识前端】全面攻破this

【重识前端】一次搞定JavaScript的执行机制

【重识前端】什么是BFC、IFC、GFC 和 FFC

【重识前端】深入内存世界

前言

事件循环这个事情,其实在我们的工作中或多或少都会碰到,可能我们只是没有去认认真真的理解他,了解他而已。今天我们一起把事件循环吃透。

单线程的JS

其实,事件循环就是对于单线程的JS应运而生的。

  • 单线程?什么是线程,诶,我好像听过进程诶,他们两兄弟啥区别?
  • 为什么js一定要单线程啊,我听说CPU不是有很多核吗?为什么不多线程?

线程和进程的爱恨纠葛

这里我推荐阮一峰老师的一篇文章

点我学习

为什么JS是单线程?

这个要回到Js历史了,布兰登·艾奇(Brendan Eich)老哥用10天创造js。当时js用来干嘛,简单的浏览器交互,验证,操作一下dom是吧。那把它设计成那么复杂干什么,而且如果多线程的话,操作dom会出现麻烦的事情,假设一个线程读取DOM节点数据的同时,另一个线程把那个DOM节点删了,呵呵。所以js一个线程就够了,也就是一步一步顺序运行下来。

浏览器中的消息队列和事件循环

这里暂时只说浏览器中的循环事件循环,有关node的话可能有些细微的差别,不过底层的原理都是差不多的。

注意

这里主要是从一个设计者的角度来模拟,从零构建浏览器中的时间循环。为了能让你更加深刻地理解事件循环机制,我们就从最简单的场景来分析,然后带你一步步了解浏览器页面主线程是如何运作的。

开始

事先约定好的执行顺序

假如我有以下几个任务

1
2
3
4
var num1 = 1+2; // 任务 1
var num2 = 20/5; // 任务 2
var num3 = 7*8; // 任务 3
console.log(num1, num2, num3); // 任务 4

如果让我来设计,我就会有一个主线程,然后把他们按顺序排进去,然后顺序执行。

1
2
3
4
5
6
function mainThread(){
var num1 = 1+2; // 任务 1
var num2 = 20/5; // 任务 2
var num3 = 7*8; // 任务 3
console.log(num1, num2, num3); // 任务 4
}

😯,我们已经设计了最简单的线程啦。

线程运行中,处理突发事件

很多时候,所有的任务不是之前就统一安排好的,比如⌨️的输入,🖱的点击等等。

如果想要在线程运行中可以很好的处理这些事件。就需要

====>事件循环。这里我们用while来实现简单的事件循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function awaitt() {
return new Promise(resolve => {
document.addEventListener('keydown', resolve);
});
}

async function mainThread() {
while(true){
const result = await awaitt();
console.log(result);
}
}

mainThread();

这样改版之后有了以下几点改进:

  • 引入了循环机制
  • 引入事件,然后整个线程在运行的过程中,活了起来,不再是死的,运行完就滚蛋了的那种,有了交互了

(🤫最简单的实现。。。别吐槽代码,都是最简单的实现和最简单的场景,助于理解而已。。。)

处理其他线程的任务

上面的版本用了事件循环的方式来获取内部的事件,但是对于外部突发情况的事件是无法解决的。所以,我们需要升级~

那么怎么设计好一个线程模型呢?我们换个角度想想思路马上就出来了。

这些外部的任务,都是有先后顺序的,哪怕是都是突发情况。他们也是有先后顺序。

所以一个比较通用的模式就是

======> 消息队列

消息队列是一种数据结构,可以存放要执行的任务。它符合队列“先进先出”的特点,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取。

我们要完成以下几个操作:

  1. 添加一个消息队列;
  2. IO 线程中产生的新任务添加进消息队列尾部;
  3. 渲染主线程会循环地从消息队列头部中读取任务,执行任务。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const arrTask = []

function addTask(fn) {
arrTask.push(fn)
}

function otherThread() {
addTask(() => {console.log('other')})
}

function awaitt() {
return new Promise(resolve => {
if(arrTask.length) {
const task = arrTask.shift()
task()
resolve()
}
});
}

async function mainThread() {
while(true){
await awaitt();
}
}


addTask(()=>console.log('in'))
addTask(()=>console.log('in'))
addTask(()=>console.log('in'))

mainThread();
otherThread()

我们用一个数组来模拟一个队列。

既基础了内部的任务,也处理了外部突发的任务

有任务进来, 就会执行任务。

退出主线程

当页面主线程执行完成之后,又该如何保证页面主线程能够安全退出呢?Chrome 是这样解决的,确定要退出当前页面时,页面主线程会设置一个退出标志的变量,在每次执行完一个任务时,判断是否有设置退出标志。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const arrTask = []
let over = false

function addTask(fn) {
arrTask.push(fn)
}

function otherThread() {
addTask(() => {console.log('other')})
}

function awaitt() {
return new Promise(resolve => {
if(arrTask.length) {
const task = arrTask.shift()
task()
if(!arrTask.length){
over = true
}
resolve()
}
});
}

async function mainThread() {
while(true){
if(over){
break;
}
await awaitt();
}
}


addTask(()=>console.log('in'))
addTask(()=>console.log('in'))
addTask(()=>console.log('in'))

mainThread();
otherThread()

单线程的缺点

通过上面的介绍,你应该清楚了,页面线程所有执行的任务都来自于消息队列。消息队列是“先进先出”的属性,也就是说放入队列中的任务,需要等待前面的任务被执行完,才会被执行。鉴于这个属性,就有如下两个问题需要解决。

如何处理高优先级的任务

场景:

假如,我现在有一大串的任务在主线程上执行。

我的dom节点变化的时候,我需要发送请求或者提示一个alert之类的业务逻辑要执行。

现在可行的有两种方案:

  • dom节点变化的话,我就停下现在正在运行的主线程上的任务,然后调用我需要执行的业务逻辑。这样确实具有实效性,但是!这样会使得当前任务的效率极度降低,比如我的dom节点变化200次,那岂不是当前的任务特别久了吗?
  • 第二个方法就可以解决上述的问题:一旦dom节点有变化,哪怕是200000次变化,我都是将业务逻辑push到队列的尾部,这样就不会影响当前任务进行以及效率了。但是!这样的话我的实效性就莫得了。就莫得了。

总结一下:其实就是实效性和任务效率的权衡问题罢了。

江江江~

微任务就应运而生啦。

加入我们把任务队列里面的每个任务都称作宏任务的话,每个宏任务里面都会包含一个微任务队列。每个宏任务结束之后,都回去清理一遍微任务队列里面的队列,这样的话既保护了实效性,也保护了任务的效率。

如何处理调用时间过长的问题

我们都知道js是单线程的。如果现在在执行一个任务的情况下。其他任务就是要等待的。那么就会出现某个任务计算的周期特别长,导致别人都在等待。

👌,我们来验证一下

1
2
while(true){}
alter(123)

下面的alter永远都不会执行了。因为while在工作,alter在等他把资源让出来。

假如说,我现在有一个动画要进行,但是运行完一帧之后,有一个极其复杂的计算,导致了我的动画不流畅,那么用户可能就会非常的烦。这不是我们希望看到的。

所以js有一个回调机制,到了特定的时间点了,回调一下,把动画的下一帧执行一下,然后继续当前的复杂计算。

总结

  • 如果有一些确定好的任务,可以使用一个单线程来按照顺序处理这些任务,这是第一版线程模型。
  • 要在线程执行过程中接收并处理新的任务,就需要引入循环语句和事件系统,这是第二版线程模型。
  • 如果要接收其他线程发送过来的任务,就需要引入消息队列,这是第三版线程模型。
  • 如果其他进程想要发送任务给页面主线程,那么先通过 IPC 把任务发送给渲染进程的 IO 线程,IO 线程再把任务发送给页面主线程。
  • 消息队列机制并不是太灵活,为了适应效率和实时性,引入了微任务

基于消息队列的设计是目前使用最广的消息架构,无论是安卓还是 Chrome 都采用了类似的任务机制,所以理解了本篇文章的内容后,你再理解其他项目的任务机制也会比较轻松。

浏览器是怎么实现setTimeout

要想知道浏览器是怎么实现的,我们先回顾一下,我们之前设计的那个事件循环系统:

有xx任务了,ok,我push到任务队列。以此循环。

所以我们写在setTimeout里面的函数执行其实也是一个任务,也是需要push到任务队列去的。

但是,我们的这个是其实是一个异步的函数,不能直接将整个函数push到队列中,否则的话我们的实效性就很有可能出错了,比如我希望他在24小时之后alter告诉我已经到了第二天了。他如果是直接push到任务队列,没有到第二天就会告诉我第二天到了,这不是让用户匪夷所思嘛。

所以肯定不是直接push的。那又是怎么弄得嘞?

你也可以思考下,如果让你在消息循环系统的基础之上加上定时器的功能,你会如何设计?

在 Chrome 中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。所以当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中

我们把之前的事件循环模拟改一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
const mainTask = [
fn1,
fn2,
fn3,
……
]

const setTimeoutTask = []

function addSetTimeoutTask(fn, timeout){
setTimeoutTask.push({
fn: fn,
id: Math.random(),
useTime: timeout + ((new Date()).getTime()
})
}

const runSetTimeoutTask (){
const runList = []
setTimeoutTask.forEach(item => {
// 已经过了时间,或者刚刚好到时间的话就取出来。待会一起执行了
if(item.useTime <= ((new Date()).getTime()) {
runList.push(item.fn)
}
})
if(runList.length) {
const fn = runList.shift()
fn()
}
}

async function mainThread() {
while(true){
const task = arrTask.shift()

// 每次执行完一个任务,都回去查找一下延迟队列里面有没有到点的函数
if(setTimeoutTask.length) {
runSetTimeoutTask()
}
}
}

mainThread();

所以这里我们会看到一个问题,那就是其实你到点了可能要不会立刻执行,必须等到之前的任务执行完了才轮到延迟队列的查询和执行。

我们代码中有一个彩蛋—-> ID

这个ID有什么用?

就是用于clearTimeout

直接传入ID,然后循环找到他,就可以直接从队列里面删掉就好了。

这里就不需要贴代码了吧?

冷知识

  • 如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒
  • 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒
    • 除了前面的 4 毫秒延迟,还有一个很容易被忽略的地方,那就是未被激活的页面中定时器最小值大于 1000 毫秒,也就是说,如果标签不是当前的激活标签,那么定时器最小的时间间隔是 1000 毫秒,目的是为了优化后台页面的加载损耗以及降低耗电量。这一点你在使用定时器的时候要注意。
  • 延时执行时间有最大值
    • 除了要了解定时器的回调函数时间比实际设定值要延后之外,还有一点需要注意下,那就是 Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒,这就意味着,如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,这导致定时器会被立即执行。
  • 使用 setTimeout 设置的回调函数中的 this 不符合直觉
    • 这里可以看我之前的文章,有对this进行攻破

总结

  • 首先,为了支持定时器的实现,浏览器增加了延时队列。
  • 其次,由于消息队列排队和一些系统级别的限制,通过 setTimeout 设置的回调任务并非总是可以实时地被执行,这样就不能满足一些实时性要求较高的需求了。

宏任务和微任务

其实通过之前的学习,我们对事件循环已经了解的差不多了,这里主要是更详细了解一下他们。

宏任务

宏任务有哪些?

除了微任务的都是宏任务。。。

那么微任务有哪些?

Process.nextTickPromise.then catch finally(注意我不是说 Promise)、MutationObserver

在之前的学习我们知道在宏任务结束之后,会去执行他自己当前的微任务队列。在这个微任务队列执行完成之后再去执行下一个宏任务。

微任务

异步函数有两种方式:

第一种是把异步回调函数封装成一个宏任务,添加到消息队列尾部,当循环系统执行到该任务的时候执行回调函数。这种比较好理解,我们前面介绍的 setTimeout 和 XMLHttpRequest 的回调函数都是通过这种方式来实现的。

第二种方式的执行时机是在主函数执行结束之后、当前宏任务结束之前执行回调函数,这通常都是以微任务形式体现的

  • 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。
  • 微任务的执行时长会影响到当前宏任务的时长。比如一个宏任务在执行过程中,产生了 100 个微任务,执行每个微任务的时间是 10 毫秒,那么执行这 100 个微任务的时间就是 1000 毫秒,也可以说这 100 个微任务让宏任务的执行时间延长了 1000 毫秒。所以-你在写代码的时候一定要注意控制微任务的执行时长。
  • 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。

期末考试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
console.log('script start')

async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()

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

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

console.log('script end')

大家可以自己在浏览器里面试试吧。

文章作者: Derrick
文章链接: http://derricktel.github.io/2020/08/10/%E3%80%90%E9%87%8D%E8%AF%86%E5%89%8D%E7%AB%AF%E3%80%91%E4%B8%80%E6%AC%A1%E6%90%9E%E5%AE%9A%E4%BA%8B%E4%BB%B6%E5%BE%AA%E7%8E%AF/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Derrick
打赏
  • 微信
    微信
  • 支付寶
    支付寶