想象妈妈在春游出发前,往孩子的书包里塞了一瓶水。孩子接过书包时浑然不觉,但到了半路口渴,一摸就摸到了那瓶水。就算妈妈早已回家,水也一直躺在书包里——只要书包不丢,水就不消失。JavaScript 的闭包(Closure)就是这个“书包”:每个函数在出生时,都会背上一只装满外部变量的背包;它会一路背着它,走到哪儿用到哪儿。外层函数执行结束不意味着背包被丢弃,只要内部函数还活着,这袋“纪念品”就常伴左右。 本文将用“背包”这个比喻贯穿始终,按照认知规律一步步拆解闭包。你将看到闭包是怎么形成的,它的“背带链”——作用域链如何运转,变量为什么能被记住、什么时候背包会变成甩不掉的负担,以及模块模式、柯里化、防抖节流三组经典用法。这些知识不仅面试常考,更是代码组织、性能优化的底层素养。

岗位关联

对 AI 从业者的价值:闭包是理解前端框架、SDK 封装与交互性能优化的钥匙;掌握它,你才能在开发 AI 应用界面、封装调用链、优化体验时真正“知其所以然”,面试和实战都更有底气。

目录
  1. 可视化演示
  2. 背包初现——闭包是怎么形成的
  3. 作用域链——背带怎么链接
  4. 背包常驻——变量的一生
  5. 背包超重——内存占用与泄漏风险
  6. 加锁背包——模块模式
  7. 分格背包——柯里化
  8. 限量背包——防抖节流
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示闭包核心原理:闭包形成机制(函数+词法作用域)、作用域链查找路径(内→外→全局)、以及内存管理与模块模式(IIFE私有封装+内存泄漏风险)。

本节配有交互动效(进入视口后自动加载,约 3KB)。

'+captions.closure+'

背包初现——闭包是怎么形成的

小学春游前,妈妈往小明书包里塞了一瓶水。小明不知道背包里装了水,但到了远足地,一打开就有水喝。JavaScript 里的函数也一样:它在“出生”(定义)时,会被塞进一袋外部变量,这袋东西叫词法环境。内层函数能看见外层函数的变量,就像孩子一摸书包就能摸到妈妈放进去的东西。

在 JavaScript 里,形成闭包需要三个条件:第一,一个函数定义在另一个函数内部,形成嵌套;第二,内层函数引用了外层函数的变量;第三,内层函数被保留下来,比如返回出去、存入数组、设为事件回调。如果内部函数不引用外部变量,背包就是空的;如果它立刻执行而不被保留,背包很快就被扔掉。两者都不构成可持续的闭包。

来看最典型的写法:outer 里定义了 inner,inner 引用了 water。outer 返回 inner 后,外部变量 fn 仍然引用着它,于是背包里的 water 没有丢。闭包的核心形态,用公式表示就是:

小明换了一座城市读书,春游那天的背包还留着,水也还在——只要不把背包扔了,里面的东西就一直在。“函数背包”就是闭包所有秘密的起点。

闭包=函数+环境记录\displaystyle \text{闭包} = \text{函数} + \text{环境记录}
  1. 函数(函数体)要执行的代码块,是背包的主人,负责收纳与使用背包里的物品。
    函数:x↦f(x)\displaystyle \text{函数}: x \mapsto f(x)
  2. 环境记录背包里装的外部变量清单,记录着外层作用域中的变量名与值。
    环境记录={v1,v2,…,vn}\displaystyle \text{环境记录} = \{ v_1, v_2, \dots, v_n \}
  3. 闭包函数携带环境记录的组合体;函数去哪儿,背包跟到哪儿。
    闭包=(f,{v1,v2,…,vn})\displaystyle \text{闭包} = (f, \{ v_1, v_2, \dots, v_n \})

作用域链——背带怎么链接

翻背包找东西时,孩子会先翻自己最里层的夹层,再翻外袋,最后翻妈妈的大包。决定这个顺序的,是一条“背带链”。在 JavaScript 里,这条链就是作用域链:当前函数作用域 → 外层函数作用域 → … → 全局作用域。

作用域是“词法”的:函数定义在哪,它的作用域链就长什么样,和它在哪儿被调用无关。查找变量时,引擎按照“由内向外、找到即停”的规则出发。如果内层作用域已经有了同名变量,外层的同名变量就会被“遮蔽”,孩子先从自己包里翻到了水,就不会再去翻妈妈的大包。

看这段代码:inner 里查找 c 能在本层找到;查找 b 需向外一层找 outer;查找 a 需要一路到全局。如果全局也没有,引擎会抛出 ReferenceError,就像翻遍所有背包都没找到水,只能回家哭。作用域链的查找规律可以写成:

孩子找水时先翻自己的小包,再翻妈妈的包,实在不行才去商店买。作用域链就是这套“先近后远、翻到即停”的规则。

resolve(n)=Sk[n],k=min⁡{i∣n∈Si}\displaystyle \text{resolve}(n) = S_k[n], \quad k = \min\{ i \mid n \in S_i \}
  1. n(变量名)要查找的名字,比如水、零食或者一个变量标识符。
    n=变量名\displaystyle n = \text{变量名}
  2. S_i(作用域层)作用域链中第 i 层作用域,相当于一层又一层的背包夹层。
    Si=第 i 层作用域\displaystyle S_i = \text{第 } i \text{ 层作用域}
  3. min(最近优先)从最近的夹层开始向外搜索,遇到第一个包含 n 的夹层就停。
    k=min⁡{i∣n∈Si}\displaystyle k = \min\{ i \mid n \in S_i \}
  4. S_k[n](取值)从命中的那一层取出变量 n 对应的值。
    resolve(n)=Sk[n]\displaystyle \text{resolve}(n) = S_k[n]

背包常驻——变量的一生

妈妈离开后,小明还背着那瓶水。妈妈原地的“作用域”已经结束,但只要背包还在,水就不会消失。这正是闭包最迷人的地方:外层函数执行结束后,调用栈的帧被销毁,但它留下的环境记录还活在闭包的背包里。

由于引擎的垃圾回收器不会回收“仍然被引用”的对象,只要内部函数幸存,外层变量就幸存。这就是计数器能持续累加的原因:createCounter 每次被调用时,count 都被放进返回函数的背包里;调用 c() 时,函数从背包取出 count,加一后再放回去,下一个回合接着用。

因此,变量的生命周期不再由代码块决定,而是由“谁还在引用它”决定。用公式表示:

妈妈不需要在场,只要背包还在,水就在。“变量的生命周期”不是由花括号决定的,而是由引用它的背包决定的。

alive(v)  ⟺  外部引用(f)>0 ∧ v∈bag(f)\displaystyle \text{alive}(v) \iff \text{外部引用}(f) > 0 \ \wedge\ v \in \text{bag}(f)
  1. v(外层变量)外层函数里的某个变量,比如计数器中的 count。
    v=外层变量\displaystyle v = \text{外层变量}
  2. f(内层函数)引用了变量 v 的内层函数,是背包的实际携带者。
    f=内层函数\displaystyle f = \text{内层函数}
  3. 外部引用(f) > 0内层函数还被外部代码引用,意味着背包没有被扔掉。
    外部引用(f)>0\displaystyle \text{外部引用}(f) > 0
  4. v ∈ bag(f)变量进入了内层函数的背包,成为环境记录的一部分。
    v∈bag(f)\displaystyle v \in \text{bag}(f)

背包超重——内存占用与泄漏风险

十年不换背包,什么旧物都往里塞,就会越背越重、越走越慢。闭包给了函数“长期持有变量”的能力,但如果用不好,背包会变成甩不掉的负担。

现代引擎一般用“标记-清除”执行垃圾回收,从根对象出发,凡是不可达的对象都会被回收。闭包造成的内存问题,本质是作用域链被意外保留:一个本该被回收的外层环境,因为某个闭包还在引用它而继续存活。典型的泄漏场景有三种:第一,把闭包挂在全局变量上长期不清理;第二,DOM 元素在事件回调或定时器里被闭包引用,元素删除后回调还存着;第三,网络请求、监听器绑定后从不释放。

解决办法也很朴素:用完把引用置为 null,主动调用 removeEventListener,断开 DOM 引用,避免把大对象直接塞进闭包的外层变量。这就像每天放学后把书包清一遍,过期饭盒、废纸统统扔掉。垃圾回收的判定可以简单写成:

定期清理背包,孩子走路才轻松。代码层面的“置空”,就是定期把背包里的旧物丢掉,让内存回归自由。

释放(v)  ⟺  RC(v)=0\displaystyle \text{释放}(v) \iff \text{RC}(v) = 0
  1. RC(v)(引用计数)变量 v 当前被多少处代码引用,相当于背包被多少只手握着。
    RC(v)=引用数(v)\displaystyle \text{RC}(v) = \text{引用数}(v)
  2. = 0(没有引用)没有任何函数或对象再指向 v,背包彻底没人背。
    RC(v)=0\displaystyle \text{RC}(v) = 0
  3. 释放(回收内存)引擎判定这块内存可回收,背包被扔进回收站。
    释放(v)  ⟺  RC(v)=0\displaystyle \text{释放}(v) \iff \text{RC}(v) = 0

加锁背包——模块模式

高级背包带密码锁,零花钱锁在最里层,只给外人看外面的卡袋。模块模式就是借用闭包给数据“上锁”:私有变量藏在闭包环境里,外界无法直接触摸,只有通过暴露出来的“锁孔”——公开方法,才能读写内部状态。

最简单的模块模式是 IIFE 写法:函数立刻执行,返回一个对象,对象里的方法通过闭包引用私有变量 privateCount。外部无论怎么操作,都无法直接拿到 privateCount,只能调用 inc 和 dec。工厂函数也很常见:每次调用产生一个独立的“锁包”,多个实例互不干扰,像是每人发一把不同的密码锁。

模块模式的价值在于数据隐藏和避免全局污染。全局变量少一个,程序就稳一分;别人用你的代码时,也不需要知道内部细节,只调接口就好。模块的抽象结构是:

密码锁背包既安全又整齐,模块模式让代码不靠全局变量“裸奔”,而靠锁孔接口彼此协作。

module=(private:v, public:g(v))\displaystyle \text{module} = \big( \text{private}: v,\ \text{public}: g(v) \big)
  1. privatev:只在闭包内部可见的变量,外面的人看不到也摸不到。
    v=私有变量\displaystyle v = \text{私有变量}
  2. publicg(v):对外暴露的方法,它可以读取甚至修改私有变量 v。
    g(v)=公开接口\displaystyle g(v) = \text{公开接口}
  3. module(模块整体)把私有变量和公开接口封装成的一个整体,安全又整洁。
    module=(v,g)\displaystyle \text{module} = (v, g)

分格背包——柯里化

露营背包分主仓、副仓、侧袋,装东西时分门别类。柯里化(Currying)就是“分仓装包”:原本一次接收多个参数的函数,被改造成一次只接收一个参数,返回一个新函数继续等待下一个参数,直到所有参数齐了才真正执行。

为什么需要这种看似绕弯的写法?核心价值在于参数复用。比如在 AI 项目里反复调用大模型,system prompt 每次都一样,用柯里化先固定 system prompt,再传入每次不同的用户消息,就不用一遍遍重复配置了。函数组合也受益于柯里化:多个单参函数像流水线一样串联,一个函数的输出正好是下一个函数的输入。

来看一个最简单的例子:add(3)(4) 先传入 3,返回一个“等待 4”的函数,再传入 4 得到 7。这不是语法糖,而是把“分步准备”变成一种编程思维方式。柯里化的数学表达简洁有力:

分格背包让物品井井有条,柯里化让参数各就各位:重复的配置只需装一次,后面的旅程自然轻松。

curry(f)(a)(b)=f(a,b)\displaystyle \text{curry}(f)(a)(b) = f(a, b)
  1. f(原函数)一个原本需要两个参数的函数,比如加法函数。
    f(a,b)\displaystyle f(a, b)
  2. curry(f)(a)(第一步传入)第一次只传参数 a,不执行 f,而是返回一个新函数 g。
    curry(f)(a)↦g\displaystyle \text{curry}(f)(a) \mapsto g
  3. (b)(第二步传入)新函数 g 接收参数 b,此时参数齐了,才真正执行 f。
    g(b)=f(a,b)\displaystyle g(b) = f(a, b)

限量背包——防抖节流

走山路时,孩子不能每走一步就停下来翻背包,又累又慢。于是有两种策略:防抖——先停下来安静一段时间,再卸包整理一次;节流——每走固定的步数,才停下整理一次。前端里的滚动、输入、拖拽就是高频事件,若不限制处理频率,函数会被疯狂调用,主线程被榨干。

防抖的核心是“重置倒计时”:每次触发都清除上一次的定时器并重新计时,直到停止触发超过 wait 毫秒,才执行最后一次。它非常适合搜索框联想:用户停止输入 300ms 后,才发一次请求。节流的核心是“冷却期”:记录上次执行时间,在 gap 毫秒内直接忽略后续触发,保证固定频率执行。它适合滚动条位置、拖拽等需要持续但低频更新的场景。

两者都用到了闭包的“背包变量”保存定时器 id 或上次时间戳。公式上,防抖可以理解为:

而节流的公式是:

限量背包让你既不用背太多东西,又不错过必需品;防抖节流让高频事件不压垮主线程,应用依然流畅顺滑。

debounce(f,t): 连续触发⇒仅最后一次执行\displaystyle \text{debounce}(f,t): \ \text{连续触发} \Rightarrow \text{仅最后一次执行}
  1. f(待执行函数)需要限速执行的任务。
    f=待执行函数\displaystyle f = \text{待执行函数}
  2. t(冷却时长)两次执行之间的最小时间间隔。
    t=冷却时长\displaystyle t = \text{冷却时长}
  3. 冷却期内(时间未到)当前时间与上次执行的间隔不足 t,函数被忽略。
    ΔT<t⇒忽略\displaystyle \Delta T < t \Rightarrow \text{忽略}
  4. 不执行(跳过本次)冷却期结束后才放行下一次调用。
    ΔT≥t⇒f\displaystyle \Delta T \geq t \Rightarrow f

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 演示

python29 行
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("讲讲事件循环"))
目的:用 Python 模拟 JavaScript 闭包——外层函数携带的背包变量被内层函数长期持有,并演示了柯里化的“固定配置、分步传参”思路。
  1. def make_counter(start=0):创建一个“背包工厂”,start 是放进背包的初始物品。
  2. count = start把 start 装进 make_counter 的环境,成为内层函数可见的私有变量。
  3. def open_bag():定义内层函数,它就是背着背包的函数。
  4. nonlocal count声明要修改外层背包里的 count,而不是新建一个局部变量。
  5. return open_bag把内层函数交出去,背包跟随它一起存活。
  6. c1 = make_counter(100)生成一个带着“100”初始值的闭包实例。
  7. print(c1())每调用一次,背包里的 count 增加 1 并返回新值。
  8. 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. 闭包能“记住”外部变量,本质是因为____。

单选题 · easy

  • A.保存变量值的副本
  • B.保留作用域链接口← 正确答案
  • C.存储了全局变量表
  • D.把变量复制到堆栈

答案:B。闭包不是复制值,而是保存外层作用域(环境记录)的引用,变量变化时闭包能实时看到最新值。A、C、D 都偏离了词法环境的本质。

Q2. 以下哪些是闭包的形成条件?

多选题 · mid

  • A.必须有函数嵌套结构← 正确答案
  • B.内层函数引用外层变量← 正确答案
  • C.内层函数被外界保留← 正确答案
  • D.必须返回函数对象

答案:ABC。三个条件缺一不可。D 错在“必须返回”,把函数存进数组、挂到对象上同样形成闭包;返回只是常见形态。

Q3. 用 var i 注册的循环定时器,为什么最后全部输出 3?

单选题 · mid

  • A.三条定时器都被立刻执行
  • B.回调共享同一个 i 变量← 正确答案
  • C.setTimeout 只会打印一次
  • D.循环正常结束前已经打印

答案:B。var 是函数作用域,三个回调共用同一个 i,循环结束后 i=3,所以全部输出 3。改成 let 或使用函数工厂即可让每个回合拥有独立背包。

Q4. 闭包造成内存占用变大,是因为____。

单选题 · mid

  • A.函数体积被复制两份
  • B.外层作用域被持续持有← 正确答案
  • C.全局对象新建了缓存
  • D.变量值被序列化保存

答案:B。闭包让外层环境记录长期无法回收,被引用的变量持续占用内存。它不是复制函数体积,也不会自动新建缓存。

Q5. 模块模式可以做到____。

多选题 · mid

  • A.隐藏私有变量← 正确答案
  • B.只暴露公共接口← 正确答案
  • C.完全阻止所有访问
  • D.让全局变量永久存在

答案:AB。C 不对,函数内部仍可访问私有变量,只是外部无法直接访问;D 与模块模式的目标相反,模块模式恰恰是为了减少全局变量。

Q6. 防抖与节流的主要区别是____。

单选题 · hard

  • A.防抖保证最后才执行,节流保持固定频率← 正确答案
  • B.防抖必须用异步,节流必须用同步
  • C.防抖只用于事件,节流只用于动画
  • D.两者并无实质区别,只是名字不同

答案:A。防抖是“停稳后执行最后一次”,节流是“固定间隔执行一次”。B、C、D 都是错误限制;两者都可用异步或同步方式实现。

Q7. 柯里化函数调用形式正确的是____。

单选题 · mid

  • A.一次性传入全部实参
  • B.每次只传入一部分参数← 正确答案
  • C.每步返回新的构造函数
  • D.每次调用都会立即执行

答案:B。柯里化核心是“分步传参”,每一步返回一个新函数,参数齐了才执行。A 是普通调用,C、D 与柯里化机制不符。

Q8. 关于箭头函数在闭包里的说法,正确的是____。

单选题 · easy

  • A.箭头函数不会产生闭包
  • B.箭头函数不能保留外层变量
  • C.箭头函数继承外层 this← 正确答案
  • D.箭头函数强制绑定原始 this

答案:C。箭头函数没有自己的 this,会继承定义时外层作用域的 this;它依然可以持有外层变量,形成闭包。A、B、D 均不正确。

Q9. 下列哪种场景更适合用节流而非防抖?

单选题 · hard

  • A.用户停止输入后搜索
  • B.滚动过程更新滚动条位置← 正确答案
  • C.连续点击后只提交一次
  • D.图片懒加载等待用户停顿

答案:B。滚动条位置需要持续低频更新,适合节流。A、D 是“停顿后执行”,适合防抖;C 一般需要防抖或一次性标志位。

Q10. 关于闭包与内存释放,正确的说法是____。

多选题 · hard

  • A.闭包变量可能长期占用内存← 正确答案
  • B.使用后把引用置为 null 可帮助释放← 正确答案
  • C.只要函数不执行就不会占用内存
  • D.断开 DOM 引用可避免泄漏← 正确答案

答案:ABD。C 错,函数只要被引用,其闭包环境就可能存活,与是否执行无关。A、B、D 分别对应占用、释放与防泄漏的正确认识。