行业资讯
📅 2026/8/31 6:12:16
JS闭包从原理到面试:手写实现与高频考点全解析
面试的时候拿到手撕闭包这四个字很多人第一反应是完了又要背一遍函数内部返回函数这种定义了。但真到了面试官面前光会背定义完全不够他要的是你当场把代码写出来写清楚还能讲明白原理。这个系列我打算从闭包最基础的概念开始一步一步拆开讲透先解决闭包是什么、怎么生成、能干什么这三个问题再往后我们再去聊闭包的高阶应用和面试中真正的考察难点。闭包是JS里绕不开的一个核心机制也是一道典型的会者不难、难者不会的面试题。它本质上是作用域链和垃圾回收机制共同作用下的产物理解了闭包的形成过程你就基本理解了JS函数的执行机制。这篇文章内容上以零基础也能听懂的方式起步但也会兼顾已经有开发经验、想在面试中把闭包讲出深度的读者。无论你是刚学JS的新手还是准备跳槽的进阶开发者把这一篇吃透闭包这个话题你就有了上桌聊的底气。1. 闭包到底是个什么东西——先把概念搞透1.1 从作用域链讲起要理解闭包绕不开作用域链。JS里每个函数在执行时都会创建一个属于自己的执行上下文这个上下文里有变量对象VO、作用域链Scope Chain和this值。而作用域链的构成方式决定了函数能访问到哪些变量。JS的作用域是词法作用域也就是说函数能访问哪些变量在代码写出来的那一刻就已经定死了而不是在调用的时候决定的。举个例子var a global; function outer() { var b outer; function inner() { var c inner; console.log(a, b, c); } inner(); } outer(); // global outer innerinner函数内部访问a的时候先在自身作用域找没找到就往上一级的外层函数outer作用域找再没有就往全局找。这个一层一层向上的查找路径就是作用域链。这里有个初学者特别容易混的点作用域链的赋值逻辑发生在函数定义的位置不是调用位置。你把inner挪到别的地方调用它还是能访问outer里的b因为inner的词法作用域在定义时就固定了。这就是后面理解闭包为什么能保留外部变量的关键前提。1.2 闭包的定义和通俗解释闭包Closure最经典的定义是一个函数和它声明时所处的词法作用域的集合。大白话说就是函数 它记住的外部变量。内部函数在外部函数里定义并且引用了外部函数的变量那么这个内部函数就是一个闭包。用一个生活化的比喻闭包就像是你带了一个移动硬盘离开公司硬盘里存着你在公司电脑上才能访问的资料。虽然你已经不在公司了外部函数执行完毕但你手里的硬盘还记录着那份资料外部函数的变量随时可以拿出来看。JS里这个机制能成立核心原因是当一个内部函数被return出去、并且还在被外部引用的时候这个内部函数会把自己的作用域链一起带走导致外部函数执行完、栈帧理论上应该销毁时那些被引用的变量因为还被内部函数持有引用所以不会被垃圾回收机制回收。这是闭包能记住变量的底层原理。1.3 闭包的三个必要条件我在面试中经常会追问什么情况下才算闭包 很多人的回答是函数套函数。但其实光有嵌套还不够要形成闭包必须满足三个条件存在函数嵌套也就是一个函数内部定义了另一个函数。内部函数引用了外部函数作用域里的变量。内部函数在外部函数的作用域之外被调用通过return、回调传递等方式。第三点是很多人忽略的。如果只是简单的函数嵌套内部函数在外部函数内部立即调用虽然它确实访问了外部变量但由于没有把引用带出外部作用域对于垃圾回收来说没有产生逃逸面试官一般不把这个当作闭包来考。真正让闭包闭起来的是把这个内部函数的引用传递到外部让它成为脱离原作用域却仍然能访问原作用域变量的特殊存在。理解了这三条你再看任何闭包相关代码都能一眼判断它是不是闭包。2. 手撕一个闭包到底怎么写——从最基础的代码开始2.1 最基础的闭包写法现在直接上手写代码。最简单的闭包就三行function outer() { let count 0; return function inner() { count; console.log(count); }; } const counter outer(); counter(); // 1 counter(); // 2拆开看这段代码的执行过程outer被调用时创建了函数作用域里面声明了count变量然后返回了inner函数。outer执行结束后按理说count应该被销毁但因为inner被赋值给了全局变量counter而inner的作用域链里持有对count的引用所以count就被保留了下来。之后每次调用counter实际上是在操作那个被保存下来的count。这就是手撕闭包的第一层能够写出一个内部函数并正确地让它引用外部函数的变量。别小看这三行很多候选人在面试中写到return之后就开始紧张尤其是当面试官要求解释为什么count的值能一直累加时反而说不清楚。2.2 闭包的参数传递——让闭包更灵活基础版本写出来了下一步要掌握的是闭包记住的变量也可以通过参数传递这样闭包可以做更多事情。来看这个例子function makeAdder(x) { return function(y) { return x y; }; } const add5 makeAdder(5); const add10 makeAdder(10); console.log(add5(2)); // 7 console.log(add10(2)); // 12makeAdder(5)返回的闭包把x的值固定为5之后无论传入什么y都是在5的基础上加。add5和add10各自持有独立的x副本互不干扰。这就是闭包最基本的记忆能力——它可以记住创建时的参数状态。这种写法在面试中很常见它是一种初步的柯里化思想。柯里化本质上就是通过闭包把多参数函数转换成一系列单参数函数的过程。你不需要在面试中一上来就写复杂的柯里化工具函数但能写出makeAdder这种模式已经可以证明你对闭包的理解是到位了。2.3 面试标准答案用闭包实现一个计数器如果面试官说手写一个计数器很多人第一反应是写一个全局变量加函数let count 0; function increment() { count; return count; }这样写能实现计数但有一个致命问题count是全局变量任何地方都能修改它。如果项目里有人不小心写了count abc整个计数逻辑就崩了。这违背了封装的原则。闭包计数器的写法我在上面已经展示过了关键点就是count被封装在外层函数作用域内外部无法直接访问只能通过返回的函数来操作。这个方案就实现了数据私有化——变量对外不可见但可以通过受控的接口去修改它。这是闭包在真实项目中最核心的应用。function createCounter(initialValue 0) { let count initialValue; return { increment: function() { count; return count; }, decrement: function() { count--; return count; }, getValue: function() { return count; } }; } const myCounter createCounter(10); console.log(myCounter.increment()); // 11 console.log(myCounter.decrement()); // 10 console.log(myCounter.getValue()); // 10这个版本返回的不是一个普通函数而是一个包含多个方法的对象每个方法都能访问同一个count变量。这种模式本质上是模块化开发的一个雏形用闭包把数据藏起来对外暴露明确接口。3. 高频面试题落地——闭包与异步竞态、循环陷阱3.1 闭包遇上循环最经典的for循环面试题闭包面试中出镜率最高的一道题就是for循环加计时器的组合for (var i 0; i 5; i) { setTimeout(function() { console.log(i); }, 1000); }问这段代码输出什么答案不是0、1、2、3、4而是5个5。为什么原因有两层。第一var声明的i是函数作用域for循环并不会为每次迭代创建新的作用域所以五个匿名函数引用的都是同一个i。第二setTimeout的回调是在循环结束之后才执行的循环结束时i已经是5了所以五个回调拿到的都是5。这里还有一个看似绕但面试官很爱问的点这段代码里到底有没有闭包有。因为在函数内部引用了外部作用域全局作用域的变量i。但这是闭包的一种典型错误用法——它记住了变量i但没能为每次循环保存独立的i值。3.2 闭包解法为每次迭代保存独立变量要让输出变成0、1、2、3、4有很多种改法但面试中最期望看到的是用闭包解决。核心思路就是借助立即执行函数IIFE为每次迭代创建新的作用域for (var i 0; i 5; i) { (function(j) { setTimeout(function() { console.log(j); }, 1000); })(i); }每次循环时IIFE立即执行把当前的i作为参数j传入。j是IIFE函数作用域内的变量每次迭代都对应一个不同的j。setTimeout回调引用了j因此每个回调保存的是各自迭代周期内的j值最终输出0到4。这个解法是最能体现闭包功力的版本因为它同时用到了函数嵌套、变量引用和IIFE三个知识点。面试官看到你能熟练写出这种解法基本就能确认你对闭包的理解不是停留在背诵层面。3.3 let的出现让这道题有了更简洁的答案ES6之后这道题有了一种看起来更简洁的解法for (let i 0; i 5; i) { setTimeout(function() { console.log(i); }, 1000); }这段代码用let替换var输出就是0、1、2、3、4。原因在于let是块级作用域每一次循环都会创建一个新的绑定也就是说每次迭代的i都是独立的变量setTimeout回调各自捕获了当次迭代的i。但这里有个非常关键的细节用let能解决这个问题的底层逻辑依然是闭包机制在起作用。let的块级作用域相当于悄悄帮你在每次迭代中创建了一个新的作用域并且setTimeout回调里的匿名函数是这个作用域的闭包。所以这道用let的解法并没有消灭闭包而是让语言特性替代了手动创建IIFE的过程。这个理解层次很加分大多数候选人只知道let能解决却讲不清根本原因。4. 闭包在真实业务场景中的落地案例4.1 数据私有化与模块化开发在真实企业项目里闭包最常见的使用场景就是数据私有化。举个例子比如你写一个表单校验模块希望保存用户校验失败的次数但又不希望这个计数被外部随意篡改const formValidator (function() { let errorCount 0; let lastErrorMsg ; function logError(msg) { errorCount; lastErrorMsg msg; console.error([校验失败${errorCount}次] ${msg}); } function getErrorCount() { return errorCount; } function reset() { errorCount 0; lastErrorMsg ; } return { logError: logError, getErrorCount: getErrorCount, reset: reset }; })(); formValidator.logError(用户名不能为空); formValidator.logError(密码长度不足); console.log(formValidator.getErrorCount()); // 2这个例子用IIFE创建了一个模块作用域errorCount和lastErrorMsg在外部完全不可访问只能通过暴露出的接口操作。这种模式在jQuery插件、UI组件库中被广泛使用是闭包在生产环境中最经典的落地方式。面试中如果面试官问闭包有什么实际用途你可以直接拿这个例子回答同时强调数据私有的价值防止全局变量污染、防止内部状态被意外篡改、模块化隔离。回答时最好带上具体代码比空谈概念有说服力得多。4.2 防抖与节流函数——闭包的高频应用防抖和节流是前端性能优化中的常见手段也是面试手撕题的大热门。它们都依赖闭包来保存定时器状态function debounce(fn, delay) { let timer null; return function(...args) { if (timer) clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); timer null; }, delay); }; } function throttle(fn, interval) { let lastTime 0; return function(...args) { const now Date.now(); if (now - lastTime interval) { lastTime now; fn.apply(this, args); } }; }两个函数都利用了闭包的特性debounce的timer在整个生命周期内只存在一份每次调用都复用这个变量throttle的lastTime记录上次执行时间闭包让它跨调用保持状态。如果不用闭包这两个变量要么是全局的导致污染要么每次调用都重新初始化为null状态就丢了。写这类函数时有一个坑需要注意return出去的函数内部要处理this指向用fn.apply(this, args)把外部函数的this传给fn否则防抖节流的目标函数可能丢失this上下文。这是一个高级细节面试时主动写出来会让面试官眼前一亮。4.3 一次性执行函数与只运行一次的初始化逻辑业务中经常有这样的场景某个初始化函数只允许被执行一次重复调用无效。用闭包可以轻松实现function once(fn) { let called false; return function(...args) { if (called) return; called true; return fn.apply(this, args); }; } const init once(function() { console.log(初始化开始...); return init done; }); init(); // 初始化开始... 返回 init done init(); // 直接返回 undefined init(); // 直接返回 undefined这个once函数是闭包的经典应用called变量被闭包记住不会因为多次调用而重置。在真实项目中这种模式常用于支付回调的防重复处理、全局初始化操作、事件绑定的幂等控制等场景。面试时如果你能一口气写出debounce、throttle、once这三个基础工具函数同时说明它们都用到了闭包的持久化变量特性那这道手撕闭包的面试题基本就到顶了。不要背题要理解背后的闭包持有状态这个核心思想。5. 闭包容易踩的坑——内存泄漏与性能问题5.1 闭包与内存泄漏的关系闭包最常见的坑就是内存泄漏。当一个闭包长期存活但它引用的外部变量本身已经很大比如一个巨大的DOM节点、数组、对象这段内存就无法被回收。举个真实案例function assignHandler() { const element document.getElementById(myButton); element.onclick function() { console.log(element.id); }; }这段代码里匿名函数是element的点击事件回调同时它又引用了element。这意味着element被它的回调函数引用回调函数又被element的onclick属性引用形成了循环引用。在旧版本的浏览器尤其IE中即使页面已经不需要这个按钮这个循环引用依然会导致element无法被回收造成内存泄漏。现代浏览器已经能处理这种循环引用但如果在单页应用中反复创建和销毁组件仍然可能因为闭包引用链过长而导致内存占用不断攀升。面试官问闭包的缺点时这个点是必答项。5.2 如何规避闭包内存泄漏问题要规避闭包导致的内存泄漏有几个实操方法在不需要使用闭包时手动解除引用element.onclick null;或者对闭包函数本身置空。避免闭包内引用大体积数据。如果只需要某个变量的值可以使用局部变量拷贝一份而不是整个引用对象。在生命周期明确的场景如组件销毁中及时清理闭包事件绑定。React函数组件中useEffect的清理函数就是做这件事的useEffect(() { const timer setTimeout(() { console.log(执行某个操作); }, 1000); return () clearTimeout(timer); // 清理阶段清除闭包中的定时器 }, []);这个清理函数返回的清理动作实际上就是在处理闭包引用的变量。不要小看这个细节前端项目很多内存泄漏问题都出在组件卸载后定时器还在跑、事件绑定还在发生这样的场景上。5.3 闭包与垃圾回收机制的关系有人会问既然闭包这么容易造成内存占用那为什么每次使用setTimeout都自然地形成了闭包却没有把内存撑爆这是因为垃圾回收机制GC有自己的判断策略。现代V8引擎会采用可达性分析来判断对象是否可以被回收。只要一个对象还能从全局对象出发被访问到它就在可达列表里不会被回收如果完全不可达了就会被标记并回收。闭包中引用的变量只要闭包函数本身还在使用中这些变量就属于可达状态不会回收。这个机制也解释了为什么闭包内存泄漏的解决方案是解除引用——一旦闭包函数不再被任何地方引用整个闭包连同它保存的外部变量都会进入不可达状态被GC正常回收。理解了这个底层逻辑你就明白闭包不是天生的内存炸弹而是取决于你怎么管理和控制它的生命周期。6. 面试官追问环节——如何把闭包从基础题答出深度6.1 追问一闭包中的var和let有什么区别如果面试官让你写闭包你用了var他可能会追问let和var的行为差异。正确答案要包含三个层面作用域不同var是函数作用域let是块级作用域。变量提升表现不同var声明的变量会提升到函数顶部初始值为undefinedlet存在暂时性死区在声明前访问会报ReferenceError。循环中的行为不同var在for循环中共享同一个变量绑定let每次迭代创建新的绑定。结合闭包的场景最能体现这三条差异的就是for循环加setTimeout那道题。所以在回答时你可以直接引用那道题说明var和let在闭包场景下会导致完全不同的结果。这个回答能把基础概念和应用场景串在一起说服力强。6.2 追问二闭包能访问外部函数的this吗这道题太经典了几乎必问。直接看代码const obj { name: obj, getName: function() { return function() { return this.name; }; } }; console.log(obj.getName()()); // undefined不是 obj为什么因为return出去的这个匿名函数在调用时它的this指向全局对象严格模式下是undefined而不是obj。this的指向规则是执行时决定的不遵循词法作用域规则。这一点和闭包访问变量不同——变量遵循词法作用域this遵循调用位置规则。如果希望内部函数也访问到obj的name老办法是先把this保存起来getName: function() { const that this; return function() { return that.name; }; }或者用箭头函数因为箭头函数不绑定this它会从定义时的外层作用域继承thisgetName: function() { return () { return this.name; }; }这个问题是闭包面试中的送命题答得好加分答不好直接暴露对this机制的盲区。我建议你把两种解法都熟练写出来并且能讲清楚为什么这样改。6.3 追问三除了闭包还有什么办法实现数据私有这个问题考察你是否理解闭包并非唯一方案。ES2022之后类支持私有字段语法class Counter { #count 0; increment() { this.#count; return this.#count; } } const c new Counter(); c.increment(); // 1 // c.#count 外部直接访问会报错#count是真正的硬私有语法层面不允许外部访问。相比之下闭包实现的私有其实是一种约定外部理论上可以通过一些手段拿到变量比如通过返回的函数去间接操作。如果你直接回答用#语法再补一句但闭包的私有化方案更灵活在函数式思路和组件封装中更常见就能体现出你的知识广度和对方案的辩证理解。7. 常见问题排查实录——我踩过的闭包相关的坑7.1 问题一计时器回调输出全一样这是闭包新手最容易遇到的现象。我之前做图片懒加载的时候用循环为每个图片绑定滚动监听结果发现所有回调拿到的索引都是最后一个。排查思路是先确认是不是闭包变量共享了解法就是我在第三部分讲的IIFE或者let。遇到这种问题不要慌先打log看中间变量再判断是作用域问题还是异步时序问题。7.2 问题二闭包内变量被意外修改另一种场景闭包保存的变量在外部被其他逻辑改掉了导致闭包行为异常。比如function createPrinter(message) { return function() { console.log(message); }; } let msg hello; const print createPrinter(msg); msg world; print(); // 输出 hello注意这里的表现因为函数参数是按值传递的print保存的是字符串hello的拷贝而非msg变量的引用所以后续修改msg不会影响print的输出。但如果闭包引用的是一个对象由于对象是按引用传递的修改对象内容会影响闭包内的行为。这类问题排查时核心是理清你保存的是值还是引用这个思路能帮你快速定位90%的闭包异常。7.3 问题三防抖函数在连续触发时最后执行前的一次丢了我自己就在表单搜索功能中遇到这个问题。debounce实现得太粗暴用户连续输入时最后一次输入可能还没等到delay时间过去组件就卸载了。排查中发现是清定时器的时机和组件卸载的生命周期没有配合好。解决方案是在组件卸载时立即执行当前尚未执行的防抖回调或者用leading版本的防抖让第一次触发立即执行。这种问题解决起来不难但能暴露出一个很重要的点闭包里的状态管理需要提前设计好生命周期不能只关注函数内部的逻辑。闭包这个东西看十遍概念不如动手写三个不同版本的计数器。我个人的建议是把文章里的代码全部手打一遍先不要复制理解了每一行的作用后再跑一下看输出然后合上代码自己用闭包实现一个带加减功能和初始值的计数器。这个过程走完闭包的手感就基本建立起来了后面再去研究柯里化、偏函数、防抖节流这些高阶应用才会真正顺畅。这个系列后面我还会继续写闭包在模块化设计中的使用、闭包与柯里化的深度应用以及如何在面试中把闭包相关的八股文答出项目实战的味道可以先收藏等更新。