从小米前端面试题,彻底搞懂闭包、作用域链与 useState 惰性初始化
本文基于一份小米前端面试复习笔记整理,把「闭包」这个被讲烂却依然容易翻车的知识点,从执行上下文、词法作用域、作用域链、内存回收 一路讲到 React Hooks 中的闭包陷阱 ,同时覆盖笔记中提到的面试理念、复习模块、AI Coding、工程化、性能优化、运维部署等全部内容。适合准备中高级前端面试、想真正理解 JS 底层机制的同学。
一、面试官的面试理念:先了解面试,再拿下面试
笔记开篇就点明了整场面试的底层逻辑:了解面试,了解面试官,知道如何拿下面试。
1.1 自我介绍与暖场
面试一开始的自我介绍和暖场,看似随意,其实面试官在观察三件事:
- 我是谁?------你的背景、技术栈、项目经历。
- 我是怎么学习的?------你的学习方法论,是否主动、是否有体系。
- 我的职业规划------你想成为什么样的工程师,和岗位是否匹配。
笔记特别强调:积极主动、有激情的自我介绍(AI 理解,项目带出来)。也就是说,自我介绍不要干巴巴念简历,而要带着项目讲,带着对 AI 的理解讲,让面试官感受到你的热情和思考。
同时要意识到:面试官是有权利的。他决定问你什么、追问多深、给不给你过。所以你的回答要主动引导,把面试官往你熟悉的、有深度的方向带。
1.2 数据结构专题的复习
笔记明确列出:栈、链表、树,编程基础。leetcode 重要性下降了。
这意味着:
- 基础数据结构仍然要会,因为它们是理解执行栈、作用域链、DOM 树、虚拟 DOM 的基础。
- 但纯粹刷 leetcode 的边际收益在下降,面试官更看重工程实践和底层理解。
1.3 前端面试热题
笔记列出的热题方向:
- event loop / 闭包------专题性复习,不是零散记忆。
- React Hooks------重点考察闭包在 React 中的体现。
- 扁平化 JS API ------比如
flat、手写扁平化、数组/对象处理。
这三块是传统前端面试的「必争之地」,后面我们会逐一展开。
1.4 前端工程化
webpack → vite,这是笔记明确指出的演进路线。
面试官想听的不只是「你会用 vite」,而是:
- 为什么从 webpack 迁移到 vite?
- vite 的 dev server 为什么快?(esbuild 预构建 + 原生 ESM)
- 生产环境为什么还用 rollup 打包?
- 工程化解决了什么问题?(模块化、热更新、构建优化、产物分析)
1.5 CSS 水平垂直居中
笔记单独列了一条:css 水平垂直居中。
这是经典手写题,至少要能说出:
- flex:
display: flex; justify-content: center; align-items: center; - grid:
display: grid; place-items: center; - absolute + transform:
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); - absolute + margin auto(定宽高)
- table-cell(老方案)
面试官可能追问:不定宽高怎么办?------那就用 flex/grid 或 transform。
1.6 AI 的使用和基本概念
笔记里 AI 部分列得非常具体:
- claude code / codex Coding Agent------AI 编程代理工具。
- SDD------Specification-Driven Development,规范驱动开发。
- 我和 AI 工具的职责------哪些事交给 AI,哪些事自己把关。
- harness------AI 工程的脚手架/测试夹具。
- 热题------AI 相关的高频面试题。
- sse + websocket------实时通信,AI 流式输出的基础。
- rag------检索增强生成,前端如何接入知识库。
这部分是笔记里「少量 AI 思想」的体现,说明即使面传统前端,AI 素养也已经成为加分项。
1.7 前端向全栈过渡的问题
笔记列出两个关键点:
- 跨域------同源策略、CORS、代理、JSONP、postMessage。
- 页面渲染过程------HTML 解析成 DOM 树,CSS 解析成 CSSOM,两者结合成静态页面,再布局、绘制、合成。
跨域是前端走向全栈的第一道坎,页面渲染过程则是理解性能优化的基础。
1.8 前沿技术与性能优化
笔记点名:webgpu。
这说明面试官关注前沿技术,尤其是高性能计算、图形渲染方向。即使不精通,也要知道:
- WebGPU 是什么?------下一代 Web 图形和计算 API,替代 WebGL。
- 它能干什么?------3D 渲染、并行计算、AI 推理加速。
- 和 WebGL 的区别?------更底层、更高效、支持 compute shader。
1.9 运维部署知识
笔记最后提到:宝塔。
这是国内中小团队常用的服务器管理面板。面试官想确认你不仅能写前端,还能:
- 部署静态资源到服务器。
- 配置 Nginx 反向代理。
- 用宝塔管理数据库、SSL、定时任务。
- 理解 CI/CD 的基本流程。
1.10 总结:面试复习的六大模块
笔记最后总结得非常清晰:
- 积极主动、有激情的自我介绍(AI 理解,项目带出来)
- 数据结构
- 前端热题
- 前端工程化题
- AI coding 及理念
- 前端高级和性能优化
- 运维 CI/CD
笔记原话:准备每个模块,每道题都精彩。
也就是说,不要只准备一两个亮点,而要每个模块都能讲出深度。下面我们以「闭包」为主线,把这些模块串起来。
二、为什么面试官总爱问闭包?
在笔记的面试理念里,面试官明确提到几个专题性复习方向:
- event loop / 闭包
- React Hooks
- 扁平化 JS API
- 工程化、CSS、AI Coding、性能优化、运维部署
闭包几乎是前端面试的「万金油」:它既能考察 JS 基础(作用域、内存),又能延伸到 React Hooks(useState、useEffect 的闭包陷阱),还能聊到性能与内存泄漏。
但很多人的回答停留在:
"闭包就是函数内部返回一个函数,可以访问外部函数的变量。"
这句话没错,但没有区分度。笔记里专门批评了这种答法:
误区:将了解的 hooks 一一回答,没有区分度。 要有深度、广度、专业度,打动面试官(想听什么)。 组织回答逻辑,分类,哪几个 hooks 是解决什么问题的。 每个 hooks 高级特性,实战中怎么用,面试的流程(时长)。 先将分类,再讲核心作用,使用场景,踩坑,不要单纯罗列 API。
这个逻辑同样适用于闭包。面试官想听的是:为什么会有闭包?它是怎么产生的?什么时候回收?在 React 里怎么踩坑?
下面我们按笔记的顺序,一层层拆开。
三、闭包的根源:词法作用域 vs 执行上下文销毁
笔记里有一句非常关键的话:
词法作用域的规则和函数调用完毕他的执行上下文一定会被销毁这一规则冲突。 解决冲突的办法:将内部函数需要使用的变量(自由变量)放到闭包(背包)中。 持久化地放到堆内存里(不会被垃圾回收),内部函数运行 outer 指向背包,拿到变量。
这句话是理解闭包的钥匙。来看一段代码:
js
function foo() {
function bar() {
var a = 1
console.log(b); // 访问外部变量 b
}
var b = 2;
return bar
}
const baz = foo()
baz(); // 输出 2
执行过程拆解
-
编译阶段(词法作用域确定)
bar函数在声明时,就确定了它的外部引用outer指向foo的作用域。- 注意:是声明位置决定,不是调用位置。这就是「词法作用域是静态的」。
-
调用
foo()- 创建
foo的执行上下文,压入调用栈。 foo内部声明b = 2,并返回bar函数。foo执行完毕,理论上它的执行上下文应该出栈销毁。
- 创建
-
矛盾出现
- 如果
foo的上下文被销毁,那b也应该没了。 - 但
bar里还引用着b,将来baz()调用时还要用。 - 于是 JS 引擎把
bar需要的自由变量{ b: 2 }放进一个「背包」------这个背包在堆内存中,不会随foo出栈被回收。 bar的outer指向这个背包。
- 如果
-
调用
baz()bar入栈,查找b时沿着outer找到背包里的b,输出2。
一张图理解
scss
调用栈 堆内存
┌─────────────┐
│ baz() │
│ outer ─────┼──────► ┌─────────────────┐
└─────────────┘ │ 闭包背包 │
│ { b: 2 } │
│ (自由变量集合) │
└─────────────────┘
▲
│ outer
┌────────┴────────┐
│ bar 函数对象 │
└─────────────────┘
笔记原话:闭包 = 自由变量的集合,在外部函数销毁后,还能访问。 js 代码的执行:编译,准备执行上下文,作用域(变量查找),变量环境,词法环境,作用域链(变量查找的路径)。
四、作用域链:变量查找的「高速公路」
笔记里对作用域链的描述:
每个执行上下文的变量环境中,都包含一个外部引用,用来指向外部的执行上下文。这个对外部引用称为 outer。 内部函数运行时,查找变量,JS 引擎:当前执行上下文 → outer → outer → ... → 全局。 变量的查找规则、路径,就是作用域链。
看这段代码:
js
function bar() {
console.log(myName);
}
function foo() {
var myName = "极客邦"
bar()
}
var myName = "极客时间"
foo(); // 输出?
很多人会答「极客邦」,因为 foo 里定义了 myName。错!
正确答案是 「极客时间」。
原因:bar 的 outer 指向的是它声明时所在的作用域 ,也就是全局作用域,而不是调用它的 foo。所以查找 myName 时:
ini
bar 执行上下文 → outer → 全局作用域 → myName = "极客时间"
这就是词法作用域:作用域由代码中函数声明的位置决定,和调用位置无关。
笔记原话:词法作用域就是作用域是由代码中函数声明(阶段)的位置来决定的。所以词法作用域是静态的作用域。通过它能够预测代码在执行过程中如何查找变量标识符。 JS 作用域链是由词法作用域链决定的。词法作用域是代码编译阶段决定好的,和函数是怎么调用没有关系。
调用栈、执行上下文、词法环境、变量环境
笔记里还提到了一组底层概念:
调用栈、执行上下文、词法环境、变量环境(outer)、闭包变量(背包对象)堆内存中。
拆开来看:
- 调用栈:函数调用时入栈,执行完出栈。
- 执行上下文:每次函数调用都会创建一个,包含变量环境、词法环境、outer 等。
- 词法环境 :存放
let、const声明的变量。 - 变量环境 :存放
var声明的变量。 - outer:指向外层执行上下文,构成作用域链。
- 闭包变量(背包对象):被内部函数引用、且外部函数已销毁的自由变量,存放在堆内存中。
这些概念是理解闭包的底层基础。
五、闭包的内存回收:什么时候会被释放?
笔记里专门讨论了「闭包是怎么回收的」:
如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大,那就尽量让它成为一个局部变量。 如果引用闭包的函数是一个全局变量,闭包会一直存在直到页面关闭。如果这个闭包以后不再使用的话,就会造成内存泄漏。 如果使用闭包的函数是个局部变量,函数销毁后,JS 引擎执行垃圾回收时,判断后,回收这块内存。
看这段记忆函数:
js
function add() {
let num = 0;
return function foo() {
console.log(++num);
}
}
const res = add()
res() // 1
res() // 2
res是全局变量,持有对内部函数foo的引用。foo的outer指向add的作用域,num被闭包保存。- 因为
res一直存在,所以num不会被回收,每次调用累加。
如果把 res 改成局部变量:
js
function test() {
const res = add()
res() // 1
res() // 2
}
test()
// test 执行完,res 销毁,闭包不再被引用,GC 可回收 num
闭包的缺点(笔记原文)
- 会保持对其外部作用域的引用,可能导致内存无法被及时释放。内存泄漏。
- 如果闭包持续存在,并且外部作用域的对象也被闭包引用,对象无法垃圾回收,可能导致在调用栈中的占用增加。
- 每次创建闭包时,都会有额外的上下文开销,尤其是在频繁创建闭包的情况下,可能会影响性能。
笔记原话:闭包无处不在。理解作用域链(静态,函数声明时(非运行时)outer 指向外层函数)是理解闭包的基础。
六、闭包在 React 中的经典陷阱
笔记里 React Hooks 部分重点讲了 useState 和闭包:
闭包捕获了旧渲染周期的状态快照,导致函数执行时拿到的是过期的旧值,从而引发状态更新丢失或逻辑异常。 组件是函数,函数内部还有函数,闭包。
看这段代码:
jsx
import { useState, useEffect } from 'react';
const App = () => {
const [count, setCount] = useState(0);
useEffect(() => {
// 闭包:函数嵌套函数
// 函数的声明相关的
// 只声明一次,回调函数执行多次?
const timer = setInterval(() => {
// console.log(count); // ❌ 永远输出 0
// setCount(count + 1); // ❌ 永远变成 1
setCount(prevCount => prevCount + 1); // ✅ 正确
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>Count: {count}</div>
}
为什么 setCount(count + 1) 会失效?
useEffect的依赖数组是[],所以回调只执行一次。- 这次执行时,
count被闭包捕获为 首次渲染的 0。 setInterval里的箭头函数每次执行,拿到的都是那个旧的count = 0。- 所以
setCount(count + 1)永远是setCount(1)。
为什么函数式更新 setCount(prev => prev + 1) 可以?
- React 在更新队列中保存的是更新函数,而不是具体值。
- 执行时 React 会把最新的 state 传给你,所以能正确累加。
笔记原话:函数式更新,让 React 在更新队列中拿到最新值计算,这是可以先完成的。
一张图理解闭包陷阱
ini
首次渲染:count = 0
│
▼
useEffect 执行一次 ──► 闭包捕获 count = 0
│
▼
setInterval 每秒触发 ──► 读到的永远是 0
│
▼
setCount(0 + 1) ──► 永远设为 1 ❌
函数式更新:
setCount(prev => prev + 1) ──► React 传入最新 prev ✅
七、useState 惰性初始化:一个容易忽略的性能优化
笔记里还提到 useState 的一个高级特性:
当
useState的初始值需要进行昂贵的计算时(大数据、复杂数学计算),使用一个惰性初始化(传函数),确保昂贵的计算仅在组件首次挂载时执行。
看这段代码:
jsx
import { useState } from 'react';
// 昂贵计算函数
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('组件渲染');
return (
<div>
<p>结果:{value}</p>
{/* 点击会触发组件重渲染,不会再执行 heavyCalc */}
<button onClick={() => setValue(v => v + 1)}>点我更新state</button>
</div>
);
}
关键区别
| 写法 | 行为 |
|---|---|
useState(heavyCalc()) |
每次渲染都会执行 heavyCalc(),结果被丢弃 |
useState(() => heavyCalc()) |
只在首次挂载时执行一次,后续渲染跳过 |
React 内部会判断 useState 的参数:
- 如果是函数,就惰性调用它来获取初始值。
- 如果不是函数,直接作为初始值。
这是一个非常实用的性能优化点,面试时能说出来会加分。
八、状态更新的异步批量:为什么连续 setCount 只加 1?
笔记里还有一段关于 useState 批量更新的说明:
js
const handleAdd = () => {
// 1,不是 3
// 批量更新会异步,合并
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
setCount((prevCount) => prevCount + 1);
}
原因
- React 18 之前,在事件处理函数中,多次
setState会被合并成一次更新。 - 每次
setCount(count + 1)里的count都是当前渲染快照里的值,不是最新的。 - 所以三次都相当于
setCount(0 + 1),最终只加 1。 - 函数式更新
prev => prev + 1则会在更新队列中依次执行,拿到最新值。
为什么要批量更新?
笔记原话:主要为性能,合并多次 state 修改,减少重复渲染。如果每次 setState 都立刻更新视图,频繁操作会大量重渲染,拖慢页面。 性能问题:频繁更新页面,触发大量的重绘重排。
九、闭包的完整案例:getName / setName
js
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()); // 输出 1,然后返回 "极客邦"
}
解析
foo返回innerBar,里面两个函数都引用了foo作用域里的变量。test1被getName引用,myName被getName和setName共同引用。- 这些变量被放入闭包背包,不会因为
foo执行完而销毁。 bar.setName("极客邦")修改的是闭包里的myName。bar.getName()读到的是修改后的myName。
注意:
test2没有被任何内部函数引用,所以它不会 进入闭包,foo执行完后可以被回收。这也是 V8 优化闭包的一个点:只保留被引用的变量。
十、闭包与 event loop、扁平化 JS API 的联系
笔记把「event loop / 闭包」放在一起,是有道理的。
10.1 event loop 中的闭包
js
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}
// 输出 3 3 3
var没有块级作用域,三个setTimeout回调共享同一个i。- event loop 把回调放到宏任务队列,等同步代码执行完才执行,此时
i已经是 3。 - 用
let或 IIFE 创建闭包,就能输出0 1 2。
10.2 扁平化 JS API 中的闭包
手写 flat 时,递归函数本身就是闭包:
js
function flatten(arr, depth = 1) {
return arr.reduce((acc, cur) => {
if (Array.isArray(cur) && depth > 0) {
acc.push(...flatten(cur, depth - 1));
} else {
acc.push(cur);
}
return acc;
}, []);
}
flatten递归调用自己,每次调用都创建新的执行上下文。depth被闭包捕获,保证递归层级正确。
十一、闭包与前端工程化、性能优化
笔记里工程化部分提到 webpack → vite ,性能优化部分提到 webgpu。闭包和它们有什么关系?
- 工程化:打包工具需要分析模块依赖,闭包会影响 tree-shaking 的效果。如果一个函数被闭包引用,打包器很难判断它是否可删除。
- 性能优化:闭包导致的内存常驻,是前端内存泄漏的常见原因。用 Chrome DevTools 的 Memory 面板可以抓取闭包快照,定位泄漏点。
- WebGPU:在 GPU 并行计算中,回调函数、闭包会频繁创建,需要注意上下文开销。
十二、闭包与 AI Coding、全栈过渡
笔记里 AI 部分提到 claude code / codex Coding Agent、SDD、harness、sse + websocket、rag。这些和闭包看似无关,但底层是相通的:
- Coding Agent 生成代码时,经常产生闭包。你要有能力 review 它,判断是否有内存泄漏风险。
- SDD(规范驱动开发) 强调先写规范再写代码,闭包的边界(哪些变量该被捕获)就是规范的一部分。
- SSE + WebSocket 的流式回调,本质是闭包在持有连接状态。
- RAG 前端接入知识库时,检索结果的缓存、回调,也会用到闭包。
笔记里「前端向全栈过渡的问题」提到 跨域 和 页面渲染过程:
- 跨域请求的回调、代理配置,常与闭包配合。
- 页面渲染过程中,HTML → DOM 树,CSS → CSSOM,两者结合成静态页面。闭包不影响渲染本身,但影响 JS 执行时机,进而影响渲染性能。
十三、总结:一张思维导图
sql
闭包
├── 面试理念
│ ├── 了解面试、了解面试官、如何拿下面试
│ ├── 自我介绍:我是谁、怎么学习、职业规划
│ ├── 数据结构:栈、链表、树
│ ├── 前端热题:event loop、闭包、React Hooks、扁平化 JS API
│ ├── 工程化:webpack → vite
│ ├── CSS:水平垂直居中
│ ├── AI:Coding Agent、SDD、harness、SSE + WebSocket、RAG
│ ├── 全栈过渡:跨域、页面渲染过程
│ ├── 前沿技术:WebGPU
│ └── 运维部署:宝塔、CI/CD
├── 根源
│ ├── 词法作用域(静态,声明时决定)
│ └── 执行上下文销毁规则冲突
├── 本质
│ └── 自由变量的集合(背包),存在堆内存
├── 作用域链
│ ├── outer 指向声明时的外部作用域
│ └── 查找路径:当前 → outer → ... → 全局
├── 回收
│ ├── 全局引用:页面关闭才释放
│ └── 局部引用:函数销毁后可回收
├── React 中的闭包
│ ├── useState 惰性初始化
│ ├── useState 批量更新
│ ├── useEffect 闭包陷阱
│ └── 函数式更新解决旧值问题
└── 缺点
├── 内存无法及时释放
└── 频繁创建有上下文开销
十四、面试怎么答才出彩?
笔记里给了一个很好的建议:
先将分类,再讲核心作用,使用场景,踩坑,不要单纯罗列 API。 有深度、广度、专业度,打动面试官(想听什么)。 组织回答逻辑,分类,哪几个 hooks 是解决什么问题的。 每个 hooks 高级特性,实战中怎么用,面试的流程(时长)。
回答「闭包」时,可以这样组织:
- 是什么:闭包是自由变量的集合,在外部函数销毁后仍能访问。
- 为什么:词法作用域要求内部函数能访问外部变量,但执行上下文又要销毁,于是用闭包解决冲突。
- 怎么工作:作用域链 + outer 指向 + 堆内存中的背包。调用栈、执行上下文、词法环境、变量环境、outer 一起构成查找路径。
- 怎么回收:全局引用不释放,局部引用可回收;使用频率不高且占用大时,尽量让它成为局部变量。
- React 中的体现 :
useState惰性初始化、批量更新、useEffect闭包陷阱、函数式更新。 - 踩坑:内存泄漏、旧值快照、频繁创建开销。
- 实战 :用函数式更新、合理使用
useRef、及时清理副作用。 - 延伸 :event loop 中的
var+setTimeout、扁平化 JS API 的递归闭包、工程化 tree-shaking、AI Coding 的代码 review、全栈过渡中的跨域与渲染。
这样回答,既有深度又有广度,还能自然带出 React、性能优化、AI、工程化、运维,覆盖笔记里所有模块,面试官想不听下去都难。