“闭包不是背包,但比背包更会藏东西”——从三段诡异代码看透 JS 作用域链与闭包的本质
副标题:别再死记“函数嵌套 + 返回内部函数 = 闭包”了!本文带你用 V8 引擎视角,拆解变量查找、词法作用域链、内存回收机制,直击大厂高频面试题核心。
🧨 开场暴击:三段代码,三个“极客”,一个答案?
先来看这三段看似独立、实则暗藏玄机的 JavaScript 代码:
// 代码片段 A
function bar() {
console.log(myName);
}
function foo() {
var myName = '极客帮';
bar(); // 运行时
}
var myName = '极客时间';
foo();
// 代码片段 B
function bar() {
var myName = "极客世界";
let test1 = 100;
if (1) {
let myName = "Chrome 浏览器";
console.log(test); // 注意:这里 log 的是 test,不是 test1!
}
}
function foo() {
var myName = "极客邦";
let test = 2;
{
let test = 3;
bar();
}
}
var myName = "极客时间";
let myAge = 10;
let test = 1;
foo();
// 代码片段 C(闭包经典)
function foo() {
var myName = "极客时间";
let text1 = 1;
const text2 = 2;
var innerBar = {
getName: function () {
console.log(text1);
return myName;
},
setName: function (name) {
myName = name;
}
};
return innerBar;
}
var bar = foo();
bar.setName("极客邦");
console.log(bar.getName()); // 输出?
问题来了:
- 片段 A 输出什么?为什么不是 “极客帮”?
- 片段 B 会报错吗?如果会,错在哪?
test到底是谁? - 片段 C 中,
foo()执行完后,里面的myName和text1不是应该被回收了吗?为什么还能访问?
如果你能秒答且解释清楚底层机制,恭喜你,已经站在了字节/阿里 P7 的门槛上。
如果还有一丝犹豫——别慌,这篇文章就是为你准备的。
🔍 第一章:JS 引擎如何“看”你的代码?编译阶段 vs 执行阶段
很多人以为 JS 是“解释型语言”,一行一行执行。错!V8 引擎其实有编译阶段。
1.1 编译阶段:变量提升(Hoisting)与词法环境构建
在代码真正运行前,V8 会做两件事:
- 变量/函数声明提升(仅声明,不赋值)
- 构建词法环境(Lexical Environment) —— 这就是作用域的底层实现
以片段 A 为例:
var myName = '极客时间'; // 全局变量
function foo() { ... }
function bar() { ... }
在编译阶段,V8 已经知道:
- 全局作用域中存在
myName、foo、bar foo内部有局部变量myNamebar内部没有声明myName,所以它会去外层作用域找
✅ 关键点:作用域链是在编译时静态确定的,和函数在哪里调用无关!
这就是词法作用域(Lexical Scope) 的核心:看函数写在哪,而不是在哪调用。
所以当 foo() 调用 bar() 时,bar 的作用域链是:
bar 的作用域 → 全局作用域
不是 foo 的作用域!因为 bar 是在全局定义的。
因此,片段 A 输出:"极客时间"。
💡 面试高频陷阱:函数作为参数传递或回调时,其作用域仍由定义位置决定。
🧱 第二章:块级作用域、let/const 与词法作用域的“铁律”
我们来看这段看似混乱、实则精准体现 JavaScript 作用域机制的代码(即提供的片段 B):
function bar() {
var myName = "极客世界";
let test1 = 100;
if (1) {
let myName = "Chrome 浏览器";
console.log(test); // 注意:这里访问的是 test,不是 test1!
}
}
function foo() {
var myName = "极客邦";
let test = 2;
{
let test = 3;
bar(); // 调用全局定义的 bar
}
}
var myName = "极客时间";
let myAge = 10;
let test = 1; // 全局变量
foo();
2.1 问题:console.log(test) 会输出什么?
很多初学者会直觉认为:
“
bar是在foo里面调用的,而foo里有test = 2和块级test = 3,所以应该输出 3 或 2。”
这是典型的误区——混淆了“调用位置”和“定义位置”。
JavaScript 使用的是 词法作用域(Lexical Scope) ,也叫 静态作用域。
这意味着:一个函数能访问哪些变量,在它被声明(写下来)的那一刻就已经决定了,和它在哪里被调用完全无关。
2.2 关键分析:bar 的作用域链是什么?
bar 函数是在全局作用域中声明的,因此它的作用域链结构为:
bar 的局部作用域
└── 全局作用域
注意:不包含 foo 的作用域!
即使 bar() 是在 foo 内部被调用的,V8 引擎在查找变量时,依然只沿着 bar 自身的作用域链向上搜索。
当执行到 console.log(test) 时,引擎按以下顺序查找 test:
- 在
bar的局部作用域中查找
→ 没有var/let/const test声明,跳过。 - 进入上一级作用域:全局作用域
→ 找到let test = 1;,且该变量已完成初始化(不在暂时性死区),
→ 于是成功读取其值:1。
✅ 最终输出:1
2.3 为什么不会报错?
有人可能会担心:“bar 里没声明 test,会不会报 ReferenceError: test is not defined?”
不会!
ReferenceError 只在以下情况发生:
- 变量从未被声明,且你试图读取它;
- 或者变量使用
let/const声明,但处于暂时性死区(TDZ) 中。
而在本例中:
- 全局作用域确实声明了
let test = 1; - 该声明在
foo()调用前已完成(JS 代码自上而下执行,test = 1在foo()之前); - 因此
test是可访问的合法变量。
✅ 结论:代码正常运行,输出
1,无任何错误。
2.4 对比实验:如果 bar 定义在 foo 内部呢?
为了加深理解,我们做个小实验:
let test = 'global';
function foo() {
let test = 'local';
function bar() { // ← 现在 bar 定义在 foo 内部!
console.log(test);
}
bar();
}
foo(); // 输出 'local'
此时,bar 的作用域链变为:
bar 局部作用域
└── foo 的作用域
└── 全局作用域
因此它会优先找到 foo 中的 test,输出 'local'。
这再次验证了核心原则:
作用域链由函数的“书写位置”决定,而非“调用位置”。
2.5 高频面试陷阱总结
| 误区 | 正确认知 |
|---|---|
| “函数在哪调用,就能访问哪的变量” | ❌ JS 是词法作用域,看定义位置 |
| “let 没提升,所以找不到变量就报错” | ❌ let 有提升,但有 TDZ;只要变量存在且已初始化,就能跨作用域访问 |
| “闭包才能访问外层变量” | ❌ 任何内部函数(无论是否返回)都能访问其词法父作用域 |
💡 小结
片段 B 的设计精妙之处在于:
- 用
bar在foo中调用的“假象”,诱导你误判作用域; - 实际通过
console.log(test)的变量名(与foo中的test同名但无关),测试你对词法作用域链的理解深度。
记住这条铁律:
在 JavaScript 中,函数永远带着它出生地的地图(作用域链)行走江湖,不管它后来去了哪里。
🎒 第三章:闭包——不是“背包”,而是“被捕获的词法环境”
终于来到重头戏:闭包(Closure) 。
很多人说:“闭包就是返回一个内部函数”。
不准确!闭包的本质是:函数 + 其定义时的词法环境的组合。
看片段 C:
function foo() {
var myName = "极客时间";
let text1 = 1;
return {
getName: function() { console.log(text1); return myName; },
setName: function(name) { myName = name; }
};
}
var bar = foo();
当 foo() 执行完毕,其执行上下文(Execution Context)确实会从调用栈弹出。
按理说,myName 和 text1 应该被垃圾回收。
但 V8 发现:有两个函数(getName/setName)引用了它们!
于是,V8 不会回收这些变量,而是将它们打包进一个“闭包环境”(Closure Scope),挂载在那两个函数的 [[Scope]] 内部属性上。
✅ 闭包形成的三个条件:
- 函数嵌套(内部函数)
- 内部函数引用了外部变量(自由变量)
- 内部函数在外部被引用(生命周期延长)
所以:
bar.setName("极客邦");
bar.getName(); // 输出 "极客邦"
myName 被成功修改并读取,即使 foo 已经执行完毕。
💡 自由变量(Free Variables):指在函数内部使用、但未在该函数内声明的变量。它们会被“捕获”进闭包。
🧠 深度拓展:闭包与内存管理、性能优化
4.1 闭包会导致内存泄漏吗?
可能,但不是必然。
- 如果闭包引用了大型对象(如 DOM 节点、大数组),且长时间不释放,就会占用内存。
- 解决方案:用完后置
null,断开引用。
bar = null; // 此时闭包环境可被 GC 回收
4.2 闭包 vs 模块模式
片段 C 其实是模块模式(Module Pattern) 的雏形:
const user = (function() {
let name = '匿名';
return {
getName() { return name; },
setName(n) { name = n; }
};
})();
这是 ES6 之前实现“私有变量”的主要手段。
4.3 现代替代方案:WeakMap / Private Fields
ES2022 引入了类私有字段:
class User {
#name = '匿名';
getName() { return this.#name; }
setName(n) { this.#name = n; }
}
更安全、性能更好,但闭包仍是理解 JS 作用域的基石。
🧪 高频面试题关联
| 面试题 | 本文对应知识点 |
|---|---|
| “解释 JS 作用域链” | 词法作用域、编译阶段构建作用域链 |
| “闭包是什么?如何形成?” | 自由变量、函数引用延长生命周期 |
| “var/let 在 for 循环中的区别?” | 块级作用域 vs 函数作用域 |
| “setTimeout 闭包陷阱” | 回调函数作用域链问题 |
| “如何避免闭包内存泄漏?” | 引用清理、WeakMap |
✅ 总结:一张图看懂 JS 作用域与闭包
[全局作用域]
├── myName ("极客时间")
├── foo()
└── bar() → 作用域链:bar → 全局
[foo 执行上下文](执行时创建)
├── myName ("极客帮")
└── 调用 bar() → 但 bar 的作用域链不变!
[闭包场景]
foo() 返回对象 → 对象方法引用 foo 内部变量
→ V8 保留 foo 的词法环境 → 形成闭包
记住:
- 作用域链 = 函数定义位置决定(静态)
- 闭包 = 函数 + 被捕获的外部变量(动态存活)
- 变量回收 = 是否还有引用(引用计数 + 可达性分析)
📣 结语
闭包不是魔法,也不是“背包”,而是 JS 词法作用域机制的自然产物。
理解它,就理解了 React Hooks 的 stale closure、Vue 的响应式原理、甚至 Node.js 的模块缓存。
下次面试官问:“闭包是什么?”
你可以微微一笑:“它是我函数的‘记忆’,只要我还活着,它就不会忘。”