Hooks 原理
问题
React Hooks 是如何实现的?useState 和 useEffect 的原理是什么?什么是闭包陷阱?
React Hooks 是如何实现的? Hooks 的实现核心是「按调用顺序匹配状态」:
- 每个函数组件对应一个 Fiber 节点,Fiber 上的
memoizedState是一条单向链表,每个节点存一个 Hook 的状态。 - 每次渲染按顺序遍历链表,第 N 次调用 Hook 就拿到链表第 N 个节点。
- 这就是为什么 Hooks 必须在顶层调用、不能放进 if/for——一旦顺序错乱,状态就会对错位。
useState 和 useEffect 的原理是什么?
- useState:首次渲染创建 Hook 节点存初始值;调用 setState 时把更新塞进 queue 并触发调度,下次渲染按 queue 依次计算出新值。同一事件里多次 setState 会被批量合并。
- useEffect:Hook 节点上保存
create、destroy、deps。每次渲染浅比较 deps,没变就跳过;变了就在 commit 后异步执行(先跑上一次的 cleanup,再跑新的 effect)。useLayoutEffect区别在于同步执行、阻塞浏览器绘制。
一次 setState 到底发生了什么?
- 调用
setState→ React 不会立刻改 state,而是把这次更新(值或函数)塞进当前 Hook 的queue里排队,同时标记 Fiber 为「脏」。 - 同一事件里多次
setState会被自动批处理(React 18),合并成一次调度。 - 调度器决定何时开始 Render 阶段:重新调用组件函数,执行到
useState时取出 queue 依次计算出最终新 state。 - 计算出新虚拟 DOM 后进入 Commit 阶段,把差异同步到真实 DOM。
- 浏览器绘制完成后,异步执行
useEffect。 - 所以
setState后紧接着读 state 还是旧值——你拿到的是当前 render 的快照,新值要等下一次 render。
什么是闭包陷阱?
- 函数组件每次渲染都会生成一份独立的 props/state 快照,事件回调、定时器捕获的就是当时那一份。
- 典型场景:
setInterval里的setCount(count + 1)拿到的永远是初始的 count,导致状态卡住。 - 解决思路:① 用函数式更新
setCount(prev => prev + 1);② 用useRef保存最新值;③ 把变量加进useEffect的依赖数组让 effect 重新执行。
答案
Hooks 是 React 16.8 引入的特性,让函数组件也能拥有状态和生命周期。Hooks 的核心实现依赖于 Fiber 架构和链表结构。
为什么会有 Hooks?
要理解 Hooks,咱们得先回到 2018 年之前——那时候 React 只有 class 组件能管状态,函数组件只能当「展示组件」。我们先看看当时写一个最简单的计数器是什么样:
Class 组件时代的 Counter
import React from 'react';
interface State {
count: number;
}
class Counter extends React.Component<{}, State> {
// 1. 状态必须放在 constructor 里初始化
constructor(props: {}) {
super(props);
this.state = { count: 0 };
// 2. 方法必须 bind(this),否则事件回调里的 this 是 undefined
this.handleClick = this.handleClick.bind(this);
}
// 3. 「挂载后做什么」放在 componentDidMount
componentDidMount() {
document.title = `点击了 ${this.state.count} 次`;
}
// 4. 「更新后做什么」放在 componentDidUpdate
componentDidUpdate() {
document.title = `点击了 ${this.state.count} 次`;
}
// 5. 「卸载前清理」放在 componentWillUnmount
componentWillUnmount() {
document.title = 'React App';
}
handleClick() {
// 注意:必须用 setState,且 this 容易丢失
this.setState({ count: this.state.count + 1 });
}
render() {
return <button onClick={this.handleClick}>{this.state.count}</button>;
}
}
看出问题了吗?一个简单到不能再简单的计数器,写了 30 多行,而且踩坑点遍地都是。咱们一个一个数。
Class 组件的四大痛点
this 指向类方法不会自动绑定 this。如果你忘了在 constructor 里 bind(this),事件触发时 this.setState 直接报错 Cannot read property 'setState' of undefined。新人 90% 都踩过这个坑。
上面那个「同步 document.title」的需求,本质是一段逻辑,但你不得不写在 componentDidMount、componentDidUpdate、componentWillUnmount 三个地方。如果还有别的副作用(比如订阅 WebSocket),它的挂载/更新/卸载也得分别写在这三个钩子里——结果就是每个生命周期钩子里塞了一堆毫不相关的逻辑。
想把「鼠标位置追踪」逻辑复用到多个组件?那时候只能用 HOC(高阶组件)或 render props,写出来长这样:
// 「Wrapper Hell」嵌套地狱
<WithRouter>
<WithTheme>
<WithUser>
<WithMouse>
<MyComponent />
</WithMouse>
</WithUser>
</WithTheme>
</WithRouter>
打开 React DevTools,组件树里全是 WithXxx 包装层,业务组件被埋在最深处。
上面那个 Counter 30 行,但其中真正有用的业务逻辑只有 2 行。剩下都是 constructor、bind、this.state.xxx、生命周期钩子的样板代码。
Hooks 时代的 Counter
同样的功能,用 Hooks 重写一遍:
import { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
// 「挂载 + 更新 + 卸载」三件事,全在一个 useEffect 里搞定
useEffect(() => {
document.title = `点击了 ${count} 次`;
return () => { document.title = 'React App'; }; // cleanup
}, [count]);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
10 行解决战斗,没有 this、没有 bind、相关逻辑聚合在一起。这就是 Hooks 想要解决的核心问题。
Hooks 到底解决了什么?
| 痛点 | Hooks 的解药 |
|---|---|
this 指向坑 | 函数组件根本没有 this,不存在这个问题 |
| 生命周期被切碎 | useEffect 把「订阅 + 清理」放在一起,按关注点而不是按时机组织代码 |
| HOC 嵌套地狱 | 自定义 Hook 完美复用状态逻辑,不再增加组件层级 |
| 样板代码多 | 一个 useState 一行解决,写法极度精简 |
Hooks 的本质是让函数组件拥有了状态和副作用能力,并提供了一种按业务关注点组织代码、按调用方式复用逻辑的全新范式。
Hooks 的基本规则
在理解原理之前,先了解 Hooks 的使用规则:
- 只能在函数组件或自定义 Hook 中使用
- 只能在顶层调用,不能在条件语句、循环或嵌套函数中调用
- 调用顺序必须保持一致
// ❌ 错误:条件语句中使用
function Component() {
if (condition) {
const [state, setState] = useState(0); // 错误!
}
}
// ✅ 正确:顶层使用
function Component() {
const [state, setState] = useState(0);
if (condition) {
// 在这里使用 state
}
}
为什么 Hooks 必须在顶层调用?
这是面试官最爱追问的点,咱们用一个生动的比喻讲清楚。
比喻:上课点名按学号,不按名字
想象一下,React 是一个只看顺序、不看名字的老师。你每次走进教室(每次 render),React 都会按你站起来的先后顺序给你发学号:
- 第 1 个站起来的同学 → 学号 1
- 第 2 个站起来的同学 → 学号 2
- 第 3 个站起来的同学 → 学号 3
React 根本不知道你叫 useState 还是 useEffect,它只记得「第 N 次被调用的 Hook,对应链表里第 N 个节点」。这就是为什么 Hooks 的顺序如此重要。
一个具体的反例
function BuggyComponent({ showName }: { showName: boolean }) {
// ❌ 把 useState 放进 if 里
if (showName) {
const [name, setName] = useState('Alice'); // 1 号位(条件成立时)
}
const [age, setAge] = useState(20); // 想做 2 号位
const [email, setEmail] = useState('a@x.com'); // 想做 3 号位
return <div>{age} - {email}</div>;
}
咱们来推演一下灾难现场:
第一次渲染(showName = true):
调用顺序 Hook 链表节点
1 → name ('Alice') ← 1 号位
2 → age (20) ← 2 号位
3 → email ('a@x.com') ← 3 号位
第二次渲染(showName = false,跳过了第一个 useState):
调用顺序 Hook 链表节点(链表本身不变!)
1 → name ('Alice') ← React 把 1 号位给了 age
2 → age (20) ← React 把 2 号位给了 email
3 → ??? ← 没有第 3 个调用,email 直接丢失
结果:你的 age 变量拿到了 'Alice' 这个字符串,email 变量拿到了数字 20,整个组件状态完全错乱,setEmail 操作的其实是 age 的状态。
这种 bug 不会立刻报错,而是悄悄地让你的状态张冠李戴。Web 应用里可能体现为表单数据莫名其妙互相覆盖、按钮的回调改了别的状态——排查起来非常痛苦。
React 是怎么防止你犯这种错的?
官方提供了一个 ESLint 插件 eslint-plugin-react-hooks,它会在你编译期就把「在 if 里调用 Hook」「在循环里调用 Hook」「在普通函数里调用 Hook」全部标红报错。所以请务必装上它。
如果你需要根据条件决定要不要执行某个逻辑,条件判断应该写在 Hook 的内部,而不是把 Hook 包在条件里:
// ✅ 正确:Hook 始终被调用,条件写在里面
useEffect(() => {
if (showName) {
// 条件逻辑
}
}, [showName]);
Hooks 的存储结构
每个函数组件对应一个 Fiber 节点,Fiber 节点的 memoizedState 属性存储 Hooks 链表:
Hook 对象结构
interface Hook {
memoizedState: unknown; // 当前状态值
baseState: unknown; // 基础状态
baseQueue: Update | null; // 基础更新队列
queue: UpdateQueue | null; // 更新队列
next: Hook | null; // 指向下一个 Hook
}
Hooks 使用单向链表存储,每次渲染时按顺序遍历链表。这就是为什么 Hooks 必须在顶层调用、调用顺序必须一致——因为 React 是通过调用顺序来确定每个 Hook 对应的状态的。
useState 原理
一次 setState 到底发生了什么?
在看代码之前,咱们先把整个流程「过一遍电影」。假设你写了一个按钮 <button onClick={() => setCount(c => c + 1)}>+1</button>,从你点击到屏幕变化,中间发生了什么?
用大白话再讲一遍
- 你点击按钮:浏览器触发
onClick事件。 setCount被调用:React 不会立刻改 state,而是把这次更新(c => c + 1这个函数)塞进当前 Hook 的queue里排队。- 标记 Fiber 脏了:React 在 Fiber 节点上打个标记「这个组件需要重新渲染」。
- 调度器决定何时渲染:如果你在同一个事件里连续
setCount三次,React 会把它们合并成一次渲染(这就是 React 18 的自动批处理)。 - Render 阶段:React 从头到尾再调用一次你的组件函数。当执行到
useState时,React 会取出 queue 里所有更新依次计算,得到最终的新 state,返回给你。 - Commit 阶段:React 把新旧虚拟 DOM 的差异同步到真实 DOM。
- 浏览器绘制:用户终于看到了新画面。
- 执行 useEffect:绘制完成后,React 异步执行
useEffect里的副作用代码。
setCount 不是「立刻修改 count」,而是「告诉 React 我想要 count 变成什么」。真正的修改发生在下一次 render 里。这就是为什么紧接着 setCount(1) 后面 console.log(count) 还是旧值——你拿到的 count 是当前这次 render 的快照,下一次 render 才会有新的 count。
简化实现
// 简化版 useState 实现
let workInProgressHook: Hook | null = null;
let currentHook: Hook | null = null;
let isMount = true; // 是否首次渲染
interface Hook {
memoizedState: unknown;
next: Hook | null;
queue: Array<(state: unknown) => unknown>;
}
function useState<T>(initialState: T): [T, (action: T | ((prev: T) => T)) => void] {
let hook: Hook;
if (isMount) {
// 首次渲染:创建新的 Hook
hook = {
memoizedState: typeof initialState === 'function'
? (initialState as () => T)()
: initialState,
next: null,
queue: [],
};
// 将 Hook 添加到链表
if (!workInProgressHook) {
fiber.memoizedState = workInProgressHook = hook;
} else {
workInProgressHook = workInProgressHook.next = hook;
}
} else {
// 更新渲染:复用已有的 Hook
hook = currentHook!;
currentHook = currentHook!.next;
// 处理更新队列
const queue = hook.queue;
let newState = hook.memoizedState;
queue.forEach(action => {
newState = typeof action === 'function'
? action(newState)
: action;
});
hook.memoizedState = newState;
hook.queue = [];
}
// 返回状态和更新函数
const setState = (action: T | ((prev: T) => T)) => {
hook.queue.push(action as (state: unknown) => unknown);
scheduleUpdate(); // 触发重新渲染
};
return [hook.memoizedState as T, setState];
}
执行流程
批量更新
function handleClick() {
setCount(count + 1); // 不会立即更新
setCount(count + 1); // 不会立即更新
setCount(count + 1); // 不会立即更新
// 只会触发一次重新渲染,最终 count + 1
}
// 正确的连续更新
function handleClick() {
setCount(prev => prev + 1); // 使用函数式更新
setCount(prev => prev + 1);
setCount(prev => prev + 1);
// 最终 count + 3
}
useEffect 原理
简化实现
interface EffectHook extends Hook {
memoizedState: {
create: () => (() => void) | void; // effect 函数
destroy: (() => void) | void; // 清理函数
deps: unknown[] | null; // 依赖数组
};
}
function useEffect(
create: () => (() => void) | void,
deps?: unknown[]
): void {
let hook: EffectHook;
if (isMount) {
// 首次渲染:创建 Effect Hook
hook = {
memoizedState: {
create,
destroy: undefined,
deps: deps ?? null,
},
next: null,
};
// 将 effect 加入待执行队列
pushEffect(hook);
} else {
hook = currentHook as EffectHook;
currentHook = currentHook!.next;
const prevDeps = hook.memoizedState.deps;
// 比较依赖是否变化
if (deps && areHookInputsEqual(deps, prevDeps)) {
// 依赖未变化,跳过
return;
}
// 依赖变化,更新 effect
hook.memoizedState = { create, destroy: undefined, deps: deps ?? null };
pushEffect(hook);
}
}
// 浅比较依赖数组
function areHookInputsEqual(
nextDeps: unknown[],
prevDeps: unknown[] | null
): boolean {
if (prevDeps === null) return false;
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
if (Object.is(nextDeps[i], prevDeps[i])) {
continue;
}
return false;
}
return true;
}
执行时机
| Hook | 执行时机 | 是否阻塞渲染 |
|---|---|---|
useEffect | DOM 更新后异步执行;非交互更新通常在浏览器绘制后执行 | ❌ 不阻塞 |
useLayoutEffect | DOM 更新后、浏览器绘制前同步执行 | ✅ 阻塞 |
大多数非交互更新中,React 会先让浏览器绘制,再执行 useEffect。但如果更新由点击、输入等交互触发,React 也可能在绘制前执行 effect,以便事件系统能观察到 effect 的结果。只要你的逻辑必须在用户看到画面前完成(比如测量布局、同步调整位置),就应该使用 useLayoutEffect。
// useEffect:异步执行,不阻塞渲染
useEffect(() => {
// 发送网络请求等副作用
fetchData();
}, []);
// useLayoutEffect:同步执行,阻塞渲染
useLayoutEffect(() => {
// 需要在渲染前完成的 DOM 操作
const rect = elementRef.current.getBoundingClientRect();
setPosition(rect);
}, []);
Effect 生命周期
一个完整的 useEffect 生命周期示例
上面的图比较抽象,咱们用一个真实的聊天室例子,把「挂载 → 依赖变化 → 卸载」三个时机的 cleanup 时序彻底讲清楚。
假设我们有一个 ChatRoom 组件,需要根据 roomId 订阅对应房间的消息:
function ChatRoom({ roomId }: { roomId: string }) {
useEffect(() => {
// 1. 进入房间(订阅)
console.log(`✅ 连接到房间 ${roomId}`);
const connection = createConnection(roomId);
connection.connect();
// 2. cleanup:离开房间(退订)
return () => {
console.log(`❌ 断开房间 ${roomId}`);
connection.disconnect();
};
}, [roomId]);
return <div>当前房间:{roomId}</div>;
}
现在咱们模拟一个用户场景:
- 用户进入页面,
roomId = 'A' - 用户切换到房间 B,
roomId变成'B' - 用户关闭聊天室,组件卸载
时间轴:每一步都发生了什么
最终的日志输出顺序:
✅ 连接到房间 A ← 挂载
❌ 断开房间 A ← 切换前先清理上一次
✅ 连接到房间 B ← 再建立新连接
❌ 断开房间 B ← 卸载时清理最后一次
一个反直觉的细节:cleanup 拿到的是「上一次」的闭包
注意上面的 T2 步骤——执行 cleanup 时,roomId 已经变成 'B' 了,但 cleanup 函数里打印的却是 'A'。这不是 bug,这是设计如此。
React 在执行 cleanup 时,调用的是上一次 render 时定义的那个 cleanup 函数。那个函数是在 roomId === 'A' 的 render 里被创建的,它的闭包里捕获的就是 'A'。
这个设计是有道理的:cleanup 的责任是清理上一次 effect 留下的副作用,所以它必须能访问到「上一次的 roomId」才能去断开正确的连接。如果它拿到的是新值 'B',那就压根不知道之前订阅了哪个房间,也就没法正确退订了。
Cleanup 就像是「上一段感情留下的尾巴」——你要先彻底告别 A(断开连接),才能干净地开始 B。React 帮你严格按这个顺序执行:先 cleanup 旧的,再执行新的。
闭包陷阱
什么是闭包陷阱?
咱们用一个**「老照片」的比喻**来理解这个 React 里最常被问到的概念。
把每次 render 想象成 React 给你的组件拍了一张快照。这张照片里凝固了那一瞬间的所有东西——props 是当时的值、state 是当时的值、所有事件函数都是当时那张照片里的版本。
照片一旦拍完就不可变了。下一次 render 时,React 会拍一张全新的照片,里面是新的 props、新的 state、新的事件函数。新照片不会回头去修改老照片。
那「闭包陷阱」是怎么发生的呢?
当你给 setTimeout、setInterval、事件监听器传一个函数时,你传的是「那张老照片里的函数」。3 秒后定时器触发,它执行的还是老照片里的函数,读到的当然也是老照片里的 count,而不是当下屏幕上显示的 count。
用一句话总结:
函数组件每次 render 都有自己独立的一份 props、state 和事件函数。旧的异步回调拿到的是旧 render 的快照,新的 render 不会自动改写它已经捕获的变量。
这不是 React 的 bug,而是函数组件的基本工作模型。理解了这一点,闭包陷阱就再也不会让你困惑了。
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setTimeout(() => {
// ❌ 闭包陷阱:count 是点击时的值,不是 3 秒后的值
console.log(count);
}, 3000);
};
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={handleClick}>Log after 3s</button>
</div>
);
}
闭包陷阱产生的原因
每次渲染都会创建新的函数,新函数捕获当次渲染的状态值。旧的 setTimeout 回调持有的仍是旧的函数闭包。
解决方案
方案 1:使用 useRef
function Counter() {
const [count, setCount] = useState(0);
const countRef = useRef(count); // 创建 ref
// 每次 count 变化时更新 ref
useEffect(() => {
countRef.current = count;
}, [count]);
const handleClick = () => {
setTimeout(() => {
// ✅ 正确:读取 ref 的最新值
console.log(countRef.current);
}, 3000);
};
return (/* ... */);
}
方案 2:使用函数式更新
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setTimeout(() => {
// ✅ 使用函数式更新获取最新值
setCount(prevCount => {
console.log(prevCount); // 始终是最新值
return prevCount; // 不修改,只读取
});
}, 3000);
};
return (/* ... */);
}
方案 3:在 useEffect 中使用依赖
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setTimeout(() => {
console.log(count); // 正确的 count
}, 3000);
return () => clearTimeout(timer);
}, [count]); // 依赖 count,每次变化重新设置定时器
return (/* ... */);
}
useEffect 中的闭包陷阱
// ❌ 错误:空依赖数组导致闭包陷阱
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1); // count 始终是 0
}, 1000);
return () => clearInterval(timer);
}, []); // 空依赖,effect 只执行一次
return <div>{count}</div>; // 始终显示 1
}
// ✅ 正确:使用函数式更新
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // 使用函数式更新
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>; // 正常递增
}
自定义 Hook
自定义 Hook 的本质是复用状态逻辑:
// 自定义 Hook:追踪鼠标位置
function useMousePosition() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
return position;
}
// 使用自定义 Hook
function Component() {
const { x, y } = useMousePosition();
return <div>Mouse: {x}, {y}</div>;
}
自定义 Hook 原理
每个组件调用自定义 Hook 时,Hook 内部的状态是独立的。自定义 Hook 只是复用逻辑,不共享状态。
常见面试问题
Q1: React Hooks 为什么不能在条件语句中使用?
答案:
Hooks 依赖调用顺序来确定每个 Hook 对应的状态:
// React 内部大致逻辑
// 第一次渲染
useState('A') // 第 1 个 Hook → 状态 A
useState('B') // 第 2 个 Hook → 状态 B
// 如果条件语句导致顺序变化
// 第二次渲染(假设条件不满足)
// 跳过了第一个 useState
useState('B') // 第 1 个 Hook → 错误地读取到状态 A!
React 使用链表按顺序存储 Hooks,每次渲染通过顺序匹配 Hook 和状态。条件语句会破坏顺序,导致状态错乱。
Q2: useState 是同步还是异步的?
答案:
useState 的更新是异步批量的(在 React 18 中):
function handleClick() {
setCount(count + 1);
console.log(count); // 旧值,不是更新后的值
setName('Alice');
setAge(25);
// 三次更新会被批量处理,只触发一次重新渲染
}
| React 版本 | 事件处理器中 | setTimeout/Promise 中 |
|---|---|---|
| React 17 | 批量更新 | 同步更新(每次 setState 触发一次渲染) |
| React 18 | 批量更新 | 自动批量更新 |
如果需要同步获取更新后的值,使用 flushSync:
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCount(count + 1);
});
console.log(count); // 仍然是旧值!因为 count 是闭包
// 但 DOM 已经更新
}
Q3: useEffect 和 useLayoutEffect 的区别?
答案:
| 特性 | useEffect | useLayoutEffect |
|---|---|---|
| 执行时机 | DOM 更新后异步执行;通常不阻塞浏览器绘制 | DOM 更新后,浏览器绘制前同步执行 |
| 是否阻塞 | 不阻塞渲染 | 阻塞渲染 |
| 使用场景 | 数据获取、订阅、日志 | DOM 测量、同步 DOM 操作 |
// useEffect:适合大多数场景
useEffect(() => {
fetchData();
}, []);
// useLayoutEffect:需要同步读取/修改 DOM
useLayoutEffect(() => {
// 读取 DOM 布局信息
const rect = ref.current.getBoundingClientRect();
// 同步更新位置,避免闪烁
ref.current.style.left = `${rect.width}px`;
}, []);
Q3-1: useInsertionEffect 是做什么的?
答案:
useInsertionEffect 会在 layout effects 执行前运行,主要给 CSS-in-JS 库用来注入样式,避免 layout effect 读取布局时样式还没插入导致计算错误。不要依赖它执行时 DOM 一定已经更新;React 官方也不建议在这里读写业务 DOM。
useInsertionEffect(() => {
// CSS-in-JS 库在这里插入 <style>
injectStyle(rule);
}, [rule]);
普通副作用用 useEffect,需要同步测量或调整 DOM 用 useLayoutEffect。只有在写样式库、需要在布局读取前插入样式时,才考虑 useInsertionEffect。它里面不能更新 state,执行时 ref 也可能还没挂好。
Q4: 如何解决 useEffect 中的闭包陷阱?
答案:
三种主要方案:
// 方案 1:添加依赖
useEffect(() => {
console.log(count);
}, [count]); // 依赖数组包含 count
// 方案 2:使用 useRef 存储最新值
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
const timer = setInterval(() => {
console.log(countRef.current); // 始终是最新值
}, 1000);
return () => clearInterval(timer);
}, []);
// 方案 3:使用函数式更新
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // 不依赖外部 count
}, 1000);
return () => clearInterval(timer);
}, []);
Q5: useMemo 和 useCallback 的区别?
答案:
| Hook | 缓存内容 | 返回值 | 使用场景 |
|---|---|---|---|
useMemo | 计算结果 | 任意值 | 避免重复计算 |
useCallback | 函数本身 | 函数 | 避免函数重新创建 |
// useMemo:缓存计算结果
const expensiveValue = useMemo(() => {
return computeExpensiveValue(a, b);
}, [a, b]);
// useCallback:缓存函数引用
const handleClick = useCallback(() => {
doSomething(a, b);
}, [a, b]);
// useCallback 等价于
const handleClick = useMemo(() => {
return () => doSomething(a, b);
}, [a, b]);
Q6: useEffect 的清除函数什么时候执行?闭包陷阱怎么解决?
答案:
useEffect 的清除函数(return 的函数)在以下两个时机执行:
- 组件卸载时:执行最后一次 effect 的清除函数
- 依赖变化导致 effect 重新执行前:先执行上一次 effect 的清除函数,再执行新的 effect
function ChatRoom({ roomId }: { roomId: string }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
console.log(`连接到房间 ${roomId}`);
// 清除函数:在下次 effect 执行前 或 组件卸载时调用
return () => {
connection.disconnect();
console.log(`断开房间 ${roomId}`);
};
}, [roomId]);
return <div>当前房间: {roomId}</div>;
}
// 假设 roomId 从 "A" 变为 "B",执行顺序:
// 1. 组件 render(roomId = "B")
// 2. DOM 更新
// 3. 执行上次的清除函数:断开房间 A
// 4. 执行新的 effect:连接到房间 B
清除函数捕获的是上一次渲染时的值,而不是最新值。这是符合预期的设计——清除函数需要清理的是上一次 effect 创建的副作用。
闭包陷阱的典型场景与解决方案:
// ❌ 闭包陷阱:count 始终是初始值 0
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // count 被闭包捕获,始终为 0
}, 1000);
return () => clearInterval(id);
}, []); // 空依赖 → effect 只执行一次 → count 永远是 0
return <span>{count}</span>; // 永远显示 1
}
// ✅ 方案 1:函数式更新(推荐,最简洁)
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // 不依赖外部闭包
}, 1000);
return () => clearInterval(id);
}, []);
// ✅ 方案 2:useRef 保存最新值
const countRef = useRef(count);
useEffect(() => {
countRef.current = count; // 每次渲染同步最新值
}, [count]);
useEffect(() => {
const id = setInterval(() => {
setCount(countRef.current + 1); // 通过 ref 读取最新值
}, 1000);
return () => clearInterval(id);
}, []);
// ✅ 方案 3:useReducer 替代(适合复杂状态逻辑)
const [count, dispatch] = useReducer((state: number, action: 'increment') => {
if (action === 'increment') return state + 1;
return state;
}, 0);
useEffect(() => {
const id = setInterval(() => {
dispatch('increment'); // dispatch 引用稳定,不需要依赖
}, 1000);
return () => clearInterval(id);
}, []);
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 函数式更新 | 状态更新只依赖前一个状态 | 简洁、无需额外依赖 | 无法访问其他状态 |
| useRef | 需要读取最新值但不触发重渲染 | 通用性强 | 多一层间接引用 |
| useReducer | 复杂状态逻辑 | dispatch 引用稳定 | 多写 reducer |
Q7: useMemo 和 useCallback 的区别?什么时候该用什么时候不该用?
答案:
useMemo 缓存计算结果,useCallback 缓存函数引用。useCallback(fn, deps) 本质上等价于 useMemo(() => fn, deps)。
// useMemo:缓存计算结果(值)
const sortedList = useMemo(() => {
return items.sort((a, b) => a.price - b.price); // 返回排序后的数组
}, [items]);
// useCallback:缓存函数本身(引用)
const handleDelete = useCallback((id: string) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []); // 函数引用在组件重渲染间保持不变
什么时候该用:
// ✅ 场景 1:传给 React.memo 子组件的回调函数
const MemoChild = React.memo(({ onClick }: { onClick: () => void }) => {
console.log('子组件渲染');
return <button onClick={onClick}>Click</button>;
});
function Parent() {
const [count, setCount] = useState(0);
// 不用 useCallback → 每次 Parent 渲染都创建新函数 → MemoChild 白白重渲染
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return (
<>
<span>{count}</span>
<MemoChild onClick={handleClick} />
</>
);
}
// ✅ 场景 2:作为其他 Hook 的依赖
const fetchData = useCallback(async () => {
const res = await fetch(`/api/items?page=${page}`);
return res.json();
}, [page]);
useEffect(() => {
fetchData(); // fetchData 作为 useEffect 的依赖
}, [fetchData]);
// ✅ 场景 3:计算开销确实很大
const result = useMemo(() => {
return heavyComputation(data); // 真正的昂贵计算
}, [data]);
什么时候不该用:
// ❌ 简单计算,缓存的开销 > 重新计算
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);
// ✅ 直接写
const fullName = `${firstName} ${lastName}`;
// ❌ 没有传给 memo 子组件的普通函数
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
// ✅ 直接写(没有子组件需要稳定引用)
const handleClick = () => setCount(c => c + 1);
// ❌ 每次都会变的依赖(缓存毫无意义)
const result = useMemo(() => format(data), [data]); // 如果 data 每次都是新对象
React Compiler 是需要在构建链路中启用的编译优化,不是升级到 React 19 后自动生效。启用后,它可以自动 memoize 组件、值和函数,减少手写 useMemo / useCallback 的需要;没有启用 Compiler 的项目,仍然需要按实际性能问题手动优化。
| 判断标准 | 该用 | 不该用 |
|---|---|---|
| 计算成本 | 耗时 > 1ms 的计算 | 简单字符串拼接、基础运算 |
| 子组件 | 传给 React.memo 包裹的子组件 | 没有 memo 的子组件 |
| 依赖稳定性 | 作为其他 Hook 的依赖 | 只在 JSX 中使用 |
| 依赖变化频率 | 依赖不经常变化 | 依赖每次渲染都变 |
Q8: useRef 除了获取 DOM 还能做什么?
答案:
useRef 的本质是创建一个在整个组件生命周期内保持不变的可变容器对象 { current: T }。修改 .current 不会触发重新渲染,这使它有很多超越 DOM 引用的用途:
1. 存储可变值(跨渲染周期保持引用)
function Timer() {
const [count, setCount] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const start = () => {
timerRef.current = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
};
const stop = () => {
if (timerRef.current) {
clearInterval(timerRef.current); // 跨渲染访问同一个 timer ID
timerRef.current = null;
}
};
return (
<>
<span>{count}</span>
<button onClick={start}>开始</button>
<button onClick={stop}>停止</button>
</>
);
}
2. 保存上一次的状态值(usePrevious)
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value; // effect 在渲染后执行,此时 ref 保存的还是旧值
}, [value]);
return ref.current; // 返回上一次的值
}
// 使用
function PriceDisplay({ price }: { price: number }) {
const prevPrice = usePrevious(price);
const trend = prevPrice !== undefined && price > prevPrice ? '📈' : '📉';
return <span>{trend} {price}</span>;
}
3. 解决闭包陷阱(保存最新值)
function useLatest<T>(value: T) {
const ref = useRef(value);
ref.current = value; // 同步更新,确保始终是最新值
return ref;
}
function SearchBox() {
const [keyword, setKeyword] = useState('');
const latestKeyword = useLatest(keyword);
const handleSearch = useCallback(() => {
// 即使 useCallback 依赖为空,也能读到最新 keyword
fetch(`/api/search?q=${latestKeyword.current}`);
}, []); // 不需要依赖 keyword
return <input onChange={e => setKeyword(e.target.value)} />;
}
4. 标记组件是否已挂载(避免内存泄漏)
function useIsMounted() {
const isMounted = useRef(false);
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false; // 卸载时标记
};
}, []);
return isMounted;
}
function DataLoader() {
const [data, setData] = useState(null);
const isMounted = useIsMounted();
useEffect(() => {
fetchData().then(result => {
if (isMounted.current) { // 只在组件还挂载时更新状态
setData(result);
}
});
}, []);
return <div>{data}</div>;
}
5. 记录渲染次数(调试用)
function useRenderCount() {
const count = useRef(0);
count.current += 1; // 每次渲染都递增,但不触发重渲染
return count.current;
}
| 特性 | useRef | useState | 普通变量 |
|---|---|---|---|
| 修改是否触发重渲染 | 否 | 是 | 否 |
| 跨渲染周期保持值 | 是 | 是 | 否(每次渲染重新创建) |
| 可以同步读取最新值 | 是 | 否(闭包) | 否(重新创建) |
| 适用场景 | 可变引用、DOM | UI 状态 | 派生计算 |
Q9: 自定义 Hook 的设计原则和最佳实践?
答案:
自定义 Hook 是复用有状态逻辑的核心手段。以下是设计原则和实战示例:
设计原则:
- 以
use开头命名:这是 React 识别 Hook 的约定,ESLint 规则依赖此命名 - 单一职责:一个 Hook 只做一件事
- 返回值语义清晰:简单值返回单值/元组,复杂值返回对象
- 可组合:自定义 Hook 可以调用其他自定义 Hook
- 参数设计合理:必要参数在前,可选配置用对象
实战示例 1:useFetch
interface UseFetchOptions<T> {
immediate?: boolean; // 是否立即执行,默认 true
initialData?: T; // 初始数据
onSuccess?: (data: T) => void;
onError?: (error: Error) => void;
}
interface UseFetchReturn<T> {
data: T | undefined;
error: Error | null;
loading: boolean;
refresh: () => Promise<void>; // 手动重新请求
}
function useFetch<T>(
url: string,
options: UseFetchOptions<T> = {}
): UseFetchReturn<T> {
const { immediate = true, initialData, onSuccess, onError } = options;
const [data, setData] = useState<T | undefined>(initialData);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(false);
// 用 useRef 保存最新的回调,避免依赖频繁变化
const onSuccessRef = useRef(onSuccess);
const onErrorRef = useRef(onError);
onSuccessRef.current = onSuccess;
onErrorRef.current = onError;
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json() as T;
setData(json);
onSuccessRef.current?.(json);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
setError(error);
onErrorRef.current?.(error);
} finally {
setLoading(false);
}
}, [url]); // 只依赖 url
useEffect(() => {
if (immediate) {
refresh();
}
}, [refresh, immediate]);
return { data, error, loading, refresh };
}
// 使用
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error, refresh } = useFetch<User>(
`/api/users/${userId}`,
{
onSuccess: (user) => console.log('加载成功', user.name),
}
);
if (loading) return <Skeleton />;
if (error) return <ErrorMessage error={error} onRetry={refresh} />;
return <div>{user?.name}</div>;
}
实战示例 2:useDebounce
// 对值进行防抖:值变化后延迟更新
function useDebounce<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer); // 值变化时清除上一个定时器
}, [value, delay]);
return debouncedValue;
}
// 对函数进行防抖:返回一个防抖后的函数
function useDebounceFn<T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number = 300
) {
const fnRef = useRef(fn);
fnRef.current = fn; // 始终保持最新的函数引用
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedFn = useCallback((...args: Parameters<T>) => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
fnRef.current(...args);
}, delay);
}, [delay]);
// 组件卸载时清理
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
return debouncedFn;
}
// 使用 useDebounce(值防抖)
function SearchPage() {
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebounce(keyword, 500);
// debouncedKeyword 变化才触发请求
const { data } = useFetch<SearchResult>(
`/api/search?q=${debouncedKeyword}`
);
return <input value={keyword} onChange={e => setKeyword(e.target.value)} />;
}
- 命名:
use+ 动词/名词,清晰表达用途(useFetch、useDebounce、useLocalStorage) - 参数:必要参数直接传,可选参数用 options 对象,支持默认值
- 返回值:2 个以下用元组
[value, setter],3 个以上用对象{ data, loading, error } - 清理:在
useEffect的 return 中清理定时器、订阅等副作用 - 引用稳定性:回调参数用
useRef保存,返回的函数用useCallback包裹 - 泛型:让 Hook 支持多种数据类型,提高复用性
Q10: React 19 的 use Hook 是什么?和 useEffect 有什么区别?
答案:
use 是 React 19 新增的 Hook,用于在渲染期间读取 Promise 或 Context 的值。它是唯一一个可以在条件语句和循环中调用的 Hook。
import { use, Suspense } from 'react';
// Promise 由父组件、Server Component、路由 loader 或缓存层稳定创建后传入
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // 渲染时读取,自动配合 Suspense
return <div>{user.name}</div>;
}
// Server Component 或路由层
function Page({ userId }: { userId: string }) {
const userPromise = getUserPromise(userId); // 来自缓存/loader,引用保持稳定
return (
<Suspense fallback={<Loading />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
import { use } from 'react';
import { ThemeContext } from './ThemeContext';
function Button({ isSpecial }: { isSpecial: boolean }) {
// ✅ use 可以在条件语句中调用,useContext 不行!
if (isSpecial) {
const theme = use(ThemeContext);
return <button style={{ color: theme.primary }}>特殊按钮</button>;
}
return <button>普通按钮</button>;
}
use 和 useEffect 的核心区别:
| 特性 | use | useEffect |
|---|---|---|
| 执行时机 | 渲染期间(同步) | 渲染后(异步) |
| 用途 | 读取已有的 Promise/Context | 执行副作用(订阅、DOM 操作、请求) |
| 数据获取模式 | Promise 由外部稳定创建并传入,配合 Suspense | 在 effect 中发起请求,管理 loading/error |
| 条件调用 | 可以在 if/for 中调用 | 不可以 |
| 清除函数 | 无 | 有(return cleanup) |
| 触发重渲染 | Promise resolve 后自动渲染 | 需要手动 setState |
// ❌ 传统方式:useEffect + useState(样板代码多)
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setLoading(true);
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <Loading />;
if (error) return <ErrorView error={error} />;
return <div>{user?.name}</div>;
}
// ✅ React 19 方式:use + Suspense + ErrorBoundary(声明式)
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // 就这一行,loading/error 由外层处理
return <div>{user.name}</div>;
}
// Server Component、路由 loader 或带缓存的数据层
function Page({ userId }: { userId: string }) {
const userPromise = getUserPromise(userId);
return (
<ErrorBoundary fallback={<ErrorView />}>
<Suspense fallback={<Loading />}>
<UserProfile userPromise={userPromise} />
</Suspense>
</ErrorBoundary>
);
}
- Promise 必须稳定:不要在 Client Component render 中直接
use(fetchUser(id)),否则每次渲染都会创建新的 Promise;应该由 Server Component、路由 loader、缓存层或父组件稳定创建后传入 - 需要 Suspense 配合:
use读取 pending 的 Promise 时会"挂起"组件,必须有Suspense边界 - 不能替代所有 useEffect:
use只用于读取数据,订阅、DOM 操作等副作用仍需useEffect - 与 Server Components 配合最佳:Server Component 可以直接
await,Client Component 用use读取传入的 Promise
更多关于 React 19 的新特性,请参考 React 19 新特性。