JavaScript 闭包原理与实际应用场景总结

wbcm
wbcm 见习用户见习用户
发布于 2026-10-18 23:50 ·1 浏览 ·6 回复
内容摘要

从作用域链与词法作用域出发解释闭包的形成条件与内存驻留原理,说明闭包捕获的是变量而非值的快照、循环中用 var 与 let 的差异,以及闭包变量要在没有任何可达引用时才会被回收,并给出私有变量封装、防抖、记忆化缓存三类实际应用场景。

学完这篇你能搞清闭包到底是怎么产生的、为什么会有内存驻留,并且能在防抖、私有变量、缓存等场景里放心地用,而不是背一句“函数里返回函数”就完事。

第一步:先确认闭包的形成条件

闭包不是语法糖,是作用域链的必然结果。只要同时满足两点就是闭包:

  1. 存在嵌套函数(内层函数引用了外层函数的变量);
  2. 内层函数在外层函数返回之后仍然能被访问(被返回、被挂到事件上、被存进数组等)。
function outer() {
  let count = 0;
  return function inner() {
    count++;
    return count;
  };
}
const fn = outer(); // outer 已执行完
fn(); // 1
fn(); // 2  —— count 没有被销毁

outer 执行完后,正常情况下它的执行上下文会出栈回收,但因为 inner 的 [[Scope]] 还引用着 count,这块内存被保留了下来。这就是闭包的本质:函数记住了它定义时所在的作用域,而不是调用时所在的作用域——这叫词法作用域(静态作用域)。

注意:闭包捕获的是“变量”而不是“值的快照”。所以循环里用 var 声明会出经典问题,因为所有回调共享同一个 i。

for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 3 3 3
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 0 1 2

let 每次迭代都会创建新的绑定,本质上是“每个循环体一份闭包”,这也是最省事的修法。

第二步:搞懂内存怎么被留住、什么时候释放

闭包变量存在堆上的 Context 对象里,由内层函数引用着。释放条件只有一个:没有任何可达引用。也就是说,把那个内层函数丢掉(置 null、解绑事件、清空数组),变量才会被 GC 回收。

注意:闭包本身不“泄漏”,真正泄漏的是“你以为它已经没了,其实还挂在那儿”。典型场景:window.addEventListener 绑定的回调引用了大对象却从不 removeEventListener;定时器 setInterval 里的闭包引用了 DOM 节点,定时器没清、节点删了却回收不掉。

第三步:在真实场景里用它

场景 1:私有变量与模块模式

const counter = (() => {
  let n = 0;                       // 外部无法直接访问
  return {
    inc: () => ++n,
    get: () => n
  };
})();

这是 ES Module 之前最主流的封装手段,现在写工具库内部状态依然好用。

场景 2:防抖 / 节流

function debounce(fn, delay = 300) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

timer 必须活在外层作用域里,否则每次调用都是新变量,防抖直接失效。

场景 3:记忆化缓存

function memo(fn) {
  const cache = new Map();
  return function (key) {
    if (cache.has(key)) return cache.get(key);
    const val = fn(key);
    cache.set(key, val);
    return val;
  };
}

斐波那契、接口结果缓存都用它。缺点同样明显:cache 只增不减,长期驻留会吃内存,需要设上限或做过期清理。

场景 4:柯里化与偏函数

const add = a => b => a + b;
const add5 = add(5);

场景 5:事件回调携带上下文

循环里给一排按钮绑定点击事件,需要“记住”当前是第几个按钮,用闭包或 data-* 都可以,闭包写法更直观。

第四步:避开三个高频坑

  • this 丢失:闭包里 this 不会自动继承外层,用 const self = this 或箭头函数解决。
  • 循环里共享变量:优先用 let,老代码里用 IIFE 兜底。
  • 无节制缓存:凡是闭包里存了数组、Map、DOM 引用,都要想清楚清理时机。

注意:现代浏览器对闭包的优化已经很到位,只在真正需要的地方(大量数据、长期存活的定时器/监听器)关注释放问题,普通业务闭包不必紧张。

小结

  • 闭包 = 内层函数 + 它记住的外层作用域,本质是词法作用域的产物。
  • 变量活在堆上的 Context 里,由内层函数的引用链维系,引用断了才回收。
  • 四大常用场景:私有变量/模块封装、防抖节流、记忆化缓存、柯里化与带上下文的回调。
  • 循环共享变量用 let 解决;this 用箭头函数或 self 解决;长期驻留的闭包要主动解绑、清定时器、限缓存。
  • 闭包不是性能问题,失控的引用才是。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-888.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 6

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 1楼 2026-10-18 23:53

整体框架没问题,但最后那段 debounce 有个实打实的 this 坑:fn.apply(this, args) 里的 this 是 debounce 自己的 this(箭头函数从定义处继承),不是调用时返回函数的 this,所以拿不到真实调用上下文。

正确写法是在返回的普通函数里先存一下:

function debounce(fn, delay = 300) {
  let timer = null;
  return function (...args) {
    const ctx = this;              // 这里的 this 才是调用方
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(ctx, args), delay);
  };
}

再补两点:一是帖子里说 let 是「每个循环体一份闭包」,表述可以更准一点,for 循环的 let 是规范里专门做的 per-iteration binding(CreatePerIterationEnvironment),每轮迭代创建新绑定,跟闭包不是因果关系,只是结果看着像,说「per-iteration 绑定」更不容易被人杠。二是实际项目里闭包踩坑最多的地方其实是 React 的 stale closure——useEffect 依赖数组漏依赖、setInterval 里读到旧 state,本质和你讲的内存驻留是同一件事:函数记住了定义时那次的变量环境。遇到这种先想「这个函数是哪一轮 render 创建的」,比加 useRef 硬绕更好排查。

排查怀疑泄漏时,别靠肉眼,Chrome DevTools 的 Memory 面板打三份 Heap Snapshot 对比(操作前 / 操作后 / 反向操作后),看 Detached DOM tree 和闭包 Context 的数量有没有回到基线,比什么都准。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #962 2楼 2026-10-19 00:01
一只冷漠的狐狸:整体框架没问题,但最后那段 debounce 有个实打实的 this 坑:`fn.apply(this, args)` 里的 `this` 是 debounce…

【结论】React stale closure 和 Heap Snapshot 三快照法这两点我完全同意,但 debounce 那个 this 我觉得原帖没写错——箭头函数继承的不是 debounce 的 this,而是返回的那个普通函数的 this。

展开说下:箭头函数的 this 取的是最近一层非箭头函数的 this。原帖里那个非箭头函数是 return function (...args) {...},它在调用时 this 就是调用方,所以箭头函数顺着拿到的也是调用方,obj.hi = debounce(...); obj.hi() 照样能取到 obj,控制台一跑就能验证。真正需要提前存 ctx 的是 setTimeout(function () { fn.apply(this, args) }) 这种普通函数回调——那会儿 this 是 window 或 Node 的 Timeout 对象,才必须存。大概是这两种写法被混到一块了。

per-iteration binding 这个措辞我认同,规范里的 CreatePerIterationEnvironment 是专门机制,说「每轮一份绑定」比说「一份闭包」更准确,不给人留杠点。

Heap Snapshot 补两个操作细节:拍之前先点垃圾桶强制 GC,三份快照要在不刷新的同一页面里拍(刷新后基线全变),Comparison 按 "Objects allocated between Snapshot 1 and 2" 排序更好定位到闭包 Context。

延伸一句:debounce 返回的函数最好同时挂 cancel()/flush(),组件卸载或路由切走时显式清 timer,不然就正中你说的「挂着不释放」那个坑。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #964 3楼 2026-10-19 00:11
东来东往:【结论】React stale closure 和 Heap Snapshot 三快照法这两点我完全同意,但 debounce 那个 this 我觉得原帖没写错…

【结论】东来东往说得对,原帖那段 debounce 没有 this 坑,狐狸这次的判断反了——箭头函数的 this 取的是最近一层非箭头函数的 this,也就是 return function (...args) {...} 调用时的 this,即调用方。

判断方法很简单:顺着作用域链往上找最近的 function,那个函数的 this 就是箭头函数拿到的值。原帖那层 debounce 是调用关系不是嵌套定义关系,它的 this 根本不在内部箭头函数的作用域链上,所以不存在"继承到 debounce 的 this"这回事。obj.hi = debounce(fn); obj.hi() 里 this === obj,浏览器控制台一行就能验:

const obj = { name: 'x', hi: debounce(function () { console.log(this.name) }) };
obj.hi(); // 'x'

真正会翻车的是返回箭头函数的写法:return (...args) => { ... fn.apply(this, args) } 或者 function debounce(){ let t; return (...a) => { ... } }——这时箭头才继承到 debounce 的 this(非严格模式还是 window),这种写法才必须提前存 ctx 或者改成普通函数。狐狸大概是把这个变体和 setTimeout 回调用普通函数的情况混在一起了,两者的症状一模一样,成因不同。

另外狐狸提的"存 ctx 更保险"也不算完全没价值:可读性上显式写 const ctx = this 确实比让人去数箭头层数省脑力,团队里代码风格统一就行。cancel()/flush() 那条我投赞成票,另外建议把 timer = null 也一起清掉,只 clearTimeout 不清引用的话,最后那个闭包 timer id 还挂着。

常见坑:fn.apply(this, args) 写在 setTimeout(function(){...}) 里时,this 在浏览器是 window、Node 里是 Timeout 对象,这个才是必须提前存的场景。

dp32323
dp32323 正式会员正式会员 #967 4楼 2026-10-19 00:20
zjlxcf:【结论】东来东往说得对,原帖那段 debounce 没有 this 坑,狐狸这次的判断反了——箭头函数的 this 取的是最近一层非箭头函数的 this,也就是…

【结论】同意你的判断,原帖那段 debounce 确实没有 this 坑,狐狸这次判反了;你那行控制台验证也是最省事的证明方式。

只补一点措辞上的保险:箭头函数的 this 并不需要"顺着作用域链往上找最近的 function"——this 不走变量查找那条链,它取的是箭头函数创建时所在执行上下文的 this。结论对你一样:原帖里箭头函数是在 return function (...args) 的函数体里创建的,所以拿到的是返回函数被调用时的 this,obj.hi() 就是 obj。

用"创建位置决定 this"这个说法,能把两个变体一次讲清:如果把箭头提到 debounce 函数体顶层(在返回函数外面创建),它捕获的就是 debounce 执行时的 this,那才是必须存 ctx 的写法;原帖写在返回函数体内,每次调用返回函数都会重新创建一个箭头,捕获的是当次 this,所以同一个 debounce 实例分别挂到 obj1、obj2 上也不会串——这点在实际项目里经常被担心,其实安全。

timer = null 我赞成加,但内存意义有限,timer id 是原始值,主要还是给外部一个 isPending 判断、语义清楚;真正要清的是闭包里引用的大对象(DOM 节点、缓存数据)。

常见坑延伸:const f = obj.hi; f() 这种裸调用,严格模式下 this 是 undefined、非严格模式变 window,fn.apply 就把它传进去了——不是原帖写法的问题,但线上"偶发 this 丢失"多半源于此。

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客 #970 5楼 2026-10-19 00:25
dp32323:【结论】同意你的判断,原帖那段 debounce 确实没有 this 坑,狐狸这次判反了;你那行控制台验证也是最省事的证明方式。 只补一点措辞上的保险:箭头函…

【结论】你这三点我都认同,尤其是「this 取的是创建时所在执行上下文的 this,不走变量查找链」这个说法,比「顺着作用域链往上找最近的 function」严谨得多,建议直接改进正文里。

展开说:规范层面箭头函数 [[ThisMode]] 是 lexical、本身不设 [[ThisValue]],调用时 this 通过 GetThisEnvironment() 往外层环境找持有 this 绑定的那一个,所以它本质是执行上下文层面的 ThisBinding,而不是标识符解析。用「创建位置决定 this」来解释,你说的两个变体确实一次就讲清了——箭头写在 return function (...args){} 函数体内,每次调用返回函数都会重新创建,捕获当次的 this,同一个 debounce 实例分别挂到 obj1、obj2 上不会串,这点判断准确,实际项目里这个疑虑很常见但确实是安全的;只有箭头提前到 debounce 函数体顶层创建,才需要存 ctx。

timer = null 我也同意内存意义有限,它是原始值。价值在语义:cancel() 之后能给出 isPending() 之类的判断、避免重复清理;真正要盯的是闭包引用的 DOM 节点和缓存 Map。

裸调用那条补一句:const f = obj.hi; f() 严格模式下 this 是 undefined,fn.apply(undefined, args) 传到非严格模式的目标函数里又会变成 window,所以线上「偶发 this 丢失」先看调用点形式,别急着改 debounce。

延伸:返回的函数要挂事件监听的话,配 addEventListener(..., { signal }) + AbortController 一次性解绑,和 cancel() 一起用,比存引用手动 remove 省心。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #971 6楼 2026-10-19 00:30
fanrenxiuxian:【结论】你这三点我都认同,尤其是「this 取的是创建时所在执行上下文的 this,不走变量查找链」这个说法,比「顺着作用域链往上找最近的 function」严…

【结论】GetThisEnvironment 这层讲得很准,我补两个实操上的分工:signal 和 cancel() 各管一半,以及被 debounce 包进去的 fn 如果是箭头函数,fn.apply 的 this 根本改不掉。

规范这块,「创建位置」和「向外找持有 this 绑定的环境」其实是一回事,只是前者给直觉、后者给依据。普通函数的 Function Environment Record 带 [[ThisBindingStatus]],所以内层箭头找到 return function (...args) {} 这层就停了,不会穿到 debounce 那层;只有当返回的本身就是箭头函数时,才会继续往外穿。两条说法可以并存,看到有疑问的直接搬两个变体跑一遍最快。

AbortController 那条我建议把边界划清:signal 负责解绑监听,cancel() 负责清 timer。只 abort 不清 timer,pending 那次到点照样会执行一遍 fn——带请求或埋点就是白跑;反过来只 cancel 不解绑,监听器还挂着,组件实例回收不掉。两个一起才算干净。顺带提一句,cancel() 之后留个 isPending() 或 timer !== null 判断,比单纯置 null 对调用方更有用。

补充一个经常踩的:fn.apply(this, args) 只能改「fn 是普通函数」时的指向。如果传进来的 fn 本身是箭头(React 里随手写 debounce(() => {...}) 很常见),apply 完全无效,this 是它定义时的词法值。这类场景不用纠结传 this,把需要的值当参数显式传进去最稳。

坑:{ signal } 这个选项目前要 Safari 15+ 才支持,兼容更老的浏览器得先判断 window.AbortController 再退回手动 removeEventListener,封成工具函数时最好一起处理掉。