ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

JavaScript闭包详解:从作用域链到内存泄漏,一文彻底搞懂

2026/9/20 21:07:26 拓冰建站 浏览量
JavaScript闭包详解:从作用域链到内存泄漏,一文彻底搞懂 很多人学 JavaScript 学到“闭包”这一章十有八九会经历这样一个过程网上翻了一堆教程每个字都认识每行代码都能读但合上页面一回想脑子里就只剩下一句“函数套函数能访问外面的变量”。你要是问他闭包到底干了件什么事他支支吾吾半天最后憋出一句“就是……能记住外面的变量”。这个状态我太熟了因为我当年就是从这个状态里爬出来的。闭包这东西难点根本不在概念本身而在于大多数资料一上来就甩原理、甩执行上下文、甩作用域链把一个本来很生活化的机制讲得像天书。所以这篇东西我打算换个路子用讲人话的方式把 JS 闭包从头到尾拆一遍。不是给你背定义是让你真的懂它看完能自己写出来能说清楚它为什么这么设计还能在项目里用上它。这篇文章适合刚学到闭包但一头雾水的初学者也适合那种会用闭包但说不清楚原理、面试总被问倒的初中级前端。内容会覆盖闭包的底层机制、核心代码、实战场景、经典面试题以及闭包和 let、箭头函数、事件循环这些知识的联动一次性把这块骨头啃干净。1. 先掰扯清楚三件事不然闭包只会越学越迷糊闭包本身并不复杂复杂的是它依赖的几个前置概念。很多人学闭包卡住不是因为闭包难而是因为前面这几个基础没夯实就像没学会走路就想跑摔跟头是必然的。1.1 作用域你能看到什么取决于你站在哪作用域Scope说白了就是一套“可见性规则”它决定了你在代码的某个位置能访问到哪些变量。JavaScript 里作用域主要分三种全局作用域、函数作用域、块级作用域ES6 引入配合 let 和 const 使用。你用 var 声明一个变量它在函数内部可见这叫函数作用域你在函数外面声明一个变量整个脚本都能访问这叫全局作用域你用 let 或 const 在花括号里声明一个变量它只在那个花括号里有效这叫块级作用域。打个比方你的家是全局作用域家里的每个人都能进客厅你的卧室是函数作用域只有你自己能进卧室里的保险柜是块级作用域只有你拿着钥匙站在卧室里才能打开。作用域的本质就是这套“你能进哪个房间”的规则。关键点是作用域是嵌套的内层作用域能看到外层作用域的东西但外层看不到内层。这跟住房子一个道理——你在卧室里能看见客厅的沙发但你站在客厅里看不见卧室保险柜里的存折。还有一个特别重要的特性叫词法作用域Lexical ScopeJavaScript 的作用域是在代码书写时就确定下来的跟函数在哪里调用没关系只跟函数在哪里定义有关系。这句话是理解闭包的钥匙后文会反复用到。1.2 执行上下文每次调函数都是一次“进场”理解了作用域还要搞明白执行上下文Execution Context是什么。作用域描述的是“规则”执行上下文描述的是“当前正在运行的代码所处的工作环境”。每次 JavaScript 引擎开始执行一段代码就会创建一个执行上下文。全局代码有全局执行上下文每个函数被调用时会创建自己的函数执行上下文。这些上下文不是孤立的它们按照调用顺序被压进一个栈里叫执行上下文栈Call Stack / 调用栈。栈顶是当前正在执行的函数函数执行完毕它的上下文就从栈里弹出销毁。我习惯把调用栈理解成食堂打饭的队列第一个窗口是个全局环境你在排队的过程中不断调用各种函数每调一个函数就像往队伍里加一个人当前正在执行的是队伍最后面那个人这个人打完了饭函数返回就从队伍里出去上下文弹出。用一段代码演示function a() { console.log(进入 a); b(); console.log(离开 a); } function b() { console.log(进入 b); } a();执行过程是先创建全局上下文压栈然后调用 a创建 a 的上下文压到栈顶a 里调用 b创建 b 的上下文再压到栈顶b 执行完弹出a 执行完弹出最后全局上下文也在程序结束或关闭页面时退出。这就是调用栈的运行规律。理解调用栈的价值在于闭包之所以“诡异”就是因为正常函数的变量会随着上下文弹出而销毁而闭包却打破了这条规则。至于它怎么打破的一会细说。1.3 垃圾回收没人引用的东西才会被扔掉还有一个总被忽略但非常关键的概念——垃圾回收Garbage Collection。JavaScript 是自动管理内存的语言变量不再被需要时引擎会自动把它占用的内存回收掉。主流的回收机制是“可达性”判断从全局对象出发凡是能被引用链追踪到的值就被认为是“可达的”不能回收一旦某个值不再被任何地方引用它就成了“不可达”的垃圾会被引擎定期清走。打个比方你手机里的相册自动备份功能。你拍照创建变量照片会留在手机里内存被占用如果你把某张照片设为“永不备份”或删除本地文件不再引用云端就不会保留它被回收。但是如果有一个相册文件夹一直引用着这些照片外部引用存在那它们就永远不会被清理。理解垃圾回收是理解闭包为什么会导致变量“活太久”的前提。闭包的本质就是让一组变量通过引用关系“存活”下来哪怕创造它们的外层函数已经执行完毕了。2. 闭包的人话定义函数背着的那个“小背包”前置概念准备好了现在可以正式面对闭包了。闭包Closure到底是个什么东西我用三句话回答你它是函数定义时“出生地点”的一套环境记录。它让这个函数能记住并访问定义时所处作用域的变量。即使定义它的外层函数已经执行结束这套环境也不会被销毁。翻译成人话闭包就是函数背着的一个小背包背包里装着它出生时所在作用域里的变量。函数走到哪背包背到哪里面的东西随拿随用。2.1 一个最小的闭包长什么样光说定义不过瘾上代码。这是全网最简单、最典型的闭包案例function outer() { let count 0; // 外层函数的局部变量 return function inner() { count; // 内层函数访问了外层变量 console.log(count); }; } const counter outer(); counter(); // 1 counter(); // 2 counter(); // 3注意看outer 执行完毕后按照正常逻辑local 变量 count 应该已经被回收了。但我们调用 counter 的时候count 依然存在而且还在自增从 1 到 2 到 3状态被记住了。这就是闭包。inner 函数背着那个“装着 count 的背包”离开了 outer背包没有被扔掉。2.2 官方定义翻译成人话MDN 对闭包的官方定义是闭包是函数和声明该函数的词法环境的组合。这句话读起来很拗口但拆开看其实就是我们上面说的那个小背包“函数”指的就是内部函数也就是你返回出去的那个家伙“词法环境”指的是函数定义位置所能访问到的全部变量集合“组合”指的是这两者被绑在一起函数走到哪环境跟到哪。关键在“词法”这两个字。上一节说过JavaScript 作用域是词法作用域函数能访问哪些变量在定义那一刻就定死了。闭包不过是用一种机制把这个“定死的环境”给保留了。所以闭包形成的条件也很明确三个条件缺一不可要有函数嵌套一个函数在另一个函数内部或者函数体内返回函数内层函数要引用外层函数的局部变量内层函数要在外层函数外部被使用被返回、被存储、被传递等。没有嵌套就没有“环境”可言没有引用外部变量闭包就算形成也没有意义没有被拿到外部去用那这个函数在外层作用域内执行完后自然销毁跟普通函数没有区别。2.3 什么样的函数不构成闭包有对比才有鉴别。看下面这几个例子想一下它们是不是闭包情况一函数 A 内部定义了函数 BB 访问了 A 的变量但 B 只在 A 内部被调用B 没有离开 A。情况二函数 A 返回了函数 B但 B 中没有访问 A 的任何变量。情况三函数 A 返回了函数 BB 访问了 A 的变量且 B 被赋值给一个外部变量。这三种情况里只有情况三是闭包。原因很简单情况一是普通的函数嵌套B 没有离开 A 所在的环境谈不上“背着环境离开”情况二是返回了一个空壳B 不依赖 A 的环境环境自然不需要被保留函数和普通函数无异情况三完美满足三个条件是真正的闭包。判断一个函数是不是闭包就盯住“是否访问了它定义时的外层变量并且被拿到更外层使用”这两点。3. 闭包背后的机制作用域链、自由变量和引用保存现在你已经知道闭包“是什么”了但面试官不会只满足于这个。你要是能把闭包的底层机制讲清楚才算是真正学透。这一节我们来拆解闭包内部那点事。3.1 函数在定义的那一刻已经“记住”了它的出生地JavaScript 引擎在执行到函数定义语句的时候会创建一个函数对象。这个函数对象内部有一个隐藏属性叫[[Environment]]也写作[[Scope]]它指向当前这个函数定义位置所处的词法环境。直白点说函数一出生就自带了“出生地的地图”。这个地图就是它能看到的所有变量层级关系也就是作用域链的雏形。举个例子function outer() { const name 张三; return function inner() { console.log(name); }; }在执行function inner定义的那一刻引擎创建了一个函数对象[[Environment]]指向 outer 的词法环境。在这个环境里有变量name。所以当后来任何地方调用 inner 时它看图找路先找自己的局部变量找不到就去出生地环境里找终于在name那里找到了值打印出“张三”。这就是词法作用域在底层的真实体现函数能找到哪些变量是定义时的一张“地图”说了算而不是调用时。3.2 outer 都执行完了它的变量怎么还活着这是闭包最关键的一个问题。要解释清楚得回到”垃圾回收“那一节。正常情况执行上下文栈是这样运作的调用 outerouter 的上下文入栈它的局部变量被创建outer 执行到最后返回 inner 函数outer 上下文出栈局部变量理论上应该被销毁。但在闭包里发生了点不一样的事outer 的局部变量count被 inner 函数引用了。当 outer 返回 inner 时inner 的[[Environment]]还在引用着 outer 的词法环境。所以垃圾回收器一扫描哦这个环境虽然已经“出栈”了但它还被 inner 这个函数对象引用着。inner 又是可达的它被存到全局变量 counter 里了。那么整个引用链就是通的全局变量 counter → inner 函数对象 → outer 的词法环境 → 变量 count。这条链路里所有东西都是可达的都不能被回收。于是 outer 的环境就“活”下来了。count 没有随着 outer 的结束而消失它变成了闭包背包里的一件行李由 inner 全权保管。用一句话总结闭包让一个函数把它的“出生地环境”带在了身上只要这个函数还活着那个环境就不会被扔掉。3.3 存的是引用不是快照很多人容易在这里犯迷糊闭包里的变量到底是被拷贝了一份还是被原样引用答案是——引用不是快照。闭包保存的是变量的引用不是当时的值的拷贝。为了验证这一点看下面这个例子function outer() { let num 100; const obj { name: 初始 }; return { getNum() { return num; }, setNum(val) { num val; }, getObj() { return obj; } }; } const api outer(); console.log(api.getNum()); // 100 api.setNum(999); // 修改的是闭包里的 num console.log(api.getNum()); // 999说明引用的还是同一个变量 console.log(api.getObj().name); // 初始 api.getObj().name 改过了;这里能够通过闭包修改并重新读取同一个num如果保存的是值的快照那修改后读到的应该还是改之前的值。正是因为保存的是引用每次访问都是那条通往原始变量的真链路所以外部能通过闭包函数间接地“读写”外层函数的局部变量。这个特性在实战中极其重要很多闭包应用比如计数器、状态管理吃的就是这一口饭。但反过来它也意味着如果外层变量的值一直在变你通过闭包读到的是最新值而不是某个时刻的“遗留值”。这也是引出后面“循环陷阱”的伏笔。4. 实战场景闭包在前端项目里干过的那些活儿闭包不是只活在教科书和面试题里的概念它是实实在在干活的。这一节我把闭包最高频的四个实际应用场景拉出来遛一遛每个场景都配上生产环境里能直接用的代码。4.1 防抖和节流用闭包给定时器“排队”前端开发中最常用的闭包案例非防抖debounce和节流throttle莫属。它们的原理非常直观闭包作为“计时器收纳箱”把 setTimeout 的返回值定时器 ID装在背包里下次触发时先检查背包里的旧定时器还在不在在就把它清了重新计时。防抖的代码长这样function debounce(fn, delay 300) { let timer null; // 这个 timer 就是背包里的行李 return function (...args) { if (timer) { clearTimeout(timer); // 之前的事件还没执行作废重来 } timer setTimeout(() { fn.apply(this, args); timer null; // 执行完把行李放空下一次重新计 }, delay); }; } // 使用 const handler debounce(() { console.log(输入停止后 300ms 触发一次); }, 300); inputEl.addEventListener(input, handler);节流略有不同它保证一段时间内只执行一次function throttle(fn, interval 300) { let canRun true; return function (...args) { if (!canRun) return; // 背包里还记着“正在冷却”直接跳过 canRun false; setTimeout(() { fn.apply(this, args); canRun true; // 冷却时间结束放行下一次 }, interval); }; }防抖和节流的区别可以概括成一句话防抖是“等等再说手一抖就重新等”节流是“限时限量到点才放行”。搜索框联想用防抖滚动监听用节流这个选择基本不会错。4.2 柯里化把一次大调用拆成小调用柯里化Currying是函数式编程里一个经典技巧本质也是闭包的绝佳应用。它的思路是让一个多参数函数变成一系列单参数函数每次接收一个参数返回一个新的函数继续接收下一个参数直到参数凑齐才真正执行计算。写一个最简单的加法柯里化function add(a) { return function (b) { return function (c) { return a b c; }; }; } console.log(add(1)(2)(3)); // 6你观察一下每层函数都背着上一个函数的参数a被第二层函数闭包引用a和b一起被第三层函数闭包引用。参数就是这样“一层层存进背包”的等到最后一层把背包里的所有行李a、b、c一次性拿出来用。在生产环境里柯里化可以用来做参数复用的函数工厂。比如同一个请求基地址给它传不同的路径返回不同接口的请求函数function request(baseURL) { return function (path) { return fetch(baseURL path).then((res) res.json()); }; } const api request(https://api.example.com); api(/users); // 请求 https://api.example.com/users api(/articles); // 请求 https://api.example.com/articlesbaseURL被闭包记住后面的每个接口函数都不用重复传它了。这种“先固定一部分参数生成一个新函数”的模式在代码复用层面特别实用。4.3 私有变量与模块模式对象里藏一个“秘密抽屉”JavaScript 在一门语言层面没有真正的“私有变量”关键字虽然#出现了但兼容性和场景都有限。在很长一段时间里闭包就是实现“私有性”的标准答案。思路很简单把变量放在外层函数作用域里只暴露专门的操作函数给外部。外部能通过函数读写变量但无法直接触碰变量本体。function createCounter() { let count 0; // 这个变量就是抽屉里的秘密外面直接访问不到 return { increment() { count; return count; }, decrement() { count--; return count; }, getCount() { return count; }, reset() { count 0; } }; } const myCounter createCounter(); myCounter.increment(); myCounter.increment(); console.log(myCounter.getCount()); // 2 console.log(myCounter.count); // undefined外面看不到它这种模式叫模块模式Module Pattern在全局环境污染横行的年代这种方式被广泛用于封装组件、插件和 SDK 内部状态。直到现在你用 Vue 的 composition API 或者 React 的 useState底层思路都跟它有千丝万缕的联系——状态被“关”在函数作用域里只有通过暴露的接口才能读写。4.4 闭包做缓存把计算结果装进背包里闭包还可以当作缓存容器用。有些计算很重的函数我们希望同样的入参只算一次后续直接返回提前存好的结果。这个思路叫记忆化Memoization用一个闭包就能实现function memoize(fn) { const cache {}; // 这个 cache 就是背包里的储物格 return function (...args) { const key JSON.stringify(args); if (key in cache) { console.log(命中缓存, key); return cache[key]; } const result fn.apply(this, args); cache[key] result; return result; }; } // 一个很“笨重”的函数做实验 function slowSquare(n) { console.log(正在计算, n, 的平方……); return n * n; } const memoizedSquare memoize(slowSquare); console.log(memoizedSquare(4)); // 计算一次 console.log(memoizedSquare(4)); // 直接返回缓存 console.log(memoizedSquare(5)); // 新参数重新计算cache藏在闭包里外部函数每次调用都先翻背包有就直接用。这种优化在递归类算法比如斐波那契数列里效果特别夸张能把指数级的时间复杂度优化成近似线性。你以后要是遇到计算量大的纯函数先想到闭包缓存大概率能省不少性能优化的功夫。5. 闭包的经典坑与面试题循环变量、内存泄漏和性能闭包用起来很香但它也有几个非常容易踩的坑这几个坑几乎是面试必问。我把它们一个个拆开来分析代码、原因、解法都放在一起说清楚。5.1 循环 闭包为什么全是同一个值面试和日常开发中出现频率最高的闭包“坑”就是 for 循环配合 setTimeout 输出同一值的问题。经典题长这样for (var i 0; i 5; i) { setTimeout(function () { console.log(i); }, 1000); } // 输出5 5 5 5 5很多人预期输出 0 1 2 3 4结果全是 5。原因拆开来看其实不复杂var声明的i是函数作用域或全局作用域的变量整个 for 循环共用同一个i。循环创建了 5 个匿名函数这 5 个函数都形成了闭包它们背着的“出生地环境”都是同一个全局/函数环境里面的i都是同一个绑定。循环执行完毕后i已经变成了 5。延迟到 1 秒后5 个函数被依次执行每个都去同一个环境里读同一个i自然全读到 5。记住我们前面说的闭包存的是引用不是快照。这 5 个闭包共享同一条名叫i的引用而不是各存各的副本。这是输出 5 5 5 5 5 的根本原因。对应的解决方案有三种原理都是“给每个闭包一个独立的环境”解法代码原理立即执行函数IIFEfor (var i 0; i 5; i) { (function (j) { setTimeout(() console.log(j), 1000); })(i); }每轮循环创建一个新的函数作用域参数j被立即传入形成 5 个独立环境把 var 换成 letfor (let i 0; i 5; i) { setTimeout(() console.log(i), 1000); }let 是块级作用域每轮循环都产生一个新的绑定闭包各自记住不同的 i提取成单独函数定义一个function log(i) { setTimeout(() console.log(i), 1000); }循环里调用每次调用 log 都创建独立上下文参数 i 被保存在那个上下文里三种方法里直接把var换成let是最省事的这也是 ES6 出现后此类问题大幅缓解的原因。但理解“闭包共享引用”这个本质依然很重要因为面试官极有可能让你把let换回var再问你怎么解。5.2 内存泄漏闭包不是凶手管不住的引用才是“闭包会导致内存泄漏”这句话在技术圈流传很广但它其实只说对了一半。闭包本身不会主动造成内存泄漏内存泄漏的本质是某些变量因为闭包被持续引用但你已经不再需要它们了它们却仍然活着。典型场景是事件监听。比如你在一个组件里给 DOM 绑定了闭包回调function setup() { const largeData new Array(1000000).fill(占用内存的数据); window.addEventListener(scroll, function () { console.log(还在滚动); }); }这个匿名回调函数形成了闭包它引用了largeData。如果setup只调用一次就不再使用但scroll监听一直挂载在 window 上那么largeData就会被一直引用永远无法被回收。即便你本意是展示一条日志整个数组却因为闭包链路被强行留在内存里页面滚着滚着越来越卡。判断是否泄漏的标准很简单闭包里引用的变量你是否还真的需要它如果不需要了就要主动“断开引用”。常见的清理手段有解绑事件监听window.removeEventListener(scroll, handler)把不再使用的闭包函数手动置为null清空定时器clearTimeout(timer)/clearInterval(interval)在用 Vue / React 这类框架时在组件的卸载钩子onUnmounted / useEffect 的清理函数里做上述操作。排查内存泄漏的实际操作我会打开 Chrome 开发者工具的 Memory 面板点击“Heap snapshot”连续采样多份堆快照重点看那些一直存在的对象再根据它们的引用链找到是哪个闭包把它挂住了。前面讲的[[Environment]]在设计工具里能直接展开查看非常直观。5.3 关于性能别把锅扣在闭包头上与内存泄漏相关的另一个说法是“闭包性能差”。这个说法同样需要辩证地看。闭包引入的性能问题主要出现在两方面一是创建闭包的成本。每个闭包都会额外保存一份词法环境如果在一个大型循环里同步创建大量闭包函数确实会带来额外的内存占用和 GC垃圾回收压力。比如// 不好的实践循环里反复创建闭包 for (let i 0; i 10000; i) { const fn generateHandler(i); someElements[i].addEventListener(click, fn); }这种场景应该采用事件委托把事件监听挂到共同的父节点上利用事件冒泡做分发避免为每个元素都创建一个独立闭包。二是持有不必要的大对象。闭包的环境里如果存放着超大对象而这个闭包又长命百岁地活着这部分内存就很难被释放。但反过来说只要合理使用闭包带来的性能收益远大于它的开销。防抖节流减少高频调用、缓存避免重复计算这些都是闭包帮忙做的。很多对闭包性能的恐惧实际来自对它认知的模糊真用好了它反而是优化性能的利器。6. 把闭包放进更大的地图let、箭头函数、IIFE 和事件循环闭包不是一个孤岛。把它跟 JS 里的其他重要概念摆在一起看你会发现很多知识其实是一张网上的节点串联起来记忆学起来轻松得多也不容易忘。6.1 let 凭什么解决了循环闭包问题前面提到 let 能解决循环闭包问题但背后的原因值得多想一步。let跟var在作用域模型上的核心区别不是简单的“能不能重声明”而是let 是块级作用域并且 for 循环的每一轮迭代都会创建一个新的词法绑定环境。这个对 for 循环的“特别关照”是 ES6 规范里明确规定的每次迭代都会用上一轮迭代的最终值作为新迭代的初始值创建独立环境。所以for (let i 0; i 5; i)这行代码里i被创建了 5 份每份对应一轮迭代。5 个 setTimeout 闭包各自引用自己那一轮的i互不干扰输出自然是 0 1 2 3 4。这个机制如果你觉得抽象可以手动模拟一下把 let 的“每轮新绑定”想象成每轮循环内部自动执行了一次(function(i) { ... })(i)原理是一模一样的。知道了这一点你才算真正理解了为什么 let 能解决 var 时代的老大难。6.2 IIFE利用闭包隔离作用域的老牌方案IIFEImmediately Invoked Function Expression立即执行函数表达式是一种语法形式声明一个函数表达式并用括号包起来后面紧跟一组括号立即调用。最常用的形态是(function () { var privateVal 我是局部的; console.log(privateVal); })();IIFE 的本质就是“创建函数作用域 立刻执行”。它跟闭包的关系是IIFE 创建了一个独立的作用域任何在这个作用域里创建的函数都能通过闭包访问私有变量。在很多老代码库和源码里你会看到模块封装、插件的自执行函数靠的全是这一招。比如早年模拟模块、防止变量污染全局var MyModule (function () { let times 0; return { click() { times; console.log(第, times, 次点击); } }; })(); MyModule.click(); // 第 1 次点击 MyModule.click(); // 第 2 次点击IIFE 顺便还解决了一个问题它让外部只能访问到 MyModule 上返回的那几个方法times被妥妥地藏在闭包里。这种包一层立即调用的写法现在虽然被 const 块级作用域替代得七七八八但它的思想在模块化历史里留下了极深的印记。6.3 箭头函数里的闭包注意 this 是“继承”的箭头函数Arrow Function跟闭包打交道时有个独特的点它自己没有独立的 this、arguments、super、new.targetthis 是从定义时所在的外层作用域继承的。这个特性在闭包里格外重要。看一个很常见的坑const obj { name: 张三, getName() { setTimeout(function () { console.log(this.name); // 报错或输出 undefined因为 this 指向 window/undefined }, 1000); } };普通函数的 this 是动态的在 setTimeout 里调函数this 绑定到了 setTimeout 的调用方指向全局或 undefined。解决方式之一就是改用箭头函数const obj { name: 张三, getName() { setTimeout(() { console.log(this.name); // 张三箭头函数定义在 getName 里this 继承自 obj.getName 的 this }, 1000); } };箭头函数定义的位置在getName方法内部而getName的 this 指向 obj所以箭头函数继承到的 this 也指向 obj。这跟闭包其实没有直接关系但它俩经常同时出现——你想想一个函数在 getNname 内部返回并被 setTimeout 延迟调用这个函数访问了外部 this它本身就是一个闭包。所以用箭头函数写闭包的时候this 的“继承”特性会让代码节省大量var that this之类的补丁代码。6.4 一段闭包 Event Loop 的经典代码串讲把闭包放进事件循环Event Loop里看能理解得更透彻。看一道综合题for (var i 0; i 3; i) { setTimeout(() { console.log(i); }, 0); } // 输出3 3 3用事件循环来解释for 循环是同步任务一口气跑完此时 i 已经变成 3三个 setTimeout 回调被塞进宏任务队列。同步代码执行完毕后事件循环才开始处理队列里的回调。等三个回调执行时它们各自读闭包里的 i读到的都是同一个已经变成 3 的变量。这里体现了开篇讲的两条核心原则闭包保存的是引用不是快照所以读到的永远是那个变量的最新值事件循环先跑完同步代码再执行异步回调异步回调执行时for 循环早结束了。把这两条规则结合5 个 5 也好、3 个 3 也好都是必然结果。这也解释了为什么把 var 换成 let 后就能输出预期结果——不仅仅是闭包问题被解决了更是“每一轮迭代的绑定都被独立保存”这个机制在处理异步任务时真正发挥了作用。这类综合题在面试里非常常见因为它一次性考了作用域、闭包、事件循环三个知识点任何一环概念模糊都会答错。你只要能用自己的话把“引用共享”和“事件循环顺序”讲清楚这道题基本就稳了。闭包这个东西你第一次遇到它觉得玄妙觉得像魔术等你把它背后的“词法环境、引用保存、作用域链”彻底吃透后你会发现它一点也不玄它就是 JavaScript 这门语言对“词法作用域”的一种自然延伸。我个人学下来的最大体会是学闭包不要死记定义要用“背包”这个模型去想象函数被传递的过程。每定义一个函数就给它背一个背包里面装着它出生时能看到的变量。函数传到哪里背包就跟到哪里。碰到循环就想想所有函数是不是共享了同一个背包碰到事件循环就想想异步回调什么时候才真正从背包里取东西。最后再分享一个我常用的学习方法在浏览器里写上面任何一个例子在调用闭包函数的地方打一个debugger打开 DevTools 的 Sources 面板执行到那一行时右侧的 Scope 一栏会清清楚楚地列出 Closure 里面到底存了哪些变量哪个变量是引用当前值是多少。眼见为实地看一次比你读十篇文章都管用。