你是否曾遇到这样的代码:
console.log(a);
var a = 1;
输出是 undefined 而不是报错?
或者写了个 for 循环,用 let 和 var 定义变量,结果行为完全不同?
更离谱的是,函数明明在别的地方调用,却还能访问它“出生地”的变量——这就是传说中的 闭包。
今天我们不讲花里胡哨的概念堆砌,而是带你一步步揭开 JavaScript 执行背后的真相:从变量提升、块级作用域,再到作用域链和闭包的本质。你会发现,这一切的背后,其实是 V8 引擎如何管理“执行上下文”和“词法环境”的精密设计。
一、问题起源:为什么 var 会“提升”?
我们先来看一个经典例子:
var n = '苗子';
function sayName() {
console.log(n); // 输出什么?
if (false) {
var n = '小树';
}
console.log(n);
}
sayName();
很多人第一反应是:
- 第一个
console.log(n)应该输出'苗子' - 因为
if(false)不会执行,所以n没被重新赋值
但实际运行结果是:
undefined
undefined
❓为什么会这样?
答案就是:变量提升(Hoisting)
在编译阶段,JavaScript 引擎会把所有通过 var 声明的变量,提升到当前作用域的顶部,并初始化为 undefined。
所以上面的代码,在引擎眼里其实是这样的:
function sayName() {
var n; // 提升到了函数顶部,值为 undefined
console.log(n); // → undefined
if (false) {
n = '小树'; // 这部分不会执行
}
console.log(n); // → 依然是 undefined
}
而外层的 var n = '苗子' 并没有影响函数内部的作用域 —— 函数有自己的局部作用域。
✅ 结论:
var声明存在变量提升 + 函数作用域,导致变量可能提前使用且不易察觉地遮蔽外部变量。
这显然不符合直觉,也容易引发 bug。比如你在函数中间声明了一个变量,以为前面用不到它,但实际上已经“被提升”了。
二、ES6 的更新:let/const 与暂时性死区
为了解决这个问题,ES6 引入了 let 和 const,它们不再支持变量提升,而是进入了 暂时性死区(Temporal Dead Zone, TDZ)。
看这个例子:
let n = '苗子';
function sayName() {
console.log(n); // 输出什么?
if (false) {
let n = '小树';
}
}
sayName(); // 输出:'苗子'
这次输出的是 '苗子'!
❓为什么没有报错?又为什么能访问外层的 n?
因为:
let n = '小树'是块级作用域内的声明,只在{}内有效。- 在
if块中虽然没执行,但它仍然创建了一个新的块级作用域。 - 函数内部并没有用
let/var/const声明n,所以查找时会沿着作用域链向上找,找到全局的n = '苗子'。
但如果我们在前面加一句:
function sayName() {
console.log(n); // ReferenceError!
let n = '小树';
}
这时就会抛出错误:
ReferenceError: Cannot access 'n' before initialization
这就是 暂时性死区 的体现:
从进入作用域开始,到 let 正式声明之前,这个变量都不能被访问。
✅
let/const不会提升,但在声明前访问会报错(TDZ),比var更安全。
三、执行上下文视角:变量环境 vs 词法环境
那么问题来了:JS 是怎么做到 var 和 let 行为不同的?难道有两个系统在同时工作?
是的!V8 引擎在构建 执行上下文(Execution Context) 时,其实维护了两个关键部分:
| 组件 | 存储内容 | 特性 |
|---|---|---|
| 变量环境(Variable Environment) | var 声明的变量 | 支持变量提升 |
| 词法环境(Lexical Environment) | let/const 声明的变量 | 支持块级作用域、暂时性死区 |
再看一个例子:
function foo() {
var a = 1;
let b = 2;
{
let b = 3;
var c = 4;
let d = 5;
}
console.log(b); // 输出 2
console.log(c); // 输出 4
console.log(d); // 报错:d is not defined
}
foo();
分析一下:
var c = 4虽然在块内,但var只认函数作用域,所以提升到foo函数顶部 → 可以访问。let b = 3是块级作用域,出了{}就销毁 → 外层b仍是2。let d = 5完全在块内,外面根本看不到 → 报错。
这背后正是 词法环境中的栈结构 在起作用:
- 每进入一个块级作用域(如
{}),词法环境就压入一个新层级。 - 查找变量时,从栈顶向下找。
- 块执行完后,该层级弹出,变量自动释放。
🔍 这就是 ES6 实现块级作用域的底层机制:词法环境是一个栈,每层对应一个作用域。
四、作用域链:变量查找的真实路径
现在我们来回答一个灵魂拷问:
函数在哪里被调用,就去那里找变量吗?
错!JS 使用的是 词法作用域(Lexical Scoping),也就是说:
✅ 变量查找的路径,是由函数定义的位置决定的,而不是调用的位置。
来看这个例子:
function bar() {
console.log(myname);
}
function foo() {
var myname = '极客帮';
bar(); // 注意:bar 是在这里被调用的
}
var myname = '极客时间';
foo(); // 输出什么?
你可能会想:“bar 是在 foo 里面调用的,应该能看到 myname = '极客帮' 吧?”
但输出结果是:极客时间
为什么?
因为:
bar是在全局定义的,它的外层作用域就是全局作用域。- 当
bar查找myname时,沿着词法作用域链向上找,直接找到了全局的myname。 - 它并不会因为“在
foo中调用”就去foo的作用域里找。
🎯 重点:作用域链是静态的,在函数定义时就已经确定了!
你可以把它想象成一条“出生证明链”:每个函数都记得自己是在哪个环境中诞生的,以后不管跑到哪执行,都会回这条链上去找变量。
五、闭包:不只是“能访问外部变量”,更是执行上下文的生命延续
我们常说:“闭包就是函数能够访问其外部作用域中的变量。”
但这只是表象。
真正的闭包,是 JavaScript 执行机制与内存管理共同作用下的高级现象 —— 它打破了“函数执行完就销毁”的直觉,让变量得以跨越时间与空间被持续访问。
让我们从一个熟悉的例子开始:
function foo() {
var myname = '极客时间';
let test1 = 1;
var innerBar = {
getname: function () {
console.log(test1); // → 1
return myname;
},
setname: function (newname) {
myname = newname;
}
};
return innerBar; // 把内部函数暴露出去
}
var bar = foo(); // foo 执行完毕,按理说该回收了
bar.setname('极客帮');
console.log(bar.getname()); // 输出:'极客帮'
❓关键问题:
foo()已经执行结束,它的执行上下文应该已经从调用栈中弹出。- 那么
myname和test1这些局部变量,为什么还能被getname和setname访问到?
这就是闭包最震撼的地方:即使定义它的环境消失了,它依然能记住那个环境里的值。
✅ 闭包的本质是:内部函数保留了对外部词法环境的引用,从而延长了自由变量的生命周期。
1. 什么是“自由变量”?
在上面的例子中:
getname: function () {
return myname; // ← myname 不是参数,也不是自己声明的
}
这里的 myname 就是一个 自由变量(free variable) —— 它既不是形参,也不是函数内部用 let/var/const 声明的,而是来自外层作用域。
JS 引擎在编译阶段就会标记这些自由变量,并建立一条通往它们出生地的作用域链。
2. 普通函数 vs 闭包函数:执行上下文的命运不同
我们来看两种情况的区别:
情况一:普通嵌套函数,未逃逸
function outer() {
let x = 10;
function inner() {
console.log(x);
}
inner(); // 立即执行
}
outer(); // 执行完后,x 被回收
分析:
inner在outer内部立即执行;- 当
outer出栈时,inner的作用域链虽然指向outer的词法环境; - 但由于没有其他引用,整个结构可以被垃圾回收。
✅ 此时不构成“实用意义上的闭包”。
情况二:函数返回并被外部持有
function outer() {
let x = 10;
return function inner() {
console.log(x);
};
}
const fn = outer();
fn(); // 仍然能输出 10
这时:
outer执行完毕,执行上下文本应销毁;- 但
inner被返回并赋值给全局变量fn; - 而
inner内部引用了x,所以 JS 引擎必须保留x所在的词法环境; - 即便
outer的执行上下文已出栈,这部分数据仍驻留在内存中。
🔥 这才是真正的闭包:函数携带了它诞生时的环境快照。
3. 闭包的底层机制:词法环境的“背包理论”
我们可以把闭包想象成一个人背着一个 专属背包(Closure Scope Bag)。
这个背包里装的是什么?
| 包裹内容 | 说明 |
|---|---|
| 外部函数的词法环境引用 | 特别是那些被内部函数使用的变量 |
| 自由变量的实际值或引用 | 如 myname, test1 等 |
| 作用域链的静态连接 | 编译时确定的查找路径 |
当内部函数被返回或存储在某处(如对象属性、数组、全局变量),V8 引擎会检测到它对某些外部变量的依赖,于是不会释放这些变量所在的词法环境,而是将其“打包”附着在函数对象上。
💡 你可以通过 Chrome DevTools 查看 Closure 面板,看到这个“背包”里到底有哪些变量。
4. 经典误区澄清:闭包不是“函数”,而是“状态 + 函数”的组合体
很多人误以为“闭包是一个函数”。其实更准确地说:
✅ 闭包 = 函数 + 其所能访问的所有自由变量的集合
举个例子帮助理解:
function createCounter() {
let count = 0;
return {
increment: () => ++count,
decrement: () => --count,
value: () => count
};
}
const counter1 = createCounter();
const counter2 = createCounter();
counter1.increment();
counter1.increment();
console.log(counter1.value()); // 2
console.log(counter2.value()); // 0
你会发现:
counter1和counter2是两个独立的计数器;- 它们各自背负着自己的“背包”;
- 每次调用
createCounter(),都会创建一个新的词法环境,进而生成一个独立的闭包。
这说明:每次外层函数执行,都可能产生一个新的闭包实例。
5. 实际应用场景:闭包如何改变我们的编程方式
场景一:私有变量模拟
JS 没有 private 关键字,但我们可以通过闭包实现:
function createUser(name) {
let _name = name; // 外界无法直接访问
return {
getName: () => _name,
setName: (newName) => {
if (typeof newName === 'string') _name = newName;
}
};
}
const user = createUser('苗子');
user.getName(); // '苗子'
user.setName('小树');
user.getName(); // '小树'
// _name 无法从外部修改 → 实现封装
直接暴露封装getter,setter函数,实现属性私有化
场景二:事件回调中的变量绑定
常见于循环绑定事件:
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 全部输出 3
}, 100);
}
问题原因:var 提升 + 闭包共享同一个 i
解决方案之一就是利用闭包隔离变量:
for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(() => {
console.log(j); // 输出 0, 1, 2
}, 100);
})(i);
}
或者更现代的方式使用 let(块级作用域自动形成闭包):
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 输出 0, 1, 2
}, 100);
}
✅
let在每次迭代时创建新的词法环境,相当于自动形成了多个闭包。
6. 闭包的代价:内存泄漏风险
凡事有利有弊。闭包的强大之处在于延长变量生命周期,但也正因如此,容易造成 内存泄漏。
例如:
function heavyTask() {
const largeData = new Array(1000000).fill('💀'); // 占用大量内存
return function () {
console.log('done');
// 但 largeData 始终无法被回收!
};
}
const task = heavyTask(); // 返回的函数没用到 largeData?
// 错!只要它在同一作用域内,就被视为潜在可访问 → 不会被回收
除非你显式解除引用:
task = null; // 才可能触发 GC 回收
所以在实际开发中要注意:
- 避免在闭包中引用不必要的大对象;
- 及时清理不再需要的函数引用;
- 使用 WeakMap/WeakSet 替代强引用,减少内存压力。
✅ 闭包总结:一句话+三个条件+一个比喻
一句话定义闭包: 当一个内部函数被外部持有时,它仍然能够访问其定义时所在作用域中的变量,这种现象称为闭包。
形成闭包的三个必要条件:
- 函数嵌套;
- 内部函数引用了外部函数的变量;
- 内部函数被传递到外部作用域并被执行。
一个形象比喻: 闭包就像一个孩子离家外出工作,但他背包里始终装着老家的照片和钥匙。无论走多远,他都能回忆起童年,也能随时“回去看看”。
六、综合实战:复杂作用域场景解析
我们来看一个更复杂的例子:
function bar() {
var myname = '极客世界';
let test1 = 100;
if (1) {
let myname = 'chrome浏览器';
console.log(test); // 输出什么?
}
}
function foo() {
var myname = '极客帮';
test = 2; // 注意:这里没有 var/let/const → 全局变量!
{
let test = 3;
bar();
}
}
var myname = '极客时间';
let test = 5;
foo();
最终输出:1
分析过程:
- 在执行的
foo函数中调用了bar函数 bar中的console.log(test)查找test:- 被if作用域包含中没有
- 外层
bar作用域也没有 - 此时
bar函数作用域根据ourter往外找 - 是指向最外层的全局作用域,而不是调用
bar的foo作用域 - 最终找到全局的
test = 1
⚠️ 关键点:没有声明符的赋值会创建全局变量,极易造成污染。
七、总结:一张图理清 JS 作用域体系
| 特性 | var | let / const |
|---|---|---|
| 作用域类型 | 函数作用域 | 块级作用域 |
| 是否提升 | 是(初始化为 undefined) | 否(进入 TDZ) |
| 重复声明 | 允许(var 可重复 var) | 不允许 |
| 全局属性 | 是(挂在 window 上) | 否 |
| 存储位置 | 变量环境 | 词法环境 |
执行上下文结构简图:
执行上下文
├── 变量环境(Variable Environment)
│ └── 存放 var 声明 → 支持提升
└── 词法环境(Lexical Environment)
└── 栈结构,每层对应一个块
├── 存放 let/const
├── 支持块级作用域
└── 实现暂时性死区
作用域链查找规则:
- 先在当前执行上下文的词法环境中查找(从栈顶往下);
- 找不到则顺着外层词法环境查找;
- 一直找到全局环境为止;
- 整个链条由函数定义位置决定(词法作用域)。
八、写给开发者的建议
- 优先使用
let和const,避免var带来的不可预测性; - 不要省略声明符,防止意外创建全局变量;
- 理解闭包的代价:它延长了变量生命周期,注意防内存泄漏;
- 调试时关注“定义位置”而非“调用位置”,这是理解作用域链的关键;
- 多思考“这个变量到底存在哪里?” —— 是在变量环境?还是词法环境的哪一层?
结语:语言的设计从来不是偶然
JavaScript 初期为了快速上线、兼容浏览器竞争,牺牲了一些严谨性(比如没有块级作用域、变量提升)。但随着发展,ECMAScript 团队并没有推翻重来,而是在原有基础上引入新的机制(如词法环境栈),实现了向后兼容的同时提升了安全性。
这就像一座城市,老城区有狭窄的巷子(var),新城区有宽阔的高架(let/const)。我们要做的,不是抱怨旧路难走,而是学会看地图,知道什么时候走哪条路最快。
当你下次再看到 undefined 或 ReferenceError,别慌,那是引擎在提醒你:去看看你的作用域链吧。
🌟 延伸阅读建议:
- 《你不知道的 JavaScript(上卷)》—— 第二部分深入讲解作用域与闭包
- V8 源码中的
Closure实现逻辑(src/objects/js-function.h)- MDN 文档:Closures
如果你觉得这篇文章讲清楚了“为什么”,不妨点赞收藏,让更多人走出 JS 作用域的迷雾。
掘金不止技术,更是认知的沉淀。