JavaScript闭包从入门到实战:概念、原理与高频场景
发布时间:2026/9/25 3:37:50 作者:尧图编辑部 阅读量:1,286

学 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。这个坑的根源就是闭包但它的本质是“闭包捕获了变量本身而不是变量的快照”。解决方案里最常见的是用letfor (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。这里有一个经典解法先用一个变量保存thisconst 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、原型、异步回调这些东西都会顺很多。最后分享一个我在项目里用闭包的核心心得写代码时多问自己一句这个函数被调用时它需要记住哪个变量记住一个闭包一个。别让函数记住一大堆无关变量越精简越不容易踩坑。能做到这一点你对闭包的理解就已经超过大部分只会背面试答案的人了。