深入理解 JavaScript 闭包与作用域
深入理解 JavaScript 闭包与作用域
在 JavaScript 开发中,闭包和作用域是两个绕不开的核心概念。它们不仅影响着代码的行为,还决定了变量的生命周期和访问权限。本文将从基础概念出发,通过实例代码和跨语言对比,带你彻底理解这两个概念。
什么是闭包
闭包(Closure)= 函数 + 函数能够访问的变量环境
这个定义听起来抽象,换个说法:闭包就是函数"记住了"它定义时的作用域,即使这个函数在作用域外被调用,它依然能访问到当时作用域里的变量。
看一个最基础的例子:
function outer() {
let count = 0;
function inner() {
count++;
console.log(count);
}
return inner;
}
const fn = outer();
fn(); // 输出 1
fn(); // 输出 2
fn(); // 输出 3
按照常规理解,outer 函数执行完毕后,它的局部变量 count 应该被销毁。但实际上,inner 函数"记住了"它定义时所在的环境,仍然能访问 count。这就是闭包的魔力:函数能"带走"它定义时的词法环境。
闭包的实际应用场景
理解了闭包的定义,我们来看看它在实际开发中的三个典型用途。
封装私有变量
JavaScript 没有像 Java 那样的 private 关键字,闭包可以模拟私有变量:
function Counter() {
let count = 0;
return {
inc: () => ++count,
get: () => count
};
}
const c = Counter();
console.log(c.inc()); // 1
console.log(c.inc()); // 2
console.log(c.get()); // 2
外部代码无法直接访问 count,只能通过暴露的方法操作,实现了数据的封装和保护。
保持状态
在事件回调、定时器等异步场景中,闭包可以帮助我们保留外部变量的值:
function setupTimer(message) {
setTimeout(() => {
console.log(message); // 闭包保持了对 message 的引用
}, 1000);
}
setupTimer('Hello from closure');
函数工厂
闭包可以根据不同参数生成定制化的函数:
function makeAdder(x) {
return function(y) {
return x + y;
};
}
const add5 = makeAdder(5);
const add10 = makeAdder(10);
console.log(add5(3)); // 8
console.log(add10(3)); // 13
每个生成的函数都"记住了"自己的 x 值,形成了独立的闭包环境。
var、let、const 的作用域差异
在深入闭包的"坑"之前,必须先理解 JavaScript 的三种变量声明方式。
三者的核心区别
| 关键字 | 作用域类型 | 是否可重复声明 | 是否可修改 | 提升行为 |
|---|---|---|---|---|
var | 函数作用域 | 是 | 是 | 提升并初始化为 undefined |
let | 块级作用域 | 否 | 是 | 提升但存在暂时性死区 |
const | 块级作用域 | 否 | 否(对象内容可变) | 提升但存在暂时性死区 |
块级作用域 vs 函数作用域
// var:函数作用域
function testVar() {
if (true) {
var x = 10;
}
console.log(x); // 10,x 在整个函数内可见
}
// let:块级作用域
function testLet() {
if (true) {
let y = 10;
}
console.log(y); // ReferenceError: y is not defined
}
块级作用域指的是由一对花括号 {} 包裹的区域,let 和 const 声明的变量只在该区域内有效。而 var 声明的变量会"穿透"块级作用域,提升到整个函数或全局作用域。
经典问题:循环中的闭包陷阱
现在我们把闭包和作用域结合起来,看一个前端开发中极其常见的"坑"。
问题演示:var 版本
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>var vs let 作用域演示</title>
<style>
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial;
padding: 24px;
}
h2 { margin: 24px 0 12px; }
.container {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 24px;
}
.box {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 6px;
cursor: pointer;
user-select: none;
}
</style>
</head>
<body>
<h2>使用 var(点击任意按钮都会 alert 5)</h2>
<div id="container-var" class="container"></div>
<h2>使用 let(点击按钮 alert 自己的索引)</h2>
<div id="container-let" class="container"></div>
<script>
// 演示一:var —— 计数器 i 是函数/全局作用域,循环结束后为 5
(function demoVar(){
var container = document.getElementById('container-var');
for (var i = 0; i < 5; i++) {
var btn = document.createElement('div');
btn.className = 'box';
btn.textContent = 'box ' + i;
btn.onclick = function () {
// 这里读取到的是同一个 i(循环结束后等于 5)
alert('This is box #' + i);
};
container.appendChild(btn);
}
})();
// 演示二:let —— 计数器 i 具有块级作用域,每次迭代都有自己的 i
(function demoLet(){
const container = document.getElementById('container-let');
for (let i = 0; i < 5; i++) {
const btn = document.createElement('div');
btn.className = 'box';
btn.textContent = 'box ' + i;
btn.onclick = function () {
alert('This is box #' + i);
};
container.appendChild(btn);
}
})();
</script>
</body>
</html>
保存为 HTML 文件打开后,你会发现:
- 第一组按钮(var):点击任何一个都弹出 “This is box #5”
- 第二组按钮(let):点击按钮 0-4 分别弹出对应的索引号
问题根源分析
var 版本:所有闭包共享同一个变量
for (var i = 0; i < 5; i++) {
var btn = document.createElement('div');
btn.onclick = function () {
alert('This is box #' + i);
};
container.appendChild(btn);
}
时间线拆解:
var i声明在函数作用域内,整个循环过程中只有一个i变量- 循环创建了 5 个点击事件处理函数(5 个闭包)
- 这 5 个闭包都引用同一个
i变量(不是值,是变量本身) - 循环迅速结束,此时
i === 5 - 用户点击按钮时,所有闭包读取的都是那个已经变成 5 的
i
关键点:闭包捕获的是"变量的引用",而不是"变量的快照"。
直观图示:
[变量 i (最终值=5)]
↑ ↑ ↑ ↑ ↑
| | | | |
闭包0 闭包1 闭包2 闭包3 闭包4
let 版本:每次迭代创建独立绑定
for (let i = 0; i < 5; i++) {
const btn = document.createElement('div');
btn.onclick = function () {
alert('This is box #' + i);
};
container.appendChild(btn);
}
ECMAScript 规范规定:当 for 循环使用 let 或 const 声明计数器时,每次迭代都会创建一个新的词法环境,相当于为 i 拷贝一份新的绑定。
时间线拆解:
- 第 1 次迭代:创建
i₀ = 0,闭包 0 引用i₀ - 第 2 次迭代:创建
i₁ = 1,闭包 1 引用i₁ - 第 3 次迭代:创建
i₂ = 2,闭包 2 引用i₂ - 第 4 次迭代:创建
i₃ = 3,闭包 3 引用i₃ - 第 5 次迭代:创建
i₄ = 4,闭包 4 引用i₄
直观图示:
[i₀=0] [i₁=1] [i₂=2] [i₃=3] [i₄=4]
↑ ↑ ↑ ↑ ↑
闭包0 闭包1 闭包2 闭包3 闭包4
ES5 环境的解决方案
如果必须使用 var(比如在老旧浏览器或遗留项目中),有三种常见的解决方案。
方案一:立即执行函数(IIFE)
for (var i = 0; i < 5; i++) {
(function (j) {
var btn = document.createElement('div');
btn.onclick = function () {
alert('This is box #' + j);
};
container.appendChild(btn);
})(i);
}
通过 IIFE 将当次的 i 值作为参数 j 传入,每个闭包引用的是不同的 j 参数。
方案二:函数工厂
function makeHandler(n) {
return function () {
alert('This is box #' + n);
};
}
for (var i = 0; i < 5; i++) {
var btn = document.createElement('div');
btn.onclick = makeHandler(i);
container.appendChild(btn);
}
将闭包的创建封装到一个工厂函数中,每次调用都产生新的词法环境。
方案三:数据属性存储
for (var i = 0; i < 5; i++) {
var btn = document.createElement('div');
btn.dataset.index = i;
btn.onclick = function () {
alert('This is box #' + this.dataset.index);
};
container.appendChild(btn);
}
不依赖闭包,将值直接存储在 DOM 元素的 data-* 属性中。
setTimeout 中的同类问题
这个问题不仅出现在事件绑定中,在定时器场景同样常见:
// 问题代码
for (var i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 0);
}
// 输出:5 5 5 5 5
// 解决方案 1:使用 let
for (let i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 0);
}
// 输出:0 1 2 3 4
// 解决方案 2:IIFE
for (var i = 0; i < 5; i++) {
(function(j) {
setTimeout(() => console.log(j), 0);
})(i);
}
// 输出:0 1 2 3 4
跨语言对比:Python 中的作用域
对于从 Python 转向 JavaScript 的开发者,理解两种语言在变量声明和作用域上的差异非常重要。
JavaScript vs Python:变量声明对比
| JavaScript | Python 对应 | 特点 |
|---|---|---|
let | 普通变量(x = 10) | 可变,Python 没有块级作用域,只有函数/类/模块作用域 |
const | 大写变量名(MAX_SIZE = 100) | Python 无真正常量机制,仅靠命名约定 |
var | - | Python 无对应,所有变量默认类似 let 但作用域更宽松 |
Python 如何表达"常量"
虽然 Python 没有内建的常量关键字,但有几种惯用方法:
命名约定(最常用)
MAX_SIZE = 100
API_KEY = "your_key_here"
开发者看到全大写变量名就知道这是常量,虽然技术上仍可修改。
类封装
class Config:
PI = 3.14159
MAX_RETRY = 3
# 使用
print(Config.PI)
# 虽然能改,但不符合约定
Config.PI = 123 # ❌ 不推荐
typing.Final 类型注解(Python 3.8+)
from typing import Final
MAX_SIZE: Final = 100
MAX_SIZE = 200 # IDE 会警告,但运行时不会报错
Final 提供了类型检查层面的约束,但不是运行时强制。
作用域的关键差异
JavaScript(let/const):
if (true) {
let x = 10;
}
console.log(x); // ReferenceError: x is not defined
Python:
if True:
x = 10
print(x) # 输出:10,变量在 if 块外仍可访问
Python 没有块级作用域,if、for、while 等结构内定义的变量在结构外依然可见。只有函数、类和模块才会创建新的作用域。
Python 中的闭包
Python 同样支持闭包,但在修改外层变量时需要 nonlocal 关键字:
def outer():
count = 0
def inner():
nonlocal count # 必须显式声明
count += 1
print(count)
return inner
fn = outer()
fn() # 输出:1
fn() # 输出:2
JavaScript 中不需要这样的声明,闭包可以直接修改外层变量。
闭包的潜在风险
闭包虽然强大,但使用不当会带来问题。
内存泄漏风险
function attachEvents() {
const largeData = new Array(1000000).fill('data');
document.getElementById('btn').onclick = function() {
// 即使不使用 largeData,闭包也会保持对它的引用
console.log('clicked');
};
}
如果闭包长期存在(比如事件处理器未被移除),它引用的所有外层变量都无法被垃圾回收。
解决方案:
function attachEvents() {
const largeData = new Array(1000000).fill('data');
// 处理完数据后立即释放引用
processData(largeData);
document.getElementById('btn').onclick = function() {
console.log('clicked');
};
}
意外的变量共享
const callbacks = [];
for (var i = 0; i < 3; i++) {
callbacks.push(() => i);
}
console.log(callbacks.map(fn => fn())); // [3, 3, 3]
这就是前面讨论过的问题,使用 let 或其他解决方案即可避免。
核心要点总结
闭包的本质:函数能"带走"它定义时的词法环境,记忆外层变量。
作用域规则:
var:一个变量,循环后值为最终值,所有闭包共享let/const:每轮迭代创建独立绑定,各自保存当轮的值
实用建议:
- 优先使用
let和const,避免var - 理解闭包捕获的是"引用"而非"值"
- 注意长生命周期闭包可能导致的内存占用
- 跨语言开发时注意作用域规则的差异
记忆口诀:
- 闭包 = 函数 + 环境
- var = 共享变量,let = 独立绑定
- 捕获引用,非快照
更多推荐


所有评论(0)