做React开发的人,几乎都绕不开“Hooks”这三个字。尤其近几年,react面经、react面试题、react重点知识这些词条里,Hooks的出场率高得吓人。无论是刚入行准备面试的新人,还是做了两三年项目想进阶的老手,都会被问到一类问题:Hooks到底有什么优缺点?底层是怎么实现的?useState、useEffect这些API的参数到底怎么用才是对的?这问题看似基础,但能把三个层面串起来讲清楚的人,真不多。很多人背了一堆“优点缺点”,但说不明白为什么依赖数组写成[]会和没写完全不同;也有人写过无数自定义Hook,却不知道useState在Fiber节点上其实是一个链表节点。
这篇文章我想从实际开发加面试准备的角度,把这三件事揉碎了讲一遍:Hooks的优缺点不是背出来的,是设计取舍的结果;底层实现不神秘,理解了“链表加数组”的模型就打通了;参数细节更不是文档背诵题,而是决定你的组件是优雅还是爆炸的关键。内容适合两类人看:准备React相关面试的人,以及已经在业务里写了不少Hooks、但总觉得哪里没想透的人。我尽量用讲人话的方式,把那些源码里看着头大的概念翻译成实战里能用上的经验。
1. 面试总问Hooks,到底在问什么
1.1 类组件时代的三座大山
聊Hooks之前,先得回到它出现之前的日子。React 16.8之前,函数组件只能做纯展示,所有带状态和生命周期的逻辑全部压在类组件身上。那个时候写业务最头疼的三件事,现在回头看都挺有时代感。
第一座大山是this指向。类组件里的方法不会自动绑定this,写个事件处理函数都要纠结是bind还是箭头函数。一旦回调被传下去,稍微不小心this就丢了,然后控制台给你一句“Cannot read property 'setState' of undefined”。这类问题在代码评审里出现频率高到让人麻木。
第二座大山是副作用逻辑被生命周期强行拆散。一个常见的场景:某个页面要监听窗口尺寸、拉取数据、订阅事件,你必须在componentDidMount里放一部分,componentDidUpdate里再放另一部分,componentWillUnmount里还要记得清理。明明是同一份业务逻辑,硬生生被拆成三段,散落在三个生命周期函数里。需求一变更,漏清理就是内存泄漏,多写一行就是重复触发。
第三座大山是逻辑复用。想让多个组件共用一段带状态的逻辑,WidthProvider也不是不行,类组件时代只有高阶组件、render props、以及各种Context嵌套一条路走到黑。一层套一层,代码层级深了之后,套娃问题比业务本身还复杂。
1.2 三个维度为什么缺一不可
搞清楚这段历史,你就明白为什么围绕Hooks的面试题总是从三个维度展开。优缺点考察的是你对设计理念的理解,看你会不会用;底层实现考察的是你遇到诡异Bug时的排查能力,看你懂不懂原理;参数考察的是细节,看你的代码能不能写出“正确且高效”的效果。
这三个维度不是孤立的,而是互相支撑的。比如“Hooks不能在条件语句里调用”这条规则,如果只当作ESLint规则去记,你早晚会写出带Bug的代码。一旦理解了底层是用链表按固定顺序存状态,你就自然明白为什么React要去校验调用顺序。同样,“为什么useState设置相同的值时组件不重新渲染”这个问题,文档里只说了一半,真正的答案藏在Object.is和链表更新的底层逻辑里。
所以我的建议一直是:准备React Hooks面试,别把这三点分开背,要当成一个完整的心智模型去理解。优点背后有设计意图,缺点背后有实现代价,参数细节背后有源码逻辑。
1.3 Hooks带来的思维转变
Hooks最大的贡献,其实是把“关注点”还给业务本身,而不是让代码跟着生命周期函数走。这一点从用它写代码的体感就能感受到。以前是“我在哪个生命周期做了什么”,现在变成“这个副作用依赖于什么数据,什么时候该重新执行”。
别小看这个转变。一旦你接受了“函数组件每一次渲染都有自己的state和props”这个模型,闭包陷阱、依赖数组、函数式更新这些问题就有了统一的解释框架。我见过很多写了三年React的人,依然用类组件的思维去理解Hooks,结果就是总在奇怪的地方踩坑。
2. Hooks优缺点:不算缺陷,都是代价
2.1 优点:为什么说Hooks让React重新变简单
先说不吹不黑的优点。Hooks解决了逻辑复用难题,这是最大的一个亮点。自定义Hook的模式让状态逻辑可以像函数一样被抽取和复用,不需要额外增加组件层级。以前用render props包一层就能多一层嵌套,现在写一个useWindowSize、useLocalStorage,想用就在组件内部调一下,树形结构干干净净。
第二个优点是告别了this。函数组件天然没有this指向问题,事件处理函数不用bind,该传函数就传函数,心智负担直接降了一个量级。新手再也不用纠结“为什么this是undefined”这种让无数人血压升高的问题。
第三是副作用逻辑的聚合能力。useEffect把挂载、更新、卸载三个阶段需要做的事情收拢到同一个地方。我用useEffect写数据请求、事件监听和清理逻辑的时候,装上卸载清理代码再也不会像以前那样“忘了写”。代码的可维护性提升非常明显。
第四个优点容易被忽略:测试更好写了。函数组件本身就是纯函数,状态和副作用的行为可以通过renderHook直接验证。不用再mount一个完整组件、触发一堆生命周期、还要处理异步等待,单测成本下降很多。
2.2 缺点:闭包陷阱、依赖数组与心智负担
接下来是缺点,这是面试官最爱追问的部分。第一个绕不开的就是闭包陷阱。函数组件每次渲染都会捕获当次的props和state,如果你在useEffect里读取一个旧的变量,而依赖数组里没写全,回调里拿到的就是“上一次渲染”的值。最经典的例子是setInterval里永远打印初始值0,修改一下依赖数组又不小心造成定时器反复重建。这类问题几乎每个React开发都遇到过。
第二个缺点是依赖数组的心智负担。eslint-plugin-react-hooks的exhaustive-deps插件能帮你补全依赖,但遇到“我想只执行一次”这种诉求时,你总是要跟lint规则斗智斗勇。业务一复杂,依赖数组写错导致的重复请求、死循环,排查起来非常折磨人。这一点用类组件生命周期语义更容易理解,所以很多人从类组件迁移过来短期会感到不适。
第三个缺点是useEffect的执行时机与直觉不同。它不是同步的,而是在浏览器绘制之后才执行。如果你在useEffect里读取布局并修改DOM,会看到闪烁和跳动。这时候又得换useLayoutEffect,但对新手来说,两个API的差别没那么容易拿捏到位。
第四个缺点来自Hooks的使用规则:只能在函数组件的顶层调用,不能被条件包裹。这条规则本身简单,但意味着当年很多用条件判断来初始化state的写法彻底失效,必须拆到子组件或者调整设计。
2.3 从场景出发:什么时候该用Hooks,什么时候不该强上
把优缺点摆在一起,你会发现所谓Hooks“缺点”,更像是从类组件迁移时的摩擦成本。真正做新项目,我还是推荐直接用函数组件加Hooks,这是整个生态的默认方向,React官方文档都已经全面转向Hooks。
但有一种情况我特别想提醒:如果你的团队里大部分人对闭包、引用类型依赖这些概念不够熟,并且项目里已经有大量类组件,不要搞一刀切全部重写。Hooks带来的收益不是凭空多出来的,它需要团队对“渲染快照”“引用比较”这些模型有一定理解。否则,迁移后出现死循环和隐式Bug的几率会非常高。
我从自己的项目经验出发,结论是这样:新代码用Hooks,老的类组件没有明确Bug或性能痛点就先留着,靠Hooks做新的逻辑复用。混着用完全没问题,React的核心调度器并不关心你用的是函数组件还是类组件。
3. 核心Hooks参数逐个拆解
3.1 useState:初始值和函数式更新
useState是接触最多的Hook,但它的参数细节里藏着不少大家容易忽略的信息。它的第一个参数是初始状态,可以直接传任意值,也可以传一个函数惰性初始化。
下面这个例子很多人写过:
const [state, setState] = useState(() => { const persisted = localStorage.getItem('cache'); return persisted ? JSON.parse(persisted) : 0; });传函数的好处是,这个函数只会在首次渲染时执行一次。如果你的初始值需要经过复杂的计算,或者要从某个Storage里读取,请务必用函数式写法,避免每次渲染都重复算一遍。
setState的参数同样有两种形态。传值、传函数都可以:
setCount(count + 1); // 依赖外部count setCount(prev => prev + 1); // 函数式更新两者的差别在于:如果你在闭包里拿不到最新值,或者一次更新里需要连续set两次,函数式更新才真正可靠。比如定时器里做累加,你用setCount(count + 1)会永远从旧的count出发,而用setCount(prev => prev + 1)就安全得多。
还有一点经常被忽略:Object.is比较。React在判定状态是否变化时用的是Object.is,所以每次更新都会先比较新旧值。如果两个值在Object.is下相等,组件就不会重新渲染。这意味着setState传一个相同对象引用,React会直接跳过渲染。很多“组件不刷新”的Bug,根源就在这。
3.2 useEffect:依赖数组、清理函数和运行时机
useEffect接受的参数是两个:一个是回调函数,一个是依赖数组。依赖数组有三种写法,每种含义完全不同。
不传依赖数组:
useEffect(() => { // 每次渲染后都执行 });传空数组:
useEffect(() => { // 只在挂载后执行一次 }, []);传依赖项:
useEffect(() => { // 依赖项变化时才执行 }, [userId]);这三种写法的区别面试必问。空数组代表“不依赖于任何props或state”,React只在mount后执行一次。但这种写法也最容易引发闭包陷阱:回调里的props、state永远停留在首次渲染那一刻。如果你需要读取最新的状态,又不想被依赖变化反复触发,函数式更新或者useRef往往才是正解。
useEffect的回调如果返回一个函数,这个函数就是清理函数。它在组件卸载时和下一次effect执行前都会被调用:
useEffect(() => { const timer = setInterval(() => { console.log('tick'); }, 1000); return () => clearInterval(timer); }, []);清理函数是面试里另一个高频考点。它解决的问题是“避免内存泄漏”和“避免竞态”。比如一个搜索组件,用户输入很快,上一次请求还没返回响应,下一次请求就发出了。你在清理函数里用一个ignore标记,就能避免旧请求覆盖新结果:
useEffect(() => { let ignore = false; fetchData(query).then(res => { if (!ignore) setResult(res); }); return () => { ignore = true; }; }, [query]);这里补充一个冷知识:依赖数组里的每一项比较,用的也是Object.is。所以如果你的依赖项是一个对象字面量,每次渲染都是新引用,useEffect就会次次执行。这也是“依赖一个普通对象导致死循环”的根源,后面我会单独讲。
3.3 useCallback与useMemo:缓存函数的参数细节
useCallback和useMemo都用于缓存,但缓存的类型不同。useCallback用来缓存函数本体,useMemo用来缓存计算结果。
useCallback的参数是“内联函数加依赖数组”:
const handleSave = useCallback(() => { saveData(id, content); }, [id, content]);返回的handleSave在依赖不变时始终是同一个引用。这个引用稳定性,在传给子组件配合React.memo时特别重要。如果父组件每次渲染都重新生成一个新函数,React.memo浅比较props时会认为函数变化了,子组件被迫跟着渲染。
useMemo参数是“工厂函数加依赖数组”:
const total = useMemo(() => { return list.reduce((sum, item) => sum + item.price, 0); }, [list]);useMemo在依赖不变时直接返回上一次的计算结果,避免每次渲染都做高开销运算。
关于这两个API,有四个点我得提醒一下。第一,useCallback在源码层面可以理解为useMemo的特例,都是先对比deps,没变就复用旧值,变了就重新执行。第二,不传依赖数组不会报错,但每次都重新计算,缓存就失去了意义。第三,把useCallback的返回值再放进useEffect的依赖数组是个常见操作,这时候一定要保证这个函数的依赖也被正确声明,否则useEffect会拿一个闭包过期的函数。第四,过度使用缓存本身也是性能问题,频繁变化的依赖会让缓存形同虚设,还要付出对比依赖的开销,所以别盲目包一层。
3.4 useReducer、useContext、useRef参数面面观
useReducer是useState的进阶版,适合状态逻辑复杂或需要多步骤更新的场景。它接收三个参数:
const [state, dispatch] = useReducer(reducer, initialArg, init);第一个参数是reducer函数,接收旧状态和action,返回新状态。第二个参数是初始状态。第三个参数init是可选函数,用来惰性创建初始状态。当初始状态需要经过复杂计算时,用init而不是直接传值,可以避免多次无效计算。
useContext参数最简单,接收一个Context对象,返回该Context的最新值:
const theme = useContext(ThemeContext);但这个API有一个使用成本:只要Context的value变化,所有消费这个Context的组件都会重新渲染。这个问题可以用拆分Provider、或者用useMemo优化value来解决。
useRef参数只有一个初始值,返回一个{ current: value }对象:
const inputRef = useRef(null); const intervalRef = useRef(0);useRef有两个常见用途。一是访问DOM节点,直接传给元素的ref属性。二是保存一个不触发渲染的可变值,用来在组件跨渲染之间共享数据。第二类用途在定时器场景里特别实用,可以把setInterval的句柄存进去,然后在其他地方读取它,不会引发额外渲染。
4. 底层实现:从Fiber到Hook链表
4.1 Fiber节点上的memoizedState到底存了什么
要理解Hooks的底层,第一步是搞清Fiber。React在运行时维护的“组件树”不是虚拟DOM,而是Fiber节点组成的Fiber树。每个函数组件对应一个Fiber节点,而这个节点上用memoizedState字段保存着这个组件所有Hooks的状态。
如果一个组件里写了三个Hooks,那它的memoizedState不是普通的数组,而是一条链表。每个Hook对应一个链表节点,通过next指针串起来。Hook节点的结构大致包含memoizedState、baseState、baseQueue、queue和next这几块。memoizedState用来保存本Hook的具体状态数据,不同Hooks类型存的内容也不同。useState存的是状态值本身;useEffect存的是effect对象;useRef存的是{ current: ... };useMemo存的是计算结果和依赖。
理解这个结构是理解“Hooks为什么不能放在条件语句里”的基础。因为React是按照调用顺序从链表上一一取状态的,初始挂载时按顺序创建链表,后续更新时也按相同顺序读取。条件语句会导致某次渲染时Hooks数量或顺序变化,从第N个节点开始全部错位,取出来的状态牛头不对马嘴。
4.2 Dispatcher双派发:mount与update的秘密
再往下一层,React使用了一个叫Dispatcher的机制,通过全局的ReactCurrentDispatcher来区分首次挂载和后续更新。首次挂载时用的是HooksDispatcherOnMount,更新时用的是HooksDispatcherOnUpdate。
你可以理解成:同一套API,在挂载和更新两个阶段分别指向不同的实现函数。比如useState,第一次渲染时走mountState,后续渲染走updateState;useEffect也一样,有mountEffect和updateEffect两个版本。
这种设计的好处是分层清晰,而且能在mount阶段做更多初始化工作。通过判断当前Fiber是不是首次渲染,React就能完全复用一套public API,内部逻辑按阶段分流。这也是为什么你在写代码时不需要关心自己到底在mount还是update,但React始终知道该走哪条分支的原因。
4.3 useState底层:为什么它只是useReducer的语法糖
useState的底层没想象中神秘。React源码里的updateState,实际调用的是updateReducer,也就是说useState可以看成useReducer的一个简化版。
这个链条有点长,我尽量描述得贴近代码实际。每次调用setState时,React会创建一个update对象,里面记录这次变化,然后把它追加到当前Hook对应的queue.pending环形链表上。接下来React进入重新渲染流程,updateReducer会从头遍历这个更新队列,把动作依次应用到上一次的state上,得到最新的state。全部应用完之后,React拿最新的state和旧的state做一次Object.is比较。
如果值没变化,React就提前退出,不触发子组件渲染。这就是为什么setState传同一个引用不渲染的原理。如果值变了,React更新Fiber上的memoizedState,继续走渲染流程。
为了便于理解,可以看看下面这个简化版模型(不是React真实源码,但是思路一致):
let state = null; let queues = []; function useState(initialValue) { const hookIndex = queues.length; queues.push(queues[hookIndex] || initialValue); const setState = (newValue) => { queues[hookIndex] = typeof newValue === 'function' ? newValue(queues[hookIndex]) : newValue; }; return [queues[hookIndex], setState]; }实际实现比这复杂得多,涉及优先级调度、批量更新、Fiber双缓冲等机制,但核心的按顺序取状态、闭合更新队列的思想是相通的。面试被问到的时候,能从“单链表加队列更新”这个层面回答,已经能甩开很多人。
4.4 useEffect底层:单向链表与commit阶段
useEffect的底层相对复杂一些,但主体思路也是链表。mount时,React会创建一个effect对象,包含create(你的回调)、destroy(你返回的清理函数)、deps(依赖数组)以及一个next指针。多个effect对象通过next串成一条单向链表,然后挂载到Fiber节点的updateQueue属性上。
当依赖项发生变化,React会根据这个effect上的flags打标记。一次渲染完成后进入commit阶段,React会专门去遍历这条effect链表,分成三步走:先执行所有需要销毁的effect的destroy函数,再依据情况调用create,最后把返回的清理函数存回destroy字段,供下一次清理时使用。
有一个细节值得单独说:useEffect中的effect回调,默认是在浏览器绘制完成之后异步执行的,而useLayoutEffect是在DOM变更后、浏览器绘制之前同步执行。如果你在useEffect里读取DOM布局并做修改,就会看到先绘制再改的跳变效果,视觉上表现为闪烁。useLayoutEffect则没有这个问题。React在commit阶段会区分“普通副作用”和“布局副作用”,然后放到不同的时机处理,这就是它们在调度顺序上的本质差别。
依赖数组的比较发生在update阶段:React逐个对比新旧依赖,用Object.is判断。任何一项变化,evaluate机制就认为需要执行本次effect。空数组时因为没有对比项,所以永远判定为“不需要重新执行”。这也解释了空数组依赖只执行一次的原因。
5. 常见问题、闭包陷阱与面试实战
5.1 闭包陷阱的成因和三个解法
闭包陷阱本质上是“渲染快照”与“最新的状态值”之间的错位。每次函数组件渲染时,组件函数体会重新执行一遍,所有变量都生成一份新的。useEffect的回调所捕获的,是它被创建的那次渲染里的变量。依赖数组不给新值,它就一直抱着旧渲染的变量不放。
最常见的翻车代码是这种:
function Counter() { const [count, setCount] = useState(0); useEffect(() => { const timer = setInterval(() => { console.log(count); // 永远输出0 }, 1000); return () => clearInterval(timer); }, []); return <div>{count}</div>; }解决方案有几种。一是函数式更新,完全不读取外部的count:
setCount(prev => prev + 1);二是把count加进依赖数组,代价是定时器每次count变化都会重建,如果你的副作用里不光是简单打印,这个重建成本需要自己评估。三是用useRef保存一份最新值:
const countRef = useRef(0); countRef.current = count; useEffect(() => { const timer = setInterval(() => { console.log(countRef.current); // 永远是最新值 }, 1000); return () => clearInterval(timer); }, []);第三个解法适合那些“要么用最新值、要么保持单一实例”的场景。
5.2 依赖数组引发的死循环与重渲染
依赖数组写错,最常见的后果有两个:死循环和多余渲染。死循环的典型例子是把一个对象字面量放进了依赖数组:
function App() { const [data, setData] = useState({}); useEffect(() => { setData({ name: 'react' }); }, [data]); }data每次都是新对象,Object.is比较永远不相等,于是反复执行setData,重新渲染,无限循环。正确的做法是只依赖基本类型的字段,或者干脆不依赖data,而是通过函数式更新去修改。
还有一个更隐蔽的“多余渲染”场景。父组件给子组件传一个对象props,比如options={{ page: 1 }},子组件内部如果把它放进依赖数组,每次父组件渲染都会触发子组件副作用。解决办法是useMemo缓存这个对象,或者把依赖改成具体字段。这类问题在真实项目里出现概率极高,排查起来也最费时间。
5.3 面试高频Hooks问答速查
结合我自己的面试经验,整理了高频问题的核心回答思路,这里直接给速查版本。
| 面试问题 | 核心回答思路 |
|---|---|
| Hooks为什么不能放在条件语句里 | Hooks在Fiber上以链表顺序存储,条件调用会破坏顺序,导致取错状态 |
| useEffect和useLayoutEffect的区别 | useEffect异步,绘制后执行;useLayoutEffect同步,绘制前执行;需要读取布局时用useLayoutEffect |
| useState设置相同值为什么不重新渲染 | 内部用Object.is比较新旧值,相同则跳过渲染 |
| setState传函数和传值的区别 | 传函数能拿到最新状态,适合连续更新或闭包内更新 |
| 为什么useEffect有清理函数 | 防止内存泄漏;在下一次effect执行前和卸载时清理上次副作用 |
| useCallback和useMemo怎么选 | 缓存函数用useCallback,缓存计算结果用useMemo,底层都是对比依赖 |
| 依赖数组不填会怎样 | 每次渲染都执行effect或重新计算,缓存失效 |
| ref能不能替代state | ref变化不触发渲染,适合保存定时器句柄、DOM引用等与渲染无关的值 |
| useReducer和useState怎么选 | 状态更新逻辑复杂、需要集中管理时用useReducer |
| 自定义Hook如何避免闭包陷阱 | 能用函数式更新就用函数式更新;需要最新值时用ref;依赖尽量精确到字段 |
5.4 从源码视角回答“为什么setCount是异步的”
关于setState的异步性,几乎所有面试都会追问。单纯回答“React为了批量更新”还不够,如果把底层机制带上就更有说服力。React 18之后,在事件处理函数里多次调用setState,都会被自动批处理,合并到一次渲染中。这是因为setState最终会进入React的调度器,而不是立即同步更新界面。调度器根据优先级安排渲染流程,同一批次内的多个更新会被合并成一次commit。
用Hooks源码里的话说,每个update对象被入队到queue.pending后,React不一定马上处理,它可能先被调度延迟,等到合适的时机一次性处理整个队列。所以你在连续调用三次setCount(c => c + 1)时,最终结果一定是加3,而不是只加1,因为React会依次flatten所有这些更新。
这几句话是面试中的加分项,因为它展示了你不仅知道现象,还知道现象背后的调度模型。
6. 写在最后:我给React学习者的三点建议
这篇文章写了挺长,最后分享几个实际开发中积累的小建议,不算总结,只是一些体会。
第一,学习Hooks最忌讳用类组件的思维去套。类组件里“状态是对象的属性”,函数组件里“状态是每次渲染时会话里的局部变量”。把“每次渲染都是一次独立快照”这个模型刻在脑子里,闭包陷阱、依赖数组、函数式更新这些问题会豁然开朗。
第二,遇到诡异Bug先别急着背答案,试着从源码或最小复现去推演。拿“依赖对象导致的死循环”举例,只要你理解Object.is引用比较,自己就能推导出问题所在,不需要别人给现成解。我调试这类问题最常用的方法就是加一行console.log,打印依赖前后的引用地址,几乎都能快速定位。
第三,面试电话里被问到Hooks时,千万别只背features,试着从“链表状态存储”和“Dispatcher分派”这两个关键词切入。能讲透“为什么状态顺序不能乱”,比背十条优点更有说服力。这也是我认为React Hooks里最值得吃透的一层内容。