前端面试必考:JavaScript闭包原理与手撕代码详解
“说说JavaScript里的闭包。”听到这句话,很多人心里一松,因为概念背过无数遍:“函数内部返回函数,内部函数能访问外部函数的变量。”可面试官十有八九会接着追问一句:“为什么外部函数执行完之后,它的变量还能被内部函数访问?”现场就沉默了。
前端面试里,闭包是绕不开的高频考点,校招问、社招问,大厂小厂都问。它题目本身不难,但考察面广——概念、原理、手写代码能一次性全测出来。这也是为什么“前端面经高频手撕”系列要从闭包开篇:搞定闭包,后面再看防抖、节流、柯里化、模块化设计,都会顺畅很多。
这篇文章写给正在准备前端面试、或者想系统补一下JavaScript基础的你。我会从最底层的词法作用域讲起,把闭包的完整原理拆开,再带你把两道最经典的面试手撕题目过一遍,最后给你一套可以直接用的答题框架和踩坑清单。
1. 为什么面试官都爱从闭包问起:一道题里藏着三个考察点
闭包这道题最“划算”的地方在于,面试官只需要抛出一个简单问题,就能同时摸清你三个层面的水平:概念是否清晰、原理是否扎实、代码能不能落地。很多候选人栽跟头,不是因为不会写代码,而是因为对闭包的理解停留在“背定义”的阶段。
1.1 第一层:概念是否能脱口而出
最基本的要求,是能用一句话说清楚“闭包是什么”。别小看这一步,我面试过不少人,让说定义的时候,能准确说出“函数和它词法作用域的组合”这个表述的,其实不到一半。多数人只会说“函数里面返回一个函数”,这不算错,但远远不够。
概念题考察的本质是:你学JavaScript的时候,是真的理解了语言特性,还是只记住了几个关键词。一句话定义说出来,面试官基本就能判断你平时的学习深度。
1.2 第二层:底层机制是否真懂
如果概念过关,面试官大概率会追加原理层面的问题:为什么外部函数return之后,局部变量没被销毁?这里涉及词法作用域、作用域链、变量生命周期三个基础机制。
这一层是拉开差距的地方。能背概念的人很多,能讲清楚“函数创建时保存了对外层词法环境的引用”的人就少一截。面试官问到底层,其实不是想刁难你,而是想看看你是“背了八股”还是“真懂原理”——搞前端开发,不懂作用域机制,写代码踩坑是早晚的事。
1.3 第三层:手撕代码会不会翻车
面试到了这个环节,通常就会进入“手撕”模式——让候选人现场写一段代码来验证理解。闭包最常见的考察方式是:手写一个计数器,实现变量累加且外部无法直接修改;或者直接甩出一道循环里用setTimeout的题,问输出什么。
“手撕”这个词听起来吓人,其实考的恰恰是最基本的代码能力。面试现场没有搜索引擎、没有编译器辅助,你写的每一行代码,都直接反映出脑子里对JavaScript执行模型的理解。我见过不少候选人前面聊得挺好,一到手撕环节写不出来,或者写完结果都不对,前面建立的好感直接就清零了。
所以这篇文章不只是讲概念,而是从概念、原理到代码,一条线全部过一遍,让你在面试现场三个层面都稳得住。
2. 先补齐两块地基:词法作用域和作用域链
闭包不是凭空冒出来的概念,它的根基是JavaScript的两个底层机制。这两块不搞清楚,闭包就永远是一团浆糊;搞清楚了,闭包反而成了最简单的东西。
2.1 词法作用域:函数能不能访问某个变量,在“写”的时候就决定了
词法作用域也叫静态作用域,意思很直白:一个函数能访问哪些变量,在代码书写的位置就已经确定了,跟它在哪被调用、什么时候被调用没有关系。
看这段代码:
const hobby = 'coding'; function outer() { const level = 3; function inner() { const score = 88; console.log(hobby); // coding console.log(level); // 3 console.log(score); // 88 } inner(); } outer();inner函数为什么能访问到outer里的level,甚至全局的hobby?因为inner定义在outer内部,而outer又定义在全局。代码一写出来,inner的“可视范围”就是:自己的作用域 + outer作用域 + 全局作用域。这就是词法作用域。
“词法”两个字,指的就是代码书写时的文本结构。JavaScript在解析代码时,会按照函数嵌套的位置,提前确定好每个函数的外层作用域是谁。面试如果被问到“什么是词法作用域”,把这个例子讲清楚,比背十遍定义都有用。
2.2 作用域链:内层找不到,就一层层往外找
那当代码真正访问一个变量时,JavaScript到底是怎么找到它的?答案是沿着作用域链逐层查找。
每个函数会维护一条作用域链,链的最前端是函数自己的词法环境,然后依次往外指向外层函数的词法环境,最后指向全局环境。查找变量时,从链的最前端开始,当前层找不到,就往下一层找,直到全局。全局也找不到,就抛ReferenceError。
用上面的代码举例,inner内部访问score时:
- 第一步,查inner自己的作用域,score = 88,命中。
- 访问level时,inner自己的作用域里没有,顺着链去outer的作用域找,命中。
- 访问hobby时,inner和outer里都没有,继续往外到全局作用域,命中。
这个过程每一步都很快,不用背,多写几段嵌套代码,脑子里自然会有这条“查找路线图”。面试时能把这段查找过程说出来,面试官对你的评价会立刻不一样。
2.3 函数创建时带上的“环境引用”是闭包的钥匙
前面说的都是“函数正常执行时的样子”,闭包的关键转折藏在一个更底层的细节里:每个函数在创建出来的时候,都会保存一份对“定义时所在词法环境”的引用。
你可以把这个引用理解成函数的一个隐藏属性,在函数出生那一刻就刻进去了。它不会因为函数被传到哪里去而丢失,也不会因为外层函数执行结束而消失。后面所有闭包现象,都建立在这个“环境引用”之上。
这也是为什么很多学JavaScript的人觉得闭包玄——因为正常的作用域查找逻辑是“函数调用时现查”,而闭包额外保留了“函数出生时的家底”,让函数哪怕被带到别处,也能翻出自己出生环境里的东西。
3. 闭包的准确定义与两个高频误区
地基打好了,现在可以正面回答“闭包是什么”了。
3.1 面试中怎么给闭包下定义更稳妥
闭包的标准定义是:函数与其词法作用域的组合。更通俗一点讲:当一个内层函数引用了外层函数的局部变量,并且这个内层函数被保留下来,可以在外层函数执行结束后继续被调用,那么这个“函数 + 外层变量环境”的组合,就叫闭包。
一个最简单的例子:
function createClosure() { let msg = 'hello closure'; return function () { console.log(msg); }; } const fn = createClosure(); fn(); // hello closure代码逻辑是:createClosure执行时创建了局部变量msg,同时返回了一个匿名函数。正常情况下,函数执行结束,局部变量就该被回收。但因为返回的函数引用了msg,而返回的函数又被赋给了全局变量fn,于是msg就被“续命”了——createClosure执行完,msg照样能被fn访问到。
这就是闭包的核心特征:外层函数已经结束,但内层函数仍然保留着对外层作用域变量的访问权。变量生命周期被延长了。
3.2 误区一:只有把函数return出去才叫闭包
这是最常见的误解。很多教材为了简化讲解,都用“返回函数”来举例,导致不少人以为“闭包必须return”。实际上,函数无论是被return、被当作参数传给其他函数、还是赋给事件监听器,只要它创建时所在的外层作用域里有变量被它引用,就算闭包。
举一个场景:
function delayLog() { const message = '2秒后输出我'; setTimeout(function () { console.log(message); }, 2000); } delayLog();setTimeout的回调函数引用了delayLog作用域里的message。delayLog执行完之后,setTimeout把回调函数挂起来,直到2秒后才执行。这期间message一直没被回收,因为回调函数还在引用它。这就是闭包,并没有“return”出现。
面试被问到“能举一个闭包的例子吗”,别只会写return版本。能说出setTimeout、事件监听、函数传参这类场景,说明你理解的是机制本身,而不是记住了一个模板。
3.3 误区二:闭包必然导致内存泄漏
“闭包会内存泄漏”这句话,我听过太多次了,它其实是被误解的。闭包本身是JavaScript的语言特性,它的产生是正常的。真正导致内存泄漏的,是闭包引用的变量在不再需要之后,仍然被无意义地保留着。
类比一下就明白了:闭包像是你给朋友留了一把家里钥匙,朋友随时可以进来拿东西。这本身没问题。问题出在“朋友搬走了,但钥匙还在他手里”,你没法确定他什么时候来,家里的东西也不能随便清理——这才是麻烦。
所以闭包的正确态度是:正常用,不滥用。在需要封装私有状态的地方,闭包是不可替代的好工具。后面第6章我会专门讲怎么避免闭包引起的内存问题。
4. 手撕第一题:for循环里setTimeout输出的为什么全是5
这道题在面经里出现频率极高,基本是闭包手撕的“必做题”。我第一次见到它是在一个模拟面试里,当时整个人都懵了:“啊?1秒后不是应该输出0、1、2、3、4吗?”跑一遍才发现不是那么回事。
4.1 题目形态与错误答案
面试官会把这道题摆在你面前,然后问:“这段代码输出什么?”
for (var i = 0; i < 5; i++) { setTimeout(function () { console.log(i); }, 1000); }不少第一次遇到的人会脱口而出:0、1、2、3、4。但实际输出是:5、5、5、5、5。
记住这个结果不算本事,能解释清楚为什么才是面试官要的。
4.2 逐步推演:这段代码究竟发生了什么
拆成两步来看。
第一步,var i没有块级作用域。var声明的变量,要么是函数作用域,要么是全局作用域。for循环的大括号不会为i创建独立作用域,所以循环里的5次迭代,用的全部是同一个i。
第二步,setTimeout不立即执行回调。setTimeout把回调函数先挂起,等当前同步代码全部执行完、调用栈清空之后,再到时间了才执行。也就是说,回调函数真正执行的那一刻,for循环早就结束了,i也早就经过5次自增停在了5。
这时候,5个回调函数各自去自己的作用域链里找i,找到的是同一个外层作用域里的同一个i,值是5。于是5个console.log全部输出5。
这里有一个非常关键的细节:回调函数本身是闭包——它们都捕获了外层for所在作用域的变量i。问题是这5个闭包捕获的是同一个变量,而不是5份不同的值。
4.3 三种解法与面试官追问
面试官问完输出结果,紧接着就会问:“那怎么改才能输出0、1、2、3、4?”下面三种解法都是白给的答案。
解法一:用IIFE包裹,每轮迭代创建独立作用域。
for (var i = 0; i < 5; i++) { (function (j) { setTimeout(function () { console.log(j); }, 1000); })(i); } // 输出:0 1 2 3 4IIFE在每一轮循环中立即执行一次,参数j接收到的就是本轮i的值。回调函数捕获的是IIFE作用域里的j,每一轮的j都是独立的,所以最终输出正确。
解法二:直接用let声明i。
for (let i = 0; i < 5; i++) { setTimeout(function () { console.log(i); }, 1000); } // 输出:0 1 2 3 4let有块级作用域,而且JavaScript引擎在for循环中会对let做特殊处理:每一轮迭代都会创建一个新的词法环境,把当前轮的i值绑定进去。每个setTimeout回调捕获的是自己那轮迭代的i,互不干扰。
解法三:利用setTimeout的第三个参数。
for (var i = 0; i < 5; i++) { setTimeout(function (j) { console.log(j); }, 1000, i); } // 输出:0 1 2 3 4setTimeout的第三个及之后的参数,会作为回调函数的参数传入。这里i的当前值被直接作为实参传给了回调的形参j。严格来说,这种方式不依靠闭包,而是通过参数传递把每轮的i值固化下来。但能说出来,也算知识面广。
三种解法的对比,我给你整理在表里:
| 解法 | 核心原理 | 面试推荐度 |
|---|---|---|
| IIFE + 闭包 | 每轮创建独立作用域,用参数固化值 | 推荐掌握 |
| let声明 | 块级作用域,每轮迭代独立绑定 | 首选答案 |
| setTimeout第三参数 | 参数直接传值 | 了解即可 |
面试官如果继续追问“let为什么可以”,你就把第4.2节里“每轮迭代创建新词法环境”的机制讲一遍。能讲到这层的候选人,基本就过关了。
5. 手撕第二题:用闭包写一个带私有状态的计数器
循环题是“闭包现象的识别”,这道题就是“闭包的主动应用”。面试官的主要目的是看你能不能主动用闭包去解决实际问题。
5.1 面试题面的常见问法
我遇到的问法有几种变体:“写一个计数器,每次调用加1,但外部不能直接修改它的值。”“实现一个模块,有私有变量,只能通过暴露的方法操作它。”本质一样:外部不能直接访问内部状态,只能通过你给的接口操作。
5.2 完整手撕代码与原理拆解
下面这段是标准答案,我建议你直接抄下来练到肌肉记忆:
function createCounter() { let count = 0; function increment() { count++; return count; } function decrement() { count--; return count; } function getCount() { return count; } return { increment, decrement, getCount }; } const counter = createCounter(); console.log(counter.getCount()); // 0 counter.increment(); counter.increment(); console.log(counter.getCount()); // 2 counter.decrement(); console.log(counter.getCount()); // 1你注意看:counter对象里压根没有count这个属性,所以counter.count拿到的只能是undefined。count被“藏”在createCounter的作用域里,只有increment、decrement、getCount三个函数能访问到它。
停止访问外部公共字段 的原因,是createCounter执行完之后,count没有像普通局部变量一样被回收——三个返回的函数都引用着createCounter的作用域,count作为这个作用域的变量,生命周期被延长了。这恰好就是闭包的定义:返回的函数 + 外层变量环境,共同组成了闭包。
面试时你可以顺手补一句:“如果不用闭包,我需要把count定义成对象属性,那外部就能直接改它了。闭包的优点就是真正做到私有。”这句话体现的是应用意识,很加分。
5.3 对比class私有字段:两种思路的取舍
面试官可能追问:“ES6的class不是也有私有字段吗?用那个不行吗?”确实可以:
class Counter { #count = 0; increment() { this.#count++; return this.#count; } getCount() { return this.#count; } } const counter = new Counter(); counter.increment(); console.log(counter.getCount()); // 1class的#私有字段是语言层面的硬性限制,外部完全访问不到,比闭包方案更“干净”。但两者解决的问题方向不同:class私有字段依赖类,而闭包是一种通用机制,不依赖类,不需要实例化,在普通函数、回调、事件处理器里都能用。
面试里如果被问到,你不用贬低任何一种方案。说清楚“class私有字段适合类场景,闭包适合通用函数场景”就够了,这说明你有技术判断力,而不是只会背一种答案。
5.4 模块模式:闭包在前端工程里的真实应用
闭包的经典应用之一是模块模式。所谓模块模式,就是用IIFE立即执行一个函数,函数内部的变量被闭包保护起来,只对外暴露一个对象接口:
const CounterModule = (function () { let count = 0; return { increment() { count++; return count; }, reset() { count = 0; }, getCount() { return count; } }; })(); CounterModule.increment(); CounterModule.increment(); console.log(CounterModule.getCount()); // 2 CounterModule.reset(); console.log(CounterModule.getCount()); // 0早期的jQuery插件开发、工具库封装,大量使用这种写法。现在虽然有了ESModule,但这种“隐藏内部状态”的思路依然是通用技能——你写组件,不希望外部随便改内部状态;你写工具库,不希望内部变量污染全局,都是闭包的用武之地。
6. 闭包的内存管理与实战踩坑
闭包能力很强,但在真实项目中,它的内存表现确实需要盯一下。这一节我会把“什么时候该担心、什么时候不用担心”讲清楚,免得你在面试里被问“闭包会导致内存泄漏吗”的时候,只知道背结论。
6.1 变量为什么“被记住”:闭包的引用链
回顾计数器例子:createCounter执行结束,按JavaScript的垃圾回收机制,不再被引用的局部变量应该被回收。但因为increment、decrement、getCount这些函数仍然被外部引用,函数对象上又带着对createCounter作用域的引用,所以count就被“记住”了,不会被GC“处理”。
从GC的视角看,垃圾回收器会从根对象出发,追踪所有可达的引用。只要闭包函数还能被外部访问到,那个函数对应的外层词法环境就一直是可达的,里面的变量就活着。
这个概念解释了前面所有现象:不是“闭包里的变量永远不会被回收”,而是“只要闭包函数本身还被引用着,它携带的变量环境就不会被回收”。
6.2 真正该警惕的泄漏场景
闭包引发问题的典型场景,是“闭包不再被需要了,但引用还在”。最常见的是事件监听器。
function setupButton() { const userId = 1001; const btn = document.getElementById('btn'); btn.addEventListener('click', function () { console.log('用户ID:', userId); }); } setupButton();这段代码本身没有问题,问题出在后续:如果按钮被从DOM中移除,但监听器没有移除,那么回调函数(闭包)仍然持有对按钮和userId的引用,两者形成了“闭包引用DOM → DOM上挂着监听器 → 监听器引用闭包”的循环,谁也释放不了。
在单页应用里,这种问题做多了就会积累成内存占用持续上升,页面越用越卡。
6.3 一个项目中的真实教训:高频循环里的闭包滥用
我维护过一个营销活动页面,滚动事件里有一段数据处理逻辑。最初的实现是:滚动事件的回调里,每次都创建一个新的函数去处理当次的数据。滚动是高频率触发的事件,一帧之内可能触发好多次,每次创建新函数、新词法环境,内存压力一下子就上来了。低配手机上页面滚着滚着就开始掉帧,不得不去排查性能问题。
后来把数据处理逻辑提取成模块级别的公共纯函数,只在真正需要捕获某个变量时才创建闭包。修改之后,滚动流畅度明显改善。
这个案例给我的教训是:闭包不是不能用,而是要“按需创建”。在低频场景里,闭包带来的便利远大于开销;在高频循环里,就得克制一点,避免每轮迭代都制造新的闭包。面试里被问到闭包性能,这个案例比背概念有说服力得多。
7. 手撕答题框架与经典误区清单(给面试现场用的)
最后这部分,是纯实战经验。你可以把它当成一张考前速查卡,在面试前一天翻一翻。
7.1 一个三段式回答,稳过概念题
当面试官问“什么是闭包”时,我特别推荐用一个三段式回答:
第一段,一句话定义:“闭包是函数和它词法作用域的组合。”
第二段,展开原理:“当一个内部函数引用了外部函数的局部变量,并且这个内部函数被保留下来,即使外部函数执行结束,内部函数也仍然能访问那些变量。因为函数创建时会保存对外层词法环境的引用,所以外层作用域不会被回收。”
第三段,随手写个例子:“比如最常见的就是计数器……”
这样的好处是:定义简单有力,原理讲到了词法环境和变量生命周期,例子直接证明你写得出来,全程不超过30秒。面试官想追问的,你已经提前说到了。
7.2 手撕环节的关键动作
手撕题和概念题不一样,面试官不只看结果,更看过程。几个细节很加分:
- 写代码之前,先口述一遍思路:“我先说下我的想法,用闭包把变量包起来,然后返回一个方法去修改它。”哪怕思路不完整,也比闷头写强。
- 写完以后,习惯性地做一个“脑内执行测试”,把几个关键调用的输入输出推算一遍,并讲出来:“我调用一次increment,count从0变1,返回值是1。”
- 不要一写完就停下,主动补充一句:“这个解法里闭包承担的角色是把state私有化,如果不用闭包,外部能直接改count,就不满足需求了。”这一句能让你从“写对了”升级到“理解了”。
7.3 闭包误区对照表
面试过程中,容易踩的坑我列了一张表,你可以对着自查。
| 常见误区 | 事实澄清 |
|---|---|
| 闭包一定会导致内存泄漏 | 闭包是语言特性,只有保留不用的引用才可能内存泄漏 |
| 只有return出去的函数才叫闭包 | 回调、事件监听、参数传递都会形成闭包 |
| 闭包就是匿名函数 | 具名函数、匿名函数都可以形成闭包 |
| 用let就没有闭包问题了 | let解决循环绑定问题,但很多场景仍天然存在闭包 |
| 闭包在日常开发中用不到 | 防抖节流、柯里化、模块化、状态封装全都在用 |
| 闭包会降低性能,要避免使用 | 合理使用没问题,在高频遍历中滥用才需要优化 |
用这套框架答题,你会发现闭包从“玄学”变成了“有逻辑的一二三”。它本身不复杂,复杂的是你脑子里有没有把词法作用域、作用域链、变量生命周期这几块拼图拼完整。
说回面试。我在面试别人的时候,最怕遇到背概念型选手——说定义头头是道,一让写代码就卡壳。反过来,能先讲清楚原理、再随手写个计数器、还能解释var和let在循环里区别的候选人,闭包这一关基本就稳了。闭包后面还连着防抖节流、柯里化、模块化设计这些实际应用,但那些都属于进阶内容了。这一篇先把基础打牢,下一篇再来继续手撕闭包的进阶场景。
