资讯动态

深入理解JavaScript闭包:原理、应用场景与最佳实践

发布时间:2026/9/9 3:48:26 来源:尧图企业网站定制
## 1. 闭包到底是什么别再用函数套函数糊弄自己 我面试过不少前端开发也带过几个刚入行的新人。每次聊到闭包十个人里有八个会背出那句经典台词闭包就是函数里面返回一个函数内部函数可以访问外部函数的变量。 这句话不能算错但它更像是一个记忆锚点而不是理解。如果你只是记住了这个形式那面试官接下来问为什么要有闭包闭包到底捕获了什么闭包的变量是值还是引用你可能就卡住了。 闭包Closure这个翻译本身就带有迷惑性。闭的是什么包的又是什么我个人的理解是闭包是函数与其词法环境Lexical Environment的组合体。这个组合体在函数被定义的那一刻就被确定了和函数在哪里执行、什么时候执行没有关系。 ### 1.1 从作用域链的视角重新理解闭包 要搞懂闭包先得把作用域链这条链子捋直。 在JavaScript这类采用词法作用域的语言里查找变量是靠链完成的。当你写了一个嵌套函数内部函数的作用域链会包含三层自身作用域、外部函数作用域、全局作用域。这条链在代码书写时就已经确定跟函数的调用位置无关。 举个例子 javascript let x 1; function outer() { let x 10; function inner() { console.log(x); } return inner; } let fn outer(); fn(); // 输出 10这段代码里inner函数在外层函数outer执行完毕后被赋值给了全局变量fn。如果你只看函数调用这一刻fn在全局环境下执行按理说应该访问不到outer作用域里的x。但实际输出是10不是1。原因就是inner在被定义时它的词法环境里已经被绑定了outer作用域。outer执行完它的执行上下文虽然销毁了但inner还持有对那个作用域对象的引用所以x10不会凭空消失。很多人的误区在于以为外部函数执行完毕里面的局部变量就没了。实际上只要还有闭包在引用它这个作用域就不会被垃圾回收。这就是闭包最核心的机制——它让一个函数的局部变量活到了函数执行完之后。提示可以把闭包理解成函数的背包。函数走到哪儿这个背包背到哪儿背包里的东西是定义时就打包好的不会因为外部函数执行完毕就被清空。1.2 一个最小的闭包实例逐个拆解我们拆一个最简单的计数器function createCounter() { let count 0; return function() { count; console.log(count); }; } const counter createCounter(); counter(); // 1 counter(); // 2 counter(); // 3这里每一步发生了什么我逐个拆给你看createCounter执行时创建了一个局部变量count以及一个匿名函数。这个匿名函数引用了count所以它携带了一个词法环境引用指向createCounter的作用域。createCounter返回匿名函数后按常理说createCounter的执行上下文应该被销毁。但因为匿名函数还握着对作用域的引用所以count所在的词法环境被保留在内存中。每次调用counter它内部的匿名函数就对同一个count做自增。注意你在控制台里看到的是1、2、3这是一个持续累积的状态。这个状态对counter函数的多次调用来说是共享的。这里引出一个非常关键的问题闭包捕获的到底是一个值还是一个可以随时读写的绑定答案是绑定Binding不是值的快照。闭包保存的是一个活的变量地址你自己去读、去改都行外部函数在return之后再去修改这个变量的值闭包里读到的是修改后的值。这个特性在我们后面讲实战场景时会反复用到。2. 闭包能做什么五个让人直呼真香的实战场景理解机制是第一步关键是要知道闭包在真实项目里到底用来干啥。闭包不是炫技用的它是JavaScript这门语言里实现状态封装和函数式组合的基石。我整理了自己常用的五个场景每个都是工作中真实会碰到的。2.1 数据私有化不用class也能封装状态很多从Java、C转过来的同学刚用JavaScript时总爱找private关键字。JavaScript在ES2022才引入了类的私有字段#前缀但在此之前实现私有状态靠的就是闭包。我写过一个小工具模块内部有个配置对象我只想暴露getter和setter不想让外部直接改function createConfig() { let config { theme: light, locale: zh-CN }; return { get: (key) config[key], set: (key, value) { config[key] value; } }; } const settings createConfig(); settings.set(theme, dark); console.log(settings.get(theme)); // dark console.log(settings.config); // undefined在这个例子里config变量对外部是完全不可见的。你拿到的返回值里只有get和set两个函数但这两个函数通过闭包机制记住了config所在的作用域。外部代码无论怎么折腾都没法绕过get/set直接读写config。这种模式在写插件、写UI库内部状态管理、甚至写一些需要隔离状态的工具函数时非常实用。闭包充当了一个私有的状态舱室比挂在this上更安全也比class字段更轻量。2.2 函数工厂与柯里化闭包让生成函数变成了一件很自然的事。你可以把闭包当作一个模具每次调用外层函数就能制造出行为略有差异的新函数。最典型的例子是柯里化Currying。所谓柯里化就是把一个接收多个参数的函数拆解成接收单个参数的多个函数。比如function multiply(a) { return function(b) { return a * b; }; } const double multiply(2); const triple multiply(3); console.log(double(5)); // 10 console.log(triple(5)); // 15double和triple就是通过闭包记住了各自的a值这会衍生出非常优雅的复用方式。我在处理数据格式化时经常用这个思路function createFormatter(unit) { return function(value) { return value unit; }; } const formatPrice createFormatter(元); const formatWeight createFormatter(kg); console.log(formatPrice(10)); // 10元 console.log(formatWeight(10)); // 10kg闭包在这里的价值是你可以用很小的代码量生成一簇行为相似但语义不同的函数让调用方不用每次重复传参数。2.3 事件监听与防抖节流前端开发里闭包最频繁的应用场景之一就是防抖Debounce和节流Throttle。这两个函数都依赖同一个机制通过闭包保存一个定时器的id让每次触发时都能访问到上一次的定时器状态。防抖的经典实现function debounce(fn, delay 300) { let timer null; return function(...args) { clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); }, delay); }; }这里的timer变量就是闭包保存的共享状态。每次用户输入外层函数返回的那个新函数都会先清掉上一次的定时器再重新设定。第一次调用时timer是nullclearTimeout(null)不会报错属于容错设计。我实测下来用防抖处理搜索框的实时推荐用户的等待体验和请求频率都能得到很好的平衡。如果不靠闭包你只能把timer挂到全局变量或者某个对象属性上既不优雅还容易在并发场景下互相污染。2.4 回调与异步场景中的状态保持异步回调里到处都是闭包。比如你需要并发请求多个接口每个请求完成后要把各自的结果按顺序拼起来function fetchData(urls) { let results []; urls.forEach((url, index) { fetch(url).then((res) res.json()).then((data) { results[index] data; // 所有请求都完成 if (results.filter(Boolean).length urls.length) { console.log(全部完成, results); } }); }); }这里results数组被forEach里的异步回调捕获了每个回调都通过闭包共享这个数组并按index精确写入。如果不靠闭包这些回调根本没法在将来某个时刻记住自己该往哪个位置写数据。我见过不少新手在这个场景里犯错把results定义到forEach内部导致每次回调拿到的是不同的新数组。这就体现了闭包的另一个关键点同一个外层函数作用域被多个内部函数共享时内部函数之间对这个作用域里变量的读写是互相可见的。2.5 模块化与IIFE闭包在模块系统出现前的抗鼎之作在还没有ES Module的时代前端模块化靠的就是IIFE立即执行函数表达式 闭包。虽然现在ES Module已经很普及但你在维护老项目或看一些底层工具库源码时依然会遇到这种写法const myModule (function() { let privateVar 0; function increment() { privateVar; } function getValue() { return privateVar; } return { increment, getValue }; })(); myModule.increment(); myModule.increment(); console.log(myModule.getValue()); // 2IIFE执行一次返回一个对象对象里的方法通过闭包访问privateVar。外部只能通过暴露给它的方法间接操作privateVar变量本身被完全隔离在IIFE作用域里。这就是JavaScript早期模块模式的核心原理。现在去读jQuery、Lodash这类库的源码你会反复看到这个套路。3. 闭包最容易翻车的三个坑原理与解法闭包好用但也是bug高发区。下面三个坑是我自己在项目里或帮同事排查时遇到过的真问题每个都值得单独讲透。3.1 循环变量捕获的经典陷阱这是面试问烂了、但实际开发里依然会遇到的坑。先说现象for (var i 0; i 5; i) { setTimeout(() { console.log(i); }, 100); }预期是输出0、1、2、3、4实际却输出5、5、5、5、5。为什么因为用var声明的i是函数作用域for循环不产生新的作用域。5次循环共用同一个i。等100毫秒后定时器里的回调执行时循环早就跑完了i变成了5。五个回调通过闭包引用的是同一个变量所以都打印5。解法其实分几个层面把var换成let。let是块级作用域每次循环都会创建一个新的i绑定每个回调捕获到的是不同的绑定问题自然解决。用IIFE包裹for (var i 0; i 5; i) { (function(j) { setTimeout(() { console.log(j); }, 100); })(i); }用forEach代替for循环forEach每次迭代的参数本身就是新绑定。从原理上讲let的写法之所以能work本质上是每轮循环创建了一个新的词法环境闭包捕获的是那个新环境里的绑定。这和传统var共用同一个环境的机制有本质区别。注意Go语言在1.22版本之前for循环的循环变量也是共用的一旦在循环内启动goroutine或者闭包同样会出现全是最后一个值的问题。所以这不是JavaScript独有的坑只是老JS环境暴露得更早。Go 1.22开始每轮迭代都创建新的变量和let的思路殊途同归。3.2 闭包造成的内存泄漏闭包持有外部作用域引用意味着这个作用域里的变量不会被垃圾回收。有时候这很合理但有时候你根本没意识到闭包帮你留了一些庞然大物。举个我踩过的真实案例做一个数据大屏页面我用闭包保存了一个很大的历史数据数组后续只用到里面的最新一条。本意是图方便结果页面挂着不动内存占用却不断上涨。最后排查下来就是这个闭包一直引用着那个大数组垃圾回收器拿它没办法。怎么避免用完即置空。如果闭包里存了大数据对象确定不用了就把引用置为nullfunction createHeavyProcessor() { let hugeData new Array(10000000).fill(x); return function() { console.log(hugeData.length); }; } const handler createHeavyProcessor(); handler(); handler null; // 释放闭包引用让GC能回收只在必要时使用闭包。能用参数传的就不要闭包捕获。尤其在写工具函数、公共库时无脑闭包会让内存消耗成倍增长。需要补充的是现代V8引擎对闭包内的变量做了优化如果闭包没用到某个外部变量这个变量不会被保留在内存中。所以不必过度恐慌但要记得长期存活的高层函数如果捕获了大体积对象那个对象很难被回收这条铁律。3.3 闭包在循环中创建函数的性能开销很多人没注意每次执行外层函数都会创建一个新的闭包。这意味着闭包的记忆是有成本的包括函数对象创建、词法环境的分配、垃圾回收压力。举个例子你有一个渲染大量列表的场景列表项绑定了点击事件每个事件处理函数都是动态生成的// 不推荐的写法 items.forEach((item) { const btn document.createElement(button); btn.addEventListener(click, () { console.log(item.id); }); container.appendChild(btn); });当items有上千条时你会创建成千上万个独立闭包。虽然现代设备不一定卡但性能损耗是实打实的。优化思路通常是事件委托container.addEventListener(click, (event) { const id event.target.dataset.id; console.log(id); });只创建一个监听器利用事件冒泡机制统一处理。这样既减少了闭包数量也减少了内存占用。我个人的经验是不要在热循环里滥用闭包。闭包适合低频、需要持久状态的场景不适合高频、批量创建的场景。写代码前先想想这个函数会被调用多少次。4. 不同语言里的闭包JS、Python、Go的差异与共性闭包不是JavaScript的专利。Python、Go、Rust、Kotlin、Swift等主流语言都支持闭包。搞懂不同语言里闭包的共性会比只盯着一门语言看得更透彻。我带团队的时候发现一个有意思的现象只写JS的人常常以为闭包是JS的特性写Go的人又以为闭包只是语法糖其实闭包背后是词法作用域 一等函数这两个语言特性的必然产物。4.1 词法作用域与捕获方式差异先看Python的实现def make_multiplier(a): def multiplier(b): return a * b return multiplier double make_multiplier(2) print(double(5)) # 10Python的闭包和JS在核心机制上很像内层函数持有对外层作用域的引用。但有一个显著差异在Python里如果你想在闭包内部修改外部变量编译器会报错除非你显式声明nonlocaldef counter(): count 0 def inner(): nonlocal count count 1 return count return inner这是因为Python默认认为函数内部对变量的赋值是局部变量如果没声明nonlocalcount1会先触发局部变量未定义的错误。JavaScript则更宽松所以隐患也更多——你没办法从语法层面看出这个变量到底是不是外部作用域的。再看Go语言func makeAdder(base int) func(int) int { return func(x int) int { return base x } } func main() { add5 : makeAdder(5) fmt.Println(add5(3)) // 8 }Go的闭包和JS非常类似也允许修改外部变量。但在Go 1.22之前有一个经典的循环变量陷阱for i : 0; i 3; i { go func() { fmt.Println(i) }() }跟JS的var版一样这段代码在Go 1.21及更早版本会打印3 3 3。解决方法是显式传参for i : 0; i 3; i { go func(i int) { fmt.Println(i) }(i) }或者用局部变量重新复制一份。这三门语言的共性闭包都依赖词法作用域都能捕获外层变量都要求函数是一等公民可以像值一样传递。差异在于语法约束的严格程度Python用nonlocal强制标识JS和Go则默认允许代价是更容易写出隐蔽的bug。4.2 闭包与栈/堆内存模型很多人学闭包时卡在一个旧观念上觉得局部变量不是存在栈上吗函数一返回栈就释放了闭包怎么还能访问这个问题的答案涉及语言运行时对内存的处理。现代主流语言的运行时V8、CPython、Go runtime并不会严格区分栈上变量和堆上变量。当编译器/解释器发现一个局部变量被内层函数捕获逃逸分析中的变量逃逸它就会把这个变量分配在堆上而不是栈上。Go语言里有专门的逃逸分析Escape Analysisfunc makeClosure() func() { x : 42 return func() { fmt.Println(x) } }go build时可以用go build -gcflags-m查看逃逸分析结果你会发现x被moved to heap了。也就是说闭包捕获的变量根本不是存在栈上的而是编译器在背后帮你把变量搬家到了堆上让它的生命周期超越了函数调用栈的存活时间。这个视角对理解闭包至关重要闭包并不是什么魔法它是编译器/运行时用堆分配换来的能力。你看到的局部变量在函数返回后依然存在实质是这个变量的存储位置早就被挪到了堆上只是语法上还感觉它是局部变量。这解释了一个常见疑问闭包的性能开销到底在哪里答案是被捕获变量的堆分配和逃逸分析决定。如果幸运编译器能证明变量不需要逃逸它就不会堆分配一旦需要捕获堆分配就不可避免。所以在性能敏感的场景里不要滥用闭包捕获大对象这句话是有底层依据的。5. 从会用到讲透闭包面试与复盘中的几个关键问题讲到这里闭包的原理、场景、坑都过了一遍。这一节我想以过来人的身份分享一下怎么把闭包这个知识点学透以及面试官真正想从你会不会闭包这个问题里听到什么。5.1 如何一句话准确回答什么是闭包我面试过几十个候选人听到最好的回答是这样的一句闭包是函数打包了它诞生时所在的词法作用域使得这个函数可以在定义位置之外被调用时仍然能访问到那些作用域里的变量。这句话里包含了三个信息点缺一不可闭包的主体是函数。它携带了一个词法作用域这个作用域是定义时绑定的不是调用时确定的。携带后即使离开定义环境变量仍然可达。很多人的回答只有第1点和第2点的变体漏掉了第3点可以跨越定义位置访问。这三点凑齐了才是一个完整的定义。如果面试官紧接着问闭包底层是怎么实现的你就可以把逃逸分析、堆分配搬出来从内存模型上再讲一层被捕获变量在栈上还是堆上其实取决于运行时会不会检测到逃逸。这会让你的回答瞬间脱离背概念的层面。5.2 我建议的闭包学习路径不要一上来就背定义。我建议按这三步走第一步自己造一个闭包。随便写一个函数在里面定义一个变量再返回一个函数去引用它。跑一遍确认函数死后变量还活着。第二步把代码拆开逐行注释。解释每一步发生的内存变化最好画一张简单的作用域链示意图。这个阶段不要用断点调试逼自己理解执行上下文和词法环境的概念。第三步到真实的框架代码里去找闭包。随便打开一个你熟悉的开源项目React、Vue、Express都行搜索return function这类语法找到闭包的实战用法。你会发现闭包几乎无处不在它不是一个孤立的语法特性而是很多设计模式的地基。我自己带前端新人时还会布置一个小练习不用class纯靠闭包写一个可撤销计数器可以增加、减少、返回历史记录、撤销上一步。这个练习能把闭包的状态封装、共享状态、多方法组合全部串起来做一遍胜过看十篇博客。深度学习闭包之后再去理解React里的HooksuseState、useEffect、Vue的setup函数、竞态处理、状态管理库的核心逻辑都会顺畅很多。闭包是JavaScript能力版图里极少数能向下连通底层内存模型、向上支撑框架设计的知识点值得你花一整段时间把它彻底搞清楚。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价