当前位置: 首页 > article >正文

深入理解JavaScript的执行机制

一、背景

日常开发中,不管你是js新手还是经验丰富者,深入理解js代码执行顺序对于你写出高质量代码和排查问题都有很大的用处。

二、关于JavaScript

我们都知道javascript是一门单线程语言,所以我们可以得出结论:
  • javascript是按照语句出现的顺序执行的

看到这里读者要打人了:我难道不知道js是一行一行执行的?还用你说?稍安勿躁,正因为js是一行一行执行的,所以我们以为js都是这样的:

const a = 1;
console.log(a);

const b = 2;
console.log(b);

然而实际上的js可能是这样的:

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

new Promise(function(resolve) {
    console.log('promise');
}).then(function() {
    console.log('then');
})

console.log('console');

依照js是按照语句出现的顺序执行这个理念,我自信的写下输出结果:

//"setTimeout"
//"promise"
//"then"
//"console"

然而,去Chrome上验证下,结果完全不对,说好的一行一行执行呢?想知道为什么就需要我们深入的去理解js事件的循环机制。

三、JavaScript事件循环

1、宏任务/微任务

既然js是单线程,那就像只有一个窗口的银行,客户需要排队一个一个办理业务,同理js任务也要一个一个顺序执行。如果一个任务耗时过长,那么后一个任务也必须等着。那么问题来了,假如我们想浏览新闻,但是新闻包含的超清图片加载很慢,难道我们的网页要一直卡着直到图片完全显示出来?因此聪明的程序员将任务分为两类:
  • 同步任务
  • 异步任务

当我们打开网站时,网页的渲染过程就是一大堆同步任务,比如页面骨架和页面元素的渲染。而像加载图片音乐之类占用资源大耗时久的任务,就是异步任务。关于这部分有严格的文字定义,但本文的目的是用最小的学习成本彻底弄懂执行机制,所以我们用导图来说明:

![](https://i-blog.csdnimg.cn/img_convert/96c1efd6b374b94c2301ef2bc78c7bcb.png)

导图要表达的内容用文字来表述的话:
  • 同步和异步任务分别进入不同的执行"场所",同步的进入主线程,异步的进入Event Table并注册函数。
  • 当指定的事情完成时,Event Table会将这个函数移入Event Queue。
  • 主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行。
  • 上述过程会不断重复,也就是常说的Event Loop(事件循环)。

2、Event Loop(事件轮询)

除了广义的同步任务和异步任务,我们对任务有更精细的定义:宏任务(macro-task)和 微任务(micro-task)

常见的宏任务和微任务分类如下:

宏任务包括: scriptsetTimeoutsetIntervalsetImmediateI/OUI rendering

微任务包括: process.nextTickPromiseprocess.nextTick 为 Node 独有)

Tips:

  • 微任务优先级高于宏任务的前提是:同步代码已经执行完成。因为 script 属于宏任务,程序开始后会首先执行同步脚本,也就是script
  • Promise 里边的代码属于同步代码,.then() 中执行的代码才属于异步代码。
  • async和await本身并不是一个宏任务也不是一个微任务,只是一个语法糖,帮助我们来梳理代码的执行顺序,async函数里await函数后面的代码要等await函数执行完成之后才会执行,async,await还可以用来控制函数的执行顺序

Event Loop 是一个程序结构,用于等待和发送消息和事件。其执行顺序如下所示:

  • 首先执行同步代码(宏任务)
  • 当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行
  • 执行所有微任务
  • 当执行完所有微任务后,如有必要会渲染页面
  • 然后开始下一轮 Event Loop,执行宏任务中的异步代码,也就是 setTimeout 中的回调函数

Tips:简化讲:先执行一个宏任务(script同步代码),然后执行并清空微任务,再执行一个宏任务,然后执行并清空微任务,再执行一个宏任务,再然后执行并清空微任务…如此循环往复(一个宏任务 -> 清空微任务 -> 一个宏任务 -> 清空微任务),流程图如下:

我们用文章最开始的那段代码来分析一下:

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

new Promise(function(resolve) {
    console.log('promise');
}).then(function() {
    console.log('then');
})

console.log('console');
  • 这段代码作为宏任务,进入主线程。
  • 先遇到setTimeout,那么将其回调函数注册后分发到宏任务Event Queue。(注册过程与上同,下文不再描述)
  • 接下来遇到了Promisenew Promise立即执行,then函数分发到微任务Event Queue。
  • 遇到console.log(),立即执行。
  • 好啦,整体代码script作为第一个宏任务执行结束,看看有哪些微任务?我们发现了then在微任务Event Queue里面,执行。
  • ok,第一轮事件循环结束了,我们开始第二轮循环,当然要从宏任务Event Queue开始。我们发现了宏任务Event Queue中setTimeout对应的回调函数,立即执行。
  • 结束。

四、案例分析

除了开篇的那段程序外,我们来分析几个经典复杂代码来帮助你真正理解js事件执行顺序:

1、案例1

1.1 案例代码

```plain setTimeout(function () { console.log(" set1"); new Promise(function (resolve) { resolve(); }).then(function () { new Promise(function (resolve) { resolve(); }).then(function () { console.log("then4"); }); console.log("then2 "); }); });

new Promise(function (resolve) {
console.log(“pr1”);
resolve();
}).then(function () {
console.log(“then1”);
});

setTimeout(function () {
console.log(“set2”);
});

console.log(2);

new Promise(function (resolve) {
resolve();
}).then(function () {
console.log(“then3”);
});


<h3 id="7MnBm">1.2 案例解析</h3>
执行所有同步代码(第一次宏任务):

```plain
setTimeout(function () { // setTimeout 内 function 放入宏任务
  console.log(" set1");
  new Promise(function (resolve) {
    resolve();
  }).then(function () {
    new Promise(function (resolve) {
      resolve();
    }).then(function () {
      console.log("then4");
    });
    console.log("then2 ");
  });
});
new Promise(function (resolve) {
  console.log("pr1"); // Promise里边的代码直接执行  打印 pr1
  resolve();
}).then(function () {
  console.log("then1"); // Promise.then 放入微任务
});
setTimeout(function () {
  console.log("set2"); // setTimeout内function 放入宏任务
});
console.log(2); // 打印 2
new Promise(function (resolve) {
  resolve();
}).then(function () {
  console.log("then3"); //Promise.then 放入微任务
});
// 此时控制台打印 : pr1  >  2
// 异步任务队列:[微任务数:2][宏任务数:2]
// 执行并清空微任务

执行并清空微任务

function () {
  console.log("then1");  // 输出 then1
}
function () {
  console.log("then3"); // 输出 then3
}
// 此时控制台打印 : then1  >  then3
// 异步任务:[微任务数:0][宏任务数:2]
// 执行一个宏任务

执行一个宏任务

function () {
  console.log(" set1");   //打印 set1
  new Promise(function (resolve) {
    resolve();
  }).then(function () {     // Promise.then 放入微任务
    new Promise(function (resolve) {
      resolve();
    }).then(function () {
      console.log("then4");
    });
    console.log("then2 ");
  });
}
// 此时控制台打印 : set1
// 异步任务:[微任务数:1][宏任务数:1]
// 执行并清空微任务

执行并清空微任务

function () {     
    new Promise(function (resolve) {
      resolve();      
    }).then(function () {
      console.log("then4");   // Promise.then 放入微任务
    });
    console.log("then2 ");    // 打印 then2
}
// 此时控制台打印 : then2
// 异步任务:[微任务数:1][宏任务数:1]
// 此时微任务列表增加并未清空,继续执行微任务

此时微任务列表增加并未清空,继续执行微任务

function () {
      console.log("then4");   // 打印 then4
}
// 此时控制台打印 : then4
// 异步任务:[微任务数:0][宏任务数:1]
// 执行宏任务

执行宏任务

function () {
  console.log("set2"); // 打印 set2
}
// 此时控制台打印 : set2
// 异步任务:[微任务数:0][宏任务数:0]
// 程序结束

完整输入顺序

pr1
2
then1
then3
set1
then2 
then4
set2

2、案例2

2.1 案例代码

```plain console.log('1');

setTimeout(function() {
console.log(‘2’);
process.nextTick(function() {
console.log(‘3’);
})
new Promise(function(resolve) {
console.log(‘4’);
resolve();
}).then(function() {
console.log(‘5’)
})
})
process.nextTick(function() {
console.log(‘6’);
})
new Promise(function(resolve) {
console.log(‘7’);
resolve();
}).then(function() {
console.log(‘8’)
})

setTimeout(function() {
console.log(‘9’);
process.nextTick(function() {
console.log(‘10’);
})
new Promise(function(resolve) {
console.log(‘11’);
resolve();
}).then(function() {
console.log(‘12’)
})
})


<h3 id="vaV1A">2.2 案例解析</h3>
第一轮事件循环流程分析如下:

+ 整体script作为第一个宏任务进入主线程,遇到`console.log`,输出1。
+ 遇到`setTimeout`,其回调函数被分发到宏任务Event Queue中。我们暂且记为`setTimeout1`。
+ 遇到`process.nextTick()`,其回调函数被分发到微任务Event Queue中。我们记为`process1`。
+ 遇到`Promise`,`new Promise`直接执行,输出7。`then`被分发到微任务Event Queue中。我们记为`then1`。
+ 又遇到了`setTimeout`,其回调函数被分发到宏任务Event Queue中,我们记为`setTimeout2`。

| 宏任务Event Queue | 微任务Event Queue |
| :---: | :---: |
| setTimeout1 | process1 |
| setTimeout2 | then1 |


+ 上表是第一轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了1和7。
+ 我们发现了`process1`和`then1`两个微任务。
+ 执行`process1`,输出6。
+ 执行`then1`,输出8。

好了,第一轮事件循环正式结束,这一轮的结果是输出1,7,6,8。那么第二轮时间循环从`setTimeout1`宏任务开始:

+ 首先输出2。接下来遇到了`process.nextTick()`,同样将其分发到微任务Event Queue中,记为`process2`。`new Promise`立即执行输出4,`then`也分发到微任务Event Queue中,记为`then2`。

| 宏任务Event Queue | 微任务Event Queue |
| :---: | :---: |
| setTimeout2 | process2 |
| | then2 |


+ 第二轮事件循环宏任务结束,我们发现有`process2`和`then2`两个微任务可以执行。
+ 输出3。
+ 输出5。
+ 第二轮事件循环结束,第二轮输出2,4,3,5。
+ 第三轮事件循环开始,此时只剩setTimeout2了,执行。
+ 直接输出9。
+ 将`process.nextTick()`分发到微任务Event Queue中。记为`process3`。
+ 直接执行`new Promise`,输出11。
+ 将`then`分发到微任务Event Queue中,记为`then3`。

| 宏任务Event Queue | 微任务Event Queue |
| :---: | :---: |
| | process3 |
| | then3 |


+ 第三轮事件循环宏任务执行结束,执行两个微任务`process3`和`then3`。
+ 输出10。
+ 输出12。
+ 第三轮事件循环结束,第三轮输出9,11,10,12。

整段代码,共进行了三次事件循环,完整的输出为1,7,6,8,2,4,3,5,9,11,10,12。

(请注意,node环境下的事件监听依赖libuv与前端环境不完全相同,输出顺序可能会有误差)

<h2 id="HlPnq">3、案例3</h2>
<h3 id="anlYM">3.1 案例代码</h3>
```plain
 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' )

3.2 案例解析

每次宏任务和微任务发生变化,我都会画一个图来表示他们的变化。

直接打印同步代码 console.log(‘script start’)

首先是2个函数声明,虽然有async关键字,但不是调用我们就不看。然后首先是打印同步代码 console.log('script start')

将setTimeout放入宏任务队列

默认<script></script>所包裹的代码,其实可以理解为是第一个宏任务,所以这里是宏任务2

调用async1,打印 同步代码 console.log( ‘async1 start’ )

我们说过看到带有async关键字的函数,不用害怕,它的仅仅是把return值包装成了promise,其他并没有什么不同的地方。所以就很普通的打印 console.log( 'async1 start' )

分析一下 await async2()

1. 前文提过await,1.它先计算出右侧的结果,2.然后看到await后,中断async函数
2. 
3. - 先得到await右侧表达式的结果。执行async2(),打印同步代码console.log('async2'), 并且return Promise.resolve(undefined)
4. - await后,中断async函数,先执行async外的同步代码
5. 
6. 目前就直接打印 console.log('async2')

被阻塞后,要执行async之外的代码

执行new Promise(),Promise构造函数是直接调用的同步代码,所以 console.log( ‘promise1’ )

代码运行到promise.then()

代码运行到promise.then(),发现这个是微任务,所以暂时不打印,只是推入当前宏任务的微任务队列中。

注意:这里只是把promise2推入微任务队列,并没有执行。微任务会在当前宏任务的同步代码执行完毕,才会依次执行

打印同步代码 console.log( ‘script end’ )

1. 没什么好说的。执行完这个同步代码后,「async外的代码」终于走了一遍
2. 
3. 下面该回到 await 表达式那里,执行await Promise.resolve(undefined)了

回到async内部,执行await Promise.resolve(undefined)

这部分可能不太好理解,我尽量表达我的想法。

对于 await Promise.resolve(undefined) 如何理解呢?

根据 MDN 原话我们知道

如果一个 Promise 被传递给一个 await 操作符,await 将等待 Promise 正常处理完成并返回其处理结果。

在我们这个例子中,就是Promise.resolve(undefined)正常处理完成,并返回其处理结果。那么await async2()就算是执行结束了。

目前这个promise的状态是fulfilled,等其处理结果返回就可以执行await下面的代码了。

那何时能拿到处理结果呢?

回忆平时我们用promise,调用resolve后,何时能拿到处理结果?是不是需要在then的第一个参数里,才能拿到结果。

(调用resolve时,会把then的参数推入微任务队列,等主线程空闲时,再调用它)

所以这里的 await Promise.resolve() 就类似于

1. Promise.resolve(undefined).then((undefined) => {
2. 
3. })

把then的第一个回调参数 (undefined) => {} 推入微任务队列。

then执行完,才是await async2()执行结束。

await async2()执行结束,才能继续执行后面的代码

如图

此时当前宏任务1都执行完了,要处理微任务队列里的代码。

微任务队列,先进先出的原则,

  • 执行微任务1,打印promise2
  • 执行微任务2,没什么内容…

但是微任务2执行后,await async2()语句结束,后面的代码不再被阻塞,所以打印

console.log( ‘async1 end’ )

宏任务1执行完成后,执行宏任务2

宏任务2的执行比较简单,就是打印

console.log(‘setTimeout’)


http://www.kler.cn/a/572534.html

相关文章:

  • 机器学习4-PCA降维
  • 14、TCP连接如何确保可靠性【高频】
  • shell指令(三)及makefile
  • Docker 的应用场景
  • Spring Expression Language (SpEL)(详解)
  • 【每日学点HarmonyOS Next知识】tabs切换卡顿、输入框焦点、打开全新web、输入框密码类型、非法变量值
  • 当电脑JDK的位置被移动,如何修改IDEA中JDK被修改后的位置
  • 深入MiniQMT:实现远程下单的高效解决方案
  • 如何设计高并发分布式系统的唯一ID?主流方案深度解析与实战选型指南
  • RabbitMQ 2025/3/5
  • 优优绿能闯上市:业绩变脸,万帮新能源多次减持,实控人忙套现
  • 3dsmax中使用python创建PBR材质并挂接贴图
  • 6、什么是重排重绘?
  • Nginx 部署 Vue.js 项目指南:结合慈云数据服务器的实践
  • Vue Table 表格列筛选,前端筛选与后端筛选的写法
  • 4 Redis4 List命令类型讲解
  • C# IEquatable<T> 使用详解
  • Serilog: 强大的 .NET 日志库
  • c++中什么时候应该使用extern关键字?
  • 大模型管理工具:LLaMA-Factory