我在循环里用 var 注册了几个回调,本想让它们各自打印 0、1、2,结果它们齐刷刷全打印了 3,我盯着这个诡异的结果排查了大半天的深度复盘
这是一个让我对 JavaScript "闭包"和"作用域"刻骨铭心的故事。我写了一个循环,在循环里,注册了几个回调函数(比如几个 setTimeout、或几个按钮的点击事件),我希望每个回调,都能记住它被创建时的那个循环变量 i 的值——第一个回调打印 0、第二个打印 1、第三个打印 2。在我朴素的认知里,这天经地义嘛:回调是在 i=0 时创建的,它就该记住 0;在 i=1 时创建的,就该记住 1。
可运行结果,把我整懵了:这几个回调,执行起来,竟然齐刷刷地、全都打印了同一个值——循环结束后的那个值 3!没有一个打印我期望的 0、1、2,而是清一色的 3。我当时百思不得其解:这几个回调,明明是在 i 等于 0、1、2 的不同时刻创建的啊,它们怎么会全都记住了 3?这个 3,还是循环结束后才有的值,创建回调的时候,根本还没到 3 啊!我反复检查,确认循环确实跑了 3 次。直到我去深究 JavaScript 的 var 作用域和闭包机制,才恍然大悟,补上了关于闭包最重要的一课:问题的核心,在两个地方。第一,var 声明的变量,是"函数作用域(function-scoped)"的,不是"块作用域(block-scoped)"的——这意味着,我用 var i 在 for 循环里声明的那个 i,并不是"每次循环都新建一个",而是整个循环、自始至终,都共用同一个 i(它属于外层的函数作用域,而非每次循环的块)。第二,闭包,捕获的是"变量"本身,而不是"变量在某一刻的值"——我那几个回调函数,都形成了闭包,它们捕获的,都是同一个 i 这个变量(的引用),而不是"创建它们时 i 的那个值的拷贝"。这两点一叠加,诡异就发生了:我的三个回调,捕获的是同一个、共享的 i;而它们真正执行(打印)的时机,是在 setTimeout 触发时、或按钮被点击时——那都是循环早就结束之后了;此时,那个被它们共享的 i,早已被循环递增到了 3(循环结束的条件)。于是,它们去读这个共享的 i,读到的,自然全是 3!我以为的"每个回调记住创建时的值",是个误解——它们记住的,是同一个变量;而等它们执行时,那个变量的值,早就变成 3 了。
故障现场:三个闭包,共享同一个 var 变量
我把这个"全打印 3"的现场,用代码摊开给你看:
// ✗ 灾难: 循环里用 var, 闭包都捕获同一个 i
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 期望打印 0,1,2
}
// 实际输出: 3, 3, 3 ← 全是 3!
// 为什么? 两个原因叠加:
// 原因1: var 是"函数作用域", 不是"块作用域"
// - 整个循环, 只有"一个" i (属于外层函数作用域, 不是每次循环新建)。
// - 三次循环, 共用、修改的, 是同一个 i。
//
// 原因2: 闭包捕获的是"变量(引用)", 不是"值的快照"
// - 三个箭头函数, 捕获的都是"同一个 i 变量", 不是"创建时 i 的值"。
//
// 时序(致命):
// - 循环飞快跑完: i 从 0→1→2→3(条件 i<3 不满足时, i 已是 3 了才停)。
// - setTimeout 的回调, 是"循环结束后"才执行的(异步, 排到宏任务)。
// - 此时回调去读那个共享的 i → 它早就是 3 了 → 三个全打印 3。
// 同样的坑, 在事件绑定里也常见:
for (var i = 0; i < buttons.length; i++) {
buttons[i].onclick = () => console.log(i); // 点哪个按钮, 都打印 buttons.length!
}
// 因为所有 onclick 闭包, 共享同一个 i; 点击时(循环早结束), i 已是 length。
// 根因: var 函数作用域(全程一个 i) + 闭包捕获变量(非值快照)
// + 回调延迟执行(执行时 i 已变成最终值) → 全打印最终值。
看着这段代码,我才算真正理解了这个"全打印 3"的根源。问题的核心,是两个机制叠加的结果。原因一:var 声明的变量,是"函数作用域(function-scoped)"的,而不是"块作用域"的。这意味着,我用 var i 在 for 里声明的 i,并不是"每次循环都新建一个独立的 i",而是——整个循环,自始至终,都只有一个 i(它属于外层的函数作用域);三次循环,共享、并修改的,都是这同一个 i。原因二:闭包,捕获的是"变量(的引用)",而不是"变量在某一刻的值"。我那三个箭头函数,都形成了闭包,它们捕获的,都是同一个 i 这个变量,而不是"创建它们时 i 的那个值的拷贝"。这两点一叠加,再配上一个致命的时序,诡异就完整了:循环会飞快地跑完(i 从 0 递增到 3,直到条件 i<3 不满足时停下,此时 i 已经是 3 了);而 setTimeout 里的那些回调,是异步的,要排到宏任务队列里、等到循环结束之后才执行;等它们终于执行、去读那个被它们共享的 i 时,这个 i,早已被循环递增到了 3。于是,三个回调,读到的,自然全是 3。这个坑,在事件绑定里也极其常见:for (var i...) { buttons[i].onclick = () => console.log(i) },你点击任何一个按钮,打印的都是 buttons.length——因为所有的 onclick 闭包,都共享着同一个 i,而点击发生时,循环早已结束、i 早已是最终值。归根结底:我以为的"每个回调,会记住它创建时 i 的值",是一个根本性的误解;真相是,它们记住的,是同一个变量 i(var 的函数作用域让它们共享了它);而等它们延迟执行时,那个共享变量的值,早就变成了循环结束后的最终值 3。是 var 的函数作用域(全程一个 i)、加上闭包捕获变量(而非值快照)、再加上回调的延迟执行(执行时 i 已是最终值),这三者共同,酿成了"全打印最终值"的结果。
第一件事:搞懂 var 函数作用域 + 闭包捕获变量
定位到根源,我必须把"var 作用域"和"闭包捕获机制"这两件事,彻底搞清楚:
var 函数作用域 + 闭包捕获"变量"(非值) = 循环闭包的经典坑
# 机制1: var 是"函数作用域", let/const 是"块作用域"
# for (var i...) { } —— i 属于"外层函数作用域", 整个循环只有"一个" i。
# for (let i...) { } —— i 是"块作用域", 每次循环迭代, 都"新建一个" i!
# → 这是 var 和 let 在循环里, 最关键的区别。
# 机制2: 闭包捕获的是"变量(的引用)", 不是"那一刻的值"
# - 闭包 = 函数 + 它能访问的外层变量(的引用)。
# - 它记住的是"那个变量", 而不是"创建闭包时该变量的值的快照"。
# - 所以, 变量后来变了, 闭包读到的, 是"变量当前的值", 而非"当初的值"。
# 机制3: 回调常常"延迟执行"(异步/事件), 此时循环早已结束
# - setTimeout/事件回调, 执行时循环已跑完, 共享变量已是最终值。
# 三者叠加(循环里用 var 注册回调):
# - var: 全程一个 i(三个闭包共享它)。
# - 闭包: 捕获的是这个 i 变量(非值)。
# - 延迟执行: 执行时 i 已是循环结束值(3)。
# → 三个回调全读到 3。
# 怎么破? 让"每次循环有独立的变量"给闭包捕获:
# - 用 let(每次迭代新建一个 i, 各闭包捕获各自的) —— 最简单!
# - 或 IIFE/把 i 作为参数传入(为每次循环创建独立作用域)。
# 核心: var 全程共享一个变量 + 闭包捕获变量 → 循环里的闭包全读到最终值。
# 用 let(块作用域, 每次迭代独立), 让每个闭包捕获各自独立的变量。
原理终于刻进脑子里了。这个经典坑,是三个机制叠加的结果。机制一:var 是"函数作用域",而 let/const 是"块作用域"——for (var i...) 里,i 属于外层函数作用域,整个循环只有一个 i;而 for (let i...) 里,i 是块作用域,每次循环迭代,都会新建一个独立的 i!这是 var 和 let 在循环里最关键的区别。机制二:闭包捕获的是"变量(的引用)",而不是"那一刻的值"——闭包,等于"函数 + 它能访问的外层变量(的引用)";它记住的,是那个变量,而不是"创建闭包时该变量的值的快照";所以,变量后来变了,闭包读到的,是"变量当前的值",而非"当初的值"。机制三:回调常常延迟执行(异步/事件)——而执行时,循环早已结束,共享变量已是最终值。三者叠加,就有了循环闭包的经典坑:var 让全程共享一个 i(三个闭包共享它),闭包捕获的是这个 i 变量(而非值),延迟执行时 i 已是循环结束值——于是三个回调全读到 3。那么怎么破?核心,是让"每次循环,都有一个独立的变量",供闭包去捕获:最简单的,是用 let(它是块作用域,每次迭代都新建一个独立的 i,于是各个闭包捕获的,是各自独立的那个 i);或者,用 IIFE/把 i 作为参数传入(为每次循环,创建一个独立的作用域)。由此,我得出了那个本该一开始就掌握的结论:var 全程共享一个变量、加上闭包捕获的是变量(而非值快照),导致循环里的闭包,全都读到了变量的最终值;而正解,就是用 let(块作用域,每次迭代独立),让每个闭包,去捕获它各自独立的那个变量——这,是我用一次"全打印 3"的事故,补上的、关于闭包与作用域最关键的一课。
第二件事:正解——用 let,让每次循环有独立的变量
搞懂了根因——"var 全程共享一个变量、闭包捕获变量"——正解就一目了然了:把 var 换成 let!let 是块作用域的,每次循环迭代,都会创建一个全新的、独立的变量;于是,每个闭包捕获的,是它各自那次迭代独立的变量,值也就各不相同、正是你期望的了。
// 正解1(最简单, 首选): 把 var 换成 let
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // ✓ 输出 0, 1, 2!
}
// 为什么? let 是块作用域: 每次循环迭代, 都"新建一个独立的 i"。
// - 第 0 次迭代有它自己的 i=0, 第 1 次有自己的 i=1, ...
// - 三个闭包, 各自捕获"各自那次迭代的、独立的 i"。
// - 即使延迟执行, 各读各的 i, 值各不相同 → 0,1,2。
// 事件绑定同理:
for (let i = 0; i < buttons.length; i++) {
buttons[i].onclick = () => console.log(i); // ✓ 点哪个就打印哪个的索引
}
// 正解2(老 ES5 没有 let 时): IIFE 立即执行函数, 创建独立作用域
for (var i = 0; i < 3; i++) {
(function (j) { // 把 i 当参数传进去, j 是这次独立的
setTimeout(() => console.log(j), 0); // 闭包捕获的是 j(各自独立)
})(i); // 立即执行, 把当前 i 的值传给 j
}
// 输出 0,1,2。原理: 每次循环, IIFE 创建一个新作用域, j 是当前 i 的"拷贝"。
// 正解3: 用 forEach 等(回调天然为每个元素创建独立作用域)
[0, 1, 2].forEach((i) => {
setTimeout(() => console.log(i), 0); // ✓ 0,1,2 (每次回调的 i 是独立的参数)
});
// 正解4: 把值"绑定"进去(如 bind, 或显式传参)
for (var i = 0; i < 3; i++) {
setTimeout(console.log.bind(null, i), 0); // bind 把当前 i 的值固定进去
}
// 核心: 让"每次循环有独立的变量"供闭包捕获。
// 现代写法: 直接用 let(块作用域), 最简单、最清晰。别再用 var 踩这个坑。
这个正解,简单到只需改一个关键字:把 var,换成 let!正解1(用 let,首选):let 是块作用域的,这意味着——每一次循环迭代,它都会创建一个全新的、独立的 i:第 0 次迭代,有它自己的 i=0;第 1 次,有它自己的 i=1……于是,三个闭包,各自捕获的,是"各自那次迭代的、独立的 i";即使它们延迟执行,各读各的 i,值也就各不相同,正是你期望的 0、1、2。事件绑定也是同理,换成 let 就好了。正解2(IIFE,老 ES5 没有 let 时):用一个立即执行函数(IIFE),把当前的 i 作为参数 j 传进去——每次循环,IIFE 都创建一个新的作用域,j 是当前 i 的一份"拷贝";闭包捕获的,是各自独立的 j。正解3(用 forEach 等):forEach 这类方法,它的回调,天然就为每个元素创建了独立的作用域(i 是每次回调的独立参数),所以也没这个坑。正解4(把值绑定进去):用 bind(或显式传参),把当前 i 的值,固定进去。归根结底,所有正解的核心,都是一件事:让"每次循环,有一个独立的变量",供闭包去捕获。而在现代 JavaScript 里,最简单、最清晰的做法,就是直接用 let——它的块作用域,天然就为每次迭代提供了独立的变量。别再用 var,去踩这个本可以一个关键字就避免的经典坑。我那次的错误,正是用了函数作用域的 var;而正解,只是把它换成块作用域的 let。
下面这张图,对比了"var"和"let"两条路径:
这张图的对比很清楚:左边红色那条,用 var(函数作用域),整个循环只有一个共享的 i,三个闭包都捕获这同一个 i,延迟执行时 i 已是最终值、全打印 3;右边绿色那条,用 let(块作用域),每次迭代新建一个独立的 i,每个闭包捕获各自独立的 i,各读各的、正确打印 0、1、2。两条路的根本分野,在于"每次循环,闭包捕获的,是不是一个独立的变量"。
第三件事:闭包与作用域的其它常见坑
填平了循环闭包这个坑,我系统排查了一遍闭包和作用域的其它常见坑:
// 闭包与作用域的其它常见坑:
// 1. 循环里 var + 闭包(本文): 全捕获同一个变量 → 用 let。
// 2. var 的变量提升(hoisting): var 声明被"提升"到函数顶部
console.log(x); // undefined(不是报错! x 被提升了, 但还没赋值)
var x = 5;
// let/const 有"暂时性死区(TDZ)": 声明前访问会报错(更安全)。
// 3. 闭包导致的"意外的内存占用": 闭包持有外层变量的引用,
// 只要闭包还在, 那些变量就不会被 GC → 可能内存泄漏(如事件监听没解绑)。
// 4. this 不是闭包捕获的: 普通函数的 this 由"调用方式"决定, 不是词法捕获。
// (箭头函数的 this 才是词法的, 即捕获定义时的 this)
const obj = {
name: "x",
greet() { setTimeout(function() { console.log(this.name); }, 0); }
// ✗ this 在普通函数里指向 undefined/window, 不是 obj!
// ✓ 用箭头函数: setTimeout(() => console.log(this.name)) —— this 词法捕获 obj
};
// 5. 在循环里用闭包"缓存"了不该缓存的(捕获了会变的变量)。
// 6. 块作用域陷阱: { let x = 1; } 外面访问 x 报错(let 块作用域)。
// 共同点: 很多坑源于"没搞清作用域(var/let/块/函数)"和
// "闭包捕获的是变量引用、this 由调用决定"。
// 原则: 用 let/const 别用 var; 理解闭包捕获"变量"、箭头函数捕获"this"。
这一排查,让我对闭包和作用域的"雷区",有了全面的认识。除了循环里 var + 闭包(本文),还有几个常见坑:var 的变量提升(hoisting)(var 声明会被"提升"到函数顶部,所以声明前访问是 undefined 而不是报错;而 let/const 有"暂时性死区 TDZ",声明前访问会报错,更安全);闭包导致的意外内存占用(闭包持有外层变量的引用,只要闭包还在,那些变量就不会被 GC,可能造成内存泄漏,比如事件监听没解绑);this 不是闭包捕获的(普通函数的 this 由"调用方式"决定,不是词法捕获;而箭头函数的 this 才是词法的,即捕获定义时的 this——所以回调里要用 this 指向外层对象时,要用箭头函数);循环里缓存了不该缓存的变量;块作用域陷阱(let 声明的变量,出了块就访问不到)。这些坑的共同点,大多源于"没搞清作用域(var/let/块/函数)"和"闭包捕获的是变量引用、而 this 由调用决定"。所以,核心原则就是:用 let/const,别用 var(规避作用域和提升的一堆坑);理解闭包捕获的是"变量"、而箭头函数捕获的是"this"。把作用域和闭包的机制吃透,这些隐蔽的坑,就都能在写代码时被你提前规避。
第四件事:var、let、const 该怎么选
这次踩坑,逼我把 var、let、const 的区别和选择,系统地梳理了一遍:
// var / let / const 的区别与选择
// var(老的, 尽量别用):
// - 函数作用域(不是块作用域)。
// - 有变量提升(hoisting): 声明被提升, 但赋值不提升 → 声明前是 undefined。
// - 可重复声明同名变量(容易出 bug)。
// - 在循环里, 全程一个变量(本文的坑)。
// let(可变的变量):
// - 块作用域({} 内有效)。
// - 有暂时性死区(TDZ): 声明前访问报错(更安全, 暴露错误)。
// - 不能重复声明同名(更安全)。
// - 循环里每次迭代新建一个(没有本文的坑)。
// const(常量, 优先用):
// - 同 let 的块作用域 + TDZ。
// - 声明后不能"重新赋值"(但注意: 对象/数组的"内容"还是能改的!)
// const obj = {}; obj.x = 1; // ✓ 可以(改内容); obj = {} ✗ 报错(重新赋值)
// - 表达"这个绑定不会变", 可读性 + 防误改。
// 选择原则(现代 JS 共识):
// 1. 默认用 const(绝大多数变量, 声明后就不重新赋值)。
// 2. 确实需要重新赋值的, 用 let。
// 3. 永远不用 var(它的函数作用域 + 提升, 是一堆坑的来源)。
// → "const 优先, 偶尔 let, 永不 var"。
// 为什么这个原则好?
// - 块作用域: 变量作用域更小、更清晰, 不会泄漏到块外。
// - TDZ: 声明前访问报错, 能尽早暴露错误。
// - const: 表达不变性, 减少"被意外重新赋值"的 bug。
// → 更安全、更可预测、更少坑。
// 核心: const 优先, 需要改用 let, 永远别用 var。
// 这一条简单的规则, 能帮你避开 var 带来的一大类作用域/提升/闭包坑。
这一梳理,让我对这三个关键字的选择,有了清晰的认识。var(老的,尽量别用):函数作用域(不是块作用域)、有变量提升(声明前是 undefined)、可重复声明同名(容易出 bug)、在循环里全程一个变量(本文的坑)。let(可变的变量):块作用域、有暂时性死区(TDZ)(声明前访问报错,更安全)、不能重复声明、循环里每次迭代新建一个(没有本文的坑)。const(常量,优先用):同 let 的块作用域和 TDZ,且声明后不能重新赋值(但要注意,对象/数组的"内容"还是能改的,不能改的是那个"绑定");它表达了"这个绑定不会变",既提升可读性、又防误改。而选择原则(现代 JS 的共识),就三条:第一,默认用 const(绝大多数变量,声明后就不会重新赋值);第二,确实需要重新赋值的,才用 let;第三,永远不用 var(它的函数作用域加变量提升,是一大堆坑的来源)——也就是"const 优先,偶尔 let,永不 var"。为什么这个原则好?因为:块作用域让变量的作用域更小、更清晰,不会泄漏到块外;TDZ 让声明前访问报错,能尽早暴露错误;const 表达不变性,减少"被意外重新赋值"的 bug——总之,更安全、更可预测、更少坑。归根结底:const 优先,需要改用 let,永远别用 var。这一条简单的规则,就能帮你避开 var 带来的、关于作用域、提升、闭包的一大类坑。我那次的"全打印 3",正是 var 惹的祸;而只要遵守这条规则,它根本不会发生。把三者的区别,整理成一张表:
| 特性 | var | let | const |
|---|---|---|---|
| 作用域 | 函数作用域 | 块作用域 | 块作用域 |
| 提升/TDZ | 提升为 undefined | TDZ,声明前报错 | TDZ,声明前报错 |
| 循环里 | 全程一个(有坑) | 每次迭代新建 | (循环计数用 let) |
| 重新赋值 | 可以 | 可以 | 不可以(内容可改) |
| 建议 | 永远别用 | 需要改时用 | 默认首选 |
第五件事:理解"作用域"和"绑定"的本质
这次踩坑,在认知层面给了我最大的纠偏——它让我意识到,要真正理解一门语言的"变量"到底是什么。我把这层反思,沉淀了下来:
认知纠偏: 理解"变量"的本质——作用域、绑定、与捕获
# 我的误解(错误的):
# 我把"变量"想得太简单——以为"循环里的 i, 每次循环就是一个新的、
# 带着当时值的东西"。没理解"作用域决定 i 是一个还是多个"、
# "闭包捕获的是变量这个'绑定', 而非值"。
# 真相: "变量"背后, 有几个需要理解的概念
# 1. 作用域(scope): 决定一个变量"在哪里有效"、"是同一个还是不同的"。
# - var 函数作用域 vs let 块作用域 → 循环里是"一个 i"还是"N 个 i"。
# 2. 绑定(binding): 变量名"绑定"到一个存储位置(值存在那里)。
# - 闭包捕获的是这个"绑定", 读它时读的是"当前的值"。
# 3. 求值时机: 闭包里的变量, 是在"闭包执行时"才读取它的值的。
# → 不是"创建闭包时"的值, 而是"执行时"那个变量的值。
# 这三者一结合, 就能解释本文:
# var → 一个绑定(一个 i); 闭包捕获这个绑定; 执行时读它当前值(3)。
# let → 每次迭代一个绑定(N 个 i); 各闭包捕获各自绑定; 执行时各读各的。
# 普遍的道理: 真正理解一门语言, 要理解它的"变量模型"
# - 变量是值还是引用? 作用域怎么划分? 闭包怎么捕获? this 怎么确定?
# - 这些"底层模型", 决定了大量看似诡异的行为。
# - 只停在"会写变量、会写循环", 碰到边界(如循环闭包)就懵。
# 正确的习惯:
# 1. 搞清你用的语言: 变量的作用域规则、闭包捕获机制。
# 2. 写闭包(尤其在循环/异步里)时, 想清楚"它捕获的是谁、执行时是什么值"。
# 3. 用更安全的构造(let/const)规避一类坑。
核心: 理解变量的"作用域 + 绑定 + 捕获 + 求值时机"。
这些底层模型, 是看懂闭包、循环、异步里那些"诡异行为"的钥匙。
这层反思,是这次踩坑给我最高维度的收获。复盘我的误解,根源是:我把"变量"想得太简单了——以为"循环里的 i,每次循环,就是一个全新的、带着当时那个值的东西";我没理解"作用域,决定了 i 到底是一个还是多个",也没理解"闭包捕获的,是 i 这个'绑定',而不是它的值"。可真相是:"变量"的背后,有几个需要真正理解的概念。第一,作用域(scope):它决定一个变量"在哪里有效"、以及"是同一个、还是不同的几个"——var 的函数作用域 vs let 的块作用域,决定了循环里是"一个 i"还是"N 个 i"。第二,绑定(binding):变量名"绑定"到一个存储位置(值存在那里),而闭包捕获的,是这个"绑定",读它时,读的是"当前的值"。第三,求值时机:闭包里的变量,是在"闭包执行时"才读取它的值的——不是"创建闭包时"的值,而是"执行时"那个变量的值。这三者一结合,就完整解释了本文:var 是一个绑定(一个 i),闭包捕获这个绑定,执行时读它当前的值(3);而 let 是每次迭代一个绑定(N 个 i),各闭包捕获各自的绑定,执行时各读各的。而这,引出一个普遍的道理:真正理解一门语言,要理解它的"变量模型"——变量是值还是引用?作用域怎么划分?闭包怎么捕获?this 怎么确定?这些"底层模型",决定了大量看似诡异的行为;只停在"会写变量、会写循环"的层面,一碰到边界(比如循环里的闭包),就懵了。由此,我立下了几条习惯:第一,搞清你用的语言:它的变量作用域规则、闭包捕获机制;第二,写闭包时(尤其在循环、异步里),想清楚"它捕获的是谁、执行时会是什么值";第三,用更安全的构造(let/const)去规避一类坑。归根结底:理解变量的"作用域 + 绑定 + 捕获 + 求值时机"——这些底层模型,正是看懂闭包、循环、异步里那些"诡异行为"的钥匙。把"把变量想简单"和"理解变量模型"两种状态对比成一张表:
| 维度 | 把变量想简单(踩坑) | 理解变量模型(掌握) |
|---|---|---|
| 循环里的 i | 以为每次是新的带值的 | 知道作用域决定一个还是 N 个 |
| 闭包捕获 | 以为捕获的是值 | 知道捕获的是变量绑定 |
| 读取时机 | 以为是创建时的值 | 知道是执行时的当前值 |
| 选关键字 | 随手 var | const 优先,别用 var |
| 碰到边界 | 百思不得其解 | 从模型上想通 |
一套"循环里写闭包该怎么做"的决策流程
把这次踩坑的全部教训,我浓缩成了一张"在循环里创建闭包/回调时、该怎么做"的决策图,贴在了团队的前端规范里:
这张图,把我"血泪换来"的整套方法论,串成了一条可执行的路径:在循环里创建闭包,先问闭包需不需要捕获循环变量——不需要就没这个坑;需要、且想各记各的值,就看用没用 let:用 let 声明循环变量(每次迭代独立、各闭包捕获各自的,正确);遗留代码只能用 var 时,用 IIFE 传参或 bind 把值固定进去。而很多时候,用 forEach/map 遍历更自然——它们的回调参数天然独立,根本没这个坑。这条"优先 let、否则隔离作用域"的决策链,现在是我们团队在循环里写闭包时的准则。
我立下的几条闭包与作用域规矩
这次"循环闭包全打印 3"的踩坑,让我把闭包与作用域的注意事项,认真地立成了几条规矩:
- 循环里要捕获循环变量,用 let 不用 var。let 每次迭代新建独立变量,各闭包捕获各自的;var 全程共享一个,全打印最终值。
- const 优先,需要改用 let,永远别用 var。规避 var 的函数作用域、提升、闭包一大类坑。
- 记牢闭包捕获的是变量,不是值。执行时读的是变量当前的值,不是创建闭包时的值。
- 遗留 var 代码可用 IIFE/bind。用立即执行函数传参,或 bind,把当前值固定进独立作用域。
- 用 forEach/map 等更自然。回调参数天然为每个元素独立,没有循环闭包的坑。
- 回调里要外层 this 用箭头函数。普通函数 this 由调用决定,箭头函数才词法捕获 this。
- 理解变量模型。作用域、绑定、捕获、求值时机——这是看懂闭包/循环/异步诡异行为的钥匙。
写在最后
这次"我循环里注册的回调全打印了 3"的经历,是我在 JavaScript 路上,一次很经典、也很受用的成长。它教给我的,远不止"循环里用 let 别用 var"这一条具体的技术经验,更是一个关于真正理解一门语言的根本认知——要理解它的"变量模型":作用域怎么划分、闭包捕获的是什么、变量何时求值。我那个"全打印 3"的诡异结果,根源就在于,我把"变量"想得太简单了,以为循环里的 i 每次都是新的、带着值的;却不知道,var 的函数作用域让全程只有一个 i,而闭包捕获的,是这个共享的变量、而非它某一刻的值。
所以,当你在循环里、异步里写闭包的时候,请别想当然地以为"它会记住此刻的值";而要冷静地想一想:它捕获的,到底是哪个变量?这个变量,是每次循环独立的、还是共享的?等它真正执行时,这个变量的值,又会是多少?就像循环里的回调,你只要把 var 换成 let、让每次迭代有一个独立的变量,就再也不会经历那种"本想要 0、1、2,却全是 3"的困惑。从"把变量当成简单的值"到"理解作用域、绑定与捕获的变量模型",从随手 var 到"const 优先、永不 var",是从一个"会写循环"的开发,走向一个"懂语言底层、思维严谨"的工程师,必经的修炼。愿你写的每一个闭包,都精准地捕获到你想要的那个值;也愿你我,在用每一门语言时,都真正读懂它"变量"二字背后,那套精妙的模型。共勉。
—— 别看了 · 2026