JavaScript作用域和闭包

作用域

var 关键字声明变量,作用域是当前函数或全局:

1
2
3
4
5
6
7
function varDemo() {
{
var x = 1;
}
console.log(x); // 1
}
varDemo();

而用 let/const 关键字声明变量或常量,作用域是当前代码块({ ... })或全局:

1
2
3
4
5
6
7
function letDemo() {
{
let y = 2;
}
console.log(y); // ReferenceError: y is not defined
}
letDemo();
1
2
3
4
5
6
7
function constDemo() {
{
const z = 3;
}
console.log(z); // ReferenceError: z is not defined
}
constDemo();

function ...() {}var 类似,通常也可以穿透代码块:

1
2
3
4
5
6
7
{
function f() {
return 1;
}
console.log(f()); // 1
}
console.log(f()); // 1

赋值给 var/let/const 声明的变量或常量的函数,则遵循对应声明方式的行为。这里不再赘述。

提升(Hoisting)

var 声明的变量会被提升到作用域(即当前函数或全局)的顶部。在 var 之前读取不会报错,只会得到 undefined。举个例子,下面这段代码:

1
2
3
console.log(x); // undefined
var x = 1;
console.log(x); // 1

等效于:

1
2
3
4
var x;
console.log(x); // undefined
x = 1;
console.log(x); // 1

let/const 则不一样。在 let/const 之前读取会报错:

1
2
3
console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 2;
console.log(y);
1
2
3
console.log(z); // ReferenceError: Cannot access 'z' before initialization
const z = 3;
console.log(z);

function ...() {}var 关键字类似,也会被提升到作用域(通常是当前函数或全局)的顶部。下面这段代码:

1
2
3
4
console.log(f()); // 1
function f() {
return 1;
}

等效于:

1
2
3
4
function f() {
return 1;
}
console.log(f()); // 1

哪怕换成 var 也不能达到同样的效果。比如下面这段代码:

1
2
3
4
console.log(f()); // TypeError: f is not a function
var f = function () {
return 1;
};

等效于:

1
2
3
4
5
var f;
console.log(f()); // TypeError: f is not a function
f = function () {
return 1;
};

可以看到执行 f()f 还是 undefined

TDZ(Temporal Dead Zone/暂时性死区)

严格地说,let/const 并非不会被提升。var 的声明和初始化(赋值为 undefined)都会被提升,而 let/const 只有声明会被提升到作用域(即当前代码块或全局)的顶部。从作用域顶部到 let/const 的区域,被称为 TDZ。

可以注意到,代码报了 ReferenceError: Cannot access '...' before initialization 而非 ReferenceError: ... is not defined。这说明解释器其实知道这些变量和常量的存在,只是它们还处在 TDZ。

闭包

理解闭包之前,先要理解词法环境(lexical environment):代码块、函数以及整个脚本都有自己的词法环境。一个词法环境保存了局部变量、常量等信息,以及对外层词法环境的引用(outer)。最外层的全局词法环境的 outernull。当代码访问一个变量时,会先在当前词法环境中查找,再沿着 outer 链逐层向外查找,直至全局词法环境。

理解了词法环境,闭包其实可以用一句话概述:当一个函数被创建时,它会记住所在的词法环境;以后不论在哪里调用这个函数,函数自身词法环境的 outer 都会指向它创建时所在的词法环境。

代码块和整个脚本会在进入作用域时创建自己的词法环境,函数则是在被调用时才创建自己的词法环境。

举个最典型的例子——在函数中返回函数:

1
2
3
4
5
6
7
8
9
10
function makeCounter() {
let count = 0;
return function () {
count += 1;
console.log(count);
};
}
const counter = makeCounter();
counter(); // 1
counter(); // 2

每次调用 counter() 时,至少会涉及三层词法环境:counter() 自己的词法环境、makeCounter() 的词法环境,以及全局词法环境。counter() 自己的词法环境会在每次调用时重新创建,访问 count 时,解释器会先在这一层查找,发现找不到后,再沿着 outermakeCounter() 的词法环境中查找,并在那里找到 count。由于 makeCounter() 的词法环境只在调用时被创建了一次,最终产生了计数器的效果。

常见陷阱

最常见的陷阱之一,就是在 for 循环里同时用 var 并创建闭包:

1
2
3
4
5
6
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 1000);
}
// 3
// 3
// 3

由于 var 是函数作用域或全局作用域,多轮循环访问的是同一个 i。而 setTimeout() 里的回调函数形成了闭包。1 秒之后,回调函数执行,读取了全局词法环境中的 i,那时它已经是 3 了。

修复的方法很简单,就是把 var 换成 let

1
2
3
4
5
6
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 1000);
}
// 0
// 1
// 2

let 声明的 i 属于 for 循环的代码块,每轮循环都有独立的 i。哪怕闭包仍然存在,回调函数执行时,会从各轮循环的词法环境读取到正确的 i

for 循环比较特殊,虽然 i 不在 { ... } 内,但它仍然属于 for 循环代码块的词法环境。

另一种方法是用 IIFE(Immediately Invoked Function Expression/立即执行函数表达式):

1
2
3
4
5
6
7
8
for (var i = 0; i < 3; i++) {
(function (i) {
setTimeout(() => console.log(i), 1000);
})(i);
}
// 0
// 1
// 2

IIFE 是一种立即执行的函数表达式。每次循环都会调用一次 IIFE,从而创建一个新的词法环境,并保存一份独立的 i,因此后面的回调函数可以读取到正确的 i

再来讲另一个陷阱。先看下面这段代码:

1
2
3
4
5
6
7
8
9
10
const { pipe } = ReactDOMServer.renderToPipeableStream(
<Root />,
{
// ...
onShellReady() {
pipe(res);
},
// ...
},
);

renderToPipeableStream() 函数返回了 pipe,而在它的参数中,却直接引用了 pipe。这看起来应该报错,但奇怪的是,这段代码能够正常运行。这很反直觉,但其实可以用上面的知识解释。

首先,const 会提升 pipe 的声明。在执行 renderToPipeableStream() 之前,pipe 就已经存在于词法环境中了。其次,onShellReady 是一个回调函数,会形成闭包。因此回调函数会记住外层词法环境中的 pipe,但只有在回调函数真正执行时,才会读取 pipe 的值。这个回调函数显然不是在执行 renderToPipeableStream() 时被立刻调用的——不然就会报错了——所以这段代码可以正常运行。

参考资料


JavaScript作用域和闭包
https://tomzhu.site/2026/06/27/JavaScript作用域和闭包/
作者
Tom Zhu
发布于
2026年6月28日
许可协议