前言
在 JavaScript 的世界里, “变量在哪里定义,就去哪里找” 这一朴素原则背后,隐藏着一套精密而优雅的底层机制——词法作用域(Lexical Scoping) 与 闭包(Closure) 。它们不是魔法,而是 V8 引擎在编译阶段就已确定的静态规则,决定了变量的查找路径、生命周期乃至内存管理。
本文将带你穿越 JavaScript 的执行全流程:
- 从 V8 引擎的编译与执行阶段 出发
- 揭秘 执行上下文、调用栈、词法环境 的协作关系
- 彻底厘清 词法作用域链 vs 动态作用域 的本质区别
- 深度剖析 闭包的形成条件、内存模型与实际应用
无论你是被“闭包”概念困扰的新手,还是想夯实底层认知的进阶者,这篇文章都将为你构建一幅清晰、准确、可落地的知识地图。
一、JavaScript 执行全景:编译 + 执行
1. V8 引擎的两阶段模型
JavaScript 并非纯解释型语言,而是 “先编译,后执行” :
| 阶段 | 核心任务 |
|---|---|
| 编译阶段 | 解析代码 → 构建 AST → 生成字节码 → 确定词法作用域链 |
| 执行阶段 | 创建执行上下文 → 管理调用栈 → 变量赋值与函数调用 |
✅ 关键洞察:
作用域链在编译时就已固定,与函数如何被调用无关!
二、执行上下文与调用栈:变量的“家”
1. 执行上下文(Execution Context)
每次进入全局代码或函数,V8 会创建一个执行上下文,包含:
- 变量环境(Variable Environment) :存放
var声明 - 词法环境(Lexical Environment) :存放
let/const/函数声明 - this 绑定
- 外部引用(Outer) :指向父级词法环境 → 构成作用域链
2. 调用栈(Call Stack)
- 函数调用时,其执行上下文压入栈顶
- 函数返回后,上下文出栈并回收(但闭包例外! )
js
编辑
function foo() {
bar();
}
function bar() {
console.log("hello");
}
foo(); // 调用栈:[全局] → [全局, foo] → [全局, foo, bar]
⚠️ 注意:调用栈决定执行顺序,但不决定变量查找路径!
三、词法作用域:静态的查找规则
1. 什么是词法作用域?
变量的查找范围,由函数在源代码中声明的位置决定,而非调用位置。
这是 JavaScript 的核心设计,也是闭包存在的基础。
2. 经典案例:为什么输出 “极客时间”?
js
编辑
function bar() {
console.log(myName); // 输出 "极客时间"
}
function foo() {
var myName = '极客邦';
bar(); // 在 foo 内部调用 bar
}
var myName = '极客时间';
foo();
🔍 分析过程:
bar函数声明在全局作用域- 编译阶段,V8 已确定:
bar的词法环境 → 全局环境 - 尽管
bar在foo中被调用,变量查找仍沿词法作用域链向上 - 最终在全局找到
myName = "极客时间"
❌ 常见误区:
“函数在哪里调用,就用哪里的变量” —— 这是动态作用域(如 Bash),JS 不是!
四、作用域链:变量的查找路径
1. 作用域链的构建
每个词法环境都有一个 [[Outer]] 指针,指向其外层词法环境,形成链式结构:
js
编辑
globalEnv
↑
fooEnv → [[Outer]] = globalEnv
↑
innerBar.getNameEnv → [[Outer]] = fooEnv
2. 查找规则(LEGB 规则)
当访问变量 x 时,引擎按顺序查找:
- Local(当前函数)
- Enclosing(外层函数)
- Global(全局)
- Built-in(内置对象,如
undefined)
✅ 静态性:这条链在函数声明时就已确定,永不改变。
五、闭包:跨越执行上下文的生命延续
1. 什么是闭包?
当一个内部函数被返回并在外部调用时,它仍能访问其定义时所在作用域的变量,这种现象称为闭包。
2. 闭包的形成条件
- 函数嵌套函数
- 内部函数引用了外部函数的变量(自由变量)
- 内部函数在外部被调用(通常通过
return)
3. 经典闭包示例解析
js
编辑
function foo() {
var myName = "极客时间";
let test1 = 1;
const test2 = 2;
var innerBar = {
getName: function () {
console.log(test1); // 自由变量
return myName; // 自由变量
},
setName: function (name) {
myName = name; // 修改闭包变量
}
};
return innerBar; // 返回内部函数
}
var bar = foo(); // foo 执行完毕,上下文应出栈
bar.setName("极客邦");
console.log(bar.getName()); // "极客邦"
🔬 底层发生了什么?
foo()执行,创建执行上下文getName和setName的词法环境记录:[[Outer]] = foo 的词法环境foo返回后,其执行上下文本应销毁- 但由于
bar.getName仍持有对foo词法环境的引用 → V8 不会回收这些变量 myName,test1等变量被保存在一个“闭包背包”中,随函数对象常驻内存
💡 闭包的本质:
函数对象 + 其定义时的词法环境快照
六、闭包的内存模型:自由变量与垃圾回收
1. 自由变量(Free Variables)
- 在函数内部使用,但未在该函数内声明的变量
- 通过作用域链从外层捕获
2. 垃圾回收(GC)规则
- 只要闭包函数未被销毁,其引用的自由变量就不会被 GC
- 当闭包函数失去所有引用,整个闭包环境才会被回收
js
编辑
var bar = foo();
// 此时 foo 的变量仍在内存中
bar = null; // 解除引用
// 下次 GC 时,foo 的变量将被回收
七、块级作用域与闭包
ES6 的 let/const 引入了块级作用域,进一步丰富了闭包场景:
js
编辑
function foo() {
var myName = "极客邦";
let test = 2;
{
let test = 3; // 块级作用域
bar(); // 调用全局 bar
}
}
function bar() {
console.log(myName); // "极客时间"(全局变量)
}
var myName = "极客时间";
foo();
✅ 即使在块中调用函数,词法作用域仍由声明位置决定。
八、大厂面试高频问题
-
闭包是什么?如何形成?
→ 内部函数访问外部变量,且在外部被调用。 -
闭包会导致内存泄漏吗?
→ 不会!只要合理管理引用,GC 会自动回收。但意外持有大对象引用可能造成问题。 -
for 循环中的闭包陷阱如何解决?
js 编辑 // 错误:所有回调共享 i for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); } // 正确:用 let 创建块级作用域 for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); } -
词法作用域和动态作用域的区别?
→ 词法看定义位置,动态看调用位置。JS 是词法作用域。
结语
词法作用域与闭包,是 JavaScript 区别于其他语言的灵魂特性。它们不是缺陷,而是强大抽象能力的体现:
- 词法作用域 提供了可预测的变量查找规则
- 闭包 实现了状态封装、模块化、回调等高级模式
理解它们,意味着你不再“猜”变量的值,而是知道它一定在哪里;你不再害怕“内存泄漏”,而是掌控数据的生命周期。
记住:
“闭包不是 bug,而是 feature;
作用域不是限制,而是秩序。”
—— 掌握底层机制,方能写出真正可靠的代码。