想象妈妈在春游出发前,往孩子的书包里塞了一瓶水。孩子接过书包时浑然不觉,但到了半路口渴,一摸就摸到了那瓶水。就算妈妈早已回家,水也一直躺在书包里——只要书包不丢,水就不消失。JavaScript 的闭包(Closure)就是这个“书包”:每个函数在出生时,都会背上一只装满外部变量的背包;它会一路背着它,走到哪儿用到哪儿。外层函数执行结束不意味着背包被丢弃,只要内部函数还活着,这袋“纪念品”就常伴左右。 本文将用“背包”这个比喻贯穿始终,按照认知规律一步步拆解闭包。你将看到闭包是怎么形成的,它的“背带链”——作用域链如何运转,变量为什么能被记住、什么时候背包会变成甩不掉的负担,以及模块模式、柯里化、防抖节流三组经典用法。这些知识不仅面试常考,更是代码组织、性能优化的底层素养。
对 AI 从业者的价值:闭包是理解前端框架、SDK 封装与交互性能优化的钥匙;掌握它,你才能在开发 AI 应用界面、封装调用链、优化体验时真正“知其所以然”,面试和实战都更有底气。
目录
可视化演示
展示闭包核心原理:闭包形成机制(函数+词法作用域)、作用域链查找路径(内→外→全局)、以及内存管理与模块模式(IIFE私有封装+内存泄漏风险)。
本节配有交互动效(进入视口后自动加载,约 3KB)。
'+captions.closure+'
背包初现——闭包是怎么形成的
小学春游前,妈妈往小明书包里塞了一瓶水。小明不知道背包里装了水,但到了远足地,一打开就有水喝。JavaScript 里的函数也一样:它在“出生”(定义)时,会被塞进一袋外部变量,这袋东西叫词法环境。内层函数能看见外层函数的变量,就像孩子一摸书包就能摸到妈妈放进去的东西。
在 JavaScript 里,形成闭包需要三个条件:第一,一个函数定义在另一个函数内部,形成嵌套;第二,内层函数引用了外层函数的变量;第三,内层函数被保留下来,比如返回出去、存入数组、设为事件回调。如果内部函数不引用外部变量,背包就是空的;如果它立刻执行而不被保留,背包很快就被扔掉。两者都不构成可持续的闭包。
来看最典型的写法:outer 里定义了 inner,inner 引用了 water。outer 返回 inner 后,外部变量 fn 仍然引用着它,于是背包里的 water 没有丢。闭包的核心形态,用公式表示就是:
小明换了一座城市读书,春游那天的背包还留着,水也还在——只要不把背包扔了,里面的东西就一直在。“函数背包”就是闭包所有秘密的起点。
- 函数(函数体)要执行的代码块,是背包的主人,负责收纳与使用背包里的物品。
- 环境记录背包里装的外部变量清单,记录着外层作用域中的变量名与值。
- 闭包函数携带环境记录的组合体;函数去哪儿,背包跟到哪儿。
作用域链——背带怎么链接
翻背包找东西时,孩子会先翻自己最里层的夹层,再翻外袋,最后翻妈妈的大包。决定这个顺序的,是一条“背带链”。在 JavaScript 里,这条链就是作用域链:当前函数作用域 → 外层函数作用域 → … → 全局作用域。
作用域是“词法”的:函数定义在哪,它的作用域链就长什么样,和它在哪儿被调用无关。查找变量时,引擎按照“由内向外、找到即停”的规则出发。如果内层作用域已经有了同名变量,外层的同名变量就会被“遮蔽”,孩子先从自己包里翻到了水,就不会再去翻妈妈的大包。
看这段代码:inner 里查找 c 能在本层找到;查找 b 需向外一层找 outer;查找 a 需要一路到全局。如果全局也没有,引擎会抛出 ReferenceError,就像翻遍所有背包都没找到水,只能回家哭。作用域链的查找规律可以写成:
孩子找水时先翻自己的小包,再翻妈妈的包,实在不行才去商店买。作用域链就是这套“先近后远、翻到即停”的规则。
- n(变量名)要查找的名字,比如水、零食或者一个变量标识符。
- S_i(作用域层)作用域链中第 i 层作用域,相当于一层又一层的背包夹层。
- min(最近优先)从最近的夹层开始向外搜索,遇到第一个包含 n 的夹层就停。
- S_k[n](取值)从命中的那一层取出变量 n 对应的值。
背包常驻——变量的一生
妈妈离开后,小明还背着那瓶水。妈妈原地的“作用域”已经结束,但只要背包还在,水就不会消失。这正是闭包最迷人的地方:外层函数执行结束后,调用栈的帧被销毁,但它留下的环境记录还活在闭包的背包里。
由于引擎的垃圾回收器不会回收“仍然被引用”的对象,只要内部函数幸存,外层变量就幸存。这就是计数器能持续累加的原因:createCounter 每次被调用时,count 都被放进返回函数的背包里;调用 c() 时,函数从背包取出 count,加一后再放回去,下一个回合接着用。
因此,变量的生命周期不再由代码块决定,而是由“谁还在引用它”决定。用公式表示:
妈妈不需要在场,只要背包还在,水就在。“变量的生命周期”不是由花括号决定的,而是由引用它的背包决定的。
- v(外层变量)外层函数里的某个变量,比如计数器中的 count。
- f(内层函数)引用了变量 v 的内层函数,是背包的实际携带者。
- 外部引用(f) > 0内层函数还被外部代码引用,意味着背包没有被扔掉。
- v ∈ bag(f)变量进入了内层函数的背包,成为环境记录的一部分。
背包超重——内存占用与泄漏风险
十年不换背包,什么旧物都往里塞,就会越背越重、越走越慢。闭包给了函数“长期持有变量”的能力,但如果用不好,背包会变成甩不掉的负担。
现代引擎一般用“标记-清除”执行垃圾回收,从根对象出发,凡是不可达的对象都会被回收。闭包造成的内存问题,本质是作用域链被意外保留:一个本该被回收的外层环境,因为某个闭包还在引用它而继续存活。典型的泄漏场景有三种:第一,把闭包挂在全局变量上长期不清理;第二,DOM 元素在事件回调或定时器里被闭包引用,元素删除后回调还存着;第三,网络请求、监听器绑定后从不释放。
解决办法也很朴素:用完把引用置为 null,主动调用 removeEventListener,断开 DOM 引用,避免把大对象直接塞进闭包的外层变量。这就像每天放学后把书包清一遍,过期饭盒、废纸统统扔掉。垃圾回收的判定可以简单写成:
定期清理背包,孩子走路才轻松。代码层面的“置空”,就是定期把背包里的旧物丢掉,让内存回归自由。
- RC(v)(引用计数)变量 v 当前被多少处代码引用,相当于背包被多少只手握着。
- = 0(没有引用)没有任何函数或对象再指向 v,背包彻底没人背。
- 释放(回收内存)引擎判定这块内存可回收,背包被扔进回收站。
加锁背包——模块模式
高级背包带密码锁,零花钱锁在最里层,只给外人看外面的卡袋。模块模式就是借用闭包给数据“上锁”:私有变量藏在闭包环境里,外界无法直接触摸,只有通过暴露出来的“锁孔”——公开方法,才能读写内部状态。
最简单的模块模式是 IIFE 写法:函数立刻执行,返回一个对象,对象里的方法通过闭包引用私有变量 privateCount。外部无论怎么操作,都无法直接拿到 privateCount,只能调用 inc 和 dec。工厂函数也很常见:每次调用产生一个独立的“锁包”,多个实例互不干扰,像是每人发一把不同的密码锁。
模块模式的价值在于数据隐藏和避免全局污染。全局变量少一个,程序就稳一分;别人用你的代码时,也不需要知道内部细节,只调接口就好。模块的抽象结构是:
密码锁背包既安全又整齐,模块模式让代码不靠全局变量“裸奔”,而靠锁孔接口彼此协作。
- privatev:只在闭包内部可见的变量,外面的人看不到也摸不到。
- publicg(v):对外暴露的方法,它可以读取甚至修改私有变量 v。
- module(模块整体)把私有变量和公开接口封装成的一个整体,安全又整洁。
分格背包——柯里化
露营背包分主仓、副仓、侧袋,装东西时分门别类。柯里化(Currying)就是“分仓装包”:原本一次接收多个参数的函数,被改造成一次只接收一个参数,返回一个新函数继续等待下一个参数,直到所有参数齐了才真正执行。
为什么需要这种看似绕弯的写法?核心价值在于参数复用。比如在 AI 项目里反复调用大模型,system prompt 每次都一样,用柯里化先固定 system prompt,再传入每次不同的用户消息,就不用一遍遍重复配置了。函数组合也受益于柯里化:多个单参函数像流水线一样串联,一个函数的输出正好是下一个函数的输入。
来看一个最简单的例子:add(3)(4) 先传入 3,返回一个“等待 4”的函数,再传入 4 得到 7。这不是语法糖,而是把“分步准备”变成一种编程思维方式。柯里化的数学表达简洁有力:
分格背包让物品井井有条,柯里化让参数各就各位:重复的配置只需装一次,后面的旅程自然轻松。
- f(原函数)一个原本需要两个参数的函数,比如加法函数。
- curry(f)(a)(第一步传入)第一次只传参数 a,不执行 f,而是返回一个新函数 g。
- (b)(第二步传入)新函数 g 接收参数 b,此时参数齐了,才真正执行 f。
限量背包——防抖节流
走山路时,孩子不能每走一步就停下来翻背包,又累又慢。于是有两种策略:防抖——先停下来安静一段时间,再卸包整理一次;节流——每走固定的步数,才停下整理一次。前端里的滚动、输入、拖拽就是高频事件,若不限制处理频率,函数会被疯狂调用,主线程被榨干。
防抖的核心是“重置倒计时”:每次触发都清除上一次的定时器并重新计时,直到停止触发超过 wait 毫秒,才执行最后一次。它非常适合搜索框联想:用户停止输入 300ms 后,才发一次请求。节流的核心是“冷却期”:记录上次执行时间,在 gap 毫秒内直接忽略后续触发,保证固定频率执行。它适合滚动条位置、拖拽等需要持续但低频更新的场景。
两者都用到了闭包的“背包变量”保存定时器 id 或上次时间戳。公式上,防抖可以理解为:
而节流的公式是:
限量背包让你既不用背太多东西,又不错过必需品;防抖节流让高频事件不压垮主线程,应用依然流畅顺滑。
- f(待执行函数)需要限速执行的任务。
- t(冷却时长)两次执行之间的最小时间间隔。
- 冷却期内(时间未到)当前时间与上次执行的间隔不足 t,函数被忽略。
- 不执行(跳过本次)冷却期结束后才放行下一次调用。
AI 应用场景
AI SDK 的“密码锁背包”
阶段:AI 应用前端的 SDK 封装与配置管理
解决的问题:调用大模型 API 时,需要把 apiKey、baseUrl、默认模型等配置集中管理。传统做法是把配置挂到全局变量,容易泄露、互相污染,还难以测试。用模块模式把配置锁进闭包背包,只对外暴露请求方法,既安全又整洁。
具体案例:封装一个 createLLMClient({ apiKey, baseUrl, model }),内部用闭包保存配置,返回 chat(messages) 和 embed(text) 两个方法。每次调用都自动携带已保存的配置,需要更换模型或密钥时,只需重新创建一个客户端,老客户端自然被回收。
AI 对话搜索栏的防抖
阶段:用户交互 → 智能联想 / 流式搜索
解决的问题:用户在输入框里连续打字时,每秒可能触发多次请求;如果不做防抖,会浪费大量 token,还会出现请求乱序导致结果覆盖。防抖让输入停顿超过 350ms 后才发送一次联想请求,既省资源又稳定。
具体案例:在聊天界面给关键词联想框绑定 debounce(async (keyword) => { const data = await fetchSuggestion(keyword); render(data); }, 350)。用户一直在打字时,闭包里的定时器不断被重置;一停下来,350ms 后只发最后一次请求。
固定 Prompt 的柯里化工坊
阶段:提示词工程 / 多次 LLM 调用
解决的问题:多轮对话中,system prompt 与温度参数每次都重复传,代码冗长且容易写错。用柯里化先固定“通用约束”,再只传用户消息,调用链变得非常干净。
具体案例:先写 const tutor = withConfig({ system: '你是前端导师', temperature: 0.3 });,之后就可以直接 tutor('讲讲闭包原理') 和 tutor('讲讲事件循环')。每一次调用,柯里化产生的背包里都自动带着已设定好的 system prompt 和温度参数。
Python 演示
def make_counter(start=0): count = start # 背包里的私有变量 def open_bag(): nonlocal count count += 1 return count return open_bag def with_system_prompt(system_prompt): def call(user_message): return "[system] " + system_prompt + " / [user] " + user_message return call if __name__ == "__main__": c1 = make_counter(100) print(c1()) print(c1()) c2 = make_counter(0) print(c2()) print(c2()) tutor = with_system_prompt("你是一名前端导师") print(tutor("讲讲闭包")) print(tutor("讲讲事件循环"))
def make_counter(start=0):创建一个“背包工厂”,start 是放进背包的初始物品。count = start把 start 装进 make_counter 的环境,成为内层函数可见的私有变量。def open_bag():定义内层函数,它就是背着背包的函数。nonlocal count声明要修改外层背包里的 count,而不是新建一个局部变量。return open_bag把内层函数交出去,背包跟随它一起存活。c1 = make_counter(100)生成一个带着“100”初始值的闭包实例。print(c1())每调用一次,背包里的 count 增加 1 并返回新值。tutor = with_system_prompt("你是一名前端导师")柯里化:先固定 system prompt,生成等待用户消息的函数。
常见误区
误区我听说闭包会把外层变量一直留在内存里,所以是不是一写闭包就会导致内存泄漏?那闭包还能用吗?
正解闭包只是让变量比外层函数活得更久,这是功能,不是 bug。真正的泄漏发生在“你一直握着背包却不清理”的前提下:把闭包挂到全局、绑在长期不销毁的 DOM 或定时器上,且之后不再解除引用。正常用完把引用置空,GC 会自动回收背包。
为何常见:教程常把闭包和内存占用的案例放在一起讲,初学者容易把“特性”和“误用的后果”混为一谈。
误区我以为闭包必须写成 `return function() {...}`,其他写法都不是闭包。
正解闭包的形成只看两点:内层函数是否引用了外层变量,以及它是否被保留。把函数 push 进数组、赋值给对象属性、作为事件监听器传入,同样构成闭包。返回函数只是最常见、最容易教学的形式。
为何常见:大多数教材都用“返回函数”作为唯一示例,读者容易把示例当定义。
误区`for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); }`,我以为会输出 0、1、2。
正解var 没有块级作用域,三个回调共享同一个变量 i,循环结束时 i 已经是 3,所以输出 3、3、3。想保留每个回合的值,用 let 声明,或者用 IIFE / 函数工厂把每次的 i 装进独立的背包。
为何常见:很多人在还没分清 var 和 let 时就被扔进闭包题海,函数作用域的特性自然成了盲区。
误区我以为对象方法内部的闭包,`this` 会自动指向外层对象,因为闭包能“记住”外部变量。
正解闭包记住的是词法作用域变量,不是 this。this 取决于调用方式。把 obj.method 取出来单独调用,this 会变成 undefined 或 window。想要正确指向,需要箭头函数或 fn.bind(obj) 显式绑定。
为何常见:大家容易把“记住外层变量”和“记住外层 this”混为一谈,于是出现经典的 this 丢失问题。
误区我以为内层函数只能读外层变量,不能改,否则会报错。
正解在 JavaScript 里,内层函数可以直接修改外层的 let / var 变量。计数器、缓存、累加器全都要靠闭包“改写”外层变量才能工作。只有在重复声明同名变量时才会发生遮蔽,这与“能不能改”是两回事。
为何常见:Python 中修改外层变量需要显式 nonlocal,从其他语言转过来的同学容易把这个规则错误套用到 JavaScript 上。
面试题
Q1. 闭包能“记住”外部变量,本质是因为____。
- A.保存变量值的副本
- B.保留作用域链接口← 正确答案
- C.存储了全局变量表
- D.把变量复制到堆栈
答案:B。闭包不是复制值,而是保存外层作用域(环境记录)的引用,变量变化时闭包能实时看到最新值。A、C、D 都偏离了词法环境的本质。
Q2. 以下哪些是闭包的形成条件?
- A.必须有函数嵌套结构← 正确答案
- B.内层函数引用外层变量← 正确答案
- C.内层函数被外界保留← 正确答案
- D.必须返回函数对象
答案:ABC。三个条件缺一不可。D 错在“必须返回”,把函数存进数组、挂到对象上同样形成闭包;返回只是常见形态。
Q3. 用 var i 注册的循环定时器,为什么最后全部输出 3?
- A.三条定时器都被立刻执行
- B.回调共享同一个 i 变量← 正确答案
- C.setTimeout 只会打印一次
- D.循环正常结束前已经打印
答案:B。var 是函数作用域,三个回调共用同一个 i,循环结束后 i=3,所以全部输出 3。改成 let 或使用函数工厂即可让每个回合拥有独立背包。
Q4. 闭包造成内存占用变大,是因为____。
- A.函数体积被复制两份
- B.外层作用域被持续持有← 正确答案
- C.全局对象新建了缓存
- D.变量值被序列化保存
答案:B。闭包让外层环境记录长期无法回收,被引用的变量持续占用内存。它不是复制函数体积,也不会自动新建缓存。
Q5. 模块模式可以做到____。
- A.隐藏私有变量← 正确答案
- B.只暴露公共接口← 正确答案
- C.完全阻止所有访问
- D.让全局变量永久存在
答案:AB。C 不对,函数内部仍可访问私有变量,只是外部无法直接访问;D 与模块模式的目标相反,模块模式恰恰是为了减少全局变量。
Q6. 防抖与节流的主要区别是____。
- A.防抖保证最后才执行,节流保持固定频率← 正确答案
- B.防抖必须用异步,节流必须用同步
- C.防抖只用于事件,节流只用于动画
- D.两者并无实质区别,只是名字不同
答案:A。防抖是“停稳后执行最后一次”,节流是“固定间隔执行一次”。B、C、D 都是错误限制;两者都可用异步或同步方式实现。
Q7. 柯里化函数调用形式正确的是____。
- A.一次性传入全部实参
- B.每次只传入一部分参数← 正确答案
- C.每步返回新的构造函数
- D.每次调用都会立即执行
答案:B。柯里化核心是“分步传参”,每一步返回一个新函数,参数齐了才执行。A 是普通调用,C、D 与柯里化机制不符。
Q8. 关于箭头函数在闭包里的说法,正确的是____。
- A.箭头函数不会产生闭包
- B.箭头函数不能保留外层变量
- C.箭头函数继承外层 this← 正确答案
- D.箭头函数强制绑定原始 this
答案:C。箭头函数没有自己的 this,会继承定义时外层作用域的 this;它依然可以持有外层变量,形成闭包。A、B、D 均不正确。
Q9. 下列哪种场景更适合用节流而非防抖?
- A.用户停止输入后搜索
- B.滚动过程更新滚动条位置← 正确答案
- C.连续点击后只提交一次
- D.图片懒加载等待用户停顿
答案:B。滚动条位置需要持续低频更新,适合节流。A、D 是“停顿后执行”,适合防抖;C 一般需要防抖或一次性标志位。
Q10. 关于闭包与内存释放,正确的说法是____。
- A.闭包变量可能长期占用内存← 正确答案
- B.使用后把引用置为 null 可帮助释放← 正确答案
- C.只要函数不执行就不会占用内存
- D.断开 DOM 引用可避免泄漏← 正确答案
答案:ABD。C 错,函数只要被引用,其闭包环境就可能存活,与是否执行无关。A、B、D 分别对应占用、释放与防泄漏的正确认识。