执行上下文、作用域与调用栈
本节目标
- 理解「执行上下文」与「调用栈」的关系
- 用一段可运行代码「打印出调用栈」,看函数是怎么一层层压入、弹出的
- 搞清词法作用域与闭包的内存模型
执行上下文(Execution Context):函数被调用时创建,包含「变量环境、作用域链、this」。全局有一个上下文,每调用一个函数就新建一个,全部压进调用栈。
下面这段 Node 代码会真实打印调用栈,让你看见 a→b→c 是怎么一层层叠上去又弹出来的:
// 运行环境:Node.js 14+(或浏览器控制台)
// 步骤:node brw-l8.js
// 重点看 c() 里打印的 error.stack —— 它展示了「当前调用栈」
function a() { b() }
function b() { c() }
function c() {
console.log('--- 此刻的调用栈(从下往上看)---')
console.log(new Error().stack)
}
a()
console.log('a 执行完后,栈已清空')栈溢出(Stack Overflow):无限递归会让调用栈超过上限,抛出 RangeError: Maximum call stack size exceeded。
词法作用域(Lexical Scope):作用域在写代码的位置就定了,与「在哪里被调用」无关——这是闭包的基础。
闭包的内存模型:内层函数引用了外层变量,外层函数即使返回了,那些变量也不会被回收:
function counter() {
let n = 0 // 被内层函数引用 → 进入「闭包环境」,外层返回也不释放
return function () { return ++n }
}
const c = counter()
console.log(c()) // 1
console.log(c()) // 2栈 vs 堆:
调用栈(Stack):存执行上下文、基本类型值 —— 后进先出,自动管理,快
堆(Heap) :存对象、函数等引用类型 —— 靠 GC 回收,慢但能任意大小常见坑:① 闭包 hold 住大对象 → 堆泄漏;② 递归过深 → 栈溢出;③ 事件监听器没移除 → 对象被引用无法回收。
名词解释
调用栈(Call Stack):记录「现在正在执行哪些函数」的栈结构,每调用一个函数就「压栈(push)」,函数返回就「弹栈(pop)」。方法:console.trace() 也能打印当前调用栈。你可以把它想成「叠盘子」:最上面的盘子(栈顶)是当前正在洗的,洗完拿走,下面的露出来。栈满(无限递归)就「盘子塌了」——栈溢出。
执行上下文(Execution Context):函数执行时的「运行环境快照」,包含变量、this、作用域链。它不像有显式方法调用,而是由引擎在函数被调用时自动创建(create)、执行完自动销毁(destroy)。可类比「每次进一个房间,房间里的陈设(变量)只在这趟访问期间有效」。
闭包(Closure):「函数 + 它出生时的词法作用域」的组合。它的方法/能力:记住(capture)外层变量、延长(extend)变量生命周期。闭包不是「语法」,而是「当内层函数逃出外层函数作用域时,引擎被迫保留外层变量」这一行为的结果。
课后练习
练习 1:下面代码输出什么?为什么和「调用位置」无关?
const x = '全局'
function foo() { console.log(x) }
function bar() { const x = '局部'; foo() }
bar()答案:输出
全局。因为 JS 是词法作用域——foo里的x在「写代码时」就绑定到了它定义处(全局)的x,和它「被bar调用」这件事无关。bar里的局部x不影响foo的查找。这正是「作用域看定义处,不看调用处」。
练习 2:下面这段代码会栈溢出吗?为什么?
function loop(n) {
if (n <= 0) return
return loop(n - 1)
}
loop(100000)答案:在默认调用栈深度下很可能溢出(
RangeError: Maximum call stack size exceeded)。因为每次递归调用都会往调用栈压一层,10 万层远超浏览器/Node 默认的栈容量(通常几千到一万多层)。改法:改成循环(for),或确认环境支持尾调用优化(TCO,但多数 JS 引擎实际未开启)。递归适合「深度可控」的场景。
总结
这一节我想让你建立的一个画面是:代码不是「平铺」执行的,而是「一层层叠起来又拆掉」的。调用栈就是那个叠盘子的架子,函数进出有秩序;一旦递归失控,架子就塌了。而闭包则是另一个方向的智慧——它让「已经返回的函数」还能记住自己的出身环境,这是 JS 实现私有状态、模块封装、函数工厂的基石。但能力越大责任越大:闭包记住的东西,GC 不敢回收,于是「长期存活的闭包 + 大对象引用」就成了内存泄漏的高发地。我的建议是:把调用栈想成「临时工位」(用完即清),把闭包想成「带锁的储物柜」(开了就占着),写代码时心里有这本账,Bug 会少一大半。