JavaScript 闭包探秘:函数读取的是最新值还是旧值?—— javascript.info 习题深度解析

原创2026-10-03 17:18:442,221 阅读
文章标签:文档/教程前端

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)。它由两部分组成:

  1. 环境记录(Environment Record):一个对象,存储该作用域内所有的局部变量(以及 this 等信息)作为其属性;
  2. 对外部词法环境的引用:指向外层代码关联的词法环境。

关键结论原文如此:“变量”不过是这个特殊内部对象 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 实战中的三个“最新值”场景

  1. 事件处理器:为多个元素绑定的回调共享外层状态变量时,回调执行时读到的都是事件触发那一刻的状态——这正是常见“循环绑定事件全部取到最后一项”问题的根源,修复思路与 5.2 节完全一致;
  2. 网络请求回调:发起请求后外部变量被修改,响应回调中读取到的已是修改后的值;
  3. 计数器工厂:见 3-counter-independent/task.md,每次调用工厂函数都会新建词法环境,因此多个计数器各自拥有独立的 count——这恰好说明“最新值”是按词法环境实例区分的,不同的环境实例互不共享。

7.2 内存与调试的注意事项

闭包章节还提醒了一个 V8 引擎的现实优化:理论上只要函数存活,其外层变量都会被保留;但 V8 会分析变量使用情况,把未被闭包使用的外层变量优化掉。副作用是:在 Chrome/Edge/Opera 的开发者工具中调试时,这类变量可能显示为“不存在”,甚至出现同名外层变量“顶替”的现象(详见 article.md 的 “Real-life optimizations” 一节)。这不是调试器 bug,而是引擎优化特性,遇到时不妨用调试器确认一下闭包实际捕获了哪些变量。

八、小结与延伸练习

一句话总结本习题的结论:JavaScript 函数读取外部变量时,取的是执行时刻词法环境中的当前值,旧值不会以任何形式被保存;变量解析发生在调用时,而非创建时。

如果你想巩固这一机制,仓库闭包章节还提供了同系列习题,可逐一验证:

把这些题目连起来思考,词法环境、[[Environment]]、闭包取值时机这三层知识就能融会贯通,足以应对绝大多数面试与实战中的闭包问题。

登录后查看全文
en.javascript.info