JavaScript 执行上下文与调用栈

第二编 · 第一章:JavaScript 执行上下文与调用栈的深入分析


从一道输出题开始

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

function bar() {
  console.log('bar');
  foo();
}

function baz() {
  console.log('baz');
  bar();
}

baz();

这道题很简单,输出是 bazbarfoo。但你有没有想过为什么?

答案是调用栈。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();

执行过程是这样的:

  1. 创建全局执行上下文
  2. 调用 a(),推入 a 的栈帧
  3. a 内部调用 b(),推入 b 的栈帧
  4. b 内部调用 c(),推入 c 的栈帧
  5. c 执行 console.log('c'),输出 c
  6. c 执行完毕,弹出 c 的栈帧
  7. b 继续执行 console.log('b'),输出 b
  8. b 执行完毕,弹出 b 的栈帧
  9. a 继续执行 console.log('a'),输出 a
  10. a 执行完毕,弹出 a 的栈帧
  11. 全局代码执行完毕

所以输出是 cba


执行上下文是什么

执行上下文(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();

执行过程:

  1. 创建全局执行上下文,推入调用栈
  2. var a = 1 在全局上下文中赋值
  3. bar() 被调用,创建 bar 的执行上下文,推入栈顶
  4. var c = 3bar 的上下文中赋值
  5. foo() 被调用,创建 foo 的执行上下文,推入栈顶(bar 的上下文还在栈中)
  6. var b = 2foo 的上下文中赋值
  7. console.log(a + b) 执行时,JavaScript 会沿着作用域链查找 ab
  8. foo 执行完毕,弹出栈帧,bar 的上下文回到栈顶
  9. console.log(a + c) 执行
  10. bar 执行完毕,弹出栈帧
  11. 全局上下文保留(因为代码可能还在执行)

作用域和执行上下文的关系

作用域(Scope)和执行上下文(Execution Context)是两个不同但相关的概念。

作用域是变量可以被访问的范围,是在代码编写时由静态的词法结构决定的。JavaScript 在 ES6 之前只有函数作用域和全局作用域,ES6 引入了块级作用域(letconst)。

执行上下文是代码执行时的动态运行环境。每次调用函数都会创建新的执行上下文,但作用域是在代码结构中静态决定的。

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 遇到异步操作时(比如 setTimeoutPromise.thenfetch),它不会阻塞等待结果,而是把回调函数注册到任务队列里,然后继续执行后续代码。

console.log('1');

setTimeout(() => {
  console.log('2');
}, 0);

console.log('3');

执行过程:

  1. console.log('1') 执行,输出 1
  2. setTimeout 注册回调函数到任务队列,继续执行
  3. console.log('3') 执行,输出 3
  4. 当前调用栈清空后,事件循环检查任务队列,把 setTimeout 的回调推入调用栈
  5. console.log('2') 执行,输出 2

所以输出是 132setTimeout 的 0 毫秒延迟并不意味着立即执行,而是"尽快执行"——必须等当前调用栈清空后才会处理任务队列里的回调。


这一章想说的

调用栈是 JavaScript 执行同步代码的核心机制。每次函数调用推入一个栈帧,函数执行完毕后弹出。执行上下文是代码运行的运行环境,包含变量存储和 this 绑定。

理解调用栈和执行上下文,是理解 JavaScript 异步机制、作用域链、闭包等高级特性的基础。后续章节会继续深入这些相关主题。