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

这张图能直接回答三个老问题。为什么 a、b 互不干扰?因为两次调用各建一个环境,两函数对象各引用各的。为什么 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、事件回调的绝大部分困惑会同时消解。
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,或把闭包需要的数据先拆出来再建闭包。
💡 关键直觉:闭包 = 函数对象 + 环境引用。看到任何「函数在外面跑、变量却活着」的现象,先在脑内画出这两个框和那条箭头,答案自动浮现。
两个高频工具函数是闭包语义的完美练手——都是「环境里存一次状态,函数体读它」。
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 章的事件委托(一个回调服务全部项),才是不放大的正解。
作用域链过长会有性能影响吗?
有但通常可忽略:现代引擎对词法环境的访问做了深度优化,且多数函数的链长在两三层。可感知的极端出现在「深层嵌套的回调金字塔」里——十几层嵌套意味着十几层环境引用串,每个自由变量的查找理论上要多爬几站(实际常被编译器优化成直接引用)。真正的实操建议是面向可读性的:嵌套超过三四层就提取函数,链短了、人也看得懂了,性能顺带受益——这个方向上,好代码与快代码再次重合。