尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

JavaScript闭包从原理到实战:作用域链、内存泄漏与经典面试题全解析

JavaScript闭包从原理到实战:作用域链、内存泄漏与经典面试题全解析 做了这么多年前端面试别人的时候几乎必问闭包被问的时候也几乎每次都得组织一下语言。这玩意说简单也简单说复杂它能延伸出作用域链、垃圾回收、内存泄漏、柯里化、模块化一大堆东西。很多初学者卡在闭包这儿不是因为概念多晦涩而是没找到一个能把“闭包到底是什么”讲清楚的切入点。我尽量用一篇完整的、能直接“抄作业”的思路把闭包从底层原理到实际应用再到面试常考的坑一次讲明白。1. 别背定义先搞清楚闭包为什么存在闭包的英文是Closure中文翻译成“闭包”确实挺绕的。我第一次学的时候教材上写的是“函数和其周围状态的引用组合”背了好多遍也没啥感觉。后来看得多了踩的坑多了才慢慢理解闭包本质上就是“函数 它出生时所在作用域里的变量”打包在一起的东西。1.1 作用域链是理解闭包的第一块基石要理解闭包必须先理解 JavaScript 的作用域机制。JavaScript 的函数在创建的时候会形成一条作用域链。这条链上挂着从当前函数内部到全局环境的每一层变量对象。var a 1; // 全局变量 function outer() { var b 2; // 外部函数的局部变量 function inner() { var c 3; // 内部函数的局部变量 console.log(a b c); // 能访问到所有层级的变量 } inner(); } outer();当inner函数执行到console.log(a b c)的时候JavaScript 引擎会先在inner自己的作用域里找a、b、c。找不到b就往上一层找在outer的作用域里找到了。找不到a再往上找到全局。这就是作用域链的查找规则从内到外一层一层往外找。很多人以为闭包是“内层函数返回出去”才产生的其实不是。只要一个函数引用了外部作用域的变量闭包就已经形成了。只不过当这个函数还在外层函数内部执行的时候你看不出它有什么特别的一旦内层函数被“传递”到了外层函数外面去执行闭包的价值就体现出来了。1.2 一个生活化的类比出差的同事给你留了台电脑想象一下这个场景你在公司工位上工作电脑里存着项目资料。现在你要出差了你跟同事说“我工位电脑上的文件夹里有一份资料你随时可以去取来用。” 同事去你工位用那台电脑不仅能拿到资料还能调用里面的工具。在这个类比里你工位上的电脑就是outer函数的环境同事就是inner函数。就算你这台电脑平时没人用、看起来好像“没人管了”但只要同事还知道它的路径他就能随时去访问里面的资料。闭包就是让一个函数“记住”它出生时的那个环境即使这个环境已经执行完毕了变量也不会被销毁。注意这个类比有一个关键点——同事去访问电脑里的资料不是复制一份带走而是直接访问原电脑里的数据。所以如果你在同事那边修改了资料你原来电脑里的数据也会变。这正好对应了闭包引用的特性内层函数操作的是外层函数作用域里的同一个变量不是副本。2. 闭包的核心机制执行上下文与垃圾回收很多教程讲闭包只讲“函数返回函数”这个形态没讲底层的关键——为什么外层函数执行完了它的变量还能被访问。这就涉及到 JavaScript 引擎的两个核心机制执行上下文的创建和垃圾回收策略。2.1 函数的出生记录比执行结果更持久在 JavaScript 里每次函数执行都会创建一个执行上下文。这个上下文包含了函数声明、参数、变量、this指向等信息存在内存里。关键在于当函数执行完毕后它的执行上下文按理说应该被销毁里面的变量会被垃圾回收器当作“垃圾”清理掉。但如果这个函数的某个内部函数被返回到了外部并且这个内部函数还在引用着外层函数的变量那么垃圾回收器就不会回收这些变量因为它们在“可达对象”的引用链上仍然存在。function createCounter() { var count 0; // 这个 count 按理说在 createCounter 执行完后就该销毁了 return function () { count; // 但这个匿名函数引用了 count return count; }; } var counter createCounter(); console.log(counter()); // 1 console.log(counter()); // 2 console.log(counter()); // 3这段代码里createCounter执行完以后count变量为什么没有被销毁因为createCounter返回的匿名函数还在“惦记”着它。这个匿名函数被赋值给了全局变量counter只要counter还在它引用的count就永远活着。这就是闭包的本质一个函数在创建时捕获了外部作用域的变量形成了持久的引用关系。2.2 闭包不等于内存泄漏但使用不当会“闭包会导致内存泄漏”这句话快被说烂了但严格来说闭包本身不是内存泄漏它只是让变量存活时间变长了。真正的问题是这个变量你明明不需要了但引用它的人还存在。我见过的最典型问题是全局变量不小心捕获了大对象var bigData null; function process() { var largeObj { a: new Array(1000000).fill(a) }; bigData function () { console.log(largeObj); // 这个函数引用了 largeObj }; } process();process执行完后largeObj本应该被销毁但由于全局变量bigData还引用着那个匿名函数而这个函数又引用着largeObj这1百万个字符串就永远留在内存里了。更麻烦的是你在bigData不再需要的时候忘了置空内存就一直被占着。解决办法很简单用完了就断开引用。// 用完后 bigData null;这样匿名函数被释放它引用的largeObj也就能被回收了。实操心得我写代码的时候有一条习惯——如果某个闭包是被全局变量或长生命周期对象持有的一定要在合适的时机手动置空。现代浏览器的垃圾回收已经很智能了但“引用断开”这件事还得程序员自己来。3. 闭包的经典实战场景从数据私有化到柯里化理解了原理接下来看几个真正在业务中高频使用闭包的场景。这些场景不是面试造火箭而是日常开发里天天在用的东西。3.1 数据私有化防抖节流的底层逻辑先看一个最简单的模块化计数器var module (function () { var count 0; // 外部无法直接访问 return { increment: function () { count; return count; }, decrement: function () { count--; return count; }, getCount: function () { return count; } }; })(); console.log(module.getCount()); // 0 module.increment(); module.increment(); console.log(module.getCount()); // 2这里count在 IIFE 的作用域里外部只能通过module.increment/module.decrement/module.getCount操作它没法直接赋值。这就是利用闭包实现了真正的“私有变量”。在原生 JavaScript 里没有private关键字的年代这是实现数据封装的主要手段。防抖和节流是闭包最典型的应用场景。它们依赖的核心就是“闭包保存变量状态”function debounce(fn, delay) { var timer null; // 这个 timer 在多次调用之间一直存在 return function () { var context this; var args arguments; if (timer) { clearTimeout(timer); } timer setTimeout(function () { fn.apply(context, args); timer null; }, delay); }; }每次调用返回的这个匿名函数都能访问到上一次调用时保存在timer里的值。如果没有闭包timer在函数执行完就被销毁了防抖逻辑根本没法写。可以说没有闭包就没有现代前端框架里的很多优化机制。3.2 柯里化函数式编程的基石柯里化Currying指的是把一个接收多个参数的函数转换成一系列接收单一参数的函数。闭包在这里的作用是“记住中间结果”。先看一个最简单的加法柯里化function add(a) { return function (b) { return function (c) { return a b c; }; }; } console.log(add(1)(2)(3)); // 6这看着像花架子但在实际项目中柯里化能极大提升代码复用性。比如现在要写几个不同的请求函数它们共享同一个baseURLfunction createRequest(baseURL) { return function (path, params) { // 这里可以在这个闭包里访问到 baseURL fetch(baseURL path, { method: GET, params: params }); }; } var request createRequest(https://api.example.com); // 之后只需要传路径和参数baseURL 已经被闭包记住了 request(/user, { id: 1 }); request(/post, { id: 2 });这种写法的好处是baseURL只需要配置一次后续每个请求函数都“记住”了它。如果以后要改接口域名只需要改createRequest(新的域名)那一处。3.3 setTimeout 循环陷阱闭包底下的经典翻车现场这是闭包最经典的面试题也是很多新手实际写代码时踩过的坑。先看错误写法for (var i 0; i 5; i) { setTimeout(function () { console.log(i); // 输出什么 }, 100); }答案是输出 5 个 5。很多初学者第一次跑这段代码都懵了。原因在于for循环用var声明的i是一个全局变量循环过程中的 5 个setTimeout回调函数虽然各自的代码看起来一样但它们引用的其实是同一个i。等100毫秒后回头执行的时候i早已经被加到了 5于是回调打印的全是 5。解决办法有很多种最经典的是用闭包“捕获”每次循环时的i值for (var i 0; i 5; i) { (function (j) { setTimeout(function () { console.log(j); // 输出 0, 1, 2, 3, 4 }, 100); })(i); }这里把每次循环的i作为参数j传给 IIFE。j是 IIFE 的局部变量每次循环都会创建一个新的作用域每个setTimeout回调捕获的都是当时传入的j互不干扰。ECMAScript 6 提供了let关键字它引入了块级作用域本质上也是一个“隐式闭包”for (let i 0; i 5; i) { setTimeout(function () { console.log(i); // 输出 0, 1, 2, 3, 4 }, 100); }注意let解决的思路和 IIFE 不一样它是在语言层面让每次循环的i都是一个独立的绑定。但底层实现的逻辑依然依赖作用域捕获理解 IIFE 写法能让你更清楚生态演化背后的原因。4. 闭包和内存回收的纠葛什么时候该断开引用上面已经聊了闭包和垃圾回收的关系这个环节再深入一些因为这是实际项目里最容易出现性能问题的部分。4.1 JavaScript 垃圾回收机制中的可达性分析现代 JavaScript 引擎V8、SpiderMonkey 等都使用“标记-清除”算法来回收内存。简单来说垃圾回收器会从一组根对象如全局对象、当前执行上下文中的局部变量出发递归地遍历所有被引用的对象。能够到达的对象会被标记为“存活”无法到达的对象就会被回收。闭包中的变量是否被回收完全取决于这个“引用是否可达”。看下面这个例子function makeClosure() { var hugeData new Array(1000000).fill(x); return function () { // 这里没引用 hugeData console.log(hello); }; } var fn makeClosure();在这个例子里makeClosure返回的函数并没有引用hugeData但在旧版引擎里hugeData依然会被保留因为引擎无法精确分析闭包引用了哪些变量只能把整个环境都保留下来。不过在 V8 较新的版本中引擎优化后这部分已经做了细化处理。而在实际项目中我们应该主动避免“非必要的大对象进闭包”// 不推荐闭包可能会长期持有大数组 function loopAndSet() { var items new Array(1000000); return function () { document.getElementById(btn).addEventListener(click, function () { // 业务逻辑并不需要 items }); }; } // 推荐把大对象隔离在闭包外面 function loopAndSet() { var items new Array(1000000); // 只返回业务函数避免把 items 暴露到事件监听器 return function () { // 不需要引用 items }; }4.2 事件监听器和闭包的内存陷阱最常见的内存泄漏场景之一是在循环里给 DOM 元素绑定事件监听器闭包又引用了 DOM 元素自身形成循环引用。var buttons document.querySelectorAll(.btn); for (var i 0; i buttons.length; i) { buttons[i].addEventListener(click, function () { console.log(按钮被点击了 i 次); }); }更隐蔽的问题在于事件监听器存放在 DOM 元素内部而 DOM 元素又被闭包引用形成了一条从 DOM 到闭包又回到 DOM 的引用环。在老版本 IE 里这是内存泄漏的经典源头现代浏览器已经能处理这种情况了但如果你长期持有 DOM 的引用问题依然存在。一个自查经验如果需要列表有很多按钮绑定事件时尽量使用事件委托用一个处理器管理所有按钮而不是每个按钮单独绑定闭包。闭包本身不是问题太多长生命周期的闭包才是问题。4.3 怎么在不影响功能的前提下减少闭包滥用闭包是个利器但不是越多越好。我给自己定过几个规矩分享给大家参考局部函数优先如果一个函数不需要跨作用域访问外部变量就不要把它定义在另一个函数内部。否则每次外部函数执行都会创建一个新的函数对象内存消耗和 GC 压力都会增大。及时清理大对象引用闭包引用的外部变量如果是大型数据如数组、DOM 集合、图表实例用完要主动置空。善用模块模式而不是到处定义闭包能通过模块封装少量函数解决的问题不要创建几十个独立的闭包。场景是否适合用闭包原因防抖、节流非常适合需要跨多次调用保存定时器状态柯里化、偏函数很适合需要提前保存参数缓存工具很适合需要保存计算结果避免重复计算循环事件绑定不适合需委托每个循环都会创建函数对象性能开销大大量临时函数不适合会造成额外的 GC 压力和函数对象创建5. 实战训练用闭包写一个带缓存的数据请求模块原理和坑都讲完了用一个完整的实战例子来把闭包串起来。这个例子是日常开发中非常常见的需求封装一个带缓存的数据请求函数同一个参数在短时间内重复请求时直接返回缓存结果避免重复发送网络请求。5.1 需求拆解和方案设计场景是这样的页面里多个模块需要请求用户信息如果用户信息已经请求过就直接用缓存的结果不用每次刷新都重新请求。用闭包来实现这个思路function createRequestWithCache(cacheTime) { var cache {}; // 缓存对象所有通过 createRequestWithCache 返回的函数共享 var lastRequestTime {}; return function (url, params) { var key JSON.stringify({ url: url, params: params }); var now Date.now(); // 有缓存且没超过缓存时间直接返回 if (cache[key] now - lastRequestTime[key] cacheTime) { return Promise.resolve(cache[key]); } // 没有缓存或缓存过期发起请求 return fetch(url, { method: GET, params: params }).then(function (res) { return res.json(); }).then(function (data) { cache[key] data; lastRequestTime[key] now; return data; }); }; } // 创建一个缓存时间为 5 分钟的请求函数 var requestWithCache createRequestWithCache(5 * 60 * 1000); requestWithCache(https://api.example.com/user, { id: 1 }).then(function (data) { console.log(第一次请求, data); }); requestWithCache(https://api.example.com/user, { id: 1 }).then(function (data) { console.log(第二次请求命中缓存, data); });这个模块的核心价值在于cache和lastRequestTime被闭包保存外部完全没法直接访问只能通过requestWithCache返回的函数间接操作数据安全性好。所有通过createRequestWithCache创建的函数共享同一个缓存池不会因为多次调用同一个接口而重复请求。缓存时间统一配置灵活可调。5.2 进一步优化支持手动清缓存和并发请求合并上面的例子已经能解决重复请求问题了但在高并发场景下还有一个问题如果同一个请求同时发出去了很多次而第一次请求还没返回后面全部都会发起重复请求。可以在闭包里存一个 Promise实现请求合并function createSmartRequest(cacheTime) { var cache {}; var pending {}; // 存放正在进行的 Promise return function (url, params) { var key JSON.stringify({ url: url, params: params }); if (cache[key]) { return Promise.resolve(cache[key]); } // 如果同样的请求已经在进行中直接复用那个 Promise if (pending[key]) { return pending[key]; } var promise fetch(url, { method: GET, params: params }) .then(function (res) { return res.json(); }) .then(function (data) { cache[key] data; delete pending[key]; // 请求完成后移除 pending 标记 // 定时清除缓存 setTimeout(function () { delete cache[key]; }, cacheTime); return data; }) .catch(function (err) { delete pending[key]; // 失败也要移除 pending否则下次请求永远不会发出去 throw err; }); pending[key] promise; return pending[key]; }; }这里的pending对象就是靠闭包在多个请求之间共享状态。没有闭包这种跨调用共享内存的逻辑写起来会非常别扭。5.3 完整的代码走读和注意点这段代码里有几个细节值得注意pending[key]存的是整个 Promise而不是null。这样如果第一个请求还没完成第二个请求直接返回同一个 Promise相当于两个调用共享同一个网络请求的结果。如果你把它存成“是否在请求中”的布尔值第二个请求不会拿到第一个请求的结果还得自己再等一遍。.catch里必须delete pending[key]。如果请求失败不清理后续所有同样参数的请求都会永远返回同一个失败的 Promise永远不会再发真实请求。定时清缓存用了setTimeout而不是一个全局的定时器轮询。这样每个接口缓存过期时间是独立的互不干扰。实操心得这个“带缓存 请求合并”的函数模板我用了很多年几乎每个前后端项目都能用上。它展示了闭包最实用的价值——把状态“关”在函数里面只暴露你需要的操作接口。如果你能把这段代码讲清楚面试基本就能过。6. 闭包的进阶拓展模块化、函数式编程与 JS 引擎优化闭包不只是面试题它在工程上还有更深层次的应用而且和现代 JavaScript 的发展密切相关。6.1 闭包如何驱动了模块化模式在没有 ES Module 和 CommonJS 的时代闭包是前端实现模块化的核心手段。通过 IIFE 加上闭包可以模拟命名空间和数据私有化var MyModule (function () { var privateVariable 我是私有的; function privateMethod() { console.log(我是私有方法); } return { publicMethod: function () { console.log(privateVariable); privateMethod(); } }; })(); MyModule.publicMethod(); // 我是私有的 / 我是私有方法 MyModule.privateVariable; // undefined访问不到这种模式就是“揭示模块模式”后来被大量前端框架继承和改造。现代 ES Module 虽然提供了语言层面的模块化能力但底层依然是依赖“作用域 导入导出引用”的机制。理解闭包能让你在使用 ES Module 的时候明白为什么import进来的变量可以被共享、为什么模块内部export的变量能被外部实时访问。6.2 函数式编程里的偏函数和组合闭包在函数式编程里的应用更深入。偏函数Partial Application指的是固定一个函数的部分参数产生一个参数更少的函数本质就是利用闭包保存已经传入的参数。function createLogger(level) { return function (message) { console.log([ level ] message); }; } var info createLogger(INFO); var error createLogger(ERROR); info(用户登录成功); // [INFO] 用户登录成功 error(数据库连接失败); // [ERROR] 数据库连接失败这里的level就是被闭包捕获的配置项。在实际工程里这种模式可以用来做日志分级、API 封装、事件处理函数的预配置等。还有一个常见的方向是“记忆化”Memoization缓存纯函数的计算结果。本质上同样是闭包保存缓存状态function memoize(fn) { var cache {}; return function (arg) { if (cache[arg] ! undefined) { return cache[arg]; } var result fn(arg); cache[arg] result; return result; }; } function expensiveCalculation(n) { console.log(正在计算, n); return n * 2; } var memoized memoize(expensiveCalculation); console.log(memoized(5)); // 正在计算 5 / 10 console.log(memoized(5)); // 10不需要重新计算 console.log(memoized(10)); // 正在计算 10 / 20这种“用内存换时间”的思路在后端、前端都有大量应用场景。6.3 V8 引擎对闭包的内部处理最后聊点底层的。V8 引擎在编译 JavaScript 时会做各种各样的优化闭包相关的优化是其中一块比较有意思的领域。V8 的“上下文”Context指的是函数内部对作用域链中变量的收集。JavaScript 的每个函数执行时都会创建自己的执行上下文其中对外层作用域的变量引用会打包成“上下文上下文”。V8 会尝试把这种上下文中需要长期存在的变量放到堆上并尽量复用对象空间减少内存分配。但 V8 的优化是有条件的如果一个函数中的变量被闭包引用那么 V8 就可能无法对它进行“栈上分配”栈上分配更快只能放到堆上导致一定的性能损失。所以在高性能场景比如循环里创建大量函数中闭包的创建成本确实比普通函数要高。这又回到了我前面提到的建议不要滥用闭包尤其是在高频执行的代码里。当一个函数需要被创建并执行成千上万次比如在循环里每多一个闭包就多一次堆分配。虽然现代引擎已经优化得很好了但在追求极致性能的代码路径上还是要小心。7. 面试中闭包必问的几个变体和自检清单因为闭包是面试高频考点我把常见的一些面试角度整理一下。这不仅是应付面试也是检验自己理解深度的一种方式。7.1 高频问法一运行结果输出题题目for (var i 0; i 3; i) { setTimeout(function () { console.log(i); }, 0); }这里不光讲了闭包还捎带考察了事件循环和var的作用域。深入一点的追问往往就是为什么是 3 个 3怎么改成输出 0、1、2有哪些改法IIFE、let、bind各有什么差异7.2 高频问法二闭包是什么它有哪些实际应用这个问题看似送分实际上很多候选人答不好。如果只背定义一下子就空了。我的建议是把问题拆成三层概念层函数 词法作用域 闭包本质是函数记住创建时的环境。原理层作用域链、执行上下文、垃圾回收的可达性判定。应用层数据私有化、防抖节流、柯里化、缓存、模块化。7.3 高频问法三闭包一定会造成内存泄漏吗这个问题要一分为二地答。先说结论闭包本身不是内存泄漏它只是让变量引用持续存在。真正的问题是程序员使用不当导致长生命周期对象长期持有不再需要的引用。然后给出实际案例和解决办法。7.4 高频问法四模拟私有变量直接手写一个 IIFE 闭包的模块展示count只能通过暴露的操作函数修改不能让外部直接module.count 100绕过。这道题能同时考察闭包、IIFE、对象封装、JavaScript 的访问控制机制。7.5 自检清单你对闭包的掌握到哪一层自检项判断标准能说出闭包的形成条件函数 引用外部作用域的变量能画出作用域链的查找过程从内到外逐层查找直到全局能用代码演示闭包保存状态计数器、缓存、防抖能解释为什么闭包变量不会被回收因为仍被引用垃圾回收器的可达性分析能修复 setTimeout 循环陷阱IIFE 或 let能辨识出使用闭包导致的内存泄漏长生命周期对象持有大变量能在实际代码中设计闭包接口模块化、柯里化、工厂函数如果这些项多数都不确定建议对照文章里的例子手打几遍一边打一边观察变量的变化比只看不练强很多。8. 聊点实际的闭包在真实项目里的几个使用心得最后分享一些我这么多年写下来的真实感受不一定都是教科书里能学到的。我第一次真正意识到闭包的重要性是接手一个老项目中混乱的全局变量时。那时候根本没有什么模块化规范所有函数共享着一堆全局状态一个变量在十几个地方被修改排查 bug 简直是在大海捞针。后来我用 IIFE 闭包把相关功能封装起来每个模块只暴露最小的操作接口代码的 bug 率肉眼可见地降下来了。还有一个心得闭包的代码往往在没有注释的情况下很难读懂因为函数和外部变量的关系藏在作用域链里不像对象或类那样有显式的属性关系。所以我建议在写闭包的地方尤其是在返回函数的地方都写上注释说明这个闭包捕获了哪些外部变量、生命周期是多久、在哪里可以被释放。这在团队协作时能省下大量互相沟通的成本。最后我用闭包最多的地方其实是“延迟初始化”和“单例模式”。比如某些工具类只需要初始化一次就把初始化后的实例存在闭包里后续调用直接返回var getConfig (function () { var config null; return function () { if (!config) { config loadConfigFromServer(); // 只在第一次调用时执行 } return config; }; })();这种模式的优雅之处在于调用方不需要关心初始化逻辑只要知道“调用 getConfig 就能拿到配置”不需要知道内部有没有缓存、什么时候初始化。闭包把复杂性封装得干干净净这正是我认为的工程美学的体现。我建议你学闭包的时候别急着背概念先把文中的例子敲一遍再自己写一个“用闭包缓存计算结果”的函数最后尝试把一段用全局变量的代码重构成闭包封装。等你真正把闭包“用顺手”了再回头看那种“函数 词法作用域”的定义会有一种“原来如此”的顿悟感。
返回列表