React 16.8 在 2019 年 2 月正式发布 Hooks。七年过去,它早已不是“新特性”,而是 React 的默认写法。但打开一个真实项目,你大概率仍会看到这样的代码:useEffect 里塞满了本该在渲染期直接计算出来的派生数据,依赖数组靠“凭感觉”填写,useMemo 和 useCallback 被当成“性能开关”到处乱贴,自定义 Hook 变成了一堆以 use 开头的普通函数。Hooks 带来的不是语法糖,而是一整套新的心智模型:把组件的状态与副作用,按照“功能”而不是“生命周期”来组织。不理解这一点,写出来的 Hooks 代码只会比类组件更难维护。本篇 70 分钟长文,从设计动机讲到实现原理,从每一个内置 Hook 的适用边界讲到自定义 Hook 的设计方法,从常见陷阱讲到测试与调试,最后给出一份可以贴在工位上的检查清单。
一、Hooks 之前的世界:类组件的三座大山
要真正理解 Hooks 的价值,得先回到它诞生之前。在 React 15/16 时代,编写一个带状态的组件意味着必须写一个 class。这不是风格问题,而是当时唯一的选择。而类组件在实践中暴露出了三个难以回避的问题。
1.1 逻辑复用:HOC 与 render props 的嵌套地狱
React 的复用单位从来都是“组件”,而不是“逻辑”。于是社区发明了高阶组件(HOC)和 render props 两种模式,试图把一段逻辑“借”给另一个组件用。结果就是著名的 wrapper hell:
export default withPermission(
withI18n(
withTheme(
withSubscription(
withRouter(MyComponent)
)
)
)
));
每一层包装都会在 DevTools 里多出一层组件节点,props 的来源变得不透明,命名冲突需要靠约定去规避。更麻烦的是,这些逻辑彼此之间无法自然地组合——你没法在一个 HOC 内部“调用”另一个 HOC 的行为。
1.2 生命周期:把一件事拆成三处写
类组件要求你按“生命周期阶段”组织代码。于是一个功能(比如订阅数据)的建立、更新、清理,会被强制拆散到三个不同的方法里:
componentDidMount() {
ChatAPI.subscribe(this.props.friend.id, this.handleStatusChange);
this.timer = setInterval(this.tick, 1000);
}
componentDidUpdate(prevProps) {
if (prevProps.friend.id !== this.props.friend.id) {
ChatAPI.unsubscribe(prevProps.friend.id, this.handleStatusChange);
ChatAPI.subscribe(this.props.friend.id, this.handleStatusChange);
}
}
componentWillUnmount() {
ChatAPI.unsubscribe(this.props.friend.id, this.handleStatusChange);
clearInterval(this.timer);
}
}
订阅逻辑被切成了三块,中间还夹着计时器逻辑。当组件增长到几百行,同一个功能的代码散落在各处,改动一个功能需要同时修改三个地方——这是 bug 的温床。
1.3 this 与编译优化
类组件还有两个“隐性成本”:一是 this 的绑定问题,事件处理函数必须手动绑定或者写成箭头函数类字段,初学者极易踩坑;二是类组件对编译器与工具链不够友好——this 的语义使得静态分析更困难,热更新时类实例的状态保留也更棘手。
Hooks 的回答
Hooks 的核心主张只有一句:让函数组件拥有状态与副作用能力,并让逻辑按“功能”而非“生命周期”聚合。于是上面那段订阅逻辑,可以完整地写在一块:
const [isOnline, setIsOnline] = useState(null);
useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}
ChatAPI.subscribe(friendId, handleStatusChange);
return () => ChatAPI.unsubscribe(friendId, handleStatusChange);
}, [friendId]);
return isOnline;
}
建立与清理写在同一个闭包里,依赖清晰地列在数组里,逻辑可以被任意组件通过一行 useFriendStatus(id) 复用,不产生任何多余的组件节点。
二、两条规则与它们背后的实现原理
每个 React 开发者都背过那两条规则:只在函数组件或自定义 Hook 的顶层调用 Hooks,以及只在 React 函数中调用 Hooks。但真正理解“为什么”,才能在写代码时不假思索地遵守它们。
2.1 Hook 是一条链表
React 并没有为每个 Hook 使用名字来存储状态——因为在压缩后名字会丢失。它用的是调用顺序。每个函数组件实例对应的 fiber 节点上,挂着一个 memoizedState 字段,它指向一个单向链表,链表上的每个节点对应一次 Hook 调用:
{
memoizedState: 0, // useState 的值 / useEffect 的 effect 对象
baseState: 0,
queue: { /* 更新队列 */ },
next: { // 指向下一个 Hook
memoizedState: false,
next: {
memoizedState: { deps: [...], create: fn, destroy: fn },
next: null
}
}
}
渲染时,React 内部维护一个“当前读取到第几个 Hook”的游标。首次渲染时按顺序创建节点;更新渲染时,按同样的顺序从链表上取下节点,把上一次的状态或缓存返回给你。所以调用顺序一旦变化,后面的所有 Hook 都会读错数据。
if (props.enabled) { useEffect(...) }第一次渲染时 props.enabled 为 true,链表上多了一个节点;第二次为 false,后面的 Hook 全部错位,状态读串。
useEffect(() => { if (!props.enabled) return; ... }, [props.enabled])Hook 本身永远被调用,条件写在回调内部,顺序保持稳定。
2.2 一次渲染中 Hook 的执行顺序
点击下面的每个节点,看看 React 在渲染一个函数组件时,内部到底发生了什么。
ReactCurrentDispatcher.current 指向不同的实现:首次渲染用 HooksDispatcherOnMount,更新用 HooksDispatcherOnUpdate。这就是为什么在普通函数里调用 Hook 会报错——此时 dispatcher 指向的是抛异常的版本。memoizedState。此时传入的初始值只被使用这一次。useEffect、useMemo、useCallback 都会把本次依赖数组与上一次逐项比较,使用的是 Object.is。注意它是浅比较:对象字面量、数组字面量每次都是新引用,必然判定为“变了”。useLayoutEffect 会同步执行——它发生在浏览器绘制之前,所以适合做测量与同步修正布局。useEffect 在浏览器绘制之后异步执行。执行顺序是:先运行上一次的清理函数,再运行本次的 effect。这个顺序保证了每一次 effect 都能看到一致的订阅状态。2.3 用 20 行代码模拟 useState
理解原理最好的方式是亲手实现一遍。下面这段代码用数组模拟了 Hook 链表的“顺序即身份”:
let cursor = 0;
function useState(initial) {
const index = cursor;
// 首次渲染写入初始值,之后沿用已存的值
if (hookStates[index] === undefined) {
hookStates[index] = typeof initial === 'function' ? initial() : initial;
}
const setState = (next) => {
// 函数式更新:基于最新值计算
hookStates[index] =
typeof next === 'function' ? next(hookStates[index]) : next;
render();
};
cursor++; // 游标后移,顺序就是身份
return [hookStates[index], setState];
}
function render() {
cursor = 0; // 每次渲染前必须重置游标
ReactDOM.render(<App />, root);
}
这段代码当然不是 React 的真实实现(真实的还要处理更新队列、优先级、批处理、并发中断等),但它精确地解释了那条规则:如果你有条件地调用 Hook,游标就会错位,后面的状态全部串位。
三、useState:比你想象的更有讲究
useState 是使用频率最高的 Hook,也是最容易被用“浅”的一个。它至少有四个值得深挖的细节。
3.1 惰性初始化:只在首次渲染时计算
如果初始状态需要昂贵计算,直接写 useState(computeExpensiveValue()) 会导致每次渲染都白算一遍——即使这个值根本不再被使用。传入函数可以避免:
const [state, setState] = useState(computeExpensiveValue());
// 只在首次渲染时执行一次
const [state, setState] = useState(() => computeExpensiveValue());
// 典型场景:从 localStorage 读取初始值
const [theme, setTheme] = useState(() => {
return localStorage.getItem('theme') ?? 'light';
});
3.2 函数式更新:解决“同一批里的连续更新”
当一次更新中需要基于前一个状态计算新状态时,务必使用函数式更新。经典的例子是“一次点击加三”:
function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}
// ✅ 正确:React 会依次把更新函数串起来执行
function handleClick() {
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
}
原理在于:setState 并不立即修改状态,而是把一个更新对象(或更新函数)入队。渲染时 React 依次取出队列中的更新,用前一个结果计算下一个。函数式更新让你永远拿到“最新值”,而不是本次渲染时的旧快照。
3.3 批处理:React 18 之后的统一行为
在 React 17 及以前,只有 React 事件处理函数中的更新会被自动批处理;在 setTimeout、Promise、原生事件监听器里的更新会触发多次渲染。React 18 引入自动批处理(Automatic Batching)后,这些场景也被统一了:
setTimeout(() => {
setLoading(false);
setData(result);
setError(null);
}, 1000);
// 如果确实需要同步刷新 DOM(极少需要)
import { flushSync } from 'react-dom';
flushSync(() => setCount(c => c + 1));
3.4 状态是替换,不是合并
这是从类组件迁移过来时最容易踩的坑。类组件的 this.setState({ a: 1 }) 会自动与旧 state 浅合并;而 useState 的 setter 是完全替换:
// ❌ age 会丢失
setForm({ name: '张玥' });
// ✅ 手动展开,或用 useReducer 管理复杂对象
setForm(prev => ({ ...prev, name: '张玥' }));
如果状态对象的结构比较复杂、更新逻辑较多,就应该考虑 useReducer——它把“怎么改”收敛到一个纯函数里,可测试性也更好。
| 写法 | 行为 | 推荐场景 |
|---|---|---|
useState(value) |
每次渲染都求值一次 | 初始值是字面量或简单表达式 |
useState(() => value) |
只在首次渲染求值 | 昂贵计算、读 localStorage、解析 URL |
setX(v) |
直接替换为 v | 值与旧值无关时 |
setX(prev => ...) |
基于最新值计算 | 连续更新、在异步回调中更新 |
useReducer |
集中式状态迁移 | 多字段联动、更新逻辑复杂 |
四、useEffect:最容易写错的一个 Hook
如果说 Hooks 只有一个难点,那一定是 useEffect。它看似简单——传个函数、传个依赖数组——但绝大多数 React 性能问题与诡异 bug 都源自对它的误解。
4.1 先纠正一个观念:useEffect 不是生命周期
把 useEffect(fn, []) 理解为 componentDidMount、把 useEffect(fn) 理解为 componentDidUpdate,是理解一切偏差的源头。React 官方文档给它的定义是:把组件与外部系统同步。它不是“某个时刻要执行的代码”,而是“在依赖变化时,重新建立这种同步关系”。
一旦接受这个定义,很多疑问就自然消解了:为什么依赖变了要先清理再执行?因为同步关系要重新建立;为什么不能在里面做派生状态的计算?因为那是渲染期就该完成的事。
4.2 依赖数组的三种形态
4.3 执行时序:一张图看懂
↓
React 提交 DOM 变更(commit)
↓
useLayoutEffect 同步执行 ← 浏览器尚未绘制
↓
浏览器绘制(paint)
↓
useEffect 异步执行 ← 先清理旧 effect,再执行新 effect
这条时序解释了三个常见现象:
- 在
useEffect里读取 DOM 尺寸,可能会看到“闪一下”——因为绘制已经发生。 - 在
useLayoutEffect里做重计算会阻塞绘制,造成掉帧。 - 在服务端渲染时
useLayoutEffect会报警告,因为服务端没有绘制阶段。
4.4 清理函数到底什么时候跑
清理函数的执行时机是:组件卸载时,以及下一次 effect 执行之前。它捕获的是上一次 effect 那次渲染的闭包变量——这一点非常关键:
const connection = createConnection(roomId);
connection.connect();
return () => {
// 这里的 roomId 是“上一次渲染”的那个值
connection.disconnect();
};
}, [roomId]);
如果 roomId 从 'A' 变成 'B',执行顺序是:用旧闭包('A')断开连接 → 用新闭包('B')建立连接。这个设计保证了资源的正确配对释放,也避免了“断错连接”的问题。
4.5 竞态条件:异步请求的头号杀手
当 effect 里发起异步请求,而依赖会快速变化时,先发出的请求可能后返回,导致界面显示错误的数据。经典的修复方式是使用“忽略标志”:
let ignore = false;
setLoading(true);
fetchUser(userId)
.then(data => {
if (ignore) return; // 已经过期,丢弃结果
setUser(data);
setLoading(false);
})
.catch(err => {
if (ignore) return;
setError(err);
});
return () => { ignore = true; };
}, [userId]);
另一种更现代的方案是使用 AbortController,在清理函数里直接取消请求。React 19 之后,也可以配合 use() 与 Suspense 让框架层处理这类问题。
4.6 依赖数组的完整性:不要对 linter 撒谎
eslint-plugin-react-hooks 的 exhaustive-deps 规则经常被开发者“用注释关掉”,理由是“加上会死循环”。但这通常意味着代码本身有问题,而不是规则太严格。三种正确做法:
- 函数依赖:把函数定义搬到 effect 内部,或使用
useCallback包裹(并保证它自己的依赖也是完整的)。 - 对象依赖:不要依赖整个对象,改为依赖它的具体字段;或在 effect 内部解构出需要的值。
- 确实只想执行一次:使用
useRef保存最新值,或用useEffectEvent(React 实验特性)读取“非响应式”的值。
// eslint-disable-next-line react-hooks/exhaustive-deps关掉 lint 之后,闭包读到的是旧值,出现“数据不更新”“状态卡住”等难以复现的问题。
useRef 保存最新值,并在 effect 中读取 ref:const latestRef = useRef(value);
useEffect(() => { latestRef.current = value; });
4.7 无限循环的三种常见成因
- 在 effect 里更新了自己依赖的状态:
useEffect(() => setCount(count + 1), [count])。 - 依赖了每次渲染都新建的对象/数组/函数:
useEffect(fn, [{ id }])或useEffect(fn, [items.filter(...)])。 - 不写依赖数组却每次都 setState:等于每次渲染都触发一次新渲染。
五、useLayoutEffect:绘制前的那一次机会
useLayoutEffect 与 useEffect 的签名完全一致,区别只在执行时机:前者在 DOM 变更后、浏览器绘制前同步执行。
什么时候必须用它
- 测量 DOM 并立即修正布局:比如根据内容高度调整 tooltip 的位置,若放在
useEffect里,用户会看到 tooltip 先出现在错误位置再跳过去。 - 需要同步读取布局信息再渲染:例如虚拟列表的可见区域计算。
- 第三方 DOM 库的初始化:有些库要求容器尺寸已经确定。
const [pos, setPos] = useState({ top: 0, left: 0 });
const tipRef = useRef(null);
useLayoutEffect(() => {
const target = targetRef.current.getBoundingClientRect();
const tip = tipRef.current.getBoundingClientRect();
// 在绘制之前就把位置算好,用户看不到跳动
setPos({
top: target.top - tip.height - 8,
left: target.left + target.width / 2 - tip.width / 2,
});
}, [children]);
return <div ref={tipRef} style={{ position: 'fixed', ...pos }}>{children}</div>;
}
代价与约束
useLayoutEffect 是同步的,它会阻塞浏览器绘制。里面做了重计算,用户就会直接感受到卡顿。同时它在服务端渲染时会输出警告(服务端没有布局阶段)。因此经验法则是:能用 useEffect 就用 useEffect,只有当“绘制前必须完成”时才用 useLayoutEffect。
| 对比项 | useEffect | useLayoutEffect |
|---|---|---|
| 执行时机 | 浏览器绘制之后,异步 | DOM 提交之后、绘制之前,同步 |
| 是否阻塞绘制 | 否 | 是 |
| 服务端渲染 | 不执行,无警告 | 不执行,有警告 |
| 典型用途 | 数据请求、订阅、日志 | DOM 测量、同步修正布局 |
六、useRef:跨渲染的储物柜
useRef 返回一个在组件整个生命周期内保持同一个引用的对象 { current: ... }。修改 current 不会触发重新渲染——这是它与 useState 最本质的区别。
6.1 三种典型用法
const inputRef = useRef(null);
function focus() {
inputRef.current.focus();
}
return <input ref={inputRef} />;
// 2. 存放不参与渲染的可变值
const timerRef = useRef(null);
useEffect(() => {
timerRef.current = setInterval(tick, 1000);
return () => clearInterval(timerRef.current);
}, []);
// 3. 保存“最新值”以打破闭包陷阱
const latestOnClick = useRef(onClick);
useEffect(() => {
latestOnClick.current = onClick;
});
6.2 ref 与 state 的取舍
| 问题 | useState | useRef |
|---|---|---|
| 修改后是否重新渲染 | 会 | 不会 |
| 是否出现在 UI 上 | 是,是渲染的输入 | 否,是渲染之外的存储 |
| 渲染期间读取是否安全 | 安全 | 不安全(应放到 effect / 事件中) |
| 典型场景 | 用户可见的状态 | 定时器、DOM、缓存标记 |
七、useContext:跨层级传值,以及它的性能陷阱
useContext 让你跳过中间层,直接读取上层 Provider 提供的值。它的用法很简单,但性能行为需要格外注意:当 Provider 的 value 引用发生变化时,所有消费该 Context 的组件都会重新渲染,无论它们是否真的用到了变化的那部分数据。
7.1 两个必做的优化
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
// ✅ 用 useMemo 稳定 value
const value = useMemo(() => ({ theme, setTheme }), [theme]);
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
但 useMemo 只解决了“value 引用稳定”的问题。如果 value 里既有频繁变化的 theme,又有稳定的 setTheme,那么 theme 一变,所有消费者仍会重渲染。真正的解法是拆分 Context:
const ThemeValueContext = createContext('light');
const ThemeActionsContext = createContext(null);
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
// actions 永远不会变,订阅它的组件永不因主题变化而重渲染
const actions = useMemo(() => ({ setTheme, toggle: () => setTheme(t => t === 'light' ? 'dark' : 'light') }), []);
return (
<ThemeActionsContext.Provider value={actions}>
<ThemeValueContext.Provider value={theme}>
{children}
</ThemeValueContext.Provider>
</ThemeActionsContext.Provider>
);
}
7.2 Context 不是状态管理库
Context 只解决“跨层级传递”,不解决“如何管理状态”。用它做全局状态管理时,常见的问题是:任何一处更新都会导致整棵子树重渲染。当状态复杂、更新频繁时,考虑引入 Zustand、Jotai、Redux Toolkit 等专门方案——它们都基于“订阅”模型,能做到只有用到该状态的组件才更新。
八、useMemo 与 useCallback:不要滥用的优化
这两个 Hook 大概是 React 里被使用得最随意的一对。很多项目里,几乎每个函数都被 useCallback 包了一层,每个对象都被 useMemo 包了一层,性能却没有任何提升,代码可读性反而大幅下降。
8.1 它们真正的作用
useMemo(fn, deps):缓存计算结果,避免在依赖未变时重复执行昂贵计算。useCallback(fn, deps):缓存函数引用,本质上等价于useMemo(() => fn, deps)。
注意,它们优化的不是“渲染次数”,而是“渲染时的计算量”和“引用稳定性”。而且缓存本身也有成本:React 需要保存依赖数组、做逐项比较。对于简单的计算,这个成本可能比重新计算还高。
8.2 什么时候才真的有用
const title = useMemo(() => `${a} - ${b}`, [a, b]);
// ✅ 有意义的优化:大数据量过滤
const visible = useMemo(
() => items.filter(matches).sort(byScore),
[items, keyword]
);
// ✅ 有意义的优化:稳定回调引用
const handleSelect = useCallback((id) => {
onSelect(id);
}, [onSelect]);
// 只有子组件被 memo 包裹,引用稳定才有意义
const Row = React.memo(function Row({ item, onSelect }) { ... });
8.3 性能问题的定位顺序
遇到卡顿,正确的排查顺序是:
- 用 React DevTools Profiler 录制一次交互,看清楚到底哪些组件重渲染了、耗时多少。
- 判断重渲染的原因:是父组件状态变化?是 Context value 变化?还是列表 key 不稳定导致的重建?
- 优先解决结构问题(拆分组件、下移状态、稳定 key),再考虑加 memo 与 useMemo。
盲目加 useMemo 最常见的结果是:性能没变,依赖数组写错反而引入了新 bug。
九、useReducer:把状态迁移写成纯函数
当状态字段变多、更新逻辑出现分支时,useState 会让组件里塞满零散的 setter 调用。useReducer 把这些逻辑收敛到一个纯函数中:
function reducer(state, action) {
switch (action.type) {
case 'loading':
return { ...state, status: 'loading', error: null };
case 'success':
return { status: 'success', data: action.payload, error: null };
case 'error':
return { ...state, status: 'error', error: action.payload };
default:
throw new Error(`未知 action: ${action.type}`);
}
}
function useRequest(fetcher) {
const [state, dispatch] = useReducer(reducer, initialState);
const run = useCallback(async (params) => {
dispatch({ type: 'loading' });
try {
const data = await fetcher(params);
dispatch({ type: 'success', payload: data });
} catch (err) {
dispatch({ type: 'error', payload: err });
}
}, [fetcher]);
return { ...state, run };
}
useReducer 的三个好处
- 可测试:reducer 是纯函数,不依赖 React,直接传入 state 与 action 断言输出即可。
- 可预测:所有状态迁移都被显式命名为 action,阅读 switch 就能看清完整的状态机。
- 易传递:
dispatch的引用永远稳定,可以安全地放进依赖数组,也可以配合 Context 下发给深层组件。
reducer 必须是纯函数
和渲染函数一样,reducer 里不能有副作用:不能发请求、不能写 localStorage、不能修改传入的 state 对象。React 在开发模式下(StrictMode)会故意重复调用 reducer 来帮你发现这类问题。如果你在 reducer 里 push 了数组,就会看到数据翻倍——这不是 React 的 bug,而是它在提醒你违反了规则。
十、其余内置 Hook:各司其职
10.1 useImperativeHandle:把 ref 的接口收窄
默认情况下,父组件通过 ref 能拿到子组件整个 DOM 节点,这破坏了封装。 useImperativeHandle 让你只暴露必要的方法:
const videoRef = useRef(null);
useImperativeHandle(ref, () => ({
play() { videoRef.current.play(); },
pause() { videoRef.current.pause(); },
seek(t) { videoRef.current.currentTime = t; },
}), []);
return <video ref={videoRef} {...props} />;
});
父组件拿到的对象只有 play、pause、seek 三个方法,无法直接操作 DOM。注意:useImperativeHandle 属于“逃生舱”,能用 props 与回调表达就别用它。
10.2 useId:生成稳定的唯一 ID
表单的 label 需要与 input 的 id 对应,服务端渲染时又不能随便生成随机数(会导致 hydration 不一致)。useId 正是为此设计:
const id = useId();
return (
<>
<label htmlFor={id}>邮箱</label>
<input id={id} type="email" />
</>
);
}
注意:useId 生成的值不能用作列表的 key,它只用于需要稳定标识的无障碍属性。
10.3 useSyncExternalStore:安全订阅外部数据源
当你需要订阅 React 之外的数据源(全局 store、浏览器 API、第三方库)时,useSyncExternalStore 是官方推荐方案。它能在并发渲染下避免“撕裂”(tearing)——即同一帧内不同组件读到不一致的数据:
return useSyncExternalStore(
// 订阅
(callback) => {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
},
// 读取快照
() => navigator.onLine,
// 服务端快照
() => true
);
}
10.4 useDebugValue:给自定义 Hook 加标签
它只在 React DevTools 中生效,用于给自定义 Hook 显示可读的调试信息:
const [isOnline, setIsOnline] = useState(null);
// ...订阅逻辑
useDebugValue(isOnline ? '在线' : '离线');
return isOnline;
}
如果格式化值的计算成本较高,可以传第二个参数做惰性格式化:useDebugValue(date, d => d.toISOString()),这样只有 DevTools 打开时才会执行。
十一、并发时代的 Hook:useTransition 与 useDeferredValue
React 18 引入的并发渲染,给 Hooks 家族增加了两个新成员。它们的共同目标是:让“紧急更新”不被“非紧急更新”阻塞。
11.1 useTransition:把更新标记为“可打断”
const [keyword, setKeyword] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleChange(e) {
// 紧急:输入框必须立刻响应
setKeyword(e.target.value);
// 非紧急:大列表更新可以被中断
startTransition(() => {
setResults(search(e.target.value));
});
}
return (
<>
<input value={keyword} onChange={handleChange} />
{isPending && <Spinner />}
<ResultList items={results} />
</>
);
}
关键点:startTransition 里的更新不会阻塞输入框的渲染;如果用户在结果渲染完成前又输入了新内容,旧的渲染会被丢弃,只有最新的状态会被提交。
11.2 useDeferredValue:只延迟某个值
当你无法把更新包进 transition(例如值来自 props),可以用 useDeferredValue 得到一个“滞后版本”:
// deferredKeyword 会“稍微慢一拍”跟上 keyword
const deferredKeyword = useDeferredValue(keyword);
const stale = deferredKeyword !== keyword;
return (
<div style={{ opacity: stale ? 0.6 : 1 }}>
<HeavyList keyword={deferredKeyword} />
</div>
);
}
11.3 两者的区别
| 对比项 | useTransition | useDeferredValue |
|---|---|---|
| 作用对象 | 更新操作 | 某个值 |
| 使用位置 | 触发更新的地方 | 消费该值的地方 |
| 能否获得 pending 状态 | 能(isPending) | 需要自己比较新旧值 |
| 典型场景 | 搜索框 + 大列表过滤 | 值来自 props 或 Context 时 |
另外,React 官方建议:在大多数场景下,如果你已经用上了 useDeferredValue 或 useTransition,就不再需要手写防抖。因为并发渲染是按帧让路的,响应会比固定时间窗口的防抖更自然。
十二、自定义 Hooks:Hooks 真正的威力所在
内置 Hook 只是积木,自定义 Hook 才是 Hooks 设计中最有价值的部分。它让“逻辑复用”第一次变得像调用函数一样自然。
12.1 三条约定
- 名字必须以
use开头。这不仅是风格,React 的 lint 规则与编译器都依赖这个前缀来判断它是不是一个 Hook。 - 内部只能调用其他 Hook 或纯函数,并且要在顶层调用,不能有条件分支。
- 它不共享状态,只共享逻辑。两个组件调用同一个自定义 Hook,得到的是两份独立的状态。
12.2 返回值:数组还是对象
const [count, setCount] = useCounter(0);
// 对象:调用方按需解构,适合返回较多值
const { data, loading, error, refetch } = useFetch(url);
// 返回对象时建议用 useMemo 稳定引用
return useMemo(() => ({ data, loading, error, refetch }),
[data, loading, error, refetch]);
12.3 五个实战自定义 Hook
useDebounce:延迟值
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
// 值在 delay 内再次变化时,清掉上一次的定时器
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
useLocalStorage:与本地存储同步
const [value, setValue] = useState(() => {
try {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
useEventListener:自动清理的事件监听
// 用 ref 保存最新 handler,避免因 handler 变化而反复解绑
const handlerRef = useRef(handler);
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
useEffect(() => {
const listener = (e) => handlerRef.current(e);
target.addEventListener(type, listener);
return () => target.removeEventListener(type, listener);
}, [type, target]);
}
useIntersectionObserver:滚动可见性
const ref = useRef(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => setInView(entry.isIntersecting),
options
);
observer.observe(el);
return () => observer.disconnect();
}, [options.root, options.rootMargin, options.threshold]);
return [ref, inView];
}
useFetch:带竞态保护的数据请求
const [state, setState] = useState({ data: null, loading: true, error: null });
useEffect(() => {
const controller = new AbortController();
setState(s => ({ ...s, loading: true, error: null }));
fetch(url, { signal: controller.signal })
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(data => setState({ data, loading: false, error: null }))
.catch(err => {
if (err.name === 'AbortError') return;
setState({ data: null, loading: false, error: err });
});
return () => controller.abort();
}, [url]);
return state;
}
12.4 设计自定义 Hook 的四条原则
十三、十个最常见的 Hooks 陷阱
重点展开:不要用 useEffect 计算派生数据
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(first + ' ' + last);
}, [first, last]);多了一次渲染,还引入了状态不同步的风险。
const fullName = first + ' ' + last;直接在渲染期计算。真正昂贵的计算才用
useMemo。
重点展开:key 的选择
key 不是普通的 prop,它是 React 用来识别“这个元素在上一次渲染中是谁”的身份标识。用数组下标做 key,在列表发生插入、删除、排序时,React 会认为“第 0 项还是第 0 项”,从而错误地复用组件实例与状态:
items.map((item, index) => <Row key={index} item={item} />)
// ✅ 使用稳定且唯一的业务 ID
items.map(item => <Row key={item.id} item={item} />)
十四、测试、调试与 StrictMode
14.1 用 renderHook 测试自定义 Hook
Testing Library 提供了 renderHook,可以在不写组件的情况下直接测试 Hook:
import { useCounter } from './useCounter';
test('递增计数', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
// 测试依赖变化:rerender 会传入新的 props
const { result, rerender } = renderHook(
({ id }) => useUser(id),
{ initialProps: { id: 1 } }
);
rerender({ id: 2 });
14.2 StrictMode:为什么我的 effect 执行了两次
在开发模式下,React 会故意对组件进行“挂载 → 卸载 → 再挂载”的双重调用,目的是暴露那些清理不彻底的副作用。如果你看到请求发了两次、订阅建立了两次,正确的反应不是关掉 StrictMode,而是检查:
- 清理函数有没有正确解绑、取消、清除定时器?
- 请求是不是被
AbortController正确取消? - 在 effect 里有没有直接修改外部可变状态?
只要副作用是“可重复、可清理”的,双重调用就不会带来问题。生产构建中不会发生双重调用。
14.3 DevTools 的实用技巧
- Components 面板可以查看每个组件的 Hooks 列表与当前值,自定义 Hook 会以嵌套形式展示。
- Profiler 面板录制的每一帧都能看到“为什么渲染”——是 props 变了、state 变了,还是父组件重渲染。
- 开启 “Highlight updates when components render”,可以直观看到哪些区域在不必要地闪烁。
- 为自定义 Hook 添加
useDebugValue,让它在面板中显示有意义的标签。
十五、Hooks 使用检查清单与代码基线
把全文的结论浓缩成一份可以贴在工位上的清单。每次提交代码前扫一眼,能挡掉绝大多数问题。
- Hook 永远在顶层调用,不在条件、循环、嵌套函数里出现。
- 自定义 Hook 以
use开头,内部遵守同样的规则。 - 能从 props / state 直接推导的值,就不要用 state 存,更不要用 effect 同步。
- 依赖数组完整,不随意关掉
exhaustive-deps。 - 不依赖新建的对象、数组、匿名函数;需要时用
useMemo/useCallback稳定。 - 连续更新用函数式写法:
setX(prev => ...)。 - 对象状态要手动展开合并,或改用
useReducer。 - effect 里的异步操作处理竞态:ignore 标志或
AbortController。 - 每个 effect 都有对应的清理逻辑(如果需要的话),保证可重复执行。
- 需要测量 DOM 并同步修正布局时才用
useLayoutEffect。 - 不参与渲染的可变值放
useRef,不触发重渲染。 - Context 的 value 用
useMemo稳定,频繁变化的数据单独拆一个 Context。 - 不做无意义的
useMemo/useCallback,先测性能再优化。 - 列表 key 使用稳定唯一的业务 ID,不用数组下标。
- 自定义 Hook 保持单一职责,返回值引用稳定,可组合、可测试。
- 不依赖
useEffect去“监听”事件——那是事件处理函数该做的事。 - StrictMode 保持开启,把双重调用当作免费的副作用检查器。
- 用 Profiler 定位性能问题,而不是凭感觉加 memo。
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
export function useResource(id, fetcher) {
const [state, setState] = useState({
data: null,
loading: true,
error: null,
});
// 用 ref 保存最新的 fetcher,避免它进入依赖数组
const fetcherRef = useRef(fetcher);
useEffect(() => {
fetcherRef.current = fetcher;
});
// 主 effect:带竞态保护的数据加载
useEffect(() => {
if (id == null) return;
let ignore = false;
setState(s => ({ ...s, loading: true, error: null }));
fetcherRef.current(id)
.then(data => {
if (ignore) return;
setState({ data, loading: false, error: null });
})
.catch(error => {
if (ignore) return;
setState({ data: null, loading: false, error });
});
return () => { ignore = true; };
}, [id]);
// 返回稳定引用,方便调用方放进依赖数组
return useMemo(() => state, [state]);
}
Hooks 最难的地方从来不是 API,而是它所要求的心智转变:渲染是纯的,副作用是同步,依赖是显式的。当你习惯用“这次渲染会计算出什么”而不是“这时候该执行哪段生命周期”来思考,绝大多数曾经诡异的 bug 都会变得一目了然。
所以,下一次你准备写下 useEffect 的时候,不妨先停半秒问自己一句:“这真的是一次副作用吗,还是我可以在渲染期直接算出来?”——答案往往会让你少写十行代码。