2.3 闭包与作用域链的内部表示


2.3 闭包与作用域链的内部表示

本节摘要:闭包不是语法特性,而是一种引擎实现上的必然结果——函数对象持有对其词法环境的引用,只要函数还活着,被它引用的环境就不能回收。本节画出闭包在堆上的真实形态,解释作用域链如何沿环境引用逐级上溯,用计数器、循环捕获、模块模式三个案例做实证,并清算闭包的内存代价。

现场还原:计数器为什么互不干扰

function makeCounter(label) { let count = 0; return function () { count++; console.log(label, count); }; } const a = makeCounter('A'); const b = makeCounter('B'); a(); a(); b(); // 实测输出: // A 1 // A 2 // B 1

makeCounter 两次调用各建一个栈帧,两个帧早就弹出了,但 count 显然还活着——a 数到 2,b 从 1 开始。老解释说「函数记住了外面的变量」,这句话对但没用。引擎真正做的事是:count 这个变量本来就不放在栈帧里,而是放在堆上的一个词法环境对象里;返回的内层函数对象身上带着一个环境引用(规范里叫 [[Environment]]),指着那个环境。只要 ab 还被程序持有着,它们的环境就不可回收。

图 2.3-1 闭包的内部表示:堆上的环境与函数对象

图 2.3-1 闭包的内部表示:堆上的环境与函数对象

这张图能直接回答三个老问题。为什么 ab 互不干扰?因为两次调用各建一个环境,两函数对象各引用各的。为什么 count 不会丢?因为回收器看见环境A 还有函数对象甲指着,不敢收。为什么说闭包「占内存」?因为整个环境被扣住了——哪怕你只用其中一个变量。

一、作用域链:定义时焊死的查找路线

作用域链的本质就是图里那条「环境引用」的串。引擎查变量的动作永远是三步:先查当前环境,命中即止;未命中沿外层引用上溯;到全局仍未命中,抛 ReferenceError。

链在什么时候定下来?函数被创建的时候,而不是被调用的时候。这就是词法作用域(静态作用域)的含义:

const x = 'global'; function outer() { const x = 'outer'; function inner() { return x; } return inner; } const f = outer(); console.log(f()); // outer(inner 出生在 outer 里,链条指向 outer 的环境)

哪怕 f 在全局执行,打出来的仍是 outer——链条焊死在出生地。反例实验(很多教材用来演示「动态作用域不存在」):

function foo() { console.log(v); } function bar() { const v = 'bar 里的 v'; foo(); // ReferenceError: v is not defined } bar();

foo 的链上没有 bar——调用栈上的邻居不代表作用域链上的邻居。这两个「链」是不同的结构:调用栈描述谁在等谁(运行时),作用域链描述变量去哪找(定义时)。把它们区分开,闭包、this、事件回调的绝大部分困惑会同时消解。

二、循环捕获:一个 var 引发的著名事故

for (var i = 0; i < 3; i++) { setTimeout(() => console.log('var', i), 0); } // 实测:var 3 / var 3 / var 3 for (let j = 0; j < 3; j++) { setTimeout(() => console.log('let', j), 0); } // 实测:let 0 / let 1 / let 2

var 版把一个 i 登记在函数级环境里,三个回调共享同一份;定时器触发时循环早已结束,i 停在 3。let 版引擎为每轮迭代创建一个全新环境,本轮的 j 拷贝进去,回调各持一份。用闭包语言说:var 版三个闭包指向同一个环境,let 版三个闭包指向三个环境。

老代码里只能用 var 时的等价修法——立即执行函数(IIFE)手工制造新环境:

for (var i = 0; i < 3; i++) { (function (k) { setTimeout(() => console.log('iife', k), 0); })(i); } // 实测:iife 0 / iife 1 / iife 2

IIFE 在这里唯一的作用就是当环境工厂。今天有 let,这个模式退役了,但读懂老代码仍然需要它。

三、模块模式与私有状态

闭包最体面的工程应用是模块模式——用函数环境当保险箱:

const wallet = (function () { let balance = 100; // 环境私有,外部摸不到 return { deposit(n) { balance += n; return balance; }, withdraw(n) { if (n > balance) throw new Error('余额不足'); balance -= n; return balance; }, get balance() { return balance; } }; })(); console.log(wallet.deposit(50)); // 150 console.log(wallet.withdraw(30)); // 120 console.log(wallet.balance); // 120 wallet.balance = 999; // setter 不存在?getter-only,静默失败(非严格) console.log(wallet.balance); // 120

balance 只活在 IIFE 的环境里,暴露的三个方法闭包引用它。这是 ES 模块出现之前 JavaScript 社区的标准私有方案,也解释了为什么 class 的 #私有字段 语法出现之前,人们用闭包或 WeakMap 模拟私有。现代代码里,闭包仍然是非类场景下做状态封装的首选:防抖节流函数存定时器句柄、React Hooks 的 useState 存状态、柯里化函数存已收参数,全是同一个模式。

⚠️ 常见坑:闭包扣住的是整个环境,不是你用到的那一个变量。一个环境里放着十个变量,闭包只用其一,其余九个也一并活到闭包死亡。大对象(比如整份接口返回数据)若与闭包同处一个环境,就是第 6 章内存泄漏清单上的常客——必要时把大对象显式置 null,或把闭包需要的数据先拆出来再建闭包。

💡 关键直觉:闭包 = 函数对象 + 环境引用。看到任何「函数在外面跑、变量却活着」的现象,先在脑内画出这两个框和那条箭头,答案自动浮现。

  • 闭包是实现的副产品:环境在堆上、函数持引用,回收器自然不敢收;
  • 作用域链定义时焊死,与调用栈是两套结构,foo 看不见 bar 里的变量是铁律;
  • var 循环事故的根源是共享环境,let 每轮新环境,IIFE 是手工版 let;
  • 模块模式用环境当保险箱,Hooks、防抖、柯里化都是同一思想的变体;
  • 闭包扣住整个环境,大对象与闭包同环境是泄漏高发组合。

综合演练:用闭包实现 once 与 memoize

两个高频工具函数是闭包语义的完美练手——都是「环境里存一次状态,函数体读它」。

once:只执行一次的函数。 支付、初始化、弹层动画触发,处处需要「首次有效」:

function once(fn) { let done = false; let result; return function (...args) { if (done) return result; done = true; result = fn.apply(this, args); // 保留 this 与参数语义 return result; }; } let calls = 0; const init = once(() => { calls++; return '已初始化'; }); console.log(init(), init(), calls); // 已初始化 已初始化 1

环境里的 done 与 result 闭包私有,外部不可伪造「重置」。注意 apply(this) 的处理:包装器不破坏原函数的 this 规则,这是工具函数的通用礼仪。

memoize:结果缓存。 纯函数的重算可以用「参数到结果」的映射省掉:

function memoize(fn, keyGen = JSON.stringify) { const cache = new Map(); return function (...args) { const key = keyGen(args); if (cache.has(key)) return cache.get(key); const value = fn.apply(this, args); cache.set(key, value); return value; }; } let computed = 0; const slowSquare = n => { computed++; return n * n; }; const fast = memoize(slowSquare); console.log(fast(9), fast(9), fast(9), computed); // 81 81 81 1

两个工程注意:keyGen 默认用 JSON.stringify,参数含对象时键可能碰撞(顺序不同但内容相同的对象生成不同键,反之亦可能同键异义),关键路径自定义键生成器;cache 无上限就是第 6 章的「无界缓存」泄漏现场——长生命周期里用,加容量上限或 TTL。这两个函数合起来说明:闭包不是「面试概念」,是状态封装的最小工具。

常见问答

闭包一定会导致内存泄漏吗?
不会,只要闭包本身可达性正常。闭包扣住环境是设计行为(否则计数器、缓存、私有状态全部失效),泄漏的判定是「闭包已无人调用、环境却仍被扣住」——症状在引用管理,不在闭包机制。把不再使用的闭包引用置空(从映射表删除、解绑事件),环境随即可回收。

模块顶层算闭包吗?
机制上算:一个 ES 模块就是一个函数级作用域被执行后的持久环境,模块内函数都闭包引用它。「模块级私有变量」正是闭包语义的最大规模应用。理解这一点后,IIFE 模块模式与 ESM 的关系也清晰了——后者是语言把前者的手工环境工厂标准化。

循环里用 var 配合闭包还有救吗?
有,三个救法按推荐度排:改 let(每轮新环境,正解);IIFE 手工造环境(老代码维护);把循环变量作为参数传给回调(setTimeout(k => ..., 0, i) 的第三参形式,最少改动)。三种救法的共同点都是「给每个回调一份独立的值副本」,区别只在环境由谁造。

词法与动态的对照实验:一次讲透「定义时」

「作用域链在定义时焊死」这句话最容易被点头划过,用一组对照实验把它钉实:

const level = '全局'; function outerFactory() { const level = '工厂内'; return function reader() { return level; // 读的是哪个 level?出生地说的算 }; } const reader = outerFactory(); function dynamicCaller(fn) { const level = '调用者内'; // 调用现场有同名变量,也轮不到它 return fn(); } console.log(reader()); // 工厂内 console.log(dynamicCaller(reader)); // 工厂内 —— 调用者的 level 完全被无视

dynamicCaller 里明明也有一个 level,而且 reader 就在它的栈帧上执行——但查找路线是「reader 的环境 → outerFactory 的环境 → 全局」,压根不经过 dynamicCaller。调用栈上的邻居不是作用域链上的邻居,这句第 2.1 节埋下的话在这里得到最彻底的展示。若这语言走动态作用域(有些老语言确实如此),第二个输出就该是「调用者内」。

再补一个「链在定义时确定、值在运行时读取」的细节实验:

function makeReader() { let state = '初始'; return { read: () => state, // 闭包持有的是「变量」,不是「取值那一刻的快照」 write: (v) => { state = v; } }; } const box = makeReader(); console.log(box.read()); // 初始 box.write('已更新'); console.log(box.read()); // 已更新 —— 同一个环境里的同一个变量

读与写共享同一个环境槽位——这就是闭包能当「带接口的私有状态」用的全部原理,也是第 7 章模块活绑定语义的微缩预演(import 的绑定同理:链在结构期定、值在求值期到)。两个实验合起来,把「定义时确定路线、运行时存取数值」这两半拼成完整的闭包心智模型。

闭包的内存账本

闭包占多少内存,能算个大概。账本三行:环境对象本体(每个被捕获的变量一个槽位,对象几十到上百字节起);被扣住的对象(按引用到达的全部数据——这才是大头,一个被扣的十万元素数组就是约八十万字节);函数对象与代码体(同源码的多个闭包共享代码体,只有环境是各自的)。所以「闭包贵不贵」的答案几乎完全取决于它扣住了多大的对象,闭包机制本身的固定开销小到可以忽略。

实操核对方法:开发者工具堆快照里,闭包函数的 Retained Size 就是这份账的总额——点开一个闭包函数,保留链会列出它拖着的整个环境。第 6 章排查泄漏时,这张账本就是「该不该置 null」的判断依据:环境里只有几个标量,随它去;环境里躺着大数组且函数还会长期存活,把大对象显式搬离环境(按值拷出需要的字段)。

最后一个常见误算:「每个回调都扣环境」导致的批量放大。给一万个列表项各挂一个闭包回调,每个闭包环境里只放该项的引用——一万个环境本体约一两兆,尚可接受;但若环境里不小心共享了一个大对象(比如整个数据集),就是一万份可达性指向同一块大内存,叠加第 5 章的事件委托(一个回调服务全部项),才是不放大的正解。

作用域链过长会有性能影响吗?
有但通常可忽略:现代引擎对词法环境的访问做了深度优化,且多数函数的链长在两三层。可感知的极端出现在「深层嵌套的回调金字塔」里——十几层嵌套意味着十几层环境引用串,每个自由变量的查找理论上要多爬几站(实际常被编译器优化成直接引用)。真正的实操建议是面向可读性的:嵌套超过三四层就提取函数,链短了、人也看得懂了,性能顺带受益——这个方向上,好代码与快代码再次重合。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U