JavaScript 闭包探秘:函数读取的是最新值还是旧值?—— javascript.info 习题深度解析
JavaScript 闭包探秘:函数读取的是最新值还是旧值?—— javascript.info 习题深度解析
本文围绕现代 JavaScript 教程(javascript.info)进阶函数 / 闭包 章节中的经典习题「函数是否拾取最新改动?」(题目原文)展开。通过这道重要性为 5 的高频题,你将彻底理解 JavaScript 闭包的核心机制:函数在执行时从词法环境(Lexical Environment)中按“当前值”读取外部变量,而不是在创建时把变量值快照保存下来。掌握这一点,你就能准确预判事件回调、定时器、循环内闭包等真实场景中的输出结果,避免一类最常见的“闭包陷阱”。
一、题目重现:sayHi 会弹出 "John" 还是 "Pete"?
原始题目位于 1-closure-latest-changes/task.md,代码如下:
let name = "John";
function sayHi() {
alert("Hi, " + name);
}
name = "Pete";
sayHi(); // what will it show: "John" or "Pete"?
题目本身只有一条核心追问:sayHi 使用外部变量 name。当函数真正运行的时候,它读取的是哪一个值?
题目还特别点明了这类场景的现实意义:函数常常被安排到创建之后才执行。无论是浏览器端还是服务端开发,一个函数可能被调度到稍后才运行——例如在用户点击之后、在一次网络请求返回之后。此时函数内部引用的外部变量可能早已被改写,那么它究竟会拿到哪一个值?
二、答案:Pete——函数总是读取最新的值
官方答案见 solution.md,结论非常明确:
答案是 Pete。 函数按“现在”的状态获取外部变量,它使用最新的值。旧的变量值不会被保存在任何地方。当函数需要某个变量时,它从自己的词法环境(Lexical Environment)或外层词法环境中取出当前值。
也就是说,当 sayHi() 被调用时,JavaScript 会沿着词法环境链向上查找 name,找到的是此刻全局环境中 name 的值——已经变成 "Pete"。函数体内并不存在一份 "John" 的旧值拷贝。
三、底层原理:词法环境与“按需取值”
要真正理解为什么是 Pete,需要进入闭包章节的底层机制。核心章节 03-closure/article.md 给出了完整的规范级解释。
3.1 什么是词法环境
在 JavaScript 中,每一个正在运行的函数、每一个代码块 {...} 以及整个脚本,都关联一个内部的(隐藏的)对象,称为词法环境(Lexical Environment)。它由两部分组成:
- 环境记录(Environment Record):一个对象,存储该作用域内所有的局部变量(以及
this等信息)作为其属性; - 对外部词法环境的引用:指向外层代码关联的词法环境。
关键结论原文如此:“变量”不过是这个特殊内部对象 Environment Record 上的一个属性;“读取或修改变量”本质上就是“读取或修改该对象的属性”。
3.2 变量查找:由内而外的链式搜索
当一个函数被调用时,会为这次调用自动创建一个新的词法环境,用来存放本次调用的局部变量和参数。于是调用期间存在两层词法环境:
- 内部词法环境:对应函数本次执行,存放参数与局部变量;
- 外部词法环境:函数创建处所在的词法环境(例如全局环境)。
内部词法环境持有指向外部词法环境的引用。当代码访问一个变量时,先搜索内部词法环境,找不到就沿引用逐层向外搜索,直到全局词法环境为止:
回到题目:sayHi 内部没有局部变量 name,因此运行时沿链向上,在全局词法环境中找到 name——此时的属性值是 "Pete"。这正是“读取最新值”的机制来源:变量在运行时才被解析,解析到的是那一刻的属性值。
3.3 函数记住创建处:[[Environment]] 属性
一个很容易混淆的问题是:函数到底“记住”了什么?答案在闭包章节中同样明确:
所有函数都记得自己被创建时所在的词法环境。技术上不存在魔法:每个函数都有一个隐藏属性
[[Environment]],保存着指向创建处词法环境的引用。该引用在函数创建时被设定一次,且永不改变。
因此函数在任何地方被调用,都能访问到创建处的词法环境链。题目中 sayHi 在全局环境中创建,[[Environment]] 指向全局词法环境,调用时也只会去这条链上取 name 的当前值。
3.4 变量在“它所居住的”词法环境中被更新
闭包章节还强调了一句容易被忽略的话:变量是在它所在的词法环境中被更新的。这意味着,如果某个变量被闭包读取,闭包读到的是该词法环境里此刻的属性值;而外部代码对该变量的任何赋值,都会实时反映在同一个属性上。旧的取值不会以任何形式被“冻结”在函数体内。
四、进阶验证:调度执行同样读取最新值
题目指出“函数可能被调度到稍后执行”,仓库中的姊妹题 4-settimeout-result/task.md 正好提供了异步场景的实证:
let i = 0;
setTimeout(() => alert(i), 100); // ?
// 假设以下循环执行耗时超过 100ms
for(let j = 0; j < 100000000; j++) {
i++;
}
定时器回调虽然提前 100ms 被“登记”,但它真正执行时,循环早已把 i 增加到 1 亿。由于回调函数同样按调用时刻从外层词法环境读取 i 的最新值,弹出的将是循环结束后的最终值,而不是 0。这再次印证:“延迟执行”不等于“取值定格在登记时刻”,定时器、事件监听、网络回调等异步场景中,闭包读取的永远是执行时刻的最新值。
五、一体两面的陷阱:循环中的闭包全部输出同一个数
理解了“读取最新值”,就能顺理成章地理解仓库中另一道经典陷阱题 10-make-army/task.md:
function makeArmy() {
let shooters = [];
let i = 0;
while (i < 10) {
let shooter = function() { // 创建射击函数
alert( i ); // 本应显示各自编号
};
shooters.push(shooter);
i++;
}
return shooters;
}
let army = makeArmy();
army[0](); // 10
army[1](); // 10
// ……全部输出 10
5.1 为什么全部输出 10
官方解析(10-make-army/solution.md)指出:所有 shooter 函数内部都没有局部变量 i,调用时统一从外层词法环境(makeArmy 调用的词法环境)读取 i。而当 army[5]() 真正执行时,makeArmy 早已结束,while 停在 i = 10。于是所有函数读到的都是同一个“最新值” 10。
这与本习题是同一机制的一体两面:
- 本习题强调:函数运行时读到的值取决于调用时刻变量在词法环境中的状态;
- make-army 则说明:当多个闭包共享同一个外层词法环境中的同一个变量时,它们必然读到该变量的同一个最新值。
5.2 修复:让每个闭包拥有独立的词法环境
解析给出的修复方案是用块级变量把当前迭代的 i 拷贝一份:
function makeArmy() {
let shooters = [];
let i = 0;
while (i < 10) {
let j = i; // 把 i 的当前值拷贝到本迭代的块级变量
let shooter = function() {
alert( j ); // 读取本迭代独立的 j
};
shooters.push(shooter);
i++;
}
return shooters;
}
let army = makeArmy();
army[0](); // 0
army[5](); // 5
while 的循环体 {...} 本身也是一个代码块,每次迭代都会生成新的词法环境。let j = i 声明了一个“迭代局部”变量,把原始值(基本类型按值拷贝)交给本迭代的词法环境;每个 shooter 的 [[Environment]] 指向各自迭代的环境,互不干扰。
更简洁的做法是直接用 for 循环:
function makeArmy() {
let shooters = [];
for(let i = 0; i < 10; i++) {
let shooter = function() {
alert( i );
};
shooters.push(shooter);
}
return shooters;
}
army[0](); // 0
army[5](); // 5
for 的每次迭代都会生成带有独立 i 的新词法环境,本质与 let j = i 相同。
六、反向验证:函数记住的是“创建处”而不是“调用处”
还有一个常见误区:函数会不会读取调用处的变量?仓库中的姊妹题 2-closure-variable-access/task.md 给出了精确的对照:
function makeWorker() {
let name = "Pete";
return function() {
alert(name);
};
}
let name = "John";
let work = makeWorker();
work(); // 显示什么?"Pete" 还是 "John"?
答案是 "Pete"。因为内部函数的 [[Environment]] 在创建时指向 makeWorker 调用的词法环境,那里有局部变量 name = "Pete"。尽管调用时全局也有一个 name = "John",但查找链从内部环境出发,先命中内层的 "Pete" 就停止了。
把两道题放在一起,就得到完整的闭包取值规则:
| 维度 | 结论 |
|---|---|
| 在哪个词法环境中找变量 | 创建处的词法环境链(由 [[Environment]] 决定),与调用位置无关 |
| 取到的是什么值 | 该变量在执行时刻的当前值,与创建时刻的旧值无关 |
七、实战要点与调试提醒
7.1 实战中的三个“最新值”场景
- 事件处理器:为多个元素绑定的回调共享外层状态变量时,回调执行时读到的都是事件触发那一刻的状态——这正是常见“循环绑定事件全部取到最后一项”问题的根源,修复思路与 5.2 节完全一致;
- 网络请求回调:发起请求后外部变量被修改,响应回调中读取到的已是修改后的值;
- 计数器工厂:见 3-counter-independent/task.md,每次调用工厂函数都会新建词法环境,因此多个计数器各自拥有独立的
count——这恰好说明“最新值”是按词法环境实例区分的,不同的环境实例互不共享。
7.2 内存与调试的注意事项
闭包章节还提醒了一个 V8 引擎的现实优化:理论上只要函数存活,其外层变量都会被保留;但 V8 会分析变量使用情况,把未被闭包使用的外层变量优化掉。副作用是:在 Chrome/Edge/Opera 的开发者工具中调试时,这类变量可能显示为“不存在”,甚至出现同名外层变量“顶替”的现象(详见 article.md 的 “Real-life optimizations” 一节)。这不是调试器 bug,而是引擎优化特性,遇到时不妨用调试器确认一下闭包实际捕获了哪些变量。
八、小结与延伸练习
一句话总结本习题的结论:JavaScript 函数读取外部变量时,取的是执行时刻词法环境中的当前值,旧值不会以任何形式被保存;变量解析发生在调用时,而非创建时。
如果你想巩固这一机制,仓库闭包章节还提供了同系列习题,可逐一验证:
- 2-closure-variable-access:函数记住创建处而非调用处;
- 3-counter-independent:不同词法环境实例之间相互独立;
- 4-counter-object-independent:对象属性版计数器;
- 6-closure-sum:用闭包实现
sum(a)(b)双括号调用; - 10-make-army:循环内闭包陷阱与修复;
- 9-sort-by-field:闭包捕获排序字段名,实现
byField('name')式调用。
把这些题目连起来思考,词法环境、[[Environment]]、闭包取值时机这三层知识就能融会贯通,足以应对绝大多数面试与实战中的闭包问题。