
很多前端老哥在简历里写“熟练掌握闭包”结果面试官一问“闭包是什么”回答就卡在“函数套函数返回函数”这个层面。再追问一句“闭包到底捕获了什么”基本就露馅了。其实不怪大家闭包这个概念确实有点绕因为它牵扯到作用域、执行上下文、垃圾回收好几块东西哪一块没打通理解就会卡壳。这篇文章我就把这套东西彻底讲透。咱们从作用域体系讲起把闭包的本质拆开揉碎再聊到箭头函数对闭包的影响最后用几道经典面试题和内存泄漏排查案例收尾。不管你是刚接触 JavaScript 的新手还是被闭包折磨过的“半熟手”这篇文章都能帮你把这块硬骨头啃下来。1. 作用域体系闭包的地基闭包这东西说白了离不开作用域。连作用域都没搞明白就去啃闭包那等于没打地基就盖楼看着能立起来风一吹就塌。所以咱们先把作用域这块地基夯实了再往上盖闭包这层楼。1.1 全局作用域、函数作用域与块级作用域JavaScript 的作用域体系经历了几个阶段才变成今天这个样子。最早只有全局作用域和函数作用域后来 ES6 引入了 let 和 const才补上了块级作用域这块短板。全局作用域比较好理解就是代码最外层任何地方都能访问到。在浏览器里全局作用域里用 var 声明的变量会挂在 window 对象上函数也是。这也是为什么全局变量容易被污染的原因——大家都在用同一个顶层的坑位你放个 data我也放个 data后定义的就把先定义的盖了。函数作用域就有点意思了。JavaScript 每个函数都会创建一个自己的作用域函数里声明的变量外面是拿不到的。function foo() { var a 10; console.log(a); // 10 } console.log(typeof a); // undefined外面访问不到块级作用域是 ES6 新增的。let 和 const 声明的变量会被限制在代码块花括号内部。这个特性对闭包影响很大当年用 var 写循环闭包那是踩坑重灾区后面我会专门展开讲。1.2 词法作用域JavaScript 是“写代码时”就定好了规则词法作用域lexical scoping这个词听着唬人其实就是说变量是属于哪个作用域的在你写代码的时候就已经定死了跟运行时的调用方式没关系。我举个例子你就明白了var x outer; function outer() { var x inner; function inner() { console.log(x); } return inner; } var fn outer(); fn(); // 输出什么答案是“inner”。因为 inner 函数定义在 outer 函数内部它词法上处于 outer 的作用域之内所以它访问 x 时先从自己的作用域找找不到就往外层找找到 outer 里的 x。这个过程叫作用域链查找。注意一个关键点这段代码最后把 inner 拿出去在全局作用域调用但它访问的还是 outer 里的 x。也就是说函数在“出生地”能访问的东西在它被“带走”之后依然能访问——这就是闭包存在的前提。如果 JavaScript 用的是动态作用域运行时根据调用栈决定变量归属那闭包这个概念就根本不会存在了。1.3 作用域链JavaScript 引擎是如何查找变量的每次执行一个函数引擎会创建一个执行上下文。这个上下文里面有一个词法环境词法环境里有两个部分环境记录存变量和函数声明和对外部词法环境的引用outer reference。这个链是这样的函数内部先找自己的环境记录。找不到顺着 outer reference 跳到外层函数的词法环境。再找不到继续往外跳直到全局环境。全局也找不到严格模式直接报 ReferenceError非严格模式返回 undefined。这条链有个特点它是单向的从内向外。内层能看到外层外层永远看不到内层。就像你站在公司工位上能看到整个办公区的动静但在老板办公室门口路过你是不可能知道里面在聊什么的。这个特性决定了闭包能做很多事但它也带来性能开销——作用域链查询比局部变量访问要慢。做性能优化时循环体里访问多次外层变量可以先把值缓存到局部变量能省不少查找时间。2. 闭包的本质函数和它的“背包”有句话我说了很多次闭包不是 JavaScript 独有的概念它只是 JavaScript 把它表现得格外明显。理解闭包的本质其实就是理解一件事——函数为什么会“记住”它定义时的环境。2.1 从一个生活场景开始感受闭包在讲技术定义之前先给你讲个生活化的场景。假设你在一个公司开发组工作每人都配了一个带锁的工位柜子这就是函数作用域。你调岗到另一个部门外层函数执行结束返回了原则上你的旧工位柜子应该被清掉留给新同事。但是——你之前写了一半的工作笔记放在柜子里闭包引用的变量而且你的项目经理说了这个柜子暂时锁着别人不许动你自己随时还能回来拿笔记继续用。这就是闭包的实际体验函数不会因为它的外层函数执行完毕就丢失对外层变量的访问权。外层函数的局部变量被“打包”进了一个背包里随函数一起带走。2.2 闭包的定义和工作原理来咱们说正经的。一个闭包有三个要素一个函数函数内部引用了外部变量这个函数存续期间它引用的外部变量不会被垃圾回收。从引擎角度看闭包发生时引擎做了什么第一次“真正”挖到本质得看执行上下文的生命周期。普通情况下函数执行完毕它的环境记录就从执行栈里弹出去局部变量失去引用被垃圾回收。但如果一个内层的函数引用了这些局部变量引擎发现这个内层函数还活着而它依赖的环境不能不在了怎么办引擎的开法是把内层函数引用的变量单独打包一份存到一个叫 closure 的夹层里。这份夹层不受外层函数执行完毕的影响随着内层函数的生命周期走。看个伪代码function createCounter() { let count 0; return function() { count 1; return count; }; } const counter1 createCounter(); console.log(counter1()); // 1 console.log(counter1()); // 2 const counter2 createCounter(); console.log(counter2()); // 1 —— 注意counter2 是全新的一份闭包这里的关键点counter1 和 counter2 各持有一份独立的 count。createCounter 执行完它的“函数体”确实销毁了但 count 没有被销毁——因为返回的匿名函数引用着它。这就是闭包在工作函数把它们定义时的环境记录一并在“背包”里带走了。2.3 词法环境 vs 执行上下文再往深处挖一步。很多人在闭包上卡住就是没分清两个词——词法环境lexical environment和执行上下文execution context。执行上下文是运行时概念函数每次调用都会创建一个新的执行上下文里面装着参数、局部变量、this、arguments 等。词法环境是静态概念在代码定义时就确定了。它描述的是“这段代码能访问哪些变量”。执行上下文在函数执行完就销毁了但词法环境不一定。闭包就是在函数定义时创建、在执行上下文销毁后依然被函数引用的那一份词法环境。打个比方执行上下文是“工作状态”下班了就没了词法环境是“工作关系”你换公司了旧同事的关系网还在想联系随时能联系上。2.4 “闭包”这个名字为什么容易误导人说实话中文“闭包”这个词翻译得确实容易让人懵——什么包包什么“闭包”的英文是 closure源自 lambda 演算中的 “closed expression” 概念一个表达式如果它的自由变量都已经绑定就是“闭合”的。也就是说闭包指的是“把自由变量绑进环境里形成一个封装的整体”。所以闭包的本质是“环境 函数”的组合并不是说闭包就是函数本体。你可以这样记闭包 函数 它出生时的环境。带着这个公式看任意闭包代码都不虚。3. Lambda 语法与闭包的本质关系热词里提到“lambda语法和闭包本质”这俩确实是老搭档。很多语言里实现 lambda 的过程就是实现闭包的过程。JavaScript 里的箭头函数就是 lambda 的本土化表达。3.1 箭头函数天生就是闭包好伙伴先看一段代码const createLogger (prefix) { return (message) { console.log([${prefix}] ${message}); }; }; const warn createLogger(警告); const error createLogger(错误); warn(磁盘空间不足); error(程序崩溃);我直接用箭头函数写完全没问题吧是因为箭头函数跟普通函数在闭包能力上是一致的——它们都能捕获外部变量。而且箭头函数捕获的字段里this 也是按词法规则来的上面这段代码里箭头函数没有自己的 this所以 this 沿用外层 createLogger 的 this而prefix就是通过闭包捕获的。箭头函数的优势在于语法更紧凑表达式更函数式。写高层次的函数作为“参数”箭头函数几乎是默认选择。3.2 函数是一等公民函数也是值Lambda 语法的核心体验是函数可以当作一个普通值传来传去。什么叫一等公民就是函数能像数字、字符串一样被赋值给变量、被作为参数传递、被作为返回值返回。// 把函数当参数传 function execute(fn, ...args) { return fn(...args); } // 把函数当返回值返回 function mutiplyBy(times) { return function(num) { return num * times; }; } const double mutiplyBy(2); console.log(execute(double, 21)); // 42这个能力跟闭包有什么关系关系大了去了——因为如果函数不能作为值传递那函数“带着自己的环境跑路”这个操作也就无从谈起。正因为函数是值你可以把它处成变量、放进数组、塞进对象而不管它走到哪里它都带着自己的背包词法环境所以闭包才真正发挥出了威力。3.3 箭头函数与普通函数闭包的时代取舍箭头函数确实很多好处但在闭包这个语境里设计里也不是没有代价。普通函数动态定义 this根据调用方式箭头函数根本不创建 this ——它在闭包里沿用了外层作用域的 this。这在很多回调场景中是利器因为你不必再写var that this或者.bind(this)。但如果在某些需要动态上下文的场景比如原型方法、arguments、构造函数里用箭头函数就会碰到坑。看这个const obj { num: 42, getNum: () { return this.num; // this 指向 window/undefined而不是 obj }, }; console.log(obj.getNum()); // undefined箭头函数因为闭包 this永远拿不到 obj 上下文的 this。所以不是说箭头函数“取代”了普通函数而是你要想清楚这个场景需不需要动态 this需不需要 arguments需不需要作为构造函数。想清楚了用起来自然顺手。用一句话总结箭头函数是 lambda 风味的函数但它的闭包捕获的是词法 this这是一个非常有用的特性也带来了一些限制。4. 闭包的招牌应用场景理论讲多了容易晕咱们看点实际的。闭包到底为什么值得学因为它能解决真实问题。我挑了三个最常见的应用场景每个都能直接用在实际项目里。4.1 数据私有化闭包在封装中的应用以前 JavaScript 没有 # 私有字段要让数据不被外部访问最常见的办法就是用闭包包一层。举个例子实现一个计数器function createCounter(initial 0) { let count initial; return { increment() { count 1; return count; }, decrement() { count - 1; return count; }, get() { return count; }, }; } const counter createCounter(10); console.log(counter.increment()); // 11 console.log(counter.decrement()); // 10 console.log(counter.count); // undefined —— 外部拿不到内部变量这里 count 就像一个“私有变量”外部只能通过暴露的方法操作直接改不了。为什么这会是有价值的因为你可以控制对数据的访问口径限制范围、做校验、记录操作日志。在实际项目里这个模式的典型应用是封装状态管理器。比如一个简单的事件订阅发布器function createStore(initial {}) { let state initial; const listeners new Set(); function getState() { return state; } function setState(next) { state { ...state, ...next }; listeners.forEach((fn) fn(state)); } function subscribe(fn) { listeners.add(fn); return () listeners.delete(fn); // 返回一个取消订阅的函数 } return { getState, setState, subscribe }; }不用类、不用 class全闭包实现。state 和 listeners 完全私有外部只有三个方法可用。这个模式在小项目里手写一个状态管理清爽不油腻。4.2 部分应用与函数柯里化闭包组合的艺术还记得 earlier 那个mutiplyBy吗那就是闭包做“部分应用”的样子。柯里化currying就是把一个多参数函数拆成一串单参数函数每个函数返回一个新函数接收下一个参数。这需要闭包来记住已经传进去的参数。function curry(fn) { return function curried(...args) { if (args.length fn.length) { return fn.apply(this, args); } return function(...more) { return curried.apply(this, args.concat(more)); }; }; } function sum(a, b, c) { return a b c; } const curriedSum curry(sum); console.log(curriedSum(1)(2)(3)); // 6 console.log(curriedSum(1, 2)(3)); // 6如果你是做业务开发的可能觉得柯里化有点花架子用不上。但如果你写工具库、配置化框架就会发现柯里化的威力把“共性参数”和“个性参数”分离提前固化一部分参数导出更专用的函数。举个例子做一个简单的请求配置const buildRequest (baseURL) (path) (payload) { console.log(POST ${baseURL}${path}, payload); }; const api buildRequest(https://api.example.com); const postUser api(/user); const postOrder api(/order); postUser({ name: Tom }); postOrder({ id: 123 });baseURL 被第一个闭包捕获path 被第二个捕获payload 在最后调用时真正生效。这个模式在很多库内部都大量使用。4.3 回调函数与事件处理闭包让“异步时还能记住上下文”前端的核心就是异步。用户点击按钮、网络请求返回、定时器触发——这些回调都发生在未来的某个时刻。回调吃需要访问“当时”的变量这种“跨越时间”的变量访问靠的就是闭包。function handleOrder(orderId) { console.log(开始处理订单 ${orderId}); setTimeout(() { console.log(订单 ${orderId} 已处理完毕); }, 2000); } handleOrder(1001); handleOrder(1002);两个 setTimeout 回调分别捕获了各自的 orderId两秒后还能各自输出正确的值。这就是闭包最典型的使用场景——在异步时代闭包是上下文“保鲜”的大功臣。再来一个更贴近业务的场景——数据请求库的封装function fetchWrapper(url) { let cache null; let lastFetchTime 0; return async function(force false) { const now Date.now(); if (!force cache now - lastFetchTime 60000) { console.log(使用缓存); return cache; } console.log(发起请求); const data await fetch(url).then((res) res.json()); cache data; lastFetchTime now; return data; }; } const fetchProfile fetchWrapper(/api/profile); // 第一次调用会发请求第二次 60 秒内走缓存 await fetchProfile(); await fetchProfile();cache 和 lastFetchTime 被闭包保护外部碰不到下次请求自动取缓存。这样一个“带缓存”的异步函数就顺手写出来了。闭包在回调场景里能有效解决“异步数据丢失”的问题——调用函数的时候状态还在回调真正执行时状态已经变了用闭包把需要的数据捕获住保证它“跟到底”。5. 闭包的高阶用法与坑闭包用得好是宝剑用不好是暗坑。这一节我把最常见的高阶用法和你可能在测试时踩到的坑一次说清楚。5.1 循环里创建闭包var 的经典面试坑说到闭包的坑必须先祭出一个被无数面试官反复鞭尸的经典问题——循环里的 var。下面这段代码输出什么for (var i 0; i 3; i) { setTimeout(() { console.log(i); }, 100); }答案是3 3 3。不是0 1 2。为什么因为 var 声明的 i 是函数作用域整个循环只用一个 i循环结束后 i 变成了 3。而三个 setTimeout 回调都是闭包它们捕获的是同一个 i执行回调的时刻 i 已经是 3 了。怎么解决有两个经典手段第一种用 let 替换 var。let 每次循环都让回调捕获当前的 ifor (let i 0; i 3; i) { setTimeout(() { console.log(i); // 0 1 2 }, 100); }第二种用函数工厂隔开一个作用域for (var i 0; i 3; i) { (function(index) { setTimeout(() { console.log(index); // 0 1 2 }, 100); })(i); }第二种的本质是每次循环时往外包一层立即执行函数IIFE把当前 i 值作为参数传给一个新的作用域回调闭包捕获的是这个新的局部变量 index而不是外层的 i。这个模式在你不想用 let、或者需要兼容旧环境时非常有用。5.2 人为延迟销毁的陷阱“闭包易爆内存”闭包会把外层变量带在身上这个特点让它可以跨时间跨空间访问但也隐藏着一个内存风险——被闭包引用的变量不会被垃圾回收。看这个例子function attachEvent() { const bigData new Array(1000000).fill(x); const handler () { console.log(clicked, bigData.length); }; document.getElementById(btn).addEventListener(click, handler); } attachEvent();bigData 被 handler 闭包引用而 handler 绑定在 DOM 事件上。哪怕页面再不点击bigData 这块 1 百万长度的数组也不会被回收。如果这个数据确实要一直保留那没办法但如果仅仅为了 event handler 里打印一下 length这是一个纯浪费。解决思路事件解绑用完 removeEventListener。在合适的时机把引用置 null破坏闭包链路。function attachEvent() { const bigData new Array(1000000).fill(x); const handler () { console.log(clicked, bigData.length); }; const btn document.getElementById(btn); btn.addEventListener(click, handler); // 页面卸载时解绑 window.addEventListener(beforeunload, () { btn.removeEventListener(click, handler); }); }5.3 意外的共享状态闭包一不小心就“全局十化”闭包的一个隐藏风险是——“你以为你是新建了一个变量其实是共享同一个”。看这个function createIncrementer() { let value 0; return function() { value 1; return value; }; } const fns []; for (let i 0; i 5; i) { fns.push(createIncrementer()); }这里每个 fns[i] 都是一个新的闭包各自有自己的 value好。但如果写成这样function createIncrementer() { let value 0; return () { value 1; return value; }; } const sharedIncrementer createIncrementer(); const fns2 []; for (let i 0; i 5; i) { fns2.push(sharedIncrementer); } // fns2 全是同一个函数它们共享同一个 value五兄弟共用一个计数器——这可能不是你想要的效果。我见过不少 bug就是因为不小心将同一个闭包函数赋值给了多个地方结果状态被所有“副本”污染了。写循环、写批量注册的时候注意每次生成闭包的函数调用是否生成了新环境。5.4 高级玩法模块模式与 IIFE 的组合拳模块模式是闭包和 IIFE立即执行函数表达式的组合。它的价值在于不用 class不用 this就能创造一组既有公共接口、又有私有数据的模块。const Logger (function() { const logs []; function log(level, message) { const entry { level, message, time: new Date().toISOString() }; logs.push(entry); console.log([${level}], message); } function list() { return logs.slice(); // 返回拷贝避免外部改动内部数组 } function clear() { logs.length 0; } return { log, list, clear }; })(); Logger.log(info, 系统启动); Logger.log(error, 配置缺失); console.log(Logger.list()); // 拷贝的数组 Logger.logs; // undefined外部无法访问 logs这个模式在浏览器端以前广泛用于创建封装库在 Node 里现在用 ES Module 也能做类似的事但闭包的模块模式依然是运行时创建私有状态非常好用的工具。6. 面试题精讲闭包常见考点与回答思路闭包是面试超级高频题面试官几乎必问。这里我整理出最常见的几个考法和回答框架大家背下来不如理解透。6.1 你为什么需要闭包——闭包如何解决“状态保持”问题面试题请解释什么是闭包为什么需要它回答框架闭包就是“函数 它定义时的词法环境”。它解决了两个问题一是函数能够记住它定义时所在的作用域环境保证函数在其他地方执行时依然能访问那些变量二是在 JavaScript 中实现数据私有——通过函数作用域锁住变量只暴露方法。一个可以直接说的例子是计数器function createCounter() { let count 0; return { increment: () count, get: () count }; }这段代码演示了count 在 createCounter 作用域内部外部不能直接访问。increment 和 get 两个函数共享同一个 count。每次调用 createCounter 都会新建一个独立的 count。面试官如果追问“闭包变量会被回收吗”你就说要往下看一眼。6.2 setTimeout 和循环变量的坑面试题这段代码输出什么如何修复for (var i 0; i 3; i) { setTimeout(function() { console.log(i); }, 100); }上文已经说过输出是 3 3 3。修复方案有 let、IIFE 等。这里考察的其实是闭包捕获的是变量本身而不是变量的当前值。闭包的延迟执行 var 固定作用域就是问题根源所以回答时要把这两点结合起来说。6.3 实现一个防抖函数Debounce实战中最常见的闭包演练习题。function debounce(fn, delay 300) { let timer null; return function(...args) { if (timer) clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); timer null; }, delay); }; }考察点timer 是闭包里的私有变量每次调用timer 都保留了上一次 setTimeout 的 id利用了 setTimeout 回调里捕获 args 和 this实现延迟执行时上下文不丢。这个函数在搜索框输入、窗口 resize、按钮防连点等场景下实用价值极高。6.4 实现一个节流函数Throttle防抖考完下一个就是节流。function throttle(fn, interval 300) { let last 0; return function(...args) { const now Date.now(); if (now - last interval) { last now; fn.apply(this, args); } }; }throttle 与 debounce 的区别面试经常对比着问debounce停止触发之后才执行最后一次。throttle固定时间间隔内最多执行一次。两者的本质都有闭包的应用——保存上一次执行的时间/定时器状态供下次判断使用。你能把一个函数手写完成并且说出里面的闭包原理面试基本就稳了。7. 常见问题与排查技巧实录最后这部分我把我在实际项目中排查过的闭包相关 bug 汇总一下希望能帮大家少踩几个坑。7.1 为什么闭包中的变量被意外修改先来看一个经典的业务场景const buttons document.querySelectorAll(button); for (var i 0; i buttons.length; i) { buttons[i].onclick function() { alert(点击了第 i 个按钮); }; }不管你点击哪个按钮弹出的都是“最后一个”。这就是“闭包捕获了共享变量”导致的——onclick 函数捕获的是外层同一个 i循环结束时 i 已经变成了 buttons.length。排查思路将 var 改为 let让 for 循环每次都形成新的块级作用域。或者使用 IIFE 传参为每个回调创建独立的作用域。或者用数据属性直接保存索引buttons[i].dataset.index i。写代码时养成习惯能 let 就不 var。特别是循环中用闭包你几乎想不出一个 var 的正确用法。7.2 闭包内存泄漏很多人问闭包到底会不会导致内存泄漏答案是“未必但确实容易”。闭包本身不泄漏泄漏是因为闭包长活了比如被全局变量持有它的环境也跟着长活。排查方法用 Chrome DevTools 的 Memory 面板拍摄堆快照然后对比前后两张快照中“闭包”和“detached”的数量。如果是大面积 DOM 节点 detached并且闭包中有这些节点的引用那就是典型泄漏。常见的泄漏场景全局监听事件但 handler 里捕获了 DOM 节点又没有解绑。全局缓存 Map 里一直往里面塞数据和闭包从不清理。setTimeout 定时器没有清掉回调还捕获着一大坨数据。按时清理引用是闭包环境下的必备素养。7.3 this 在闭包中为什么丢了有的朋友会在闭包里调外层对象的 this发现 this 变了。这其实跟闭包本身关系不大是函数 this 动态绑定的问题。const obj { name: Tom, greet: function() { setTimeout(function() { console.log(this.name); // undefined }, 100); }, }; obj.greet();setTimeout 里的普通函数this 指向全局对象非严格模式所以 this.name 是 undefined。解决办法用箭头函数箭头函数不创建自己的 this就会捕获外层 greet 里的 this。在外层先把 this 存到变量const self this;闭包里用 self。7.4 闭包变量为什么不是最新的另一个坑闭包里访问变量时拿到的是变量最新值而不是捕获时的值。这经常发生在异步回调里。function fetchData(url) { let processing true; fetch(url).then(() { if (processing) { console.log(正在处理...); } }); processing false; // 这个赋值比回调先执行 }这里 then 回调执行时processing 已经变成 false所以不会打印“正在处理”。这就是闭包捕获的是变量本身而不是变量当前值。如果你想要“捕获那一刻的值”可以用 let 配合函数默认参数或者立即执行一个作用域。fetch(url).then(() { const isProcessing true; // 把需要的状态提前捕获进新作用域 if (isProcessing) { console.log(正在处理...); } });或者用函数参数传值function createCallback(flag) { return () { if (flag) console.log(正在处理...); }; } fetch(url).then(createCallback(processing));7.5 排查技巧小工具与调试习惯真到排查闭包问题我分享几个顺手的小工具和习惯Console 里打印闭包层级你可以在回调里 console.dir(fn)看 [[Scopes]] 属性。Chrome DevTools 会展开一个 Scopes 数组里面能看到闭包里捕获的每个变量和值——这是分析闭包最直观的办法。内存面板对比两次快照拍摄内存快照 → 操作页面 → 再拍快照对比是否多出来大量闭包对象。如果增多很快说明闭包被长生命周期持有需要清理。打日志 断点在闭包执行前打断点在 Sources 面板的 Scope 窗口看闭包的变量值比盲猜靠谱得多。代码审查时注意全局监听器全局对象上挂的事件监听、全局定时器是闭包潜在泄漏的温床代码审查优先扫这两个位置。结尾闭包这个东西看再多教程不如自己踩两回坑。关于理解它的关键我用一句话收尾闭包不是魔法它只是“函数把定义时的环境一并带走了”。你只要记住这句话面试时被问到“闭包是什么”你就说——闭包就是函数和它的词法环境的组合写代码时定义了外层作用域函数被抛到任何地方执行它依然能访问外层变量——能把这个说清楚同时也把 var 循环的坑、this 丢失、内存泄漏这几个问题串进去闭包这块就算真正过关了。剩下的就是去代码里多写、多调试用闭包封装状态用顺手了你会发现这个世界其实很香。