学 JavaScript 的人,几乎都会在闭包这个点上卡一下。我在带新人的时候发现一个规律:理解闭包的人,看 Vue 源码、写防抖节流、做组件封装都是顺的;不理解的人,每次遇到函数返回函数就开始发虚,面试被问“闭包是什么”只能背定义,背完也不知道它到底能干嘛。
这篇文章的目标只有一个:让你看完之后能用自己的话解释闭包,能在代码里主动写出闭包,能在别人问起的时候讲明白它解决了什么问题。不追求八股式的严谨定义,追求的是每个知识点后面都有一个完整的“人话理解”。
1. 先用三个生活场景,把闭包长什么样看出来
1.1 外卖订单的“随身卡片”
你点了一份外卖,骑手备注栏里写了“送到A栋楼下”。这个订单生成的时候,地址信息就被“打包”记录下来了。不管骑手绕了多远、换了多少条街,他手里始终拿着你的这张卡片,随时能翻到当初记下的地址。
JS 里的闭包就是这个样子。一个函数在“出生”的时候,会把当时能看到的变量环境一起带在身上。哪怕这个函数后来被拿到别的地方执行,它依然能访问出生时那个环境里的变量,就像骑手始终拿着那张写着地址的卡片。
人话理解:闭包 = 函数 + 它出生时自带的记忆包。
1.2 游戏存档:状态不因场景切换而丢失
你玩一款单机游戏,打到第三关,存档。第二天打开游戏读档,关卡进度、道具、金币数全都在。为什么?因为存档把“那一刻的整个世界”记录下来了。
闭包也是。当我们把一个函数 return 出去,它提交出去的不只是函数代码本身,还捎带上了“那一刻的函数世界”。这个“世界”里有哪些变量,取决于它是从哪里出生的——也就是它定义时所在的作用域。
这个类比能解释一个常见疑问:“闭包到底存了什么?”答案很简单:存的是它出生时能看到的那一层作用域。不是整个程序,而是它当时能够触摸到的那个范围。
1.3 回音谷:函数喊完话,声音还在回荡
再打个比方。你在山谷里喊一嗓子,声音不会立刻消失,而是在山谷间来回反射,隔几秒还能听到回音。函数的局部变量本来函数一执行完就会被销毁,看起来“喊完就没了”,但闭包让这声“回音”留了下来。在后面的某个时间点,你还能再次听见它、再次访问到那个变量。
用代码来看:
function echo(text) { return function () { console.log(text); }; } const sayHi = echo('你好,世界'); sayHi(); // 你好,世界这里有个反直觉的地方:echo已经执行完了,它内部的参数text按理说应该被回收了,为什么后面调sayHi还能打印出来?
这就是闭包。echo内部返回的那个匿名函数,在出生时就看到了参数text,于是text被绑定进了它的“记忆包”。只要sayHi还活着,text就还活着。
1.4 一句话定义
综合上面三个场景,可以这么说:
闭包:一个函数能够访问它定义时所在作用域中的变量,即使这个函数是在那个作用域之外执行的。
注意,是定义时,不是执行时。这个区别很多人栽过跟头,后面第二个大节我会展开解释。这里先记住一句话:闭包是 JS 函数自带的记忆能力。
2. 为什么需要闭包:变量生命周期和作用域链在背后搞事情
2.1 变量分类:全局的能活很久,局部的用完就没了
先看一个最基础的差异。
- 全局变量:谁都能访问,页面不关就一直在。
- 局部变量:函数内部声明,函数执行完就被垃圾回收。
这个机制本身没啥问题,但它带来一个不便:如果我们想要一个“只在这一个函数里能访问、但值还能跨多次调用保存”的变量,普通局部变量做不到。
2.2 一个无法用普通方式解决的计数需求
假设产品经理说:写一个计数器,每次调用加 1,返回当前次数。但我不想让别人随便改这个计数。
第一反应可能是这样:
let count = 0; function add() { count++; return count; }能用,但问题很明显:count暴露在全局,任何人写一行count = 999,计数器就废了。数据不安全,也不符合封装的思想。
如果放在函数内部呢:
function add() { let count = 0; // 每次调用都重新初始化为 0 count++; return count; }这个更惨,每次调用都从 0 开始,永远输出 1。因为count在add执行完后就被销毁了。
普通局部变量做不到“跨多次调用保存”,全局变量又做不到“不对外暴露”。那怎么办?答案就是闭包。
2.3 函数返回函数:闭包能成立的第一个前提
JS 里函数是“一等公民”:函数可以像数字、字符串一样,被赋值给变量,被当作参数传递,也可以被另一个函数返回。
这个语言特性太关键了。只有“函数能返回函数”,我们才能把内层函数和它的记忆包一起带到外层作用域之外去执行。
function createCounter() { let count = 0; return function () { count++; return count; }; } const add = createCounter(); console.log(add()); // 1 console.log(add()); // 2createCounter只执行了一次,它内部的count只在那一瞬间被创建。之后count并没有被销毁,因为它被内层函数引用着。说白了,有闭包在,count就有了长期居住证。
2.4 作用域链:函数自带的“查找地图”
闭包能生效,核心机制是作用域链。
JS 里,每个函数在定义的时候会有一条作用域链:从自己的局部作用域开始,一层一层向外,直到全局作用域。执行时,如果变量在内部找不到,就沿着这条链向外找。
关键是:这条链是出生时确定的,不是调用时确定的。
所以就有了那句考核八股:闭包保存的是定义时的作用域链。这也是为什么很多人都听过“闭包能记住外部变量”,但说不清它怎么记——就是靠这条作用域链记的。内层函数出生时,它把外层函数的可变量环境整个打包进了自己的作用域链。
调试的时候我经常用 Chrome DevTools 的 Sources 面板,在createCounter的 return 函数里打断点,右侧 Scope 面板会清晰显示一个 Closure 区域,里面就是被捕获的count。亲眼看到一次,你对闭包的理解会立刻从“概念”变成“实体”。这也解释了为什么搜索热词里总有“js 执行上下文”——闭包和它几乎是一对孪生兄弟。
3. 闭包在真实项目中的三个高频用场
3.1 数据私有化:模块模式
第一个高频用场是数据私有化。这个在前端开发中非常常见。
const userModule = (function () { let token = ''; function setToken(value) { token = value; } function getToken() { return token; } return { setToken, getToken }; })(); userModule.setToken('abc123'); console.log(userModule.getToken()); // abc123 console.log(userModule.token); // undefined这里用一个立即执行函数创建了一个封闭空间,token只存在于这个空间里。外部通过setToken/getToken间接操作它,直接访问token拿不到。这就是闭包数据私有化的经典写法。
有人会问,这和对象属性有什么区别?对象属性的确可以直接定义私有字段(用#前缀),但那是 ES2022 之后的事情,而且在一部分老旧运行环境里不是特别友好。闭包这招从 ES3 时期就能用,兼容性极好,而且连外部修改都防住了。
3.2 函数工厂:把配置变成能力
闭包第二个高频用场是“函数工厂”:用一个工厂函数,根据参数生成行为不同的新函数。
最经典的例子是防抖和节流里的配置函数。我们看一个更简单的:
function createMultiplier(factor) { return function (number) { return number * factor; }; } const double = createMultiplier(2); const triple = createMultiplier(3); console.log(double(10)); // 20 console.log(triple(10)); // 30double和triple都是通过同一个工厂函数生成的,但它们各自记住了不同的factor。factor就是闭包捕获的变量。
这种模式在真实项目里特别有用。比如接口请求封装的时候,可以用一个工厂函数根据baseURL生成不同服务的请求方法;做权限系统的时候,可以根据角色权限生成不同的校验函数。配置一次,反复使用,每次使用时闭包里的配置数据都还在。
3.3 事件回调与异步任务:前端里闭包的“隐身衣”
前端每天写的点击事件、定时器、Promise 回调,几乎都是闭包的体现。
const btn = document.getElementById('btn'); let count = 0; btn.addEventListener('click', function () { count++; console.log('点击了', count, '次'); });addEventListener的回调函数定义在全局作用域,并且它引用了count。当回调在未来的某次点击时执行,它依然能读取并修改count。这就是闭包,只是因为它太常见,很多人根本没意识到。
再看一个异步场景:
function fetchData(url) { fetch(url).then(function (res) { console.log('url is', url); // 这里能访问 url }); } fetchData('/api/user');fetch是异步的,回调在请求返回后才执行。那时fetchData早就执行完了,但回调依然能访问url,因为闭包替它保住了这个变量。
理解了这一点,你也就理解了为什么 React 的useEffect和 Vue 的生命周期钩子里,回调能安全访问组件作用域里的状态——本质上都是闭包在起作用。
4. 闭包翻车现场:四个坑及完整排查链路
4.1 循环里的 var 陷阱:为什么条件全是最后一个数
看一段每个前端人几乎都踩过的代码:
for (var i = 0; i < 3; i++) { setTimeout(function () { console.log(i); }, 1000); }输出是什么?很多人第一反应是 0 1 2,实际是 3 3 3。
为什么?关键在于var没有块级作用域。这个 for 循环里的i是在全局(或函数级)作用域里,三个setTimeout回调都在定义时捕获了同一个i的引用,而不是i的值。循环结束后i已经变成 3,三个回调执行时读到的自然都是 3。
这个坑的根源就是闭包,但它的本质是“闭包捕获了变量本身,而不是变量的快照”。
解决方案里最常见的是用let:
for (let i = 0; i < 3; i++) { setTimeout(function () { console.log(i); }, 1000); } // 0 1 2let每次迭代都会创建新的绑定,每个回调捕获的是自己那一轮的i,互不干扰。这是最推荐的写法。
如果必须用var,也有两种方案:
- 用立即执行函数传参:
for (var i = 0; i < 3; i++) { (function (j) { setTimeout(function () { console.log(j); }, 1000); })(i); }- 用
bind传参:
function log(j) { console.log(j); } for (var i = 0; i < 3; i++) { setTimeout(log.bind(null, i), 1000); }我个人的建议是尽量别折腾var下的兼容写法,除非你的项目还要兼容特别老的浏览器。现在let和const已经是标配,直接把var换掉是最省力的。
4.2 内存占用:闭包不是不销毁,是它不让东西销毁
闭包本身不是内存泄漏,但闭包用不好,很容易造成内存无法释放。
看一个典型例子:
function createElementHandler() { const bigData = new Array(1000000).fill('x'); document.getElementById('btn').addEventListener('click', function () { console.log('clicked'); }); }事件回调被添加后,闭包捕获了bigData。即使bigData在createElementHandler执行完后已经“用不到了”,但它依然被闭包引用着,无法被垃圾回收。除非移除事件监听,否则这 100 万个元素就一直躺在内存里。
这个问题的关键不是“闭包不能用了”,而是“闭包捕获的范围太大”。解决办法有两种思路:
- 缩小捕获范围:把
bigData移到回调函数外面,只让回调访问确实需要的数据:
const btn = document.getElementById('btn'); btn.addEventListener('click', function () { console.log('clicked'); });- 在合适时机解绑:
function handler() { console.log('clicked'); } btn.addEventListener('click', handler); // 在组件卸载时 btn.removeEventListener('click', handler);排查内存问题时可以打开 Chrome 的 Memory 面板录制堆快照,看看是不是有不该存在的大数据结构滞留。我在实际工作中碰到过一个线上页面内存持续增长的问题,最后定位到就是某处事件监听绑在了一个无限循环创建的对象上,闭包把每个对象都留住了。
4.3 this 指向与闭包叠加:回调里打开神秘新世界
闭包本身只管变量,this又是另一个维度。两者一旦叠加,很容易出现诡异行为。
const obj = { name: '小明', getLater: function () { setTimeout(function () { console.log(this.name); }, 1000); }, }; obj.getLater(); // undefined这段代码里,setTimeout里的回调是一个普通函数,执行时this指向全局对象(严格模式下是 undefined),自然拿不到obj.name。
这里有一个经典解法:先用一个变量保存this:
const obj = { name: '小明', getLater: function () { const self = this; setTimeout(function () { console.log(self.name); // 小明 }, 1000); }, };第二个经典解法是箭头函数。箭头函数没有自己的this,会从外层作用域继承。于是:
const obj = { name: '小明', getLater: function () { setTimeout(() => { console.log(this.name); // 小明 }, 1000); }, };这里有一个初学者很容易误解的点:箭头函数“没有自己的 this”本质上也是一种闭包行为,它捕获的是箭头函数定义时所在作用域的this。理解了这个底层逻辑,你就能明白为什么箭头函数和普通函数在回调里表现完全不同。
4.4 以为私有其实公开:闭包不是保险箱
第四个坑更隐蔽。闭包能实现数据私有化,但不是绝对意义的“私有”。
function counter() { let count = 0; return { getCount: function () { return count; }, setCount: function (n) { count = n; }, }; } const c = counter(); c.setCount(100); console.log(c.getCount()); // 100一旦你公开了setCount,那count就不再是安全的。所以闭包提供的“私有”只是“不能直接访问”,如果接口里暴露了修改方法,别人还是能改。
另外,闭包也不代表数据安全。它只是提供了一种作用域隔离,而不是加密。如果代码被整体加载到页面里,任何访问到这段代码作用域的方式都有可能泄漏。比如在控制台里通过调试工具断点,依然能看到闭包里的值。
所以在设计模块时,我的建议是:返回值里只暴露必要的方法,不要为了方便把所有 getter/setter 都扔出去。每多暴露一个,就多一份被误改的风险。
5. 三个阶梯练习,验证自己是不是真懂了
5.1 第一阶:手写一个计数器
不要复制粘贴,先自己闭卷写。要求:
- 调用
add()每次加 1 - 不允许用全局变量
- 不允许类属性
如果写不出来,回头看第一节和第二节。
5.2 第二阶:手写一个防抖函数
防抖是一个非常好的闭包练习题,因为它在业务中高频使用,而且必须依赖闭包才能保存timer。
function debounce(fn, delay) { let timer = null; return function (...args) { if (timer) { clearTimeout(timer); } timer = setTimeout(() => { fn.apply(this, args); }, delay); }; }这里需要注意两点。第一,timer必须放在debounce的返回函数外面的作用域里,这样多次调用返回的防抖函数才能共享同一个timer。第二,回调里一定要用fn.apply(this, args)把this和参数传进去,否则原函数内部如果依赖this就会出错。
如果看不懂这段代码,说明闭包的基本概念还不够牢固。建议把第三阶的练习先放下,回到前面章节把作用域链的部分重新过一遍。
5.3 第三阶:解释框架里的闭包
最后一步,去看现成框架里的闭包应用。比如:
- Vue 里,
setup函数返回的 render 函数为什么能访问setup内部定义的变量?因为闭包。 - React 里,
useState为什么能在多次渲染之间保持状态?本质上也是因为闭包把 state 和 setter 一起送进了组件对应的数据结构里。 - 防抖节流、观察者模式、柯里化,几乎全是闭包的应用。
看这些代码的时候,不用刻意背源码,只要遇到“一个函数访问了它定义作用域之外的变量”,就在心里标注一下:这里用了闭包。多标几次,闭包就彻底刻进脑子里了。
5.4 我的学习建议
如果只能给三条建议,我会说:
- 不要只记定义,要去调试工具里亲眼看一次 Closure 区域。
- 不要死记八股,要把闭包和实际场景关联起来,每遇到一个函数返回函数,就想想它捕获了什么。
- 遇到闭包问题不要慌,先从作用域链找原因,作用域链看不懂,就从
var/let的区别找原因。
我带了几年前端新人,发现能把闭包讲清楚的人,对 JS 整体运行机制的理解都不差。反过来也一样,闭包一旦通了,后面的this、原型、异步回调这些东西都会顺很多。
最后分享一个我在项目里用闭包的核心心得:写代码时多问自己一句,这个函数被调用时,它需要记住哪个变量?记住一个,闭包一个。别让函数记住一大堆无关变量,越精简,越不容易踩坑。能做到这一点,你对闭包的理解就已经超过大部分只会背面试答案的人了。