Background 背景
JavaScript 是一种单线程编程语言,这意味着一次只能发生一件事情,即 JavaScript 引擎一次只能在一个线程中处理一个语句。虽然单线程语言不必担心并发问题而简化了代码编写,但是这也意味着必须在不阻塞主线程的情况下执行诸如网络访问之类的长时间操作。想象一下从 API 请求一些数据,这时候服务器可能需要一段时间来处理请求,而处理请求的同时会进行阻塞主线程,使网页无响应,这时就需要异步 JavaScript 来发挥作用了。使用异步 JavaScript(Callback,Promise 与 async / await),这样我们就可以执行长网络请求,而不会进行阻塞主线程,当然如果网络请求时间过长,我们就需要做一些优化渲染的工作(Loading|呼吸态等)来改进用户体验。
Synchronous JavaScript 工作原理
在深入研究异步 JavaScript 之前,先了解一下同步 JavaScript 代码是如何在 JavaScript 引擎内执行的。要了解同步代码如何在 JavaScript 引擎内执行,我们必须了解执行上下文和调用堆栈(也称为执行堆栈)的概念。
Execution Context 执行上下文
执行上下文是评估和执行 JavaScript 代码的环境的抽象概念。每当任何代码在 JavaScript 中运行时,它都会在执行上下文中运行。功能代码在功能执行上下文内执行,而全局代码在全局执行上下文内执行。每个函数都有其自己的执行上下文。
Call Stack 调用堆栈
顾名思义,调用堆栈是具有 LIFO(后进先出)结构的堆栈,该结构用于存储在代码执行期间创建的所有执行上下文。JavaScript 具有单一调用堆栈,因为它是一种单线程编程语言。调用堆栈具有 LIFO 结构,这意味着只能从堆栈顶部添加或删除项目。
示例代码
const second = () => {
console.log("Hello there!");
};
const first = () => {
console.log("Hi there!");
second();
console.log("The End");
};
first();
代码执行过程
- 代码执行时会先创建一个全局执行上下文(以 main()表示)并将其推送到调用堆栈的顶部。
- 当 first()被调用时,它将被推到堆栈的顶部。
- first()内部的第一句 console.log('Hi there!')将被推到堆栈的顶部并在完成后从堆栈中弹出。
- 之后 second()开始被调用,因此 second()被推入堆栈的顶部。
- second()内部的第一句 console.log('Hello there!')被推到堆栈顶部并在完成后从堆栈中弹出,second()函数执行完成,因此将从堆栈中弹出。
- first()内部的最后一句 console.log('The End')被推到堆栈的顶部,并在完成后从堆栈中弹出,first()函数执行完成,因此将其从堆栈中删除。
- 程序执行完成,因此全局执行上下文(main())从堆栈中弹出。
Asynchronous JavaScript 工作原理
了解同步 JavaScript 的工作原理之后,让我们回到异步 JavaScript。要了解异步 Javascript 我们需要先了解什么是 Blocking 锁。
Blocking 锁
假设我们以同步方式进行图像处理或网络请求
const processImage = (image) => {
/**
* doing some operations on image
**/
console.log("Image processed");
};
const networkRequest = (url) => {
/**
* requesting network resource
**/
return someData;
};
const greeting = () => {
console.log("Hello World");
};
processImage(logo.jpg);
networkRequest("www.somerandomurl.com");
greeting();
代码执行过程
- 进行图像处理和网络请求是需要时间的。因此 processImage()从被调用到执行结束将需要一段时间,而时间将具体取决于图像的大小。
- processImage()函数完成后将从堆栈中弹出,networkRequest()函数将被调用并推入堆栈,与 processImage()的执行一样也需要一段时间来完成执行。
- networkRequest()函数完成完成后将从堆栈中弹出,greeting()函数将被调用并推入堆栈,并且由于它仅包含一条 console.log 语句且 console.log 语句通常比较快,因此 greeting()函数将会被立即执行并返回。
在执行上述代码时我们需要等待 processImage()或 networkRequest()执行完成了才能进入下一步,而这就意味着这些函数阻止了调用堆栈或主线程。因此在这些函数执行时我们不能执行任何其他操作,这是相当不理想的。
Solution 解决方案
最简单的解决方案是异步回调。我们使用异步回调使我们的代码成为非阻塞的。
- setTimeout 并不是 JavaScript 引擎的一部分,而是 Web API(在 Browser 浏览器中)和 C/C++ API(在 Node.js 中)的一部分。
- Web API 和 Message Queue 消息队列 / 任务队列 Task Queue 并不是 JavaScript 引擎的一部分,它是浏览器的 JavaScript 运行环境或 JavaScript 的 Node.js 运行时环境的一部分,在 Node.js 中,Web API 被 C/C++ API 取代。
- Event Loop 事件循环的工作是调查调用堆栈(Call Stack)并确定调用堆栈是否为空。调用堆栈为空时它将检查消息队列(Message Queue),以查看是否有任何待执行的回调。
const networkRequest = () => {
setTimeout(() => {
console.log("Async Code");
}, 2000);
};
console.log("Hello World");
networkRequest();
console.log("The End");
代码执行过程(Browser 浏览器中)
- 首先 console.log('Hello World')将被推到堆栈的顶部,并在完成后从堆栈中弹出。
- 然后 networkRequest()函数被调用并被推到堆栈的顶部。
- 之后 setTimeout()函数被调用并被推到堆栈的顶部。
- setTimeout()将一个 2 秒的计时器推入 Web API 中,至此 setTimeout()在调用堆栈中执行完成并从堆栈中弹出。
- 再之后 console.log('The End')将被推到堆栈的顶部并在执行完成后从堆栈中弹出。计时器过期将回调推到消息队列中,但回调并不会立即执行。
- 最后事件循环检测到消息队列中包含一个回调,并且此时调用堆栈为空,因此事件循环会将回调推到堆栈的顶部,将 console.log('Async Code')其推到堆栈的顶部并在执行完成后从堆栈中弹出,至此回调执行完成,它将从堆栈中弹出,程序执行完成。
想要深入了解 Event Loop 事件循环,我们还需要了解 DOM 事件。
DOM 事件
消息队列(Message Queue)还包含来自 DOM 事件的回调,如点击事件和键盘事件的回调。
document.querySelector(".btn").addEventListener("click", (event) => {
console.log("Button Clicked");
});
对于 DOM 事件,事件侦听器位于 Web API 环境中,等待某个事件(此例为 click 事件)发生,并且当该事件发生时,回调将被推到消息队列中等待执行。当事件循环检查调用堆栈为空时,事件循环会将回调推到堆栈中并在执行完成后从堆栈中弹出。
ES6 Job Queue/Micro-Task Queue 作业队列/微任务队列
ES6 引入了作业队列/微任务队列的概念,Promise 在 Javascript 中使用了该概念。消息队列和作业队列之间的区别在于,作业队列的优先级高于消息队列,这意味着作业队列/微任务队列中的 Promise 将在消息队列中的回调之前执行。
console.log("Script start");
setTimeout(() => {
console.log("setTimeout");
}, 0);
new Promise((resolve, reject) => {
resolve("Promise resolved");
})
.then((res) => console.log(res))
.catch((err) => console.log(err));
console.log("Script End");
输出
Script start
Script End
Promise resolved
setTimeout
我们可以看到 Promise 是在 setTimeout 之前执行的,因为 Promise 存储在比消息队列具有更高优先级的微任务队列中。
console.log("Script start");
setTimeout(() => {
console.log("setTimeout 1");
}, 0);
setTimeout(() => {
console.log("setTimeout 2");
}, 0);
new Promise((resolve, reject) => {
resolve("Promise 1 resolved");
})
.then((res) => console.log(res))
.catch((err) => console.log(err));
new Promise((resolve, reject) => {
resolve("Promise 2 resolved");
})
.then((res) => console.log(res))
.catch((err) => console.log(err));
console.log("Script End");
输出
Script start
Script End
Promise 1 resolved
Promise 2 resolved
setTimeout 1
setTimeout 2
我们可以再次看到 2 个 Promise 是在 2 个 setTimeout 的回调之前执行的,因为事件循环将微任务队列中的任务优先于消息队列/任务队列中的任务。当事件循环正在执行微任务队列中的任务时,如果一个 Promise 的状态变为了完成,另一个 Promise 将被添加到同一微任务队列的末尾,并且都将在消息队列的回调之前执行,不管微任务队列中的任务需要执行多少时间。
console.log("Script start");
setTimeout(() => {
console.log("setTimeout");
}, 0);
new Promise((resolve, reject) => {
resolve("Promise 1 resolved");
}).then((res) => console.log(res));
new Promise((resolve, reject) => {
resolve("Promise 2 resolved");
})
.then((res) => {
console.log(res);
return new Promise((resolve, reject) => {
resolve("Promise 3 resolved");
});
})
.then((res) => console.log(res));
console.log("Script End");
输出
Script start
Script End
Promise 1 resolved
Promise 2 resolved
Promise 3 resolved
setTimeout
因此微任务队列中的所有任务将在消息队列中的任务之前执行。也就是说事件循环将首先清空微任务队列,然后再执行消息队列中的任何回调。
Conclusion 结论
异步 JavaScript 的工作原理及其他概念(调用堆栈,事件循环,消息队列/任务队列与作业队列/微任务队列)共同构成了 JavaScript 运行时环境,而了解Javascript的运行时环境将能帮助我们更好地组织Javascript代码,从而使得开发者在代码可读性与性能追求上再接再厉。虽然不必学习所有这些概念就可以成为一名出色的 JavaScript 开发人员,但是了解这些原理与概念一定会对我们日常开发遇到的问题产生帮助的。