闭包是 JavaScript 里被讲得最多、也被误解得最深的机制。有人把它当成“面试八股”背下定义,有人以为它一定会造成内存泄漏,还有人写了五年业务代码却说不清 let 和 var 在循环里的差别到底从何而来。这些困惑的根,几乎都指向同一个地方:没有真正理解作用域与词法环境。闭包不是某个需要刻意使用的技巧,它是词法作用域的必然产物——只要你在一个函数里定义了另一个函数,闭包就已经发生了,区别只在于你有没有意识到它、有没有用好它。本篇 35 分钟长文,从作用域的定义讲起,一路走过执行上下文、词法环境、变量查找、闭包的六种经典用法、循环陷阱、内存与性能、this 的混淆、五个手写实现,最后落到一份可以贴在工位上的检查清单。读完之后,你再看任何一段涉及闭包的代码,都应该能在大脑里“看见”它的作用域链。
一、作用域:变量能被看见的那片区域
先给一个足够朴素的定义:作用域(Scope)就是一套规则,它决定了在代码的某个位置,你能访问到哪些变量,以及访问不到哪些变量。换一个更工程化的说法:作用域是变量的“可见性边界”。
JavaScript 采用的是词法作用域(Lexical Scope),也叫静态作用域。这四个字的关键在于“词法”二字——作用域由代码书写的位置决定,在代码被解析的那一刻就已经确定,与函数最终被谁调用、在哪里调用无关。
看一段能立刻把人分层的代码:
function f() {
console.log(x);
}
function g() {
var x = 2;
f();
}
g(); // 输出 1,不是 2
如果 JavaScript 是动态作用域,f 会去“调用它的地方”找 x,也就是在 g 的作用域里找到 2。但结果是 1——因为 f 的作用域在定义它的那一刻就被钉死了:它在全局被定义,所以它永远只能看见全局的 x。
词法作用域带来的三个直接结论
- 作用域在编译期确定:引擎解析代码时就会建立作用域结构,运行时不再改变。
- 函数在哪里定义,比在哪里调用重要得多:这解释了为什么回调函数总能“记得”它出生时的环境。
- 闭包是词法作用域的自然结果:既然函数记得自己的出生地,那么只要这个函数活得比它的出生地更久,它就必须把出生地一起带在身上。
f 是在 g 里面被调用的,那它当然能拿到 g 里的变量。”——这是把调用关系当成了作用域关系,是动态作用域的思维方式。
为什么语言要设计成词法作用域
词法作用域不是 JavaScript 的独创,C、Java、C#、Go、Python 全都采用词法作用域。原因很实际:
- 可预测:读代码时,你不用追踪运行时的调用栈,静态地看源码就能判断变量从哪来。
- 可优化:引擎在编译期就能确定变量的存储位置(栈槽、上下文槽),不必每次运行时查找。
- 可推导:静态分析工具、类型检查器、IDE 的“跳转到定义”都依赖这个性质。
动态作用域会让调试变成噩梦——同一个函数在不同调用链下行为不同,而你只能靠运行时日志去猜。JavaScript 把这个不确定性收敛到了唯一一个地方:this。这一点我们会在第八节专门展开。
二、作用域的种类与作用域链
ES6 之后,JavaScript 里实际存在四种作用域。搞清楚它们的分工,是理解闭包的前提。
var 与函数声明会成为 window 的属性
{ }、if、for、while、switch 创建,只对 let / const / class 生效
var 无视块级作用域,这是历史上大量 bug 的源头
遮蔽(Shadowing):内层会盖住外层
当内层作用域声明了一个与外层同名的变量,外层的那个就被“遮蔽”了。这不是错误,但要小心——它常常是排查 bug 时最容易被忽略的一环。
function demo() {
let name = 'inner';
console.log(name); // 'inner'
{
let name = 'block';
console.log(name); // 'block'
}
console.log(name); // 'inner'
}
demo();
console.log(name); // 'outer'
作用域链:变量查找的真实路径
当引擎执行到某个标识符时,它不会“凭空知道”这个变量在哪。它会沿着一条链,从最内层开始逐级向外查找,直到找到第一个匹配的声明为止。这条链就是作用域链。点击下面的每一环,看看查找的过程。
var 声明、let/const 声明都在这里。命中即返回,不再继续向外。{ } 内部,引擎会继续在外层的块级环境中查找。这也是 for 循环里的 let 能够被闭包正确捕获的原因。window;用 var 声明的全局变量与函数声明都会成为它的属性。ReferenceError: xxx is not defined。注意:如果是赋值操作且处于非严格模式,会在全局隐式创建变量——这是另一个历史遗留坑。查找是单向的,且代价随链长增长
作用域链的查找方向永远是“由内向外”,反向是不可能的:外层作用域无法访问内层作用域的变量。这解释了一个常见的困惑——为什么在全局拿不到函数内部定义的变量?因为查找根本不会往里走。
另外,虽然现代引擎对变量查找做了大量优化(例如把常见查找内联化),但作用域链越长,理论上查找成本越高。这也是“尽量不要在深层嵌套里频繁访问最外层变量”这条经验法则的来源——在极热点的循环里,把外层变量先缓存到局部,确实可能有收益。
for (let i = 0; i < 10000; i++) {
render(config.theme, config.size, config.locale);
}
// 优化后:把外层变量缓存在局部
const { theme, size, locale } = config;
for (let i = 0; i < 10000; i++) {
render(theme, size, locale);
}
三、执行上下文与词法环境:引擎内部发生了什么
作用域是“规则”,而执行上下文与词法环境是这套规则在引擎里的“数据结构”。理解它们,你才能真正明白变量提升、暂时性死区这些现象为什么存在。
执行上下文栈
每当 JavaScript 开始执行一段代码,引擎就会创建一个执行上下文(Execution Context),并把它压入执行上下文栈。全局代码创建全局上下文,每次函数调用创建函数上下文,调用结束则弹出。
console.log('enter a');
b();
console.log('leave a');
}
function b() {
console.log('enter b');
}
a();
// 输出顺序:
// enter a
// enter b
// leave a
// 栈的变化:
// [全局] → [全局, a] → [全局, a, b] → [全局, a] → [全局]
调用栈溢出(RangeError: Maximum call stack size exceeded)就是因为上下文栈被无限压入而没有被弹出——递归缺少终止条件时的经典错误。
一个执行上下文里有什么
- 词法环境(Lexical Environment):存放
let/const声明、块级作用域变量。 - 变量环境(Variable Environment):存放
var声明与函数声明。 - this 绑定:当前上下文中的
this指向。 - outer 引用:指向外层词法环境,正是这个引用把各个环境串成了作用域链。
每个词法环境内部又包含一个环境记录(Environment Record),它是真正存放“变量名 → 值”映射的地方。环境记录有三种:
| 类型 | 出现位置 | 特点 | 典型表现 |
|---|---|---|---|
| 声明式记录 | 函数 / 块级作用域 | 直接存储绑定 | 局部变量、参数 |
| 对象式记录 | with 语句、全局 | 绑定即对象属性 | var 全局变量挂在 window 上 |
| 全局记录 | 全局环境 | 两者的混合体 | let 全局变量不挂 window |
声明提升与暂时性死区
执行上下文的创建阶段会先“扫描”一遍函数体,把声明提前登记好,然后才逐行执行。这个“先登记、后执行”的机制,就是变量提升的来源。
console.log(a); // undefined(不报错)
var a = 1;
// 函数声明:整体提升,连函数体一起
foo(); // 'foo'(可以正常调用)
function foo() { console.log('foo'); }
// let / const:提升但不初始化,进入暂时性死区
console.log(b); // ReferenceError
let b = 2;
// 函数表达式不会被提升
bar(); // TypeError: bar is not a function
var bar = function () {};
// 类声明同样存在 TDZ
new Foo(); // ReferenceError
class Foo {}
把“暂时性死区”(Temporal Dead Zone,TDZ)理解清楚很关键:let 声明的变量在进入作用域时就已经被登记,但直到声明语句执行前都处于“未初始化”状态,任何访问都会抛错。这不是设计缺陷,而是一个刻意的保护——它让“先使用后声明”这类低级错误立刻暴露,而不是悄悄给你一个 undefined。
if (!user) {
var user = fetchUser();
}
return user; // 永远拿到 undefined
}
if (!user) {
let user = fetchUser();
}
return user; // ReferenceError,立刻发现
}
四、闭包:函数背着自己的出生地
铺垫了三节,终于可以给闭包一个准确的定义了。先说一个流传最广、但容易让人误入歧途的说法:
“闭包是指有权访问另一个函数作用域中变量的函数。”——这个定义没错,但它描述的是结果,不是本质。
更准确的表述是:
闭包 = 函数 + 该函数定义时所处的词法环境的引用。
注意两个关键词。第一是“定义时”——不是调用时,这是词法作用域的直接推论。第二是“引用”——闭包持有的不是变量的值副本,而是变量本身。这个区别极其重要,我们马上会看到它的后果。
let count = 0;
return function () {
count += 1;
return count;
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3
const another = createCounter();
another(); // 1 —— 全新的作用域,互不影响
按常理说,createCounter 执行完毕之后,它的执行上下文应该出栈,里面的 count 应该被回收。但 counter 依然能一次次把它加上去。为什么?
因为返回的那个匿名函数,在自己的 [[Environment]] 内部槽里,保存了对 createCounter 词法环境的引用。只要这个函数还活着,这个引用就存在;只要引用存在,count 就不会被回收。垃圾回收器的工作方式是“可达性分析”,而不是“作用域是否执行完毕”。
闭包捕获的是变量,不是值
这是初学者最容易栽跟头的地方。看下面这段代码:
let value = 10;
const read = () => value;
const write = (v) => { value = v; };
return { read, write };
}
const { read, write } = make();
read(); // 10
write(99);
read(); // 99 —— 两个闭包共享同一个 value 绑定
read 和 write 是同一个 make 调用中产生的两个闭包,它们共享同一个词法环境,因此也共享同一个 value 变量。通过 write 修改后,read 读到的就是新值。闭包之间可以互相通信,这是模块模式能够实现私有状态的根本原因。
闭包形成的三个条件
三个条件缺一不可。尤其是第三条——如果内层函数只是在外部函数里被立即调用一次,那外层作用域随调用结束就销毁了,不会产生“活得比出生地更久”的现象。
let x = 1;
function inner() { return x; }
inner(); // 立即调用,没有外流
}
outer();
let x = 1;
return function () { return x; };
}
const fn = outer(); // 活了下来
五、闭包的六种经典用法
闭包不是炫技,它在真实项目中的出场频率远比你想象的高。下面六种用法,覆盖了日常开发中的绝大多数场景。
用法一:私有变量与模块模式
在 ES Module 普及之前,IIFE + 闭包是模拟“私有成员”的唯一手段。即使在今天,当你需要隐藏内部状态、只暴露受控接口时,这个模式依然有效。
let count = 0; // 外部无法直接访问
function increase() {
count += 1;
}
function getCount() {
return count;
}
return { increase, getCount };
})();
counter.increase();
counter.getCount(); // 1
counter.count; // undefined —— 拿不到
用法二:函数工厂
用闭包批量生成“行为相似但参数不同”的函数,可以避免重复代码。
return function (value) {
return value >= min && value <= max;
};
}
const isAge = createValidator(0, 150);
const isScore = createValidator(0, 100);
isAge(30); // true
isScore(120); // false
用法三:柯里化
把多参数函数拆成一系列单参数函数,每一层都用闭包保存上一层的实参。
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
}
return (...rest) => curried.apply(this, args.concat(rest));
};
}
const add = (a, b, c) => a + b + c;
const curriedAdd = curry(add);
curriedAdd(1)(2)(3); // 6
curriedAdd(1, 2)(3); // 6
curriedAdd(1)(2, 3); // 6
用法四:事件回调中的状态记忆
这是闭包在业务代码里出现频率最高的地方。每次注册监听器时,闭包都会记住注册那一刻的上下文。
tabList.forEach(function (tab) {
tab.addEventListener('click', function () {
// 每个回调都记住了自己那一次的 tab
console.log('激活:', tab.dataset.name);
});
});
}
用法五:记忆化(Memoization)
用一个闭包内的缓存对象,把函数的计算结果存起来,相同入参直接返回缓存。
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
const slowFib = (n) => (n <= 1 ? n : slowFib(n - 1) + slowFib(n - 2));
const fastFib = memoize((n) => (n <= 1 ? n : fastFib(n - 1) + fastFib(n - 2)));
用法六:防抖与节流
这两个工具的“状态”(定时器 ID、上次执行时间)必须跨越多次调用保持,而闭包正是保存这种状态的最自然的方式。
function debounce(fn, delay) {
let timer = null;
return function (...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
timer = null;
}, delay);
};
}
function throttle(fn, interval) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last < interval) return;
last = now;
fn.apply(this, args);
};
}
注意这两个实现里都用到了 fn.apply(this, args)。这不是多余的:如果不转发 this 和参数,被包装的函数在作为对象方法使用时就会丢失上下文。这是闭包与 this 交汇处的经典细节,我们在第八节会深入。
六、循环中的闭包:一道被问了二十年的题
如果闭包只有一个知识点值得反复咀嚼,那就是它。下面这段代码,是 JavaScript 历史上被讨论最多的片段之一。
setTimeout(function () {
console.log(i);
}, 0);
}
// 实际输出:3 3 3
// 原因:var 是函数作用域,三个回调共享同一个 i
// 循环结束时 i 已经是 3,回调才依次执行
let 到底做了什么
规范里对 for 语句中的 let 有一套专门的处理:每次循环迭代开始时,引擎都会创建一个新的词法环境,把上一轮的变量值复制进来,然后执行循环体。这意味着:
- 第 0 次迭代的回调,捕获的是第 0 次迭代的环境,里面的
i是 0。 - 第 1 次迭代的回调,捕获的是第 1 次迭代的环境,里面的
i是 1。 - 以此类推,三个回调各持一份,互不干扰。
这套机制有个名字,叫“每次迭代绑定”(Per-Iteration Binding)。它不是 let 在块级作用域之外的额外魔法,而是 for 语句配合 let 时的标准行为。
ES5 时代的三种解法
在 let 出现之前,开发者用各种方式“手动制造”每次迭代独立的作用域。理解这些写法,能帮你更深刻地理解闭包。
for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(function () {
console.log(j);
}, 0);
})(i);
}
// 输出 0 1 2
function logLater(value) {
setTimeout(function () {
console.log(value);
}, 0);
}
for (var i = 0; i < 3; i++) {
logLater(i);
}
// 输出 0 1 2
for (var i = 0; i < 3; i++) {
setTimeout(function (j) {
console.log(j);
}, 0, i);
}
// 输出 0 1 2 —— i 在注册时就被作为实参传入
一个更容易踩坑的变体
下面这段代码里,闭包捕获的是同一个 i,但循环体内的代码却是同步执行的,所以输出是正常的 0、1、2。真正出问题的只有“异步回调”和“事件回调”。
for (var i = 0; i < 3; i++) {
console.log(i); // 0 1 2
}
// 异步:出问题
for (var i = 0; i < 3; i++) {
Promise.resolve().then(() => console.log(i)); // 3 3 3
}
// 事件绑定:同样出问题
for (var i = 0; i < buttons.length; i++) {
buttons[i].onclick = () => console.log(i); // 全是最后一个下标
}
判断标准很简单:如果回调的执行时机晚于循环结束,而它又捕获了 var 变量,就一定有问题。养成习惯——只要写 for 循环,就用 let。
七、闭包与内存:到底会不会泄漏
“闭包会导致内存泄漏”是一个被过度传播的说法。准确的说法应该是:闭包会延长被捕获变量的生命周期,如果这些变量本身持有大量数据或 DOM 引用,就可能造成事实上的内存占用居高不下。
生命周期是怎么被延长的
普通函数的局部变量,在函数返回后就可以被回收;而一旦某个变量被闭包捕获,且闭包本身可达,这个变量就会一直存活。判断一个变量是否会被回收,只需要问一个问题:从根对象(全局对象)出发,能不能顺着引用链找到它?
const hugeData = new Array(1e6).fill('x'); // 约 8MB
const el = document.getElementById('panel');
el.addEventListener('click', function () {
// 只用到了 el,却让整个作用域都活了下来
el.classList.toggle('active');
});
}
// hugeData 永远不会被回收,尽管回调里根本没用到它
不过,现代 JavaScript 引擎(V8、SpiderMonkey、JavaScriptCore)都做了闭包变量裁剪优化:如果某个变量在闭包中完全没有被引用,引擎可能不会把它保留在上下文中。但这个优化依赖于具体的编译路径与优化层级,不能作为编写代码时的依赖。稳妥的做法还是主动规避。
三种真实存在的泄漏场景
对应的三种解法
function setup(el) {
const handler = () => { /* ... */ };
el.addEventListener('click', handler);
return () => el.removeEventListener('click', handler);
}
// 2. 清除定时器:用 AbortController 或显式 clear
const controller = new AbortController();
fetch(url, { signal: controller.signal });
window.addEventListener('resize', onResize, {
signal: controller.signal,
});
// 需要时:controller.abort();
// 3. 有界缓存:给 Map 设上限,配合 LRU 淘汰
class LRUCache {
constructor(limit = 100) {
this.limit = limit;
this.map = new Map();
}
get(key) {
if (!this.map.has(key)) return undefined;
const value = this.map.get(key);
this.map.delete(key);
this.map.set(key, value);
return value;
}
set(key, value) {
if (this.map.has(key)) this.map.delete(key);
this.map.set(key, value);
if (this.map.size > this.limit) {
this.map.delete(this.map.keys().next().value);
}
}
}
用 WeakMap 切断强引用
当你需要给 DOM 元素附加数据,又不希望阻止它被回收时,WeakMap 是标准答案。它的键是弱引用——只要键对象被回收,对应的值也会自动释放。
const cache = new Map();
cache.set(el, metadata);
// 正解:WeakMap 让元素与数据同生共死
const weakCache = new WeakMap();
weakCache.set(el, metadata);
// el 被移除并被回收后,metadata 也随之释放
| 写法 | 内存风险 | 说明 |
|---|---|---|
| 闭包只捕获小变量 | 极低 | 计数器、开关、配置项等 |
| 闭包捕获大数组 / 大对象 | 中等 | 引擎可能裁剪,但不能依赖 |
| 闭包捕获 DOM 且不解绑 | 高 | 典型泄漏,SPA 中尤其常见 |
| 闭包持有未清除的定时器 | 高 | 回调会持续执行,性能与内存双输 |
| 用 WeakMap 关联 DOM | 极低 | 推荐方案 |
最后强调一句:闭包本身不是泄漏,忘记清理才是。用闭包实现的状态,就应该配一个对称的清理逻辑——这在组件化的前端框架里体现为 useEffect 的返回函数、onUnmounted 钩子、disconnectedCallback 生命周期。
八、闭包与 this:两个常被混淆的概念
初学者常把闭包和 this 混为一谈,因为它们都涉及“函数能拿到外部的什么东西”。但它们遵循完全不同的规则:
this,会捕获定义时外层的 this——这一点与闭包捕获变量类似
经典陷阱:回调中的 this 丢失
seconds: 0,
start() {
setInterval(function () {
this.seconds++; // TypeError:this 是 undefined 或 window
}, 1000);
}
};
timer.start();
为什么会这样?因为 setInterval 内部是以普通函数调用方式执行回调的(相当于 cb()),此时 this 在严格模式下是 undefined,非严格模式下是全局对象。回调在定义时确实位于 start 方法内部,但作用域能捕获变量,不能捕获 this。
四种修复方式
start() {
setInterval(() => {
this.seconds++;
}, 1000);
}
// 方案二:提前保存 this
start() {
const self = this;
setInterval(function () {
self.seconds++;
}, 1000);
}
start() {
setInterval(function () {
this.seconds++;
}.bind(this), 1000);
}
// 方案四:类字段 + 箭头函数
class Timer {
seconds = 0;
tick = () => { this.seconds++; };
start() { setInterval(this.tick, 1000); }
}
箭头函数的 this 是“词法 this”
箭头函数没有自己的 this、arguments、super 和 new.target。它内部出现的 this,会沿着作用域链去找最近的一个非箭头函数的 this。所以箭头函数的 this 行为,其实与闭包捕获变量非常相似——都是“定义时决定”。
this 的原型方法、需要 arguments 的函数。const obj = { name: 'a', hi: () => this.name } ——这里的 this 指向外层,不是 obj。
Promise 链、定时器、需要固定 this 的场景。它们本来就不需要自己的
this,用箭头函数反而更安全。
一个综合案例:防抖函数中的 this 转发
回到第五节的防抖实现。如果写成下面这样,会丢掉调用者的 this:
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
// fn 是以普通函数形式调用的,this 丢失
};
}
// 正确版本:转发 this
function debounce(fn, delay) {
let timer = null;
return function (...args) {
const context = this; // 捕获调用时的 this
clearTimeout(timer);
timer = setTimeout(() => fn.apply(context, args), delay);
};
}
注意 context 这个变量——它被闭包捕获,因此箭头函数在延迟执行时依然能拿到正确的 this。这是一个把“闭包捕获变量”和“this 动态绑定”两个机制结合起来解决实际问题的典型例子。
九、五个手写实现:把理解变成肌肉记忆
读完理论,最好的检验方式是手写。下面五个实现,覆盖了闭包在工程中最常见的形态。建议先遮住答案自己写一遍。
实现一:once —— 只执行一次
let called = false;
let result;
return function (...args) {
if (called) return result;
called = true;
result = fn.apply(this, args);
return result;
};
}
const init = once(() => {
console.log('初始化');
return 42;
});
init(); // '初始化',返回 42
init(); // 什么都不做,返回 42
关键点:不仅要记住“是否调用过”,还要缓存返回值,否则第二次调用会返回 undefined,破坏调用方的一致性预期。
实现二:带缓存的 memoize(支持自定义 key)
const cache = new Map();
const memoized = function (...args) {
const key = resolver ? resolver.apply(this, args) : args[0];
if (cache.has(key)) {
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
memoized.cache = cache; // 暴露缓存,便于手动清理
return memoized;
}
// 用法:自定义 key,避免 JSON.stringify 的性能开销
const getUser = memoize(fetchUser, (id, type) => `${id}:${type}`);
实现三:完整的 curry
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
}
const self = this;
return function (...rest) {
return curried.apply(self, args.concat(rest));
};
};
}
// 占位符版本(lodash 风格)
const _ = Symbol('placeholder');
function curryWithPlaceholder(fn) {
return function curried(...args) {
const ready = args.length >= fn.length && !args.slice(0, fn.length).includes(_);
if (ready) return fn.apply(this, args);
return (...rest) => {
const merged = args.map((v) => (v === _ && rest.length ? rest.shift() : v));
return curried.apply(this, merged.concat(rest));
};
};
}
实现四:带立即执行选项的 debounce
let timer = null;
let result;
function debounced(...args) {
const context = this;
if (timer) clearTimeout(timer);
if (immediate) {
const callNow = !timer;
timer = setTimeout(() => { timer = null; }, delay);
if (callNow) result = fn.apply(context, args);
} else {
timer = setTimeout(() => {
fn.apply(context, args);
timer = null;
}, delay);
}
return result;
}
debounced.cancel = function () {
clearTimeout(timer);
timer = null;
};
return debounced;
}
cancel 方法的加入非常重要——它给闭包状态提供了一个显式的释放入口,这正是第七节所说的“对称清理”。
实现五:简易沙箱 / 命名空间
let state = { ...initialState };
const listeners = new Set();
function getState() {
return { ...state }; // 返回副本,防止外部篡改
}
function setState(patch) {
const prev = state;
state = { ...state, ...patch };
listeners.forEach((fn) => fn(state, prev));
}
function subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn); // 返回取消订阅函数
}
return { getState, setState, subscribe };
}
const store = createStore({ count: 0 });
const unsubscribe = store.subscribe((s) => console.log(s.count));
store.setState({ count: 1 }); // 1
unsubscribe();
store.setState({ count: 2 }); // 无输出
这个模式几乎就是 Vuex、Pinia、Redux 的雏形。它把 state 和 listeners 完全封闭在闭包里,外部只能通过返回的三个方法间接操作——这就是用闭包实现封装的最小完整示例。
十、十个最常见的闭包误区
var 写 for 循环并绑定事件,然后困惑为什么全都指向最后一个元素
this 当成作用域的一部分,以为闭包能“记住 this”
let 在循环里“每次都是新变量”是语法糖,没意识到是规范定义的每次迭代绑定
两个需要展开说的点
关于误区 3:闭包本身只是一个引用关系,它不分配额外的“泄漏空间”。真正的问题是引用链上的对象太大了、或者生命周期太长了。把“闭包 = 泄漏”改成“闭包 = 延长的生命周期,需要配套的释放策略”,心态会健康很多。
关于误区 9:V8 确实会在某些情况下做变量裁剪,但它的行为依赖于代码是否被优化、优化是否被回退(deopt)。在生产代码里依赖这种未公开的优化是非常危险的。正确做法是——只捕获你真正需要的东西。如果只需要一个值,就在创建闭包前先把它取出来。
function setup(config) {
return () => render(config.theme);
}
// 推荐:只捕获需要的字段
function setup(config) {
const theme = config.theme; // 提前取出
return () => render(theme);
}
十一、调试技巧:在 DevTools 里看见闭包
理解闭包最好的方式,是亲眼在调试器里看到它。Chrome DevTools 提供了相当完整的闭包可见性。
在 Sources 面板打断点
- 打开 DevTools → Sources 面板,找到你的脚本文件。
- 在闭包内部的某一行打上断点,触发执行。
- 右侧的 Scope 面板会列出四类作用域:
Local、Closure、Script、Global。 - 展开
Closure,你会看到引擎为这个函数保留的每一个外层变量,以及它们当前的实时值。
有一个非常直观的实验:在 createCounter 返回的函数里打断点,连续点击几次,观察 Closure 里的 count 是如何在多次调用之间持续累加的。这比读十遍定义都管用。
用 console.dir 查看函数内部槽
let secret = 'hidden';
return () => secret;
}
const fn = outer();
console.dir(fn);
// 展开 [[Scopes]],可以看到:
// Closure (outer)
// secret: "hidden"
[[Scopes]] 是引擎暴露给调试器的一个内部属性。它清楚地展示了闭包到底“带着”什么——有时候你会惊讶地发现某个变量被保留了,而你完全没在闭包里用到它,那就说明存在可以优化的空间。
用 Memory 面板定位泄漏
- 打开 Memory 面板,选择 Heap snapshot,拍摄快照。
- 在页面上执行一遍会创建闭包的操作,再拍一次快照。
- 用 Comparison 视图对比两次快照,筛选
Detached HTMLElement——如果这个数字持续增长,基本可以确认存在 DOM 相关的闭包泄漏。 - 点开某个对象,查看 Retainers 面板,它会告诉你“谁在引用它”,顺着引用链就能找到没被清理的闭包。
一个快速自检的小技巧
function isClosure(fn) {
// 这是调试技巧,不是生产代码
const str = fn.toString();
return str.includes('[native code]') ? false : true;
}
// 更实用的做法:直接 console.dir 看 [[Scopes]]
最后提醒一句:调试器看到的是“当前引擎在非优化状态下保留的作用域”,可能比生产环境优化后保留的更多。所以调试时看到的闭包内容,是上界而不是精确值。
十二、检查清单与代码基线
把全文的结论压缩成一份可以随时对照的清单。写代码时扫一眼,能挡掉绝大多数与闭包、作用域相关的坑。
- 默认使用
let/const,只有在确实需要函数作用域时才用var——实际上几乎没有这种场景。 - 循环绑定事件一律用
let,不要依赖 IIFE 补救。 - 记住闭包捕获的是变量绑定,多个闭包共享同一变量时会互相影响。
- 只捕获需要的变量,不要为了省事把整个大对象留在闭包里。
- 为每一个闭包状态配一个释放入口:
unsubscribe、cancel、destroy、abort。 - DOM 事件监听器要成对解绑,尤其在单页应用的组件卸载时。
- 定时器、轮询、WebSocket 必须显式清理。
- 缓存必须有上限,用 LRU 或 TTL 约束,不要写无界的 Map。
- 关联 DOM 的附加数据用
WeakMap。 - 回调里需要
this就用箭头函数,或者显式保存const self = this。 - 包装函数要转发
this和参数:fn.apply(this, args)。 - 不要用闭包实现全局可变状态,那会让测试变得极其困难。
- 调试时用 DevTools 的 Scope 面板确认闭包内容,不要靠猜。
- 用
console.dir(fn)查看[[Scopes]],验证闭包到底持有什么。 - 怀疑泄漏时用 Memory 面板的 Comparison 视图,重点看 Detached HTMLElement。
// 1. 状态封装 + 显式销毁
function createController() {
let disposed = false;
const handlers = new Set();
function add(fn) {
if (disposed) throw new Error('controller disposed');
handlers.add(fn);
return () => handlers.delete(fn);
}
function dispose() {
disposed = true;
handlers.clear();
}
return { add, dispose };
}
// 2. 只捕获需要的值
function bindOnce(el, config) {
const { type, delay } = config;
const handler = debounce(() => { /* 用 type */ }, delay);
el.addEventListener(type, handler);
return () => el.removeEventListener(type, handler);
}
// 3. 循环里正确使用闭包
for (const [index, item] of list.entries()) {
buttons[index].addEventListener('click', () => select(item));
}
// 4. 包装函数转发 this 与参数
function wrap(fn) {
return function (...args) {
return fn.apply(this, args);
};
}
// 5. 有界缓存
const cache = new WeakMap(); // 键是对象时优先
const lru = new Map(); // 键是原始值时限长限容
最后的话
闭包之所以让人又爱又恨,是因为它把“函数的定义位置”这个平时被忽略的信息,变成了运行时的实际行为。它让 JavaScript 拥有了私有状态、工厂函数、装饰器模式,也让一个没写好的 for 循环能在生产环境藏三个月才被发现。
理解闭包,本质上是理解一件事:函数不只包含代码,还包含它出生时的整个世界。当你写下一个内层函数并让它逃逸到外部时,你就把那个世界一起带了出来。带出来的东西有多大、要放多久、什么时候该放下——这些决定,应该由你来做,而不是由垃圾回收器猜。
所以,下一次你写下 return function () { ... } 的时候,不妨多问自己一句:“这个函数带走了什么?它什么时候才该放手?”——答案清晰了,闭包就从玄学变成了工具。