跳转至

闭包与作用域 — 函数怎么带着出生时的世界离开

这一章讲两件事,其实是一件事: 闭包——函数被创建时会自动打包带走它出生地的所有变量, 这个包裹让函数可以在任何地方、任何时候访问那些变量,哪怕出生地早已不复存在; 以及作用域——哪些变量对函数「可见」的完整规则,闭包正是这套规则的一个副作用。 读完你会明白:为什么循环里的定时器打印的是 0、1、2 而不是 3、3、3,以及为什么闭包会漏内存。

1. 闭包:一个「保护泡」

先给定义。原书的说法:

闭包(closure)让一个函数可以访问、操作函数外部的变量——具体说, 是能访问函数定义时处于作用域内的所有变量和其他函数。1

作用域(scope) 在这里是正式的词:「程序里某个名字绑定到某个变量的可见范围」—— 简单说,这段代码里,这个名字指谁。2

定义看着平淡,真正惊人的是它的推论:函数可以晚于它的出生地被执行。 一个函数被定义在某个作用域里、随后被带出去(存进变量、传出去、export), 等它被调用时,定义它的那段代码早就执行完了、那个作用域早就不在了—— 可函数照样能读到当时的变量。C 这类传统语言做不到这一点;JavaScript 能,靠的就是闭包。

主线例子:一个会记得自己见过多少人的计数器

全章用它走到底:

function makeCounter() {
  let count = 0;                       // 活在 makeCounter 作用域里的变量
  return () => {                       // 箭头函数被造出的瞬间,打包带走了 count
    count++;
    return count;
  };
}
const counterA = makeCounter();
const counterB = makeCounter();
counterA();   // 1
counterA();   // 2
counterB();   // 1  —— B 有自己的 count,互不相干

带数走一遍:makeCounter() 第 1 次执行,造出变量 count(值 0),返回一个函数。 返回的那一刻,这个函数和 count 的绑定被打包成闭包——makeCounter 随后执行完毕、 作用域理论上「结束」,但 count 没被回收,因为闭包还引用着它。 之后每次调 counterA(),改的都是同一个 count:1、2、3…… 第 2 次 makeCounter() 造出的是另一个 count 和另一个闭包,所以 counterB 从 1 数起。3

原书用了个贴切的比方:闭包像个保护泡——包住函数和它在定义点能看到的所有变量, 只要函数活着,泡就在,变量就不能被垃圾回收。4

书里还有两个更「剥光衣服」的例子值得过一眼:

// 例①(书中 Listing 7.1 的精神):函数 export 到别的模块再调,断言照样过
const outerValue = "ninja";
const outerFunction = () => {
  console.assert(outerValue === "ninja", "I see the ninja.");
};
// outerFunction 被拿到别的模块、在很久以后调用——outerValue 依然可见

// 例②(Listing 7.2):函数从内部作用域「逃脱」到外部变量
let func = () => { console.assert(false, "The original func should never be called."); };
const outerFunction = () => {
  const innerValue = "ninja";
  const innerFunction = () => {
    console.assert(outerValue === "samurai" && innerValue === "ninja");
  };
  func = innerFunction;              // 内部函数被存到外部变量
};
outerFunction();
func();   // 断言通过:innerValue 明明「不属于」这里,却读得到

例②是闭包最反直觉的形态:innerFunction 被赋值给外部变量 func,在 outerFunction 已经执行完之后 才被调用——此刻 innerValue 的作用域已经「死」了,可断言仍然通过。5 机制就一句话:定义函数的那一刻,它和当时作用域里所有变量的通道就被永久焊死了。

2. 闭包的第一份正经工作:私有变量

上一章 memoize 的 cache 其实已经演示过一次;这里把它提升为模式。 面向对象语言里有 private variable(私有变量):只有对象自己的方法能碰的数据。 JavaScript 早年没有这种语法,工程师们发现闭包可以等效模拟:6

function makeCounter() {
  let count = 0;                 // 没有任何外部代码能直接摸到它……
  return {
    increment: () => ++count,   // ……除非通过这两个函数
    read:      () => count,
  };
}
const c = makeCounter();
c.increment();                   // 唯一能改 count 的途径

count 不是任何对象的属性,它是 makeCounter 作用域里的局部变量——语法上, 外部代码没有任何路径碰到它;唯一能接触它的,是定义在同一作用域里的那两个箭头函数(靠闭包)。 闭包 = 用作用域的可见性规则,造出了访问控制。 这是「作用域」这个概念的付费时刻。

今天 ES2022 给类加了 # 私有字段(第 9 章),函数作用域这套不再是最优解, 但理解它仍是理解模块封装的基础——每个模块文件就是一个大作用域,模块顶层变量天然只对自己可见。

3. 闭包的第二份工作:回调读外边的变量

第二个典型场景:回调在未来的某个时刻执行,执行时它需要的数据来自定义它的现场。 书里是一个滴答计数器:7

let tick = 0;
const timerId = setInterval(() => {
  tick++;
  console.log(`tick ${tick}`);
  if (tick === 5) clearInterval(timerId);   // 停掉自己
}, 10);

输出 tick 1 到 tick 5。机制细节值得停一秒:回调函数体里引用了 timerId, 而这个变量和回调出现在同一行、声明在它右边——回调却照样能用。 规则是:函数可以引用词法作用域内的任何东西,哪怕声明在函数之后; 只要执行时那个变量已经初始化(被赋了值)即可。这预告了下一节的提升规则。8

4. 作用域的完整规则:块、let/var、提升与死区

4.1 谁能造作用域

原书列了造作用域的四种构造,这一条是本章最该背下来的清单:9

  1. 函数体 —— 永远造一个作用域(函数作用域);
  2. 模块 —— 每个模块文件顶层就是它自己的作用域(不是全局);
  3. for、if、switch 等块 —— 花括号 `{} 包着的都是块级作用域;
  4. 独立的花括号块 —— 裸写 { const x = 1; },只为造作用域而写,叫 standalone block。

一条经验法则:找离你最近的一对花括号(对象字面量除外),那就是变量的家。 例外:for 循环初始化部分声明的变量,和循环体算同一块。10

对照走一遍书里的嵌套示例:

const moduleScope = "module";                 // 模块作用域
{
  const standaloneBlockScope = "standalone";  // 独立块作用域
  for (let loopScope = 0; loopScope < 3; loopScope++) {   // 循环作用域
    switch (loopScope) {
      case 2: {
        const caseBlockScope = "case";        // case 后的 {} 也是块
        console.log(caseBlockScope);
      }
    }
  }
}

四个变量四个家,互不可见,层层嵌套——内层看得见外层,外层看不见内层。11

4.2 var 的割据:函数作用域,不理会块

上面规则有一个著名的破坏者:var。用 var 声明的变量只认函数(和模块), 对中途的花括号视而不见——块里声明的 var,会「漏」到函数层。12

原书的态度很干脆:var 是 legacy(遗产)关键字,现代代码一律 const/let, 并用 ESLint 的 no-var 规则强制。本书所有示例都不用 var—— 你读老代码时会大量遇到它,知道它在「无视块」这一点上不一样,就够。13

4.3 每个迭代一个块:let 循环的魔法

块级规则有一个漂亮的推论,书用了一个定时器例子展示——这是本章的招牌演示:14

for (let i = 0; i < 3; i++) {
  setTimeout(() => { console.log(i); }, 10);
}
// 打印:0  1  2

三个定时器都在循环结束后才触发。若循环变量是 var i(函数作用域,三个回调共享同一个 i), 打印会是 3 3 3——这是 JavaScript 面试题的第一名。而 let i 下, 循环的每一次迭代都算一个新的块:三次迭代 = 三个 i,每个回调闭包各自焊住自己那一轮的 i。 0、1、2,一次成型。15

4.4 提升与暂时性死区:名字先于值

先看一个会炸的例子:16

console.assert(x === 1, "x is 1");
const x = 1;
// ReferenceError: Cannot access 'x' before initialization

错误信息很讲究:「before initialization(初始化之前)」——不是「没定义」。 对比一下:如果第二行整个删掉,错误会变成「x is not defined」。 两种错误提示一个事实:引擎在执行任何代码之前,已经扫描了整个作用域、登记了里面所有的名字—— 所以它能分辨「根本没声明」和「声明了但还没轮到初始化」。

后一种状态有个正式的恐怖名字:暂时性死区(Temporal Dead Zone, TDZ)—— 一个名字已在作用域里登记、但引用它会抛 ReferenceError 的阶段。let、const、class 声明的变量都有 TDZ; var 没有(var 声明即初始化为 undefined),函数声明更特殊——根本没有死区。17

区分清楚很重要:

  • 在作用域里(visible)≠ 能用。可见性由词法结构定,可用性还受初始化时序约束;
  • 「先引用后声明」合法与否,看的是执行顺序,不是书写顺序。 这段代码完全合法:
const verifyX = () => { console.assert(x === 1, "x is 1"); };  // 定义时不执行
const x = 1;
verifyX();   // 执行时 x 早已初始化 ✓

函数体里的引用在执行那一刻才被检查,那时 x 早好了。18

提升(hoisting) 专属于两个东西:19

  • 函数声明:整个函数被「提」到作用域顶部,所以声明之前的行也能调用它:
greetNinja();           // 正常运行
function greetNinja() { console.log(getGreeting()); }
function getGreeting() { return "Ossu!"; }   // 它引用的函数声明更靠后,也没事
  • import 语句(模块导入,原书安排在后续章讲)。

其他一切——函数表达式、箭头函数、let/const 变量——都不提升。 「函数声明可以在定义前调用,表达式不行」这个第 5 章埋下的悬念,此处兑现。20

4.5 底下发生了什么:两遍解析与惰性解析

引擎怎么做到「执行前就知道作用域里有什么」?原书给了一个简化模型:解析分两遍。21

  第 1 遍:语法校验 —— 扫全部代码,专查语法错(比如同一作用域里 let 声明了两次同名变量)
  第 2 遍:完整解析 —— 编译成字节码,同时建立作用域(术语:scope analysis,作用域分析)

第一遍有个直接可见的性能后果:程序越大,启动越慢——哪怕你只想跑第一行, 引擎也得先扫完全部代码找语法错。(对策是懒加载,后续章。)好在语法校验比完整解析快得多。22

第二遍有个聪明的优化叫惰性解析(lazy parsing):作用域分析可以推迟到「这个作用域第一次执行」时再做。 把一个几百个 case 的 switch 从顶层挪进一个函数体,启动时引擎只需扫语法 + 解析外层—— 提示符弹出的时间立刻变短;空转时它还能后台把那个大函数编译完。23

(原书注明:这是简化模型,真实引擎更复杂,可能合并两遍、还有各种即时编译优化——v8.dev 有公开文章。 作为心智模型它足够准确。)

5. 全局对象:特殊的作用域,特殊的变量

最后一类作用域是全局。globalThis 是 ECMAScript 2020 定下的标准名字, 指「这个运行时的全局对象」:浏览器里它是 window,Node 里是 global,而 globalThis 两边都通用。24

为什么需要这个标准名?历史上浏览器里全局对象叫 window,Node 来了又叫 global, 一份要在两种环境跑的代码,连「怎么称呼全局」都无从写起;globalThis 就是来补这个裂缝的。

往全局对象上挂属性 = 造全局变量:

globalThis.ninjaName = "Naruto";
// 此后程序任何角落:ninjaName === "Naruto",globalThis.ninjaName 也一样

这是有意为之时的正确姿势——但历史上全局变量更多的是事故造的:老代码在函数里 ninjaName = "Naruto"(没写 var/let/const)会顺手创造一个全局;严格模式把这条路封死 (直接 ReferenceError),模块制又让「顶层」天然不是全局——事故率已大幅下降。25

作用域链(scope chain) 把全章收拢成一个查找算法——任何时候你引用一个变量,引擎按这个顺序找:26

  1. 当前作用域里有这个名字吗?
       有 → 已初始化(或能提升)就用它;在死区里 → 抛 ReferenceError
  2. 没有 → 到父作用域重复第 1 步
  3. 一路到全局都没有 → 抛 ReferenceError

想「探测一个可能不存在(尤其可能没声明)的全局变量」,优雅的姿势是 typeof: typeof ninjaName === "undefined" 对未声明的名字也安全(返回 "undefined" 而非抛错), 比 ninjaName === undefined 多罩了一层「它可能根本没被声明过」的情况。 注意 typeof 只对全局探测免疫死区——局部变量上照样炸。27

6. 闭包的暗面:内存泄漏实证

前面全是闭包的好;这一节是它的账单。JavaScript 是垃圾回收(garbage collection)语言: 运行时自动释放「不再被引用」的对象占的内存。问题:什么叫「不再被引用」? 闭包说了算。

只要一个函数还活着,它闭包里那些变量的引用就还在,那些变量就不能回收。 正常情况这正是你要的;异常情况见书里这个 Node.js 实测(它依赖 Node 的 v8 模块,是引擎内部调试接口):28

import { writeHeapSnapshot } from "v8";

const prepareNinjaPower = () => {
  const powerBuffer = new ArrayBuffer(99_000_000);   // ≈ 99MB 的大对象

  (() => { console.log(`Ninja power level: ${powerBuffer.byteLength}`); })();  // 用一次

  return () => { console.log("The ninja power is safe."); };  // 返回值:不碰 powerBuffer
};

const reportNinjaPower = prepareNinjaPower();   // 留下的函数不引用大对象
reportNinjaPower();
writeHeapSnapshot("1-before-gc.heapsnapshot");
global.gc(true);                                // 强制垃圾回收(需 --expose-gc)
writeHeapSnapshot("2-after-gc.heapsnapshot");

直觉判断:powerBuffer 只被那个立即执行的函数用过一次,返回的函数完全不碰它, 函数一返回它就「应该」被回收。实测:不回收。 两份堆快照里,都躺着那个约 100MB 的对象。29

V8 的推理(书里转述)是:30

  1. 这个作用域里有个闭包引用了 powerBuffer
  2. 这个作用域里还有个函数活在 prepareNinjaPower 结束之后
  3. ⟹ 保守起见,powerBuffer 只要那个函数活着就不能回收

这个推理是错的(powerBuffer 在 IIFE 之后就没用了),但修正它的代价高昂: 引擎得精确追踪「哪个闭包引用了哪个变量」,元数据会让其他场景变慢—— 这是工程上的两难,不是疏忽。书里引了 Jake Archibald 2024 年的博客文章作为出处, 并给了一句忍者式的结论:最好的防御是知道这回事;大对象有疑虑时,用完显式解除引用 (TypeScript 5.2 的 using 关键字是语法糖,底层是 ECMAScript 的显式资源管理提案)。31

7. 作者的判断与证据

书内可验的机制:

  • 循环定时器 0 1 2 vs 3 3 3 的对照实验、提升的 greetNinja 实验,全部可跑;
  • 内存泄漏一节直接给了堆快照的实验方法(含强制 GC 的开关),是本章最强的实证;
  • 「两个错误消息不同」(before initialization vs is not defined)是可直接观察的证据。32

作者立场:

  • 「现代代码一律 const/let、用 no-var 强制」是作者团队的规范取向;
  • 「typeof 探测比 try/catch 优雅」是惯例判断,小作用域里其实把声明上移更干净(原书也承认);
  • 惰性解析的「大函数挪进函数体」优化建议,在小项目里收益有限——原书没给量化阈值。

8. 边界与局限

  • 闭包的正式定义在规范层面比「保护泡」比方更细(环境记录、外部环境引用),原书选了直觉模型; 对写代码而言足够,做引擎研究需另找资料。
  • using 只是 TypeScript 5.2 的语法,底层提案仍在 ECMAScript 流程中,运行时支持要查 compat 表。
  • 内存泄漏的 V8 行为是「as of this writing」,引擎启发式可能随版本改进;实验方法(堆快照)比结论耐用。
  • 第 5 章说「第 6 章揭晓提升」,实际完整规则在本章——原书自己把声明提前调用的讨论分散在两章。

9. 可带走的(合上这一章,你应该有这十条)

  1. 闭包是函数的出厂配置:定义即打包,带走当时可见的一切变量;函数活着,变量就活着。
  2. 私有变量不需要语法:局部变量 + 返回一组闭包函数,就是访问控制。
  3. 花括号造作用域(函数体、模块、for/if/switch、裸块),内见外、外不见内;找最近一对花括号就是变量的家。
  4. var 无视块,只认函数——legacy 关键字,现代代码用 const/let。
  5. let 循环每迭代一个新块,三个定时器各闭包各的 i——打印 0、1、2 的机制。
  6. 「可见」≠「可用」:引擎先登记名字再执行;let/const 有暂时性死区,引用即 ReferenceError。
  7. 只提升两样:函数声明和 import;判断标准永远是执行顺序而非书写顺序。
  8. 解析两遍:先扫语法错,再建作用域;大 switch 挪进函数体 = 惰性解析 = 启动变快。
  9. 全局对象的标准名是 globalThis(浏览器 window、Node global);typeof 是探测未声明全局的安全姿势。
  10. 闭包会留活变量:同一作用域里任一函数活着,V8 可能连不再被引用的 99MB 对象都不回收——大对象用完手动解除。

11. 深读一:闭包的两个基础示例,逐行走一遍

第 1 节给了 makeCounter,这里把书里的两个「剥光衣服」的例子走完整, 它们是理解闭包的最低层地基。

11.1 例①:同一作用域的简单可见性

const outerValue = "ninja";
const outerFunction = () => {
  console.assert(outerValue === "ninja", "I see the ninja.");
};
outerFunction();

断言通过,似乎不值一提。但书里的关键一步是:把 outerFunction export 到别的模块、 在很久以后调用——断言照样通过。 此刻定义它的模块早已执行完毕, 那个作用域早已「不存在」,可 outerValue 仍然可见。 这就是闭包的第一层含义:作用域的寿命,可以超过它的执行期。1

11.2 例②:从内层「逃脱」到外层的完整机制

const outerValue = "samurai";
let func = () => {
  console.assert(false, "The original func should never be called.");
};

const outerFunction = () => {
  const innerValue = "ninja";
  const innerFunction = () => {
    console.assert(outerValue === "samurai", "I see the samurai.");
    console.assert(innerValue === "ninja", "I see the ninja.");
  };
  func = innerFunction;              // 关键一步:内部函数被存到外部变量
};

outerFunction();
func();                              // 此刻 outerFunction 已经执行完了

两条断言都通过。书里的问题是:「What magic allows the innerValue variable to still be 'live' when we execute the inner function, long after the scope in which it was created has gone away?」答案没有魔法,只有机制—— 声明 innerFunction 的那一刻,它和当时作用域里所有变量(outerValue、innerValue) 的通道就被一起打包了;之后无论它被存到哪、何时被调,都走这个包。2

11.3 保护泡的工程化表述

把「保护泡」比方翻译成工程语言(书里给了,值得照录):

闭包阻止 JavaScript 引擎回收作用域内的变量,直到函数本身被回收为止。3

这句话有两个方向的应用,正好是闭包的两张脸:

  • 要用它时(私有变量、回调读外参):泡的存在正是你要的保证—— 回调晚执行时数据还在;
  • 要防它时(内存泄漏):泡的存在意味着「函数活着 = 它见过的变量全活着」—— 第 6 节的 100MB 实验就是这张脸的实证。

12. 深读二:作用域规则的完整清单,含 var 的割据地图

12.1 造作用域的四种构造,加一张「谁能看见谁」图

const moduleScope = "module";                 // 模块作用域:全文件可见
{
  const standaloneBlockScope = "standalone";  // 独立块:块内可见
  for (let loopScope = 0; loopScope < 3; loopScope++) {
    // 循环作用域:loopScope 与循环体同块;每次迭代一个新块
    switch (loopScope) {
      case 2: {
        const caseBlockScope = "case";        // case 后的 {}:又一块
        console.log(caseBlockScope);
      }
    }
  }
}

可见性规则一句话:内层看得见外层,外层看不见内层; 同层两个块互不可见。找变量的家,找最近一对花括号(对象字面量除外)。4

12.2 var 的割据:一张对照表结束这个话题

维度 let / const / class var function 声明
作用域 最近的块 最近的函数(无视块) 最近的函数
声明前引用 死区,ReferenceError 得到 undefined 提升,可用
重复声明 语法错误 允许,后者覆盖 允许(非严格)

「var 无视块」的最经典演出就是循环计数器:for (var i = 0; …) 的 i 挂在整个函数上,循环结束后还活着;for (let i = 0; …) 的 i 属于循环块, 出了循环就没了。原书的态度很硬:var 是遗产,现代代码一律 const/let、 用 ESLint 的 no-var 强制,本书示例零 var。5

13. 深读三:提升与死区,两个错误消息的完整教学

13.1 两个 ReferenceError 不一样,这是考点

console.assert(x === 1, "x is 1");
const x = 1;
// ReferenceError: Cannot access 'x' before initialization

console.assert(y === 1, "y is 1");
// ReferenceError: y is not defined

两个报错,两种病(书里特意对照):前者是「声明了、还没初始化」—— x 已在作用域登记,只是死区未过;后者是「压根没声明」。 引擎能分辨这两者,本身就证明了一件事:执行任何代码之前,它已扫完整个作用域、 登记了所有名字。这个「先登记、后执行」的两阶段模型,是全章机制的底座。6

13.2 函数声明为什么「全文可用」

greetNinja();                        // 正常执行,输出 "Ossu!"
function greetNinja() {
  console.log(getGreeting());        // 调用的函数声明得更靠后,也没事
}
function getGreeting() { return "Ossu!"; }

greetNinja 调用发生时,它后面的代码一行没跑——但函数声明在「登记」阶段 就已就位,而函数体里的 getGreeting() 要到执行那一刻才解析, 那时登记阶段早已完成。原书强调:这个待遇只给函数声明和 import; 函数表达式、箭头函数、let/const,都不提升。7

另一个容易误判的场景(书里给了纠正):函数体内引用一个「声明在函数之后的变量」 完全合法,只要调用发生在初始化之后——「先引用后声明」的合法性由执行顺序决定, 不由书写顺序决定。TypeScript 无法静态判断调用时机,所以要靠 ESLint 的 no-use-before-define 兜底。8

14. 深读四:引擎的两遍解析,性能含义的完整版

14.1 语法校验:为什么大程序启动慢

  第 1 遍:语法校验 ── 扫【全部】代码,只查语法错
      例:同一作用域 let a 两次 → SyntaxError: Identifier 'a' has already been declared
      特征:任何一行都还没执行,console.log 在报错前面也【不会】先打印
      依据:ECMAScript 规范强制「先全量语法校验,再执行」

性能推论书里明说:程序越大,启动越慢——引擎必须扫完所有代码才能开跑第一行。 缓解之道是后续章的懒加载;安慰是语法校验远快于完整解析。9

14.2 完整解析与作用域分析:CLI 例子的前后对照

书里的大 switch 对照(改写前后各一段),是惰性解析的最佳教材:10

// 改前:几百个 case 的 switch 躺在顶层 while 里
while (true) {
  const input = await rl.question('Enter command or ".exit":\n> ');
  switch (input) { case ".exit": process.exit(0); /* …几百个 case… */ }
}
// 启动时:语法校验(全量) + 完整解析(全量,含大 switch) → 提示符来得慢

// 改后:同样的 switch 收进 handleInput 函数体
const handleInput = (input: string) => { switch (input) { /* …同上百 case… */ } };
while (true) {
  const input = await rl.question('Enter command or ".exit":\n> ');
  handleInput(input);
}
// 启动时:语法校验(全量) + 完整解析(只到外层作用域)
// 大函数的完整解析推迟到它第一次被调用——而事件循环空转时,
// 引擎在后台顺手把它编译完了,用户敲第一条命令前多半已就绪

「把大代码块收进函数体」因此是一个零成本习惯:语法上不改变任何行为, 启动路径上的解析工作量却实打实减少。原书注明这是简化模型, 真实引擎可能合并两遍、还有更多的即时编译优化(v8.dev 有公开文章)—— 作为心智模型,它足够指导你写「启动友好」的代码。11

15. 深读五:全局作用域与作用域链,探测技术的完整版

15.1 全局对象的三个时代

书里给了一段简史,值得一记:早年浏览器里全局对象叫 window; Node 出现后又有了 global——一份要在两端跑的代码,连「怎么称呼全局」都写不出来。 ECMAScript 2020 的 globalThis 终结了这个分裂:它是个标准名, 浏览器里 globalThis === window,Node 里 globalThis === global。 书里给的使用建议:跨环境代码用 globalThis,只有引用环境特定 API (如 window.addEventListener)时才用环境名。12

15.2 故意造全局与故意防全局

「想造全局」时的正规姿势(书里 Listing 7.7):

// globals.mts
declare global { var ninjaName: string; }   // 告诉检查器:我要声明一个全局
globalThis.ninjaName = "Naruto";             // 挂到全局对象上

// main.mts
import "./globals.mts";                      // 执行 globals 模块
console.log(`It's me, ${ninjaName}!`);       // 直接可读

「防误造全局」的故事同样完整:早年没有模块、没有严格模式, 函数里 ninjaName = "Naruto"(忘了写 var)会顺手创造一个全局; 今天模块制让顶层不再是全局,严格模式让未声明赋值直接 ReferenceError—— 事故率大幅下降,但「declare global + globalThis」这条正路要认得。13

15.3 作用域链算法与 typeof 探测术

书里的查找算法(照录):

  1. 当前作用域有这个名字?
       有 → 已初始化(或可提升)就用;在死区 → 抛 ReferenceError
  2. 没有 → 父作用域重复
  3. 到全局还没有 → 抛 ReferenceError

typeof 是唯一对「未声明」也温柔的探测(书里给了完整边界):

if (typeof ninjaName === "undefined") {      // ninjaName 可能压根没声明过
  globalThis.ninjaName = "Naruto";           // typeof 不炸,返回 "undefined"
}

边界两条(书里都点了):typeof 的温柔只管全局——局部变量上照样撞死区; 真要探测局部变量初始化没有,只剩 try/catch 一条路,但更好的解法是把声明 挪到作用域顶部,让问题消失。14

16. 深读六:内存泄漏实验的完整复盘

第 6 节的实验值得按「假设—方法—结果—归因」复盘一遍,因为它是全书中 实证强度最高的一段:15

  假设:返回的函数不引用 powerBuffer → 它应该被回收
  方法:prepareNinjaPower() 造 99_000_000 字节的 ArrayBuffer,
       内部一个 IIFE 引用它一次,返回的函数【不】引用它;
       调用返回值 → writeHeapSnapshot(快照1) → global.gc(true) 强制回收 → 快照2
  结果:两份快照里都有约 100MB 的对象 → 【假设被证伪】
  归因:V8 的保守推理——作用域里有个闭包引用了 powerBuffer,
       且有个函数活过 prepareNinjaPower,于是它决定:powerBuffer 不能回收

对照实验(书里给了):不接收返回值直接调 prepareNinjaPower(), 那 100MB 就消失了——活下来的函数是触发保守推理的必要条件。 书里的归因态度很克制:V8 的工程师们知道这个问题,修正它要引擎维护 「哪个闭包引用哪个变量」的精确账本,其他场景的性能会因此受损—— 两难无优解,忍者该做的是知道它存在。防御姿势:大对象有疑虑就显式解除引用; TypeScript 5.2 的 using 关键字(ECMAScript 显式资源管理提案的语法糖)是语法层的辅助。16

17. 原文地图

主题 原书章 原文位置
闭包定义与「晚调用」推论 Chapter 7: Functions for the master: closures and scopes text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:28(搜「access and manipulate variables」)
scope 的正式定义 同上 :32(搜「A scope refers to the visibility」)
Listing 7.2 逃脱示例 同上 :60(搜「Listing 7.2」) · :87(搜「What magic」)
保护泡比方与垃圾回收阻止 同上 :96(搜「protective bubble」) · :102(搜「garbage-collecting」)
memoize 即闭包模式 同上 :113(搜「Saving values in function-scoped variables」)
定时器回调与先引用后初始化 同上 :138(搜「Referencing surrounding variables」) · :165(搜「declared after the function」)
造作用域的四种构造 同上 :179(搜「Creating block scopes」)
var 的割据与 no-var 同上 :190(搜「plays by its own rules」)
最近花括号法则与嵌套示例 同上 :196(搜「rule of thumb」) · :202(搜「Listing 7.4」)
每迭代一个块:定时器 0 1 2 同上 :235(搜「Listing 7.5」) · :247(搜「that would be the behavior」)
before initialization 错误 同上 :255(搜「Execution order and hoisting」) · :268(搜「Cannot access」)
TDZ 边栏 同上 :279(搜「TEMPORAL DEAD ZONE」)
执行顺序 vs 书写顺序 同上 :292(搜「verifyX」)
提升:函数声明与 import 同上 :308(搜「always treated as if they were declared at the top」) · :334(搜「Aside from function declarations」)
两遍解析与语法校验强制 同上 :341(搜「two separate passes」) · :372(搜「mandated by the ECMAScript spec」)
惰性解析与大函数优化 同上 :377(搜「lazy parsing」) · :403(搜「get the prompt faster」)
globalThis 与历史名 同上 :441(搜「global scope object」) · :497(搜「globalThis identifier」)
意外全局与严格模式封堵 同上 :446(搜「by accident」) · :458(搜「ReferenceError: ninjaName is not defined」)
作用域链算法 同上 :512(搜「scope chain」)
typeof 探测与死区例外 同上 :526(搜「typeof」) · :543(搜「dreaded Temporal Dead Zone」)
V8 泄漏实证 同上 :578(搜「Listing 7.8」) · :611(搜「approximately 100MB」) · :615(搜「heuristic reasoning」)
全章总结 同上 :639(搜「Summary」)

  1. 出处:「Chapter 7: Functions for the master: closures and scopes」第 27–58 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:55,搜「the scope is still」)。模块执行完毕后作用域仍然「活着」的断言。 ↩↩

  2. 出处:「Chapter 7: Functions for the master: closures and scopes」第 60–94 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:87,搜「What magic」)。innerValue 越狱示例与「What magic allows」的原问。 ↩↩

  3. 出处:「Chapter 7: Functions for the master: closures and scopes」第 96–104 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:103,搜「garbage-collected」)。保护泡的工程表述原文。 ↩↩

  4. 出处:「Chapter 7: Functions for the master: closures and scopes」第 179–233 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:196,搜「rule of thumb」)。四种造作用域构造与最近花括号法则。 ↩↩

  5. 出处:「Chapter 7: Functions for the master: closures and scopes」第 190–195 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:190,搜「plays by its own rules」)。var 的函数作用域与 no-var 强制建议。 ↩↩

  6. 出处:「Chapter 7: Functions for the master: closures and scopes」第 255–277 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:268,搜「Cannot access」)。两个 ReferenceError 的对照教学。 ↩↩

  7. 出处:「Chapter 7: Functions for the master: closures and scopes」第 308–339 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:312,搜「known as hoisting」)。提升定义与「只给函数声明和 import」的清单(第 334–337 段)。 ↩↩

  8. 出处:「Chapter 7: Functions for the master: closures and scopes」第 292–307 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:301,搜「executed after」)。执行顺序 vs 书写顺序;no-use-before-define 建议见第 303–306 段边栏。 ↩↩

  9. 出处:「Chapter 7: Functions for the master: closures and scopes」第 341–376 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:374,搜「scan through all of its code」)。两遍解析模型与「程序越大启动越慢」的推论。 ↩↩

  10. 出处:「Chapter 7: Functions for the master: closures and scopes」第 377–437 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:381,搜「lazy parsing」)。惰性解析定义;CLI 大 switch 对照与「user will get the prompt faster」见第 410–433 段。 ↩↩

  11. 出处:「Chapter 7: Functions for the master: closures and scopes」第 349–354 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:349,搜「simplified model」)。「简化模型」声明与 v8.dev 指引。 ↩↩

  12. 出处:「Chapter 7: Functions for the master: closures and scopes」第 441–510 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:497,搜「globalThis identifier」)。全局对象命名史与 globalThis 的跨环境语义。 ↩↩

  13. 出处:「Chapter 7: Functions for the master: closures and scopes」第 446–504 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:458,搜「illegal」)。意外全局的历史与严格模式封堵;declare global 姿势见第 467–486 段。 ↩↩

  14. 出处:「Chapter 7: Functions for the master: closures and scopes」第 512–553 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:545,搜「declared in a local scope」)。typeof 探测的温柔边界与局部死区例外。 ↩↩

  15. 出处:「Chapter 7: Functions for the master: closures and scopes」第 557–613 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:578,搜「Listing 7.8」)。泄漏实验的完整代码;对照实验(不保留返回值则消失)见第 613–614 段。 ↩↩

  16. 出处:「Chapter 7: Functions for the master: closures and scopes」第 615–635 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:627,搜「challenging tradeoff」)。V8 保守推理的三条转述与「修要性能代价」的两难;using 关键字见第 631–634 段边栏。 ↩↩

  17. 出处:「Chapter 7: Functions for the master: closures and scopes」第 279–288 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:287,搜「Temporal Dead Zone」)。TDZ 术语来自 ECMAScript 规范;let/const/class 有死区、var/function 没有。 ↩

  18. 出处:「Chapter 7: Functions for the master: closures and scopes」第 289–307 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:301,搜「executed after」)。「a statement about execution order, not the order of the code itself」。 ↩

  19. 出处:「Chapter 7: Functions for the master: closures and scopes」第 308–339 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:312,搜「known as hoisting」)。提升定义;「Functions defined with any other syntax don't have this strange behavior」与 import 同为仅有的提升项(第 334–337 段)。 ↩

  20. 出处:「Chapter 7: Functions for the master: closures and scopes」第 315–325 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:317,搜「greetNinja」)。提升实验的完整代码与输出「Ossu!」。 ↩

  21. 出处:「Chapter 7: Functions for the master: closures and scopes」第 341–355 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:345,搜「two separate passes」)。两遍解析模型;「简化模型」声明见第 349–353 段边栏。 ↩

  22. 出处:「Chapter 7: Functions for the master: closures and scopes」第 356–376 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:374,搜「scan through all of its code」)。「程序越大启动越慢」的推论与「语法校验远快于完整解析」。 ↩

  23. 出处:「Chapter 7: Functions for the master: closures and scopes」第 377–437 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:381,搜「lazy parsing」)。惰性解析定义;CLI 大 switch 挪进函数体的优化与「user will get the prompt faster」(第 429–433 段)。 ↩

  24. 出处:「Chapter 7: Functions for the master: closures and scopes」第 441–504 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:497,搜「globalThis identifier」)。globalThis 的引入年份与 window/global 的对应。 ↩

  25. 出处:「Chapter 7: Functions for the master: closures and scopes」第 446–463 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:444,搜「by accident」)。意外全局的历史与严格模式封堵(第 458 段的 ReferenceError 示例)。 ↩

  26. 出处:「Chapter 7: Functions for the master: closures and scopes」第 512–524 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:513,搜「scope chain」)。作用域链查找算法的两步原文。 ↩

  27. 出处:「Chapter 7: Functions for the master: closures and scopes」第 525–553 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:527,搜「get the string "undefined"」)。typeof 探测机制;「typeof can only be used to check whether a variable exists in the global scope」与局部死区反例(第 543–549 段)。 ↩

  28. 出处:「Chapter 7: Functions for the master: closures and scopes」第 557–613 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:578,搜「Listing 7.8」)。Node 实测代码;依赖 v8 模块与 --expose-gc 的前提见第 572–576 段边栏。 ↩

  29. 出处:「Chapter 7: Functions for the master: closures and scopes」第 611–614 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:611,搜「If you examine the heap snapshot files, you」)。两份堆快照都有大对象的实测结论;不赋值返回值的版本则消失。 ↩

  30. 出处:「Chapter 7: Functions for the master: closures and scopes」第 615–627 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:615,搜「heuristic reasoning」)。V8 三条保守推理的转述。 ↩

  31. 出处:「Chapter 7: Functions for the master: closures and scopes」第 627–635 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:627,搜「challenging tradeoff」)。「修要更多元数据、其他场景变慢」的两难;using 关键字与显式资源管理提案的出路(第 631–634 段边栏)。 ↩

  32. 出处:「Chapter 7: Functions for the master: closures and scopes」第 270–273 段(text/10-ch07-chapter-7-functions-for-the-master-closures-and-.txt:272,搜「is not defined」)。两个错误消息的对照,即「先登记名字」的直接证据。 ↩