从一道输出题开始
function foo() {
console.log('foo');
}
function bar() {
console.log('bar');
foo();
}
function baz() {
console.log('baz');
bar();
}
baz();
这道题很简单,输出是 baz、bar、foo。但你有没有想过为什么?
答案是调用栈。JavaScript 引擎在执行代码时维护一个调用栈(Call Stack),每次调用一个函数就把一个栈帧(Stack Frame)推入栈顶,函数执行完毕后把栈帧弹出。
调用栈是什么
调用栈是一个 LIFO(后进先出)的数据结构。当 JavaScript 引擎开始执行代码时,它首先创建一个全局执行上下文(Global Execution Context),然后按照代码顺序执行。
function a() {
b();
console.log('a');
}
function b() {
c();
console.log('b');
}
function c() {
console.log('c');
}
a();
执行过程是这样的:
- 创建全局执行上下文
- 调用
a(),推入a的栈帧 a内部调用b(),推入b的栈帧b内部调用c(),推入c的栈帧c执行console.log('c'),输出cc执行完毕,弹出c的栈帧b继续执行console.log('b'),输出bb执行完毕,弹出b的栈帧a继续执行console.log('a'),输出aa执行完毕,弹出a的栈帧- 全局代码执行完毕
所以输出是 c、b、a。
执行上下文是什么
执行上下文(Execution Context)是 JavaScript 引擎在执行代码时创建的运行环境。每次调用一个函数,都会创建一个新的执行上下文。
执行上下文包含三个重要部分:
词法环境(Lexical Environment):存储函数内部定义的变量、函数声明、以及 this 绑定。
变量环境(Variable Environment):在 var 声明的变量的存储区域(在 ES6 中,变量环境和词法环境合并了)。
this 绑定:这个上下文中的 this 指向什么。
当一个函数内部嵌套定义了另一个函数时,内层函数能访问外层函数的变量,是因为内层函数的词法环境形成了作用域链。
栈溢出是怎么回事
调用栈的大小是有限的。当嵌套调用太深时,调用栈会溢出。
function recursive() {
recursive();
}
recursive();
这段代码会引发 Maximum call stack size exceeded 错误。因为 recursive 函数会无限调用自己,每次调用都往调用栈推入一个栈帧,直到栈溢出。
这个限制主要是为了防止无限递归耗尽内存。浏览器通常把调用栈限制在几万层深度,不同浏览器的限制不同。
同步代码的执行过程
JavaScript 是单线程的,意味着同一时刻只能执行一个任务。调用栈是理解 JavaScript 同步执行的关键。
const a = 1;
function foo() {
const b = 2;
console.log(a + b);
}
function bar() {
const c = 3;
foo();
console.log(a + c);
}
bar();
执行过程:
- 创建全局执行上下文,推入调用栈
var a = 1在全局上下文中赋值bar()被调用,创建bar的执行上下文,推入栈顶var c = 3在bar的上下文中赋值foo()被调用,创建foo的执行上下文,推入栈顶(bar的上下文还在栈中)var b = 2在foo的上下文中赋值console.log(a + b)执行时,JavaScript 会沿着作用域链查找a和bfoo执行完毕,弹出栈帧,bar的上下文回到栈顶console.log(a + c)执行bar执行完毕,弹出栈帧- 全局上下文保留(因为代码可能还在执行)
作用域和执行上下文的关系
作用域(Scope)和执行上下文(Execution Context)是两个不同但相关的概念。
作用域是变量可以被访问的范围,是在代码编写时由静态的词法结构决定的。JavaScript 在 ES6 之前只有函数作用域和全局作用域,ES6 引入了块级作用域(let 和 const)。
执行上下文是代码执行时的动态运行环境。每次调用函数都会创建新的执行上下文,但作用域是在代码结构中静态决定的。
const x = 10;
function foo() {
console.log(x); // 能访问 x,因为词法作用域在定义时就决定了
}
function bar(fn) {
const x = 20;
fn(); // 仍然输出 10,而不是 20
}
bar(foo);
foo 能访问 x,是因为 foo 定义时的词法环境中有 x,而不是因为调用时 bar 的作用域里有 x。这就是 JavaScript 的词法作用域(Lexical Scope)特性。
异步代码和调用栈
当 JavaScript 遇到异步操作时(比如 setTimeout、Promise.then、fetch),它不会阻塞等待结果,而是把回调函数注册到任务队列里,然后继续执行后续代码。
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
console.log('3');
执行过程:
console.log('1')执行,输出1setTimeout注册回调函数到任务队列,继续执行console.log('3')执行,输出3- 当前调用栈清空后,事件循环检查任务队列,把
setTimeout的回调推入调用栈 console.log('2')执行,输出2
所以输出是 1、3、2。setTimeout 的 0 毫秒延迟并不意味着立即执行,而是"尽快执行"——必须等当前调用栈清空后才会处理任务队列里的回调。
这一章想说的
调用栈是 JavaScript 执行同步代码的核心机制。每次函数调用推入一个栈帧,函数执行完毕后弹出。执行上下文是代码运行的运行环境,包含变量存储和 this 绑定。
理解调用栈和执行上下文,是理解 JavaScript 异步机制、作用域链、闭包等高级特性的基础。后续章节会继续深入这些相关主题。