ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JavaScript闭包彻底搞懂:原理、应用与内存陷阱

JavaScript闭包彻底搞懂:原理、应用与内存陷阱 闭包这词儿JavaScript 开发者早晚得遇上。我见过太多人面试前背概念代码里避开写一出 bug 就绕道走。原因很简单网上教程大多只讲函数套函数、返回函数听起来像是语法游戏根本没讲清楚它到底为了解决什么问题、背后是怎么回事。这篇内容我打算彻底把它讲透不和你绕弯子。这篇文章适合所有写 JavaScript 的人。不管你是刚开始学函数、被各种回调搞得头大还是工作中经常碰到状态管理、防抖节流、事件绑定的怪问题理解闭包都会是一个分水岭——过了这个坎看很多源码和框架逻辑都会豁然开朗。1. 拆解闭包核心思路与设计逻辑1.1 闭包的本质是什么一句话先放这儿闭包就是函数记住了自己出生时的环境并且能继续访问那个环境里的变量。注意关键词是记住。普通函数执行完内部变量就被垃圾回收了像是用完即弃的一次性餐具闭包不同它把诞生时的局部变量记在脑子里哪怕外部函数早就跑完了这些变量照样活着只有闭包自己能碰。我打个比方帮你建立直观印象。你小时候住在老院子里后来搬走了老院子也拆迁了。但如果你留了一把钥匙、还存着邻居的电话你依然能描述出老院子的布局甚至能联系上邻居。闭包就是这把钥匙加通讯录函数本身搬到了外面被外面调用但它还攥着当年那个环境里的变量引用。所以闭包的形成需要三个条件缺一不可一个函数嵌套在另一个函数内部内部函数引用了外部函数的局部变量内部函数被带出外部函数在外部被执行。第三点是关键。如果内部函数只是内部自己玩外部函数结束它也结束那谈不上什么闭包价值只有把它带出去才能真正发挥记忆功能。最常见的带出方式就是 return或者赋值给全局变量、作为回调传参。1.2 它解决了什么问题解决了变量生命周期被函数边界锁死的问题。JavaScript 里函数执行完局部变量就该清理这本来是天经地义的但有些场景我们需要在函数结束后仍然能访问、甚至修改这些局部变量。最典型的场景就是计数器、缓存、私有数据。你可以想象一个场景你想统计一个按钮被点击了多少次点击行为分散在多个地方。最笨的办法是搞一个全局变量 count到处 。但全局变量是公共的任何脚本都能改你不小心就把它覆盖了而且命名污染、多人协作时全是隐患。闭包就能把 count 藏在函数内部只暴露你允许的操作——比如一个 add 函数。这就是数据私有化也是闭包在模块化开发中最重要的角色之一。另一个价值是延续状态。函数不该每次都被重置它可以在多次调用之间保持有状态的记忆。这在处理异步逻辑、事件流、防抖节流这些场景中特别有用。说白了闭包让 JavaScript 函数从一次性计算器升级为有记忆的执行者。1.3 选择闭包方案的取舍为什么 JavaScript 要设计成这样这和它的函数是一等公民有关。函数可以当参数传、当返回值返回那就必然出现函数被传出去时它上下文里的变量怎么办的问题。设计者没有选择把变量复制一份——那样数据就不共享了而是选择让函数持有原变量的引用。这就是闭包方案的取舍点。代价很明显外部函数虽然执行完了但因为它被内部函数引用它的作用域对象就不能被销毁会一直驻留在内存里。这就是闭包内存开销的来源。但好处也很大变量状态能在不同的调用间持续、数据能真正封装。实际项目中你不用担心这点开销现代引擎V8对闭包的内存管理已经做得非常精细只有那些循环里大量创建闭包、又长期持有引用的写法才可能导致内存膨胀。2. 深入原理执行上下文、作用域链与内存视角2.1 从执行上下文理解闭包的形成要真正这次懂了你起码得理解执行上下文这个词。每一次函数调用JavaScript 引擎都会创建一个执行上下文里面装着变量环境、作用域链、this 指向等信息。函数执行结束后普通执行上下文会被销毁连同里面的变量一并清空。但注意销毁的只是执行上下文本身变量对象Variable Object能不能被回收取决于还有没有谁在引用它。闭包的情况是内部函数还在它的作用域链上还挂着外部函数的环境引擎标记这个外部变量对象为仍被引用于是不清空它。于是你看到了神奇的一幕外部函数结束返回但它的局部变量还在内存里等内部函数将来某一天调用它时照样拿到那个变量。我强调一下引用而不是复制。闭包拿到的永远是外部变量的实时值而不是快照。这意味着如果你在闭包外部修改了那个变量的值前提是有办法拿到它的引用比如返回了一个 setter闭包再读的时候就是新值。很多新手在这里翻车以为闭包捕获的是当时的固定值结果循环里动态变化的变量被闭包读到最终结果就是经典的循环陷阱。2.2 作用域链如何串联起来想理解闭包不可回避作用域链。JavaScript 的作用域是词法作用域——不看你在哪里被调用而看你在哪里被定义。每个函数定义时引擎就给它确定了一条作用域链当前函数自己的变量对象加上它的外部函数的变量对象一直追溯到全局对象。闭包之所以能在外部函数已经结束的情况下访问外部变量本质就是这条链还活着。内部函数的作用域链引用着外部函数的变量对象直到内部函数自己被回收为止。这就是闭包的生命周期——闭包不死它的外部作用域对象就不死。理解词法作用域还有一个实战好处以后看到异步回调、事件处理器里的变量就不用猜来猜去了直接看它定义在哪、引用哪个外部作用域逻辑一下就清楚了。2.3 闭包的内存视角和垃圾回收上面说了外部作用域对象因为被引用而不被回收。但这里的引用要区分强度如果外部变量是一个对象闭包引用的是变量对象本身也就是说哪怕闭包实际只用了其中一个变量整个变量对象里的所有东西都会被保留。比如function createComplex() { let bigArray new Array(1000000).fill(x); let small 1; return function() { console.log(small); }; } const closure createComplex();这里闭包只用了small但bigArray依然被保留着因为它是同一个变量对象里的属性。这就是闭包内存泄漏最常见的隐藏原因。解决办法是如果你确实只会用到少数变量把其他变量挪到函数内部另一个块级作用域里或者不再使用闭包时手动把引用置为 null让整个闭包连同环境一起被回收。另外提醒一句现代 V8 其实做了优化会分析闭包实际引用了哪些变量没有引用的变量可能不会长期保留。但你不能把代码正确性押在优化上你该保证的是逻辑正确再考虑内存释放。如果真遇到内存暴涨记得把闭包里没用到的大对象移出去然后再用 DevTools 的 Memory 面板对比确认。3. 实战拆解闭包的核心应用与实现细节3.1 数据私有化与状态封装这是闭包最基础也是最重要的应用。估计你的项目里有不少全局变量是没必要公开的把它们拿进去用闭包封装起来。来看一个最典型的计数器function createCounter() { let count 0; return { increment: function() { count; }, decrement: function() { count--; }, get: function() { return count; } }; } const counter createCounter(); counter.increment(); counter.increment(); console.log(counter.get()); // 2不知道你注意到没有count在外面完全够不着没有直接读写的途径只能通过返回的三个方法操作。这就是把数据私有化的核心优势外部只能按你定义的方式来修改状态不担心意外污染也不担心命名冲突。延伸到现实中你可以用它模拟一个实现私有字段的类或者做一个缓存池——数据存在闭包里读写的逻辑也在闭包里避免了全局可见。很多开源库的模块模式都基于此。3.2 防抖与节流闭包不该缺席的战场面试高频题、实战高频需求。防抖的原理是连续触发事件时只有最后一次触发后等待一段时间才执行函数节流是固定时间内最多执行一次。用闭包实现核心就是保存一个定时器的状态让每次调用都能共享它。function debounce(fn, delay) { let timer null; return function(...args) { if (timer) clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); timer null; }, delay); }; }闭包在这里干了什么它在debounce的执行环境里保存了timer每次返回的函数被调用都能看到旧的timer状态并更新它。如果没有闭包timer要么作为全局变量被各个事件处理器共享要么是每次调用都新创建的局部变量根本起不到防抖效果。闭包让你能够一个状态多个调用方共享而且这个状态不会泄漏到全局。同样的思路可以用在节流上保存上次执行时间戳每次调用判断是否超过时间间隔。这种共享状态的需求闭包几乎是唯一优雅的解。3.3 函数柯里化与偏函数如果你写过函数式风格代码你会知道柯里化把多个参数的函数改写成一系列单参数函数的组合。闭包在这里负责记住前面已经传入的参数。看个直观例子function curry(fn, args []) { return function(...nextArgs) { const merged [...args, ...nextArgs]; return merged.length fn.length ? fn(...merged) : curry(fn, merged); }; } function add(a, b, c) { return a b c; } const curriedAdd curry(add); curriedAdd(1)(2)(3); // 6注意到没有每调用一次curriedAdd(1)返回的新的函数闭包里就记住了已经传入的1下一次再传2就能拿到[1,2]直到参数攒够了才真正调用原始add。这背后全是闭包在默默保存参数累积状态。偏函数也类似比如你想预设一个加 5的函数用闭包把基准值记住非常省事。这类应用在 React 里其实也常见比如用.bind绑定参数、用高阶函数预设配置本质上都是闭包在发挥状态延续的特性。3.4 循环陷阱与 let 的救赎讲闭包绕不开这个经典案例。很多人经历过这个 bug循环里绑定事件每次都 console.log(i)结果点击任何一项打印出来的全是最后一个值。for (var i 0; i 3; i) { setTimeout(function() { console.log(i); }, 100); } // 输出 3, 3, 3原因在这里var没有块级作用域三个闭包引用的是同一个i等 100ms 毫秒过去循环早跑完了i已经变成 3三个闭包读到同一个值。这不代表闭包有问题恰恰说明了闭包捕获的是引用是最终的变量值而不是复制快照。解决办法有两个传统方案用立即执行函数IIFE把每次的i传进一个参数里for (var i 0; i 3; i) { (function(j) { setTimeout(function() { console.log(j); }, 100); })(i); }或者直接用let——let具有块级作用域每次循环迭代都创建新的绑定三个闭包引用三个不同的变量for (let i 0; i 3; i) { setTimeout(() console.log(i), 100); }说实话现在新项目基本都用let了这个坑越来越少。但面试时还是值得把闭包捕获引用、var 共享绑定这两个点都想清楚因为实际代码里类似问题可能会以更隐蔽的方式出现比如事件监听、异步请求回调。4. 闭包踩坑指南常见问题与排查技巧4.1 意外修改共享变量典型的闭包陷阱闭包的记忆如果记忆的是同一个变量那多个闭包共享同一个变量时你改了一个其他闭包读到的值也变了。这个在特定场景下是特性但如果你没意识到就会莫名出 bug。我见过一个案例多个事件处理器被同一个闭包共用其中某一段错误地修改了一个共用状态导致其他处理器行为异常。排查方法其实很简单把闭包引用的变量名打印出来看看多个函数是否持有同一个变量对象的引用。如果确实需要各自独立的值就在循环内部额外创建一个作用域比如用 IIFE 或 let把那一份拷贝传给每个闭包。经验闭包里最好只保存你确实想共享的东西其他局部数据该在函数新建就新建别图省事全放最外层。4.2 内存泄漏闭包持有不必要的大对象前面提到过闭包会让外部变量对象保持存活。如果有人不小心在全局保存了一个闭包而这个闭包又引用了一个巨大的数组那一整块内存就全被占住。就算闭包很小变量对象里包含的没被使用的大对象也可能泄漏。排查思路打开 DevTools 的内存面板做几次 Heap Snapshot对比闭包创建前后的内存变化在 Sources 里搜索闭包引用看哪些变量被长期持有检查是否有不再使用的闭包被全局数组、事件监听、定时器一直持有。如果确认问题最直接的修法是不再需要的时候把持有闭包的变量置为 null或者使用WeakMap、WeakRef这类弱引用数据结构让对象在没有强引用时可以回收。这里多说一句不少人把WeakMap当成闭包的替代方案其实不太对WeakMap更适合用来做有垃圾回收保护的缓存闭包守住的是状态和模块化边界两者关注点不同。4.3 闭包内的 this 丢失async 回调里的经典错误闭包和 this 是两套机制但配合不好容易出事故。比如const obj { value: 42, get: function() { return function() { return this.value; }; } }; obj.get()(); // undefined原理是闭包函数被当普通函数调用时this是全局对象严格模式下是 undefinedobj 的上下文找不到。这不是闭包本身的问题而是this取决于调用方式。初学闭包时最容易犯的错就是以为this和词法作用域一样绑在定义处。解决方案包括箭头函数箭头函数没有自己的 this会从外部作用域继承或者用.bind(obj)把 this 固定住或者在闭包外层先把self this存下来。现在写代码我几乎一律用箭头函数省心。4.4 闭包调试DevTools 下的观察方法有时候闭包不按预期工作你怀疑是闭包本身的问题但多半是变量的生命周期和预期不符。给一个调试建议在 DevTools 的 Sources 面板里打断点观察右侧 Scope 面板展开 Closure 部分可以看到闭包捕获的变量列表和当前值。这里的价值在于你能直观看到哪些变量被闭包引用、引用的是当前值还是已变的值。我遇到过不少次明明期望闭包里保存的是初始值结果发现它引用的是已经循环完的变量。当你亲眼在 Scope 里看到那个变量的变化轨迹时问题基本就定位了。这个招数比反复打日志来得快得多。4.5 闭包性能优化的实用原则闭包不是洪水猛兽但也不是越用越好。我的经验是几条原则能用块级作用域let/const解决的问题就不要构造闭包。闭包一定伴随着外部变量对象的保留能回避就回避循环内创建闭包时把不必要的变量都排除在闭包可见范围外这样引擎可以更好地分析优化给闭包设置一个生命周期。比如事件监听器在组件卸载后要移除别让闭包长期挂在全局实在要大量创建闭包注意确认内存正增长而非泄漏状态用 Performance 面板测一下再说。说到底闭包的复杂度主要来自状态延续带来的副作用。你把状态控制好了它就是你最顺手的工具。5. 闭包延伸模块化与框架层背后的逻辑5.1 模块模式里的闭包说到底ES Module 出现之前JavaScript 实现模块的一种经典模式就是闭包。IIFE 外层函数构建作用域返回对象提供对外接口内部所有实现细节和状态都藏在闭包里外界无法直接接触。const myModule (function() { let privateData []; function add(item) { privateData.push(item); } function list() { return privateData.slice(); } return { add, list }; })();现在项目普遍用 ES Module 了这种模式少了但理解它后的核心能力——封装状态、暴露最小接口——依然影响你写组件、写工具函数的设计思路。甚至可以说现在每个模块的顶层作用域本身就是一个闭包你得意识到它和模块生命周期有关联。5.2 React Hooks 与闭包如果你写 React闭包简直无处不在。最典型的一个useEffect的回调、useCallback、useMemo它们都构成了闭包捕获了渲染时的 props、state 值。经典坑就是闭包陷阱stale closure一个useEffect里依赖了某个 state但因为依赖数组没写回调闭包捕获的永远是第一次渲染时的值之后 state 变了它也不知道。useEffect(() { const timer setInterval(() { console.log(count); // 永远是初始值 }, 1000); return () clearInterval(timer); }, []);修复方式就是正确处理依赖把count加入依赖数组或者用useRef保存最新值让闭包里的引用始终指向最新值。这里的关键洞察是每次渲染时函数组件本身会重新执行新的闭包会被创建。如果 React 复用了旧闭包那它永远只能看到旧的渲染结果。理解了这个React 引入的渲染闭包概念就掌握了大半。5.3 事件系统与定时器中的闭包价值最后强调一下事件系统。每个事件监听器本质上就是一个闭包抓取它注册时所在作用域的变量。这就是为什么你在某个函数里注册的监听器即使外层函数返回了事件触发时依然能用外层变量。实际开发中利用闭包给事件处理器传参数、做状态重置、复用工具函数都是很顺手的技巧。定时器、回调函数同理。只要你能预判这个回调将来什么时候、在什么上下文里被调用你就能顺势设计出该捕获哪个变量、怎么不产生意外副作用。刻意练习几次你会发现闭包不再是一个概念而是一个你每天都在用的思维方式。最后分享一点我个人的经验越来越觉得理解闭包这件事急不得但绕不得。它不像箭头函数那样背个语法就能用它要求你把作用域、变量生命周期、引用、调用时机这四件事摆在桌面上通盘想清楚。你可以做几个小 demo 亲手验证比如先写一个计时器闭包再写一个防抖再试着给自己封装一个小模块。多来几回那个模模糊糊的感觉就会变成一种直觉。看完这篇之后希望你再看代码里的闭包时心里想的不再是这么绕干嘛而是原来这里我需要它来帮我记住一些东西。这种感觉一旦建立你的 JavaScript 水平是真的会上一个台阶。
返回列表