写在前面:今天这节课有点特别------不是学新框架,是准备面试 。readme 开门见山:"小米前端面试题,传统前端(少量 AI 思想、后端运维)面试题,题量比较大,难度中等,需要一轮复习就可以拿下。" 而整份大纲里,被单独拎出来做"专题性复习"的只有两样东西:event loop、闭包 。为什么闭包这么重要?因为它无处不在------
useState的坑是它挖的,useEffect的坑也是它挖的。今天我们用课堂的 6 个文件,把闭包从"概念"一路拆到"React 实战"。以下所有代码均来自课堂真实文件。
一、先看面试到底考什么
readme 列了一张完整的"面试地图":
css
├── 自我介绍(暖场,面试官的权利)
│ ├── 我是谁?
│ ├── 我是怎么学习的?
│ └── 我的职业规划
├── 数据结构专题:栈、链表、树
│ └── leetcode 重要性下降了
├── 前端面试热题
│ ├── event loop、闭包 ← 专题性复习
│ ├── react hooks
│ └── 扁平化 js api
├── 前端工程化:webpack → vite
├── CSS 水平垂直居中
├── AI 的使用和基本概念
│ ├── claude code / codex Coding Agent
│ ├── SDD
│ ├── 我和 AI 工具的职责 harness
│ ├── sse + websocket
│ └── rag
├── 前端向全栈过渡
│ ├── 跨域
│ └── 页面渲染过程(HTML DOM 树 + CSSOM → 静态页面)
├── 前沿技术、性能优化
│ └── webgpu
└── 运维部署知识
└── 宝塔
看完这张图,有个感受------"传统前端"的边界正在被撑大。 除了 CSS 居中、数据结构这些老面孔,还多了 Coding Agent、SSD、harness、RAG、宝塔部署。readme 的总结也很实在:
"积极有激情的自我介绍(AI 理解,项目带出来);数据结构;前端热题;前端工程化题;AI coding 及理念;前端高级及性能优化;运维 CI/CD。"
但今天只讲一个点------闭包。因为它被 readme 单独标了"专题性复习"。
面试官的提醒:别背 API
readme 里有一段特别值得抄下来的话,讲的是"怎么回答 React hooks 题":
"误区:将了解的 hooks 一一回答,没有区分度。有深度、广度、专业度,打动面试官(想听什么)。组织回答逻辑,分类------哪几个 hooks 是解决什么问题的。每个 hooks 高级特性、实战中怎么用。"
以及方法论总结:
"先将分类,再讲核心作用、使用场景、踩坑,不要单纯地罗列 API。"
这就是今天这篇文章的组织方式------不罗列,只讲陷阱。
二、闭包到底是什么:一场"规则冲突"的产物
readme 用了一个非常独特的切入角度------闭包不是因为设计者想加个功能,而是为了解决两条规则的冲突。
"为什么有闭包这个概念?词法作用域的规则,和函数调用完毕它的执行上下文一定会被销毁这一规则冲突。"
翻译一下这两条规则:
| 规则 | 说的是什么 |
|---|---|
| 词法作用域 | 函数能访问自己声明位置外层的变量 |
| 执行上下文销毁 | 函数调用结束后,它的执行上下文会从栈里弹出、内存被回收 |
冲突在哪?
javascript
function foo() {
var b = 2;
function bar() {
console.log(b); // bar 要靠词法作用域拿到 foo 的 b
}
return bar;
}
const baz = foo(); // foo 执行完了!按规则 2,b 该被销毁
baz(); // 但 bar 还要用 b ------ 冲突!
foo 已经返回了,按"执行上下文销毁"规则,b 该被回收。但 bar 被返回出去,它按"词法作用域"规则还要访问 b。两条规则打架了。
readme 给出的解法:
"解决冲突的办法:将内部函数需要使用的变量(自由变量),放到闭包(背包)中,持久化地放到堆内存里(不会被垃圾回收),内部函数运行 outer 指向背包,拿到变量。"
背包------这是今天最重要的比喻。
ini
调用栈(用完就弹走) 堆内存(持久存在)
┌──────────┐ ┌──────────┐
│ foo 的 │ │ 背包 │
│ 执行上下文 │ ──搬进──→ │ b = 2 │ ← 自由变量
└──────────┘ └──────────┘
执行完弹出 一直留着
↑
bar 的 outer 指向这里
foo 的执行上下文弹走了,但里面的变量 b 被"打包"进背包,放到了堆内存------不会随着出栈被回收 。bar 运行时,沿着 outer 指针找到背包,拿到 b。
1.js 的注释把这个过程写得更口语化:
javascript
// 闭包,其实就是一个背包(堆内存中,不会因为函数的出栈被回收),
// 放到这个背包里面,闭合在两个函数间的变量背包
// 把闭包中的变量叫自由变量
所以------
闭包 = 自由变量的集合,在外部栈销毁后,还能访问。
注意"自由变量"这个词 :b 在 bar 内部被使用,但不是在 bar 内部声明的------它是"自由的"。凡是这种"在内部使用、在外部声明"的变量,就是自由变量。装自由变量的背包,就是闭包。
三、陷阱一:作用域链是"静态"的,不是"动态"的
第一条规则的核心是词法作用域。readme 的解释:
"词法作用域就是:作用域是由代码中函数声明的位置来决定的。所以词法作用域是静态的作用域。通过它能够预测代码在执行过程中如何查找变量标识符。"
"JS 作用域链是由词法作用域决定的。词法作用域是代码编译阶段决定好的,和函数是怎么调用的没关系。"
"和函数是怎么调用的没关系"------这句话就是陷阱的伏笔。
3.js:那道经典题
javascript
function bar() {
console.log(myName);
}
function foo() {
var myName = "极客邦"
bar()
}
var myName = "极客时间"
foo()
问:打印什么?
如果你用"动态作用域"的直觉去想------bar() 是在 foo() 里面被调用的,那 bar 应该能拿到 foo 里的 myName,也就是打印"极客邦"。
错。答案打印"极客时间"。
原因:查找变量的顺序由"声明位置"决定,不是"调用位置"。 bar 声明在全局,它的 outer 就指向全局作用域。所以:
ini
bar 运行时查找 myName:
bar 的作用域 → 没有
↓ outer
全局作用域 → myName = "极客时间" ✅ 找到
(foo 里的 "极客邦" 根本不在查找路径上)
foo() 里那个 myName = "极客邦" 压根没被访问到------尽管 bar 是在 foo 内部被调用的。
readme 把变量查找过程讲得很清楚:
"内部函数运行时,查找变量,JS 引擎:当前执行上下文 → outer → outer ----全局。变量的查找路径规则、路径,作用域链。"
作用域链的三件套
readme 还列了完整的执行上下文结构:
"调用栈、执行上下文、词法环境、变量环境(outer)、闭包变量(背包对象)在堆内存中。每个执行上下文的变量中,都包含一个外部引用,用来指向外部的执行上下文。这个对外部引用成为 outer。"
一句话概括整个链路:
sql
调用栈 → 执行上下文 → { 词法环境 / 变量环境(outer) } → outer 指向外层 → ... → 全局
↓
自由变量存在堆内存的"背包"里
以及代码执行的完整流程:
"js 代码的执行:编译 → 准备执行上下文 → 作用域 → 变量环境、词法环境 → 作用域链(变量查找的路径)。"
先编译定位作用域,再执行。 这解释了为什么词法作用域是"静态"的------编译阶段就定死了,运行时改不了。
四、陷阱二:闭包会"记住"变量,不是"快照值"
理解了作用域链,第二个陷阱就来了------闭包记住的是变量本身(引用),不是某个瞬间的值。
2.js:记忆函数
javascript
// 记忆函数
function Add() {
let num = 0;
return function foo() {
console.log(++num);
}
}
const res = Add()
res() // 1
res(); // 2
Add() 执行完,num 本该被回收。但它被闭包装进背包了------
- 第一次
res():++num→ 1 - 第二次
res():同一个背包,num还是上次的 1,++num→ 2
如果闭包记的是"快照值",第二次应该还是 1。但它记的是变量本身,所以会累加。
这就是"记忆函数"(也叫计数器、状态保持)的经典实现------闭包是用来保存状态的容器。
1.js:验证自由变量
javascript
function foo() {
function bar() {
var a = 1
console.log(b); // b 在 bar 里没有声明 ------ 自由变量
}
var b = 2; //
return bar
}
// var b = 3; ← 注释掉了
foo();
const baz = foo() // this 指向 动态 能销毁吗?一定是要离开调用栈的栈顶的,
// 垃圾回收?矛盾就来了,
baz();// bar函数 入栈,outer 指向 foo,引用 foo b
注意代码里注释掉的 var b = 3------课堂应该是演示过"如果全局也有 b,会取哪个"。
而这里 b 只在 foo 里声明(值为 2),bar 的 outer 指向 foo 的背包,所以 baz() 打印 2。
代码注释还留了一句很有价值的思考:
"函数调用完毕,执行上下文一定会被销毁 / 垃圾回收?矛盾就来了。"
矛盾 → 闭包。 这行注释把整节课的逻辑串起来了------正是因为"该销毁"和"还得用"的矛盾,才有了背包这个方案。
五、陷阱三:闭包不只是"读",还能"写"
很多人以为闭包只是"记住变量",其实闭包能修改外部变量------这才是它最实用的地方。
4.js:用闭包造私有变量
1.html 引入了这个文件:
html
<script src="./4.js"></script>
4.js 的内容:
javascript
function foo() {
var myName = "极客时间"
let test1 = 1; // 自由变量
const test2 = 2;
var innerBar = {
getName: function() {
console.log(test1);
return myName;
},
setName: function(newName) {
myName = newName;
}
}
return innerBar
}
{
let bar = foo()
bar.setName("极客邦")
console.log(bar.getName());
}
执行结果:
arduino
1 ← getName 里的 console.log(test1)
极客邦 ← console.log(bar.getName()) 的返回值
拆一下发生了什么:
| 步骤 | 做了什么 | 背包状态 |
|---|---|---|
foo() 返回 |
innerBar 被返回,foo 出栈 | 背包:{myName: "极客时间", test1: 1, test2: 2} |
bar.setName("极客邦") |
闭包修改了 myName | 背包:{myName: "极客邦", ...} |
bar.getName() |
读 myName | 返回 "极客邦" |
getName 和 setName 共享同一个背包。 setName 改的 myName,getName 读得到------这就是闭包的"读写能力"。
这也是一种数据封装 的经典手法------myName 像是私有属性,只能通过 setName / getName 这两个"接口"访问,外部拿不到原始变量。
代码注释点出了关键:
"自由变量。"
test1(let 声明)被标注为自由变量------getName 里用到了它,但它声明在 foo 里。整个背包装的就是这些自由变量。
六、把闭包带去 React:三个必考陷阱
readme 在 useState 部分列了三件事,每一件都跟闭包有关系:
"1. 惰性初始化 2. 状态更新是异步批量更新 3. 闭包捕获了旧渲染周期的状态快照。"
陷阱 A:惰性初始化------别在 useState 里放昂贵计算
App.jsx(第二份)演示了这个问题:
javascript
// 昂贵计算函数
function heavyCalc() {
console.log('🔥 执行昂贵计算');
// 模拟大数据循环/复杂运算
let sum = 0;
for(let i = 0; i < 1000000; i++) sum += i;
return sum;
}
export default function App() {
// ✅ 惰性初始化:传入函数,仅挂载时运行一次
const [value, setValue] = useState(() => heavyCalc());
// 普通写法 ❌ 每次组件重渲染都会调用 heavyCalc()
// const [value, setValue] = useState(heavyCalc());
console.log('组件渲染');
...
}
区别在哪?
| 写法 | 执行时机 | 后果 |
|---|---|---|
useState(heavyCalc()) |
每次渲染都调用 heavyCalc() | 一百万次循环,每次重渲染都跑一遍 |
useState(() => heavyCalc()) |
只在首次挂载时调用一次 | 后续重渲染直接复用结果 |
关键在第二行注释:"点击会触发组件重渲染,不会再执行 heavyCalc"。
为什么传函数就只跑一次?因为 React 内部对 useState 的初始值有判断------如果是函数,就延迟调用 它,并且只调用一次(首次挂载)。如果直接传 heavyCalc() 的结果,那这个函数在调用 useState 之前就已经执行完了------每次渲染都会执行。
readme 的原文:
"useState 的初始值需要进行昂贵的计算时(大数据,复杂数学运算),使用一个惰性初始化(传函数)确保昂贵的计算仅在组件首次挂载时执行。"
javascript
const [users] = useState(() => heavyComputation());
陷阱 B:状态更新是异步批量的
readme 的原文:
"状态更新是异步批量更新。主要为性能,合并多次 state 修改,减少重复渲染。如果每次 setState 都立刻更新视图,频繁操作会大量渲染,拖慢页面。"
代码示例:
javascript
const handleAdd = () => {
// 1 不是 3
// 批量更新会异步,合并。
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
setCount((prevCount) => prevCount + 1);
}
// 函数式更新,让React在更新队列中拿到最新值计算,这是可以先完成的。
注意那行注释------"1 不是 3"。
假设 count 初始为 0:
- 前三行
setCount(count + 1):三次传入的都是setCount(1)(因为count还是旧的 0,且三次读的是同一个闭包里的count) - React 把它们合并------三次一样的更新,等于一次。所以三次调用只让 count 从 0 变成 1
- 第四行
setCount(prevCount => prevCount + 1):函数式更新,React 在更新队列里拿到最新的值 1,再 +1 → 2
最终结果 2。 前三行只贡献了 1 次增长,这就是注释"1 不是 3"的意思。
为什么会有这种反直觉?因为组件的 count 是这一轮渲染时的快照 ------四次调用读到的都是同一个旧值。要打破这个局限,就得用函数式更新 prev => prev + 1,让 React 在队列里按顺序算出最新值。
陷阱 C:闭包捕获旧快照------useEffect 的定时器地狱
这是前两个陷阱的"合体",也是最经典的面试题。App.jsx(第一份):
javascript
import { useState } from 'react';
const App = () => {
const [count, setCount] = useState(0);
useEffect(() => {
// 闭包 函数嵌套函数
// 函数的声明相关的
// 只声明一次,回调函数执行多次
const timer = setInterval(() => {
// console.log(count);
// setCount(count + 1);
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(timer);
},[])
return <div>Count: {count}</div>
}
export default App;
代码注释把问题说得很清楚:
"只声明一次,回调函数执行多次。"
useEffect 的依赖数组是 []------这个 effect 只在挂载时执行一次。 它里面创建的那个 setInterval 回调,就是用首次渲染时的闭包创建的。
那个闭包捕获的 count 永远是 0。所以:
| 写法 | 效果 |
|---|---|
setCount(count + 1)(被注释掉) |
每次都是 setCount(0 + 1) → count 永远显示 1 |
setCount(prev => prev + 1)(实际使用) |
React 拿到队列里最新的值再 +1 → 正常每秒递增 |
注释掉的 console.log(count) 和 setCount(count + 1) 是课堂故意留的"错误示范"------这就是闭包捕获旧快照的坑。
readme 对第三个陷阱的总结:
"闭包捕获了旧渲染周期的状态快照,导致函数执行时拿到的是过期的旧函数值,从而引发状态更新丢失或逻辑异常。"
注意 readme 里那句话的小笔误("逻辑一场"应为"逻辑异常"),意思很清楚------过期快照会让状态更新丢失。
所以 prev => prev + 1 不只是一个"更优雅的写法",它是闭包陷阱的正解。
七、闭包的代价:它也有缺点
面试题不可能只问好处。readme 专门列了闭包的缺点:
"闭包的缺点:1. 会保持对其他外部作用域的引用,可能导致内存无法及时释放,内存泄漏。如果闭包持续存在,并且外部作用域的对象也被闭包引用,对象无法垃圾回收。2. 每次创建闭包时,都会有额外的上下文开销,尤其是在频繁创建闭包的情况下。"
两条:
| 缺点 | 原因 |
|---|---|
| 内存泄漏 | 闭包长期持有外部变量引用,对象无法被回收 |
| 上下文开销 | 每次创建闭包都要分配背包空间,频繁创建开销大 |
闭包是怎么被回收的?
readme 给了一段非常实用的判断标准:
"如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大,那就尽量让它成为一个局部变量。"
"如果引用闭包的函数是一个全局变量,闭包会一直存在直到页面关闭。如果这个闭包以后不再使用的话,就会造成内存泄漏。"
"如果使用闭包的函数是个局部变量,函数销毁后,JS 会执行垃圾回收,判断后,回收闭包。"
翻译成决策树:
闭包被谁引用?
├── 全局变量 → 活到页面关闭(不用的就是内存泄漏)
└── 局部变量 → 函数销毁后,可被垃圾回收
实用建议:能用局部变量就别挂全局。 这也是 React 里 useEffect 返回清理函数(return () => clearInterval(timer))的原因------把引用断掉,让闭包可以被回收。
八、回到面试:怎么把这道题答出彩
readme 的方法论是"先分类,再讲核心作用、使用场景、踩坑"。那这道闭包题的标准答案应该怎么组织?
第一步:定义(一句话讲清矛盾)
闭包是词法作用域规则与执行上下文销毁规则冲突的产物------内部函数需要访问外部变量,但外部函数的执行上下文已经出栈。JS 的解法是:把这些自由变量放到堆内存的"背包"中持久保存,内部函数通过 outer 指针访问。
第二步:作用域链(讲清查找路径)
作用域链由词法作用域决定------在编译阶段就确定了,跟调用位置无关 。变量查找路径:当前执行上下文 → outer → outer → ... → 全局。这就是 3.js 那道题打印"极客时间"而不是"极客邦"的原因。
第三步:实战场景(讲清能用来干嘛)
- 状态保持:
2.js的记忆函数(计数器) - 数据封装:
4.js的 getName / setName 私有变量 - React hooks:
useState的惰性初始化、useEffect的定时器 - 函数柯里化、防抖节流
第四步:踩坑(讲清风险)
- 循环里的闭包(var 声明导致都拿到最后一个值)
useEffect空依赖 + 闭包捕获旧快照 → 状态更新丢失- 内存泄漏:全局持有的闭包不会被回收
第五步:性能与回收(讲清优化)
- 能局部就别全局
- 及时断开引用(清理函数)
- 频繁创建的闭包要注意上下文开销
这套答法,覆盖了 readme 说的"深度、广度、专业度",避开了"一一罗列 API"的误区。
九、一张图收尾
arduino
js 代码执行
↓ 编译阶段
准备执行上下文 → 作用域 → { 变量环境, 词法环境 }
↓
作用域链(静态,由函数声明位置决定)
↓
函数嵌套,内部函数引用外部变量 = 自由变量
↓
外部函数出栈,上下文销毁 ← 与词法作用域规则冲突!
↓
解法:自由变量装入"背包"(堆内存,不被回收)
↓
闭包 = 自由变量的集合,外部栈销毁后仍可访问
↓
实战:记忆函数 / 私有变量 / React hooks
↓
代价:内存泄漏风险 + 上下文开销
↓
回收:全局引用活到页面关闭;局部引用随函数销毁被回收
PS:readme 最后留了四行没写完的标题------"了解面试官 / 如何拿下面试 / 理解作用域链是理解闭包的基础 / 闭包无处不在"。前两个是被砍掉的"软技能"章节,后两个是硬核结论。面试官挖的坑,基本都是闭包填的;而闭包这个背包,是你自己背上身的------什么时候卸下来,看你了。