💫 闭包是个背包:拆解小米前端面试题里的三道"闭包陷阱"

写在前面:今天这节课有点特别------不是学新框架,是准备面试 。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 最后留了四行没写完的标题------"了解面试官 / 如何拿下面试 / 理解作用域链是理解闭包的基础 / 闭包无处不在"。前两个是被砍掉的"软技能"章节,后两个是硬核结论。面试官挖的坑,基本都是闭包填的;而闭包这个背包,是你自己背上身的------什么时候卸下来,看你了。

相关推荐
天若有情6731 小时前
粒子星空背景单页HTML模板|炫酷动态星空特效网页(纯前端)
前端·html·css动画·网页特效·粒子星空·静态网页模版
工业涂料百问1 小时前
【趋势前沿】系列(三)工业涂料国产替代的三个台阶:航空、海工、电子的突破路径与认证壁垒
前端
前端snow1 小时前
ai agent --- agentic RAG
前端
gyratesky1 小时前
记录一种很新的大屏开发方式
前端·数据可视化
DeepAgent2 小时前
AI Agent 入门指南(05):Agent 循环——观察、思考、行动
面试·agent
xy34532 小时前
Axure 9.0 中继器核心结构
前端·ui·html·axure·原型·产品设计
老王以为2 小时前
走进 AI Agent 第四篇(上):知识获取管道——RAG 基础
前端·人工智能·全栈
夏天要喝冰可乐3 小时前
一处写作,多平台分发:我给掘金做了一款开源 Chrome 插件
前端·chrome·vibecoding