
前言
本来是完全不想写这篇文章的,奈何 JS 单线程、异步、事件循环经常被拿来面试,而这些其实是个连环、整体。
好了言归正传,大家都知道 JS 是一门单线程的语言,类似的语言还有 Python、Ruby 都属于单线程,不过它们各自都有多线程的库来实现多线程编程。JS 的单线程意味着在同一个 JS 引擎上一次只能做一件事,就像这样:
ts
console.time();
console.log(1);
console.log(2);
console.log(3);
console.timeEnd();

而单线程最大的问题是当一段代码执行特别耗时,用户就要等待很久才能看到结果,也就是阻塞。模拟如下:
ts
function executeTask() {
for (let i = 0; i < 1000000; i++) {
if (i === 1000000 - 1) console.log('task over');
}
}
console.log('Start!');
console.time('very slow');
executeTask();
console.timeEnd('very slow');
console.log('Done!');

当 JS 引擎解析并运行代码时发生了什么?要想知道发生了什么,首先要知道它处在什么样的环境中。
浏览器架构
过去的浏览器采用单进程架构,在打开一个浏览器应用时,系统就分配了一块供数据使用的内存以及多个线程,线程之间允许通信。比如网络线程、插件线程、渲染线程、GPU 线程,所有标签页都共用一个进程中的所有线程。这样的架构设计意味着一旦其中一个线程崩溃,那么整个进程就会跟着崩溃。这就是为什么当你玩 4399 卡顿时,整个浏览器就疯狂转圈圈的原因,于是不得不重新启动浏览器。
而现代的浏览器大多采用多进程架构,因此在打开一个浏览器时,浏览器进程、网络进程、GPU 进程和插件进程都准备好了,在打开新的标签页时,浏览器会为该标签页创建一个新的渲染进程,它将负责处理该标签页的内容渲染和交互。这些彼此独立的进程让浏览器更加稳定。为了进一步保证数据安全,每一个页面的渲染进程和插件进程会被放入沙箱内。
渲染进程中的组件和功能则与我们息息相关。
组件/功能 | 概述 | 备注 |
---|---|---|
渲染引擎 | 负责解析 HTML、CSS 和 JavaScript,并将其转换为可视化的网页内容 | 排版引擎:WebKit、Blink |
布局引擎 | 负责计算和确定网页元素的位置和大小,以便正确显示在浏览器窗口中 | CSS 盒模型以及其他布局规则来处理网页布局 |
JavaScript 引擎 | 解析和执行网页中的 JavaScript 代码 | Chrome 的 V8 引擎。将 JavaScript 代码转换为可执行的指令,并与其他组件进行交互,实现网页的动态功能和交互性 |
GPU 加速 | 利用计算机的图形处理单元(GPU)来加速图形渲染 | 并行的 GPU 渲染线程 |
网络栈 | 负责处理网络请求和响应 | 与浏览器的网络进程进行通信,获取网页所需的资源,如 HTML、CSS、JavaScript 文件、图像和视频等 |
事件处理 | 负责处理用户的交互事件,如鼠标点击、键盘输入和滚动等 | 将这些事件传递给适当的组件和 JavaScript 代码,以触发相应的行为和功能 |
从表格中不难发现,上面的阻塞代码就发生在渲染进程中。在渲染进程拿到网络进程中传过来的 HTTP 文档后,渲染引擎就会对 HTTP 文档中的标记进行解析,从而创建 DOM 树,背后则是利用栈的这种数据结构,比如当遇到开闭标签时,完成一组节点的构建,如此这般创建出 DOM 树。
然而当解析到 script 标签时,HTML 解析器则会暂停,因为 JS 作为用户交互和实现动态网页的关键,必然会对 DOM 元素进行改动,此时,JS 引擎对代码进行下载、解析和执行,执行完成后才会恢复 HTML 解析器的运行。这就是为什么我们要把 JS 代码放在 HTML 文档的最下方的根本原因(defer 和 async 属性不在讨论范围),总不能都不存在这个 DOM 元素还去操作吧,比如下面这个 HTML 文档,在打印 body 元素时,body 都不在 DOM 树上呢,显然结果为 null
。
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>single thread in js</title>
<script>
console.log(document.body);
</script>
</head>
<body>
Hello world
</body>
</html>
那如果我们将最开始的阻塞代码放到这份文档的 script 部分又会发生什么呢?那就是长达 1.5s 的白屏。如果按照 CSR 架构方式进行代码研发,页面内容由 JS 动态渲染形成,最大的问题就是,大型的前端项目的 JS 文件体积很大或者 JS 文件特别多而又不做优化,可想而知,白屏将会对用户体验产生多么大的影响以及对服务器的压力,当然还有 SEO 的影响,这就要考虑使用 SSR 了。
JS 单线程
回到那个问题------当 JS 引擎解析并运行代码时发生了什么?我把步长改为 10,下面是在 JS 调用栈中的可视化结果(红色箭头表示下一步要执行的代码,绿色箭头表示刚刚执行过的代码):

executeTask 函数被放进全局上下文,它的值是一个引用类型,引用堆中某个对象。
全局上下文在 JS 引擎开始解析代码时被创建的,是代码执行的起点(可以理解为一种环境,比如你在厨房这个环境下,有锅碗瓢盆、油盐酱醋,然后就能做出美味佳肴),其中放了全局对象(在浏览器环境下是 window 对象、在 node.js 环境下是 global 对象)、全局变量、全局函数定义(例如这里的 executeTask 函数声明,如果是函数表达式则属于全局变量)、this 关键词(在全局上下文中,this关键字引用全局对象)。
当有函数被调用时,就会创建函数执行上下文,然后推入全局上下文的执行调用栈的顶部,如果嵌套函数,那么会创建新的函数执行上下文,然后继续往栈里推,而这些函数执行上下文就形成了作用域链,从而决定了变量和函数的可访问性。在执行完成后,函数执行上下文就会被弹出调用栈,作用域也随之销毁(闭包除外),这样就保证了代码的执行顺序。

console.log('Start!')
执行后打印 "Start!"
,就像上面解释的,这里是有所简化的。
console
属于浏览器提供的全局对象(window.console
),log
是 console
对象的一个方法。在执行 console.log
方法时,会创建一个新的函数执行上下文(包含了函数的局部变量、参数和函数的作用域链),然后被推入调用栈。之后,解析器会把字符串 "Start!"
作为参数传递给 log
方法,在打印完成后,console.log
的上下文就会从栈中弹出(此时内存将会得到释放),而全局执行上下文继续执行其他代码,直到程序结束。

console.time('very slow')
执行后记录开始时间。

调用 executeTask 函数,创建函数执行上下文,入栈,扫描函数作用域下的变量,此时 i 为 undefined。

执行 i 的初始化
因为使用了 let,块级作用域下,i 初始化为 0。

初始化完成,i 为 0。

if 条件判断
判断条件。

新一轮迭代,i< 10,i 的值增加。

i 的值为 1。

if 条件判断完毕,准备下一次迭代。以此类推。

判断 i 是否等于 9。

打印 task over。

判断是否还能进入下一次迭代,此时 i 为 10,退出 for 循环。

函数返回 undefined。

executeTask 执行完毕,函数执行上下文销毁,弹出执行调用栈。

打印出 very slow 的耗时。

打印出 Done!
,程序运行结束。
在关闭当前 Tab 标签页后,渲染进程也随之关闭,全局上下文自然也没有了。
最后,用一张图来总结:

需要补充的一点是,CSS 的下载和解析也会占用主线程,毕竟 DOM 元素需要用 CSS 来布局和上色。不过,在一般情况下,渲染引擎会在解析 HTML 时提前开一个预解析线程扫描 CSS 和 JS 文件引用,提前下载好这些文件。
异步编程 ------ callback
在常见的应用场景中,假设所有代码只能通过同步的方式编写和运行,那将是一场灾难。好在 JS 支持异步编程模型,我们通过使用回调函数、Promise、async/await 等机制,实现非阻塞的异步操作。
在起初,我们使用回调函数处理异步任务,这里使用定时器来模拟异步的网络请求:

ts
console.log('其他代码1');
console.log('其他代码2');
// 选择相片
function getPhoto(cb) {
setTimeout(() => {
console.log('1. 选取相片');
const name = '天上有朵云';
cb(name);
}, 1000);
}
// 设置滤镜效果
function setFilters(data, cb) {
setTimeout(() => {
console.log('2. 设置滤镜效果');
cb({
photo: data,
filters: '自然美',
});
}, 1000);
}
// 添加标题
function addTitle(data, cb) {
setTimeout(() => {
console.log('3. 添加标题');
cb(data.filters + '-' + data.photo + '.png');
}, 1000);
}
// 发布作品
function publish(data) {
setTimeout(() => {
console.log('4. 作品发布成功!', data);
}, 2000);
}
function main() {
console.log('execute main');
getPhoto(function (photo) {
setFilters(photo, function (filteredData) {
addTitle(filteredData, function (finalData) {
publish(finalData);
});
});
});
}
main();
console.log('其他代码3');
console.log('其他代码4');
console.log('其他代码5');
console.log('其他代码6');
显然,main 函数的执行并不会阻塞其他代码的执行。

但 main 函数还是有一个问题,回调函数一旦有很多层级,那么代码的逻辑就会变得难以理解和维护,这就是回调地狱。
异步编程 ------ Promise
ES6 中的 Promise 对象解决了这个问题。一个 Promise 实例对象表示异步操作最终的完成(或失败)以及其结果值。它有三种状态,初始状态是 pending,当请求成功就调用 resolve,状态从 pending 到 fulfilled,结果值会放在 Promise#then
中处理;失败的话调用 reject,状态从 pending 到 rejected,结果值会放在 Promise#catch 中处理。两种状态的转换只能是单向的。

一开始接触 Promise 是很难理解的,可以看到各种翻译,例如期约、承诺。其实它的关键,在我看来不在 Promise,而在 then,then 这个词有"到时候"的意思,所以 Promise 对象就变成了一个别人对我的承诺,承诺当然不会立即兑现,等过了一段时间之后,才能看到是否兑现了诺言以及拿到结果,也就是"你讲了你的承诺,咱们到时候拿到结果再怎么怎么样"。
ts
function getPhoto() {
return new Promise(function (resolve, reject) {
setTimeout(() => {
console.log('1. 选取相片');
const name = '天上有朵云';
resolve(name);
}, 1000);
});
}
function setFilters(data) {
return new Promise(function (resolve, reject) {
setTimeout(() => {
console.log('2. 设置滤镜效果');
resolve({
photo: data,
filters: '自然美',
});
}, 1000);
});
}
function addTitle(data) {
return new Promise(function (resolve, reject) {
setTimeout(() => {
console.log('3. 添加标题');
resolve(data.filters + '-' + data.photo + '.png');
}, 1000);
});
}
function publish(data) {
return new Promise(function (resolve, reject) {
setTimeout(() => {
console.log('4. 作品发布成功!', data);
resolve();
}, 2000);
});
}
function main() {
console.log('execute main');
getPhoto()
.then(function (photo) {
return setFilters(photo);
})
.then(function (filteredData) {
return addTitle(filteredData);
})
.then(function (finalData) {
return publish(finalData);
})
.catch(function (error) {
console.error('Error:', error);
});
}
console.log('其他代码1');
console.log('其他代码2');
main();
console.log('其他代码3');
console.log('其他代码4');
console.log('其他代码5');
console.log('其他代码6');
从上面的例子中就可以理解为,先选取照片,成功选完通过 resolve 把状态和结果给出,后面接上 then 方法,到时候再来处理这个结果,在处理结果的回调函数中再次返回新的 Promise 对象,调用链式的 then,这样就形成了调用顺序,代码变得更加直观可控。
异步编程 ------ async/await
除了 Promise,还有 ES7 中的替代方案------async/await,这是一种语法糖,让异步代码写起来像在写同步代码。上面的代码就可以改写成:
ts
// ...
async function main() {
console.log('execute main');
try {
const photo = await getPhoto();
const filteredData = await setFilters(photo);
const finalData = await addTitle(filteredData);
await publish(finalData);
} catch (error) {
console.error('Error:', error);
}
}
console.log('其他代码1');
console.log('其他代码2');
main();
console.log('其他代码3');
console.log('其他代码4');
console.log('其他代码5');
console.log('其他代码6');
main 函数中,不需要再写链式调用了。
而且,async 函数的返回值一定是一个 Promise 对象,如果不是,会隐式地包装在一个 promise 中。就比如:
ts
async function foo() {
return 1;
}
// 等价于
// function foo() {
// return Promise.resolve(1);
// }
const res = foo();
console.log(res);

因此,当看到 async 函数就要第一反应出它返回的是一个 Promise 对象!
并发请求
尽管如此,也并不是所有页面的异步请求只有一个。假设在一个低代码的页面中需要两组数据,数据间彼此独立。一个是画布数据,一个是节点数据:

ts
// 获取节点列表
const fetchNodes = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(['node1', 'node2', 'node3']);
}, 2000);
});
};
// 获取画布数据
const fetchGraph = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
id: 1,
start: 'node1',
end: 'node2',
});
}, 1000);
});
};
const init = async () => {
const res1 = await fetchNodes(); // 2s
const res2 = await fetchGraph(); // 1s
console.log(res1);
console.log(res2);
return [res1, res2];
};
init();
在上面的代码中,执行 init
函数总计花了 3s,其中 fetchNodes
花了 2s,fetchGraph
花了 1s。这是一种按照先后顺序发送请求的方式。然而,从业务上讲,左侧的节点数据和右侧的画布数据是互相独立的。显然,使用并发的方式去请求数据更加适合这样的业务场景。
ts
// ...
const init = async () => {
const res1 = fetchNodes(); // 2s
const res2 = fetchGraph(); // 1s
console.log(await res1);
console.log(await res2);
return [await res1, await res2];
};
init();
在上面的代码中,fetchNodes
和 fetchGraph
即时发送,并没有通过 await 来控制顺序,到了后面通过 await 分别把 Promise 实例中的结果拿到,然后返回。这就实现了请求并发,执行 init 函数的时间为 2s(较慢的那个)。
不过在并发的场景下,使用 Promise.all
方法更加直观,也不容易忘记 await。
ts
// ...
const init = () => {
const promises = Promise.all([fetchNodes(), fetchGraph()]);
promises.then((res) => {
console.log(res);
return res;
});
};
init();

Event Loop
不管是定时器、AJAX 还是 DOM 的事件监听,都是异步任务,这些 Web API 操作会在各自的线程上执行,然后,注册回调函数进入任务队列等待,当调用栈空了的时候,就会执行任务队列中的回调函数,从而保证 JS 代码的非阻塞执行(在其他语言中,也有类似的机制)。
这种流转的机制就是 Event Loop。在 Event Loop 中,有三种数据结构必须了解:
-
stack 栈,在函数执行时会产生一个栈,即函数调用栈。
-
heap 堆,堆是一个用来表示一大块(通常是非结构化的)内存区域,所有的对象被分配在堆中。
-
queue 队列,一个 JavaScript 运行时包含了一个待处理的任务队列。每一个任务都关联着一个用以处理这个任务的回调函数。
在任务队列的区分上,主要分为任务(宏任务)和微任务队列。
- 任务队列(Task Queue) :也称为宏任务队列(Macro Task Queue) ,用于存放宏任务。宏任务包括整体的 script 代码块、setTimeout、setInterval、setImmediate(在 Node.js 环境中可用)、I/O 操作、UI 渲染等。当这些宏任务中的某个任务完成时,会被添加到任务队列中等待执行。
- 微任务队列(Microtask Queue) :也称为微任务队列(Micro Task Queue) ,用于存放微任务。微任务包括 **Promise 的回调函数、MutationObserver 的回调函数、process.nextTick(在 Node.js 环境中可用)**等。当微任务队列为空时,会立即执行微任务队列中的所有任务。
其中,任务来自宿主环境提供的任务,而微任务来自 JS 引擎提供的任务。
浏览器环境下的 Event Loop

简单来说,在浏览器环境下,Event Loop 的运行方式如下:
- 当异步任务(如 setTimeout,Promise 等)被调用时,他们会被添加到任务队列(Task Queue)或微任务队列(Microtask Queue)中。
- 当主线程空闲时,Event Loop 从任务队列中取出一个任务执行。
- 如果存在微任务,那么在每个 task 执行完成后,所有的微任务(microtasks)都会被执行,直到微任务队列为空。
- 上述步骤重复。
用代码来表示浏览器环境下的 Event Loop:
ts
while (true) {
queue = getQueue();
task = queue.pop();
execute(task);
// 如果存在微任务,执行微任务
while (microtaskQueue.hasTasks()) {
doMircotask();
// 是否需要重绘页面
if (isRepaintTime()) {
animationTasks = animationQueue.copyTasks();
for (task in animationTasks) {
doAnimationTask(task)
}
repaint();
}
}
}
上面的代码中有一个 isRepaintTime
函数,用来模拟浏览器以一定的频率进行的 UI 渲染。这个频率通常与显示器的刷新率相匹配,一般为每秒 60 次(60 Hz)。这意味着浏览器每秒会尝试进行 60 次渲染,以更新屏幕上的内容。那么渲染什么呢?就是在浏览器原理中提到的布局(Layout)、绘制(Paint)和合成(Composite)等步骤。当浏览器需要渲染新的内容时,它会触发渲染流水线,按照一定的顺序执行这些步骤,最终将内容呈现在屏幕上。
Node.js 环境下的 Event Loop

Node.js 环境与浏览器环境不同,没有 script 标签、用户交互、动画帧的回调、渲染流水线。它的 Event Loop 包含以下几个阶段:
- timers :此阶段执行
setTimeout
和setInterval
回调。 - pending callbacks:执行延迟到下一个循环迭代的 I/O 回调。
- idle, prepare:仅系统内部使用。
- poll :检索新的 I/O 事件; 执行与 I/O 相关的回调(除了关闭回调,定时器,和
setImmediate
); node 会在适当的时候阻塞在这里。 - check :
setImmediate()
回调在这里执行。 - close callbacks :一些关闭的回调函数,如:
socket.on('close', ...)
。
在每个阶段之间,Node.js 会执行微任务队列中的任务,包括 Promise 回调和其他微任务。 用代码来表示 Node.js 环境下的 Event Loop:
ts
while (tasksAreWaiting()) {
queue = getNextQueue();
if (queue.hasTasks()) {
task = queue.pop();
execute(task);
// 如果存在 process.nextTick,执行该微任务
while (nextTickQueue.hasTasks()) {
doNextTickTask();
}
// 如果存在 promise,执行该微任务
while (promiseQueue.hasTasks()) {
doPromiseTask();
}
}
}
setImmediate()
和 process.nextTick()
是比较奇葩的两个函数,第一个叫做立即执行,第二个叫做在下一个 tick 执行,但实际的表现上却相反,process.nextTick()
会立即执行,而setImmediate()
会在下一个 tick 执行。
浏览器和 Node.js 下的 Event Loop 的不同:
- 微任务(Microtasks)处理的位置不同:在浏览器中,微任务在每个任务(MacroTask)后面执行。而在 Node.js 中,微任务在 Event Loop 的每个阶段之间执行。
- 任务队列(Task Queues)的结构不同:浏览器中通常有一个任务队列,而 Node.js 有多个任务队列,每个队列对应 Event Loop 的一个阶段。
- I/O 处理方式不同:Node.js 的 Event Loop 专门有一个阶段来处理 I/O 操作。
Event Loop 可视化
网上有一个不错的可视化工具(JavaScript Visualizer 9000),可以更好地理解 Event Loop,大家可以玩玩看:www.jsv9000.app/ ,有JS 代码、任务队列、微任务队列、函数调用栈、Event Loop。在 Event Loop 中可以看到执行机制,与"浏览器环境下的 Event Loop"一节不谋而合。

面试题
有点绕的一道题。
ts
console.log('A stack');
queueMicrotask(function () {
console.log('B microtask');
});
requestAnimationFrame(function () {
console.log('C rAF');
});
console.log('D stack');
setTimeout(function () {
console.log('E task');
queueMicrotask(function () {
console.log('Inside E microtask');
});
}, 0);
console.log('F stack');
Promise.resolve()
.then(function () {
console.log('G microtask');
})
.then(function () {
console.log('H microtask');
});
requestAnimationFrame(function () {
console.log('I rAF');
});
console.log('J stack');
setTimeout(function () {
console.log('K task');
}, 0);
queueMicrotask(function () {
console.log('L microtask');
});
console.log('M stack');

实际的执行顺序,但不完全正确:

按照 Event Loop 的执行机制,自己写的时候还是出现了错误。在 L 打印后,微任务队列中还有微任务,所以应该先执行刚刚创建的打印 H 的微任务的回调函数,再往后执行......

然而, 因为屏幕刷新次数的随机性,加上 requestAnimationFrame
以后,答案就充满了不确定性,C 和 I 随机地插入在打印顺序中。所以,并没有什么正确答案。
好了,当你遇到八股文面试或者八股文面试官时,会选择放弃哪一个?
总结
到这里,想必大家对JS 单线程、异步、事件循环都可以做到心中有数了。
以上,如有谬误,还请斧正,感谢您的阅读。
👏 对了,如果你还没有我的好友,加我微信:enjoy_Mr_cat ,备注 「掘金」
,即有机会加入高质量前端交流群,在这里你将会认识更多的朋友;也欢迎关注我的公众号 见嘉 Being Dev
,并设置星标,以便第一时间收到更新。
参考: