React Hooks 实战:状态、Effect、异步请求与性能边界
本文目录
本文保留旧笔记的归档日期,主体按现代 React 重写。Hooks 不是把类生命周期换成函数名,而是在函数组件中声明状态、订阅和与外部系统同步的方式。先判断数据从哪里来、何时变化、谁拥有它,再决定是否需要某个 Hook。
规则与渲染模型
Hook 只能在函数组件或自定义 Hook 的顶层调用,不能放进条件、循环、事件处理器或普通工具函数。每次 render 都必须以相同顺序调用 Hooks;自定义 Hook 名称以 use 开头,方便 React Hooks lint 规则静态检查。
组件 render 应保持纯粹:相同 props/state 得到相同 UI。不要在 render 期间修改外部变量、直接操作 DOM 或发请求。需要和浏览器 API、订阅、计时器、网络连接等 React 外部系统同步时,才考虑 Effect。
useState:每次 render 看到的是状态快照
状态更新不会改写当前事件处理器已经捕获的变量。若新状态依赖旧状态,使用 updater 函数,让 React 按队列顺序计算:
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
function addThree() {
setCount(value => value + 1);
setCount(value => value + 1);
setCount(value => value + 1);
}
return (
<button onClick={addThree}>
点击次数:{count}
</button>
);
}
当初始值需要较重的计算时,传入惰性初始化函数,例如 useState(createInitialState),避免每次 render 都执行初始化计算。状态对象应按不可变方式创建新值;不要原地改对象后再传回相同引用。
不要为了保存可由现有 props/state 计算出的值而再建一份 state。派生数据通常在 render 中计算;只有确实昂贵且经过测量后,才考虑 useMemo。
useEffect:同步外部系统,并对称清理
Effect 的核心是建立和清理一段外部同步,而不是机械对应 componentDidMount、componentDidUpdate、componentWillUnmount。依赖项必须覆盖 Effect 里读取的响应式 props/state;依赖变化时,React 先用旧值执行 cleanup,再用新值执行 setup,卸载时再清理:
import { useEffect } from "react";
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <p>当前会话:{roomId}</p>;
}
空依赖数组表示此 Effect 没有响应式依赖,不等于任何环境下绝对只运行一次。开发环境启用 StrictMode 时,React 会额外执行一次 setup → cleanup → setup,用来暴露清理遗漏。正确的 Effect 应能承受重复建立和清理,而不是靠“只运行一次”的标志掩盖问题。
不要为消除 lint 提示而删依赖或关闭 exhaustive-deps。优先重构数据流:把只用于事件处理的操作留在事件处理器中,把可在 render 直接计算的派生值移出 Effect,把稳定常量移出组件,把只在 Effect 内用的临时对象放进 Effect。
带取消和错误处理的请求
请求随搜索词变化时,要处理 HTTP 错误、组件清理和较早请求晚返回的竞态。AbortController 可以取消尚未完成的 Fetch;忽略标记则保护不支持取消或已经完成的后续逻辑:
import { useEffect, useState } from "react";
function SearchResults({ query }) {
const [result, setResult] = useState({
status: "loading",
data: [],
error: null,
});
useEffect(() => {
const controller = new AbortController();
let ignore = false;
setResult({ status: "loading", data: [], error: null });
async function load() {
try {
const response = await fetch(
"/api/search?q=" + encodeURIComponent(query),
{
credentials: "same-origin",
signal: controller.signal,
},
);
if (!response.ok) {
throw new Error("Search request failed: " + response.status);
}
const data = await response.json();
if (!ignore) {
setResult({ status: "success", data, error: null });
}
} catch (error) {
if (ignore || error.name === "AbortError") return;
setResult({ status: "error", data: [], error });
}
}
load();
return () => {
ignore = true;
controller.abort();
};
}, [query]);
if (result.status === "loading") return <p>加载中…</p>;
if (result.status === "error") return <p role="alert">搜索失败,请重试。</p>;
return <SearchList items={result.data} />;
}
Effect 适合描述“query 改变时同步搜索结果”。若请求由一次明确的用户提交触发,通常直接在提交处理器中发起;生产应用若已有路由 loader、请求缓存或服务端状态库,应优先沿用它们,避免组件 Effect 与缓存层重复请求。
常用 Hook 怎么选
| Hook | 主要用途 | 常见误用 |
|---|---|---|
| useState | 组件拥有的简单状态 | 把可推导的数据重复存一份 |
| useReducer | 多个相关状态及明确的状态转移 | 为简单布尔值引入多余抽象 |
| useContext | 读取树上共享的配置或状态 | 把所有状态一股脑放进一个高频变化的 Context |
| useRef | DOM 引用或跨 render 保存非界面值 | 期待修改 ref.current 自动触发重渲染 |
| useEffect | 和外部系统建立同步 | 用 Effect 复制 props、计算展示值或响应按钮点击 |
| useLayoutEffect | paint 前必须完成的布局读取/修正 | 用在普通数据请求或所有组件中 |
| useMemo / useCallback | 测量后缓存计算结果或函数引用 | 当作正确性保证或默认加到每个组件 |
| useId | 生成可访问性属性之间的稳定关联 ID | 当列表 key 使用 |
| useSyncExternalStore | 订阅 React 外部的可变 store | 用它替代普通组件 state |
| useImperativeHandle | 有明确需求时限制父组件可调用的命令式接口 | 公开整棵子组件 DOM 或过多内部方法 |
状态逻辑复杂时用 useReducer
Reducer 把“当前状态如何响应某类动作变化”放在一个纯函数中。适合多个字段必须一起更新或状态迁移较多的场景:
import { useReducer } from "react";
function reducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "reset":
return { count: 0 };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>增加</button>
<button onClick={() => dispatch({ type: "reset" })}>重置</button>
</>
);
}
useRef 保存不驱动界面的值
ref 对象跨 render 保持同一身份,但修改 current 不会触发 render。常见用途是聚焦 DOM、保存 timer ID 或保存不需要展示的第三方实例:
import { useRef } from "react";
function SearchBox() {
const inputRef = useRef(null);
return (
<>
<input ref={inputRef} aria-label="搜索" />
<button onClick={() => inputRef.current?.focus()}>聚焦</button>
</>
);
}
如果一个值变化后应该更新 UI,它通常应该是 state,而不是 ref。
useLayoutEffect 的使用边界
useLayoutEffect 在浏览器 repaint 前同步运行,并会阻塞绘制;它适用于“先测量 DOM,再根据尺寸立即调整布局”这类不能闪烁的场景。普通订阅和请求使用 useEffect。服务端渲染没有浏览器布局,useLayoutEffect 不会在服务端执行;不要用它承载首屏必须有的业务数据。
性能 Hook:先测量,再缓存
useMemo 缓存计算结果,useCallback 缓存函数引用,React.memo 可在 props 未变化时跳过部分子组件渲染。它们都是性能优化,不是语义保证;缓存可能被 React 丢弃,代码即使不缓存也必须正确。
先用 React Developer Tools Profiler 定位真实耗时,再评估:
- 计算是否确实昂贵,依赖是否通常稳定。
- 缓存的对象/函数是否传给 memo 子组件或作为其他 Hook 的依赖。
- 是否能通过缩小状态范围、移除不必要 Effect 或保持纯 render 直接解决。
React 19 开始函数组件可以把 ref 作为 prop 接收;新代码通常不必再使用 forwardRef。useImperativeHandle 仍适合少数明确的命令式接口,但优先设计成 props、状态和事件的声明式数据流。
自定义 Hook:复用逻辑,不共享那份状态
自定义 Hook 用 use 开头,可以封装一段有明确业务含义的 Hook 逻辑。两个组件各自调用同一个 Hook,通常得到两套独立状态;Hook 复用的是状态逻辑,不会自动共享状态。需要多处共享同一份状态时,应提升状态、使用 Context 或外部 store。
不要为了模仿生命周期创建 useMount、useEffectOnce 一类包装。它们容易隐藏依赖,让 lint 无法检查,也会掩盖组件需要响应哪些变化。给 Hook 起能表达用途的名称,例如 useChatRoom、useOnlineStatus。
排查清单
- Hook 是否只在组件或自定义 Hook 顶层调用?
- 状态更新是否依赖旧值?依赖时是否使用 updater 函数?
- Effect 是否真的同步了 React 之外的系统?
- 依赖是否完整,cleanup 是否撤销了 setup 的所有订阅、计时器或连接?
- StrictMode 下重复 setup/cleanup 是否仍能正确工作?
- 异步请求是否处理取消、HTTP 错误和旧响应覆盖新响应?
- 派生状态能否直接计算?性能缓存是否有 Profiler 证据?
- ref 是否只保存不驱动 render 的值?
官方资料
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。