JavaScript 如何执行代码:栈、堆、事件循环与生成器
JavaScript运行时导览:调用栈与帧、堆引用、Node.js事件循环、DOM、回调函数、Promise、async/await以及生成器。
来源: JavaScript
异步 JavaScript 的行为常常让开发者感到意外:日志信息似乎“过早”出现,await 语句好像跳过了某些步骤,生成器只输出了部分内容。一旦你掌握了运行时执行代码的清晰模型,这些意外几乎都会消失。本文将逐步构建这一模型,从调用栈和堆开始,再到 Node.js 事件循环、DOM,以及 promises、async/await 和生成器,这样你就能在阅读代码之前就预测出其输出结果。
运行时的组成部分
JavaScript 在单个主线程上运行,但程序在等待计时器、网络响应和文件时并不会卡住,这是因为引擎并非独自工作:
- 调用栈,用于记录当前正在执行的函数
你的代码在调用栈上执行,而运行时则负责处理耗时的操作。当某个操作完成后,其对应的回调会被加入队列,一旦调用栈空出,事件循环就会将其放入栈中执行。
Node.js 事件循环概览
该图展示了 Node.js 的执行周期。首先运行同步代码,接着是排队的微任务,然后是循环的各个阶段:定时器(setTimeout、setInterval)、待处理的回调函数、轮询阶段(大多数文件和网络相关回调在此运行)、检查阶段(setImmediate)以及关闭回调函数。
┌───────────────────┐
│ CALL STACK │
│ JavaScript │
└─────────┬─────────┘
│
│ Synchronous code finishes
▼
┌───────────────────┐
│ MICROTASK QUEUE │
│ │
│ Promise.then() │
│ Promise.catch() │
│ queueMicrotask() │
└─────────┬─────────┘
│
▼
╔═════════════════════════════════════════════╗
║ EVENT LOOP ║
║ ║
║ ┌───────────────┐ ║
║ │ TIMERS │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ PENDING │ ║
║ │ CALLBACKS │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ POLL │ ║
║ │ I/O │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ CHECK │ ║
║ │ setImmediate │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ CLOSE │ ║
║ │ CALLBACKS │ ║
║ └───────┬───────┘ ║
║ └───────────────────────────────┐ ║
║ │ ║
╚══════════════════════════════════════════╪══╝
│
▼
┌───────────────────┐
│ CALL STACK │
│ Execute JS │
└───────────────────┘
有两点需要特别注意。微任务并非在每次循环中只处理一次:Node会在每个回调之后清空微任务队列(以及其前的process.nextTick队列),因此Promise的.then处理函数会在下一个定时器回调之前执行。此外,浏览器虽然遵循任务与微任务共存的原则,但没有setImmediate或libuv相关机制,且会交错进行渲染操作。关于Node特有的细节,请参阅libuv、事件循环与线程池如何协同工作。
调用栈
调用栈记录了当前正在运行的函数以及哪些函数正在等待它返回。它采用后进先出结构:最新调用的函数必须执行完毕,其下方的函数才能继续运行。
在下面的示例中,func1 调用 func2,func2 又调用 func3,而 func3 会输出一条消息:
function func1() {
func2();
}
function func2() {
func3();
}
function func3() {
console.log("Hello");
}
func1();
调用以这样的顺序依次叠加:
func1()
↓
func2()
↓
func3()
↓
console.log()
就像一叠盘子一样,console.log 最后被执行且最先完成,接着 func3、func2 和 func1 依次返回。只有当调用栈为空时,排队的回调函数才能执行,这就是为什么漫长的同步循环会阻塞其他所有操作的原因。
堆内存
堆内存用于存储那些生命周期不依赖于单次调用的数据,例如对象、数组和闭包。
创建一个对象字面量时,它会被分配到堆内存中:
const user = {
name: "Rayane",
age: 20
};
该变量仅保存对存储在堆内存中的对象的引用:
CALL STACK HEAP
┌───────────────┐ ┌────────────────────┐
│ user variable │─────────▶│ { │
│ │ │ name: "Rayane", │
└───────────────┘ │ age: 20 │
│ } │
└────────────────────┘
将user赋值给另一个变量只是复制了引用而非对象本身,因此对任一变量的修改都会在另一变量中体现出来。一旦没有可用的引用指向某个对象,垃圾回收器就可以回收它;而那些捕获了该对象的长期存在的闭包则会使其保持存活状态。
事件循环的协调作用
事件循环将你的代码与异步任务的完成情况联系在一起。在Node.js中,V8负责执行你的代码;对Node API的调用,比如读取文件,会传递给libuv,再由libuv将其交给操作系统或线程池处理。当任务完成后,回调函数就会准备好,最终由事件循环在栈上执行它。
JavaScript code
│
▼
V8 Engine
│
▼
Call Stack
│
▼
Node.js APIs
│
▼
libuv
│
├───────────────┐
▼ ▼
OS Thread Pool
│ │
└───────┬───────┘
▼
Asynchronous operation
│
▼
Callback becomes ready
│
▼
Event Loop
│
▼
Callback Queue
│
▼
Call Stack
路由的处理方式取决于操作类型:套接字通常由操作系统通过epoll或kqueue来处理,而文件系统操作以及某些加密和DNS调用则由线程池来处理。
栈帧
每次函数调用都会压入一个栈帧,其中保存着函数运行时所需的信息:
- 函数的参数
- 函数的局部变量
- 返回地址,即执行在调用者处继续的位置
- 引擎的其他管理信息
以一个用于计算两个数之和的函数为例:
function add(a, b) {
const result = a + b;
return result;
}
add(2, 3);
当执行 add(2, 3) 时,其栈帧中包含参数、局部计算结果以及指向调用者的链接:
CALL STACK
┌─────────────────────┐
│ stack frame: add │
│ │
│ a = 2 │
│ b = 3 │
│ result = 5 │
│ return → caller │
└─────────────────────┘
函数返回后,该栈帧就会被丢弃:
CALL STACK
┌─────────────────────┐
│ frame removed │
└─────────────────────┘
整个生命周期非常简洁:
Function call
↓
Stack Frame created
↓
Function executes
↓
Function returns
↓
Stack Frame removed
如果递归过深,栈帧会不断累积,最终导致引擎因超过最大调用栈大小而抛出 RangeError 错误。
DOM
文档对象模型是浏览器基于HTML文档构建的动态节点树结构。通过它,JavaScript可以:
- 读取元素
- 更改其内容
- 插入新元素
- 删除现有元素
- 修改样式和属性
DOM是浏览器API,并非语言本身的组成部分,因此纯Node.js环境中不存在document。
以下是HTML中的一个标题元素:
<h1 id="title">Hello</h1>
脚本可以通过元素的id获取对该元素的引用:
const title = document.getElementById("title");
然后替换其文本内容:
title.textContent = "Hello Rayane!";
在修改之前,该元素的子文本节点中保存着原始字符串:
document
│
▼
<h1 id="title">
│
▼
"Hello"
执行完相同的赋值操作后:
title.textContent = "Hello Rayane!";
节点树会反映出新的文本内容:
document
│
▼
<h1 id="title">
│
▼
"Hello Rayane!"
浏览器随后会重新绘制页面。在实际代码中,当没有匹配项时,需检查getElementById返回的null值;对于不可信的字符串,建议使用textContent而非innerHTML,因为前者不会解析HTML。
回调函数
回调函数是指传递给另一个函数的函数,以便日后调用。在这个例子中,greet函数被传递给execute函数,后者会传入一个姓名来调用它:
function greet(name) {
console.log(`Hello ${name}`);
}
function execute(callback) {
callback("Ajami");
}
execute(greet);
程序会输出:
Hello Ajami
在execute函数内部,名为callback的参数指的是greet函数,这一行代码会调用它:
callback("Ajami");
回调函数本身并非异步的;这个例子中的回调会立即执行。只有当接收方在某个事件发生之后触发它时,比如定时器触发,它才会变成异步的。
Promise对象
Promise代表了异步操作的最终结果。你启动该操作后可以继续执行其他代码,等到Promise状态确定后再处理其结果。
fetch是一个典型的返回Promise的API:
const promise = fetch("https://api.example.com/data");
你可以通过绑定处理函数来响应结果:
.then()用于处理成功的结果.catch()用于处理失败的情况.finally()用于在无论何种情况下都需执行的清理操作
fetch("https://api.example.com/data")
.then((response) => {
console.log("Request finished");
})
.catch((error) => {
console.log("Something went wrong:", error.message);
})
.finally(() => {
console.log("Request completed");
});
启动请求不会造成阻塞;处理函数会作为微任务在稍后执行。注意,fetch仅在网络故障时才会拒绝Promise。404或500状态仍然会使Promise满足,因此需要自行检查response.ok。
Promise状态
Promise始终处于以下三种状态之一:
PENDING
│
├───────────────┐
▼ ▼
FULFILLED REJECTED
- 待处理:操作尚未完成。
- 已完成:操作成功完成并有了结果值。
- 已被拒绝:操作失败且有原因,通常是错误。
一个承诺只会被处理一次,之后添加的处理函数仍然能收到已处理的结果。
Async/await
async函数总是返回一个承诺,而await则允许你按顺序编写异步步骤。该函数会先输出日志,等待请求完成后再输出一次日志,调用结束后还会再输出一条日志:
async function leetfinish() {
console.log("The function starts");
const res = await fetch("https://api.example.com/finish");
console.log("The request finished");
}
leetfinish();
console.log("Let's review our code");
可能的输出结果是:
The function starts
Let's review our code
The request finished
为什么外层的日志会第二次输出
函数先打印第一条消息,然后执行到这一行:
const res = await fetch("https://api.example.com/finish");
fetch会返回一个待处理的承诺对象,而await会暂停函数执行并将控制权交还给调用者,同时不会冻结线程。调用者会先输出其消息;只有当响应到达后,函数才会继续执行。因此:
The function starts
Let's review our code
The request finished
await暂停的是函数,而非线程。而且“很可能”这一点很重要:如果请求失败,最后那行代码将永远不会被执行,未被捕获的错误就会变成未处理的拒绝状态。
使用try、catch和finally处理错误
在async/await模式下,可以使用普通的try/catch/finally结构。这个示例用于获取数据、验证响应及预算信息,并始终重置加载状态标志。其中的注释指出了两个要点:HTTP错误不会导致拒绝,而且response.json()也会返回一个承诺对象。
let isLoading = true;
async function checkBudget() {
try {
// fetch() returns a Promise.
// await pauses this function until the Promise settles.
const response = await fetch(
"https://api.example.com/khouribga/business/budget"
);
// fetch() does NOT reject automatically for HTTP errors
// such as 404 or 500.
if (!response.ok) {
throw new Error("Failed to fetch business data");
}
// response.json() also returns a Promise.
const business = await response.json();
if (business.budget < 10) {
throw new Error("Budget is too low");
}
console.log("Budget is sufficient:", business.budget);
} catch (error) {
console.log("Something went wrong:", error.message);
} finally {
// This runs whether the request succeeds or fails.
isLoading = false;
console.log("Loading finished:", isLoading);
}
}
checkBudget();
逐步说明:
fetch()用于发送请求,而await则会使函数暂停执行。- 当响应到达时,函数会继续执行。
- HTTP状态码出错时会转换为异常并抛出。
- JSON数据会被解析,同时预算也会被验证。
try块中的任何错误,包括网络故障,都会被捕获到catch块中。finally块始终会被执行,用于清除加载状态。
练习:并行运行异步函数
一个不错的练习是LeetCode第2721题,该题要求你同时启动多个操作而非依次等待,本质上就是自己实现一个Promise.all函数。
生成器
生成器函数可以暂停和恢复执行,从而逐步产生值。它使用function*声明,暂停点则用yield标记:
function* generatorAjami() {
// Instructions ...
yield i;
// More instructions ...
yield i;
// More instructions ...
}
比较 yield 和 return:
return会返回一个值并结束函数。yield会返回一个值并暂停函数执行,同时保留其状态。
逐步执行生成器
这个生成器在三个 yield 语句之前都会打印一条消息:
function* generatorAjami() {
console.log("First born in casa");
yield 10;
console.log("Second move to Khouribga");
yield 20;
console.log("Third live in Khouribga");
yield 30;
}
调用它时不会执行任何代码块内容:
const generator = generatorAjami();
你会得到一个生成器对象,即可以通过 .next() 获取值的迭代器:
console.log(generator.next());
第一次调用会执行到第一个 yield 语句,并打印第一条消息:
First born in casa
{ value: 10, done: false }
执行会在该语句处停止:
yield 10;
并返回 10。每次 .next() 调用的结果都有两个属性:
value,即被 yield 出来的值
done,表示生成器是否已完成因此结果如下:
{ value: 10, done: false }
意为“这里是10,还有更多内容即将输出”。
再次调用 .next():
console.log(generator.next());
会产生:
Second move to Khouribga
{ value: 20, done: false }
第二条消息现在才出现,是因为生成器在之前的 yield 之后立即继续执行。
第三次调用:
console.log(generator.next());
会打印第三条消息并输出 30:
Third live in Khouribga
{ value: 30, done: false }
第四次调用则:
console.log(generator.next());
提示已没有剩余内容:
{ value: undefined, done: true }
由于没有显式的 return,最终的 value 为 undefined。
生成器究竟是什么
生成器不会多次返回值:一次执行只会开始并结束一次,但可以通过暂停和继续来输出多个值。第一次调用会执行到 yield 10:
generator.next()
↓
Execute instructions
↓
yield 10
↓
Pause and return { value: 10, done: false }
第二次调用会从那里继续执行,直到 yield 20 才停止:
generator.next()
↓
Resume after yield 10
↓
yield 20
↓
Pause and return { value: 20, done: false }
第三次调用会继续执行,直到 yield 30 才停止:
generator.next()
↓
Resume after yield 20
↓
yield 30
↓
Pause and return { value: 30, done: false }
第四次调用时找不到更多语句,便会报告执行完毕:
generator.next()
↓
No more instructions
↓
Return { value: undefined, done: true }
for...of 语句以及展开运算符会自动调用 .next(),并在 done: true 时停止执行。更多相关模式可参阅使用生成器实现可暂停函数与惰性迭代。 实践:无限斐波那契数列生成器
LeetCode第2648题非常适合用此方法。生成器会先输出当前值,然后同时推进两个运行中的值。while (true)循环是安全的,因为每次迭代都会在yield处暂停。
var fibGenerator = function* () {
let a = 0;
let b = 1;
while (true) {
yield a;
const next = a + b;
a = b;
b = next;
}
};
状态的变化过程如下:
a = 0, b = 1
↓
yield 0
↓
a = 1, b = 1
↓
yield 1
↓
a = 1, b = 2
↓
yield 1
↓
a = 2, b = 3
↓
yield 2
值是按需计算的,每次调用.next()才会计算一个值;切勿在没有限制的情况下进行展开或迭代操作。
整合模型
在Node.js中:V8利用其栈和堆来运行代码,Node API会将耗时的任务交给带有事件循环和线程池的libuv处理,而操作系统则负责实际的网络操作。
┌─────────────────────────────────────────────┐
│ NODE.JS │
│ │
│ ┌─────────────┐ │
│ │ V8 │ │
│ │ │ │
│ │ Call Stack │ │
│ │ Heap │ │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ Node.js APIs │ │
│ └───────┬───────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ libuv │ │
│ │ │ │
│ │ ┌─────────┐ │ │
│ │ │ Event │ │ │
│ │ │ Loop │ │ │
│ │ └─────────┘ │ │
│ │ │ │
│ │ Thread Pool │ │
│ └───────┬───────┘ │
│ │ │
└───────────┼─────────────────────────────────┘
│
▼
┌───────────────┐
│ OS │
│ │
│ Networking │
│ Kernel │
│ epoll/kqueue │
└───────────────┘
关键要点
- 只要调用栈不为空,任何排队等待的任务都不会执行,因此长时间的同步操作会阻塞所有进程。
- 变量保存的是对堆对象的引用;无法访问的对象会被回收。
fetch 仅在网络错误时才会拒绝请求,因此需要检查 response.ok 的值。await 会暂停函数执行,而非线程。yield 时会暂停,在调用 .next() 时继续执行,从而实现延迟处理甚至无限序列的生成。