跳到主要内容

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 节点上保存 createdestroydeps。每次渲染浅比较 deps,没变就跳过;变了就在 commit 后异步执行(先跑上一次的 cleanup,再跑新的 effect)。useLayoutEffect 区别在于同步执行、阻塞浏览器绘制。

一次 setState 到底发生了什么?

  1. 调用 setState → React 不会立刻改 state,而是把这次更新(值或函数)塞进当前 Hook 的 queue 里排队,同时标记 Fiber 为「脏」。
  2. 同一事件里多次 setState 会被自动批处理(React 18),合并成一次调度。
  3. 调度器决定何时开始 Render 阶段:重新调用组件函数,执行到 useState 时取出 queue 依次计算出最终新 state。
  4. 计算出新虚拟 DOM 后进入 Commit 阶段,把差异同步到真实 DOM。
  5. 浏览器绘制完成后,异步执行 useEffect
  6. 所以 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

Counter.tsx (class 组件版)
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 组件的四大痛点

痛点 1:恼人的 this 指向

类方法不会自动绑定 this。如果你忘了在 constructor 里 bind(this),事件触发时 this.setState 直接报错 Cannot read property 'setState' of undefined。新人 90% 都踩过这个坑。

痛点 2:生命周期被切成三段

上面那个「同步 document.title」的需求,本质是一段逻辑,但你不得不写在 componentDidMountcomponentDidUpdatecomponentWillUnmount 三个地方。如果还有别的副作用(比如订阅 WebSocket),它的挂载/更新/卸载也得分别写在这三个钩子里——结果就是每个生命周期钩子里塞了一堆毫不相关的逻辑。

痛点 3:逻辑复用要靠 HOC,嵌套地狱

想把「鼠标位置追踪」逻辑复用到多个组件?那时候只能用 HOC(高阶组件)或 render props,写出来长这样:

// 「Wrapper Hell」嵌套地狱
<WithRouter>
<WithTheme>
<WithUser>
<WithMouse>
<MyComponent />
</WithMouse>
</WithUser>
</WithTheme>
</WithRouter>

打开 React DevTools,组件树里全是 WithXxx 包装层,业务组件被埋在最深处。

痛点 4:代码量大、心智负担重

上面那个 Counter 30 行,但其中真正有用的业务逻辑只有 2 行。剩下都是 constructorbindthis.state.xxx、生命周期钩子的样板代码。

Hooks 时代的 Counter

同样的功能,用 Hooks 重写一遍:

Counter.tsx (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 的使用规则:

Hooks 规则
  1. 只能在函数组件或自定义 Hook 中使用
  2. 只能在顶层调用,不能在条件语句、循环或嵌套函数中调用
  3. 调用顺序必须保持一致
// ❌ 错误:条件语句中使用
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>,从你点击到屏幕变化,中间发生了什么?

用大白话再讲一遍

  1. 你点击按钮:浏览器触发 onClick 事件。
  2. setCount 被调用:React 不会立刻改 state,而是把这次更新(c => c + 1 这个函数)塞进当前 Hook 的 queue 里排队。
  3. 标记 Fiber 脏了:React 在 Fiber 节点上打个标记「这个组件需要重新渲染」。
  4. 调度器决定何时渲染:如果你在同一个事件里连续 setCount 三次,React 会把它们合并成一次渲染(这就是 React 18 的自动批处理)。
  5. Render 阶段:React 从头到尾再调用一次你的组件函数。当执行到 useState 时,React 会取出 queue 里所有更新依次计算,得到最终的新 state,返回给你。
  6. Commit 阶段:React 把新旧虚拟 DOM 的差异同步到真实 DOM。
  7. 浏览器绘制:用户终于看到了新画面。
  8. 执行 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执行时机是否阻塞渲染
useEffectDOM 更新后异步执行;非交互更新通常在浏览器绘制后执行❌ 不阻塞
useLayoutEffectDOM 更新后、浏览器绘制前同步执行✅ 阻塞
useEffect 的执行时机不是绝对"绘制后"

大多数非交互更新中,React 会先让浏览器绘制,再执行 useEffect。但如果更新由点击、输入等交互触发,React 也可能在绘制前执行 effect,以便事件系统能观察到 effect 的结果。只要你的逻辑必须在用户看到画面前完成(比如测量布局、同步调整位置),就应该使用 useLayoutEffect

// useEffect:异步执行,不阻塞渲染
useEffect(() => {
// 发送网络请求等副作用
fetchData();
}, []);

// useLayoutEffect:同步执行,阻塞渲染
useLayoutEffect(() => {
// 需要在渲染前完成的 DOM 操作
const rect = elementRef.current.getBoundingClientRect();
setPosition(rect);
}, []);

Effect 生命周期

一个完整的 useEffect 生命周期示例

上面的图比较抽象,咱们用一个真实的聊天室例子,把「挂载 → 依赖变化 → 卸载」三个时机的 cleanup 时序彻底讲清楚。

假设我们有一个 ChatRoom 组件,需要根据 roomId 订阅对应房间的消息:

ChatRoom.tsx
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>;
}

现在咱们模拟一个用户场景:

  1. 用户进入页面,roomId = 'A'
  2. 用户切换到房间 B,roomId 变成 'B'
  3. 用户关闭聊天室,组件卸载

时间轴:每一步都发生了什么

最终的日志输出顺序:

✅ 连接到房间 A    ← 挂载
❌ 断开房间 A ← 切换前先清理上一次
✅ 连接到房间 B ← 再建立新连接
❌ 断开房间 B ← 卸载时清理最后一次

一个反直觉的细节:cleanup 拿到的是「上一次」的闭包

注意上面的 T2 步骤——执行 cleanup 时,roomId 已经变成 'B' 了,但 cleanup 函数里打印的却是 'A'这不是 bug,这是设计如此

为什么 cleanup 拿到的是旧值?

React 在执行 cleanup 时,调用的是上一次 render 时定义的那个 cleanup 函数。那个函数是在 roomId === 'A' 的 render 里被创建的,它的闭包里捕获的就是 'A'

这个设计是有道理的:cleanup 的责任是清理上一次 effect 留下的副作用,所以它必须能访问到「上一次的 roomId」才能去断开正确的连接。如果它拿到的是新值 'B',那就压根不知道之前订阅了哪个房间,也就没法正确退订了。

形象的比喻

Cleanup 就像是「上一段感情留下的尾巴」——你要先彻底告别 A(断开连接),才能干净地开始 B。React 帮你严格按这个顺序执行:先 cleanup 旧的,再执行新的。

闭包陷阱

什么是闭包陷阱?

咱们用一个**「老照片」的比喻**来理解这个 React 里最常被问到的概念。

核心心法:每次 render 都是一张老照片

把每次 render 想象成 React 给你的组件拍了一张快照。这张照片里凝固了那一瞬间的所有东西——props 是当时的值、state 是当时的值、所有事件函数都是当时那张照片里的版本

照片一旦拍完就不可变了。下一次 render 时,React 会拍一张全新的照片,里面是新的 props、新的 state、新的事件函数。新照片不会回头去修改老照片

那「闭包陷阱」是怎么发生的呢?

当你给 setTimeoutsetInterval、事件监听器传一个函数时,你传的是「那张老照片里的函数」。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 的区别?

答案

特性useEffectuseLayoutEffect
执行时机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]);
业务代码一般不用 useInsertionEffect

普通副作用用 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 的函数)在以下两个时机执行:

  1. 组件卸载时:执行最后一次 effect 的清除函数
  2. 依赖变化导致 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 创建的副作用。

闭包陷阱的典型场景与解决方案

场景:setInterval 中的闭包陷阱
// ❌ 闭包陷阱: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 Compiler 是需要在构建链路中启用的编译优化,不是升级到 React 19 后自动生效。启用后,它可以自动 memoize 组件、值和函数,减少手写 useMemo / useCallback 的需要;没有启用 Compiler 的项目,仍然需要按实际性能问题手动优化。

判断标准该用不该用
计算成本耗时 > 1ms 的计算简单字符串拼接、基础运算
子组件传给 React.memo 包裹的子组件没有 memo 的子组件
依赖稳定性作为其他 Hook 的依赖只在 JSX 中使用
依赖变化频率依赖不经常变化依赖每次渲染都变

Q8: useRef 除了获取 DOM 还能做什么?

答案

useRef 的本质是创建一个在整个组件生命周期内保持不变的可变容器对象 { current: T }。修改 .current 不会触发重新渲染,这使它有很多超越 DOM 引用的用途:

1. 存储可变值(跨渲染周期保持引用)

存储定时器 ID
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)

自定义 usePrevious Hook
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. 解决闭包陷阱(保存最新值)

useLatest Hook
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 vs useState vs 普通变量
特性useRefuseState普通变量
修改是否触发重渲染
跨渲染周期保持值否(每次渲染重新创建)
可以同步读取最新值否(闭包)否(重新创建)
适用场景可变引用、DOMUI 状态派生计算

Q9: 自定义 Hook 的设计原则和最佳实践?

答案

自定义 Hook 是复用有状态逻辑的核心手段。以下是设计原则和实战示例:

设计原则

  1. use 开头命名:这是 React 识别 Hook 的约定,ESLint 规则依赖此命名
  2. 单一职责:一个 Hook 只做一件事
  3. 返回值语义清晰:简单值返回单值/元组,复杂值返回对象
  4. 可组合:自定义 Hook 可以调用其他自定义 Hook
  5. 参数设计合理:必要参数在前,可选配置用对象

实战示例 1:useFetch

hooks/useFetch.ts
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

hooks/useDebounce.ts
// 对值进行防抖:值变化后延迟更新
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)} />;
}
自定义 Hook 设计清单
  1. 命名use + 动词/名词,清晰表达用途(useFetchuseDebounceuseLocalStorage
  2. 参数:必要参数直接传,可选参数用 options 对象,支持默认值
  3. 返回值:2 个以下用元组 [value, setter],3 个以上用对象 { data, loading, error }
  4. 清理:在 useEffect 的 return 中清理定时器、订阅等副作用
  5. 引用稳定性:回调参数用 useRef 保存,返回的函数用 useCallback 包裹
  6. 泛型:让 Hook 支持多种数据类型,提高复用性

Q10: React 19 的 use Hook 是什么?和 useEffect 有什么区别?

答案

use 是 React 19 新增的 Hook,用于在渲染期间读取 Promise 或 Context 的值。它是唯一一个可以在条件语句和循环中调用的 Hook。

use 读取 Promise
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>
);
}
use 读取 Context(可条件调用)
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 的核心区别

特性useuseEffect
执行时机渲染期间(同步)渲染后(异步)
用途读取已有的 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>
);
}
use 的注意事项
  1. Promise 必须稳定:不要在 Client Component render 中直接 use(fetchUser(id)),否则每次渲染都会创建新的 Promise;应该由 Server Component、路由 loader、缓存层或父组件稳定创建后传入
  2. 需要 Suspense 配合use 读取 pending 的 Promise 时会"挂起"组件,必须有 Suspense 边界
  3. 不能替代所有 useEffectuse 只用于读取数据,订阅、DOM 操作等副作用仍需 useEffect
  4. 与 Server Components 配合最佳:Server Component 可以直接 await,Client Component 用 use 读取传入的 Promise

更多关于 React 19 的新特性,请参考 React 19 新特性

相关链接