React Hooks 深度解析
React Hooks 深度解析
React Hooks 是 React 16.8 引入的革命性特性,它彻底改变了 React 组件的编写方式,让函数组件也能拥有状态和生命周期特性,同时解决了 class 组件中 this 指向混乱、生命周期方法逻辑分散、逻辑复用困难(HOC 和 render props 带来的嵌套地狱)等问题。
在 Hooks 出现之前,函数组件被称为"无状态组件",只能接收 props 渲染 UI。想要状态和生命周期,就必须写 class,而 class 带来了三大痛点:this 绑定令人困惑、相关逻辑被拆散在不同生命周期方法里(一个订阅的建立在 didMount、销毁在 willUnmount)、组件间复用状态逻辑只能靠层层包裹的 HOC。Hooks 用一组以 use 开头的函数一举解决了这些问题——用同一个 useEffect 把"建立订阅"和"清理订阅"写在一起,用自定义 Hook 优雅地复用逻辑。
Hooks 为什么必须"按顺序、在顶层"调用
这是理解 Hooks 的关键。React 内部并不是靠变量名来记住每个 Hook 的状态,而是靠调用顺序。每个函数组件对应一个 Hooks 链表,第一次渲染时按调用顺序依次创建节点,后续每次渲染再按同样顺序取出对应节点。所以如果你把 Hook 放进 if 或循环里,某次渲染少调用一个,整个链表就"错位"了,状态会张冠李戴。
// 用一个极简实现理解"顺序依赖"的本质
let hookStates = [];
let hookIndex = 0;
function useStateMock(initial) {
const currentIndex = hookIndex;
hookStates[currentIndex] = hookStates[currentIndex] ?? initial;
const setState = (value) => {
hookStates[currentIndex] = value;
render(); // 触发重新渲染
};
hookIndex++;
return [hookStates[currentIndex], setState];
}
function render() {
hookIndex = 0; // 每次渲染前重置索引,保证按相同顺序取值
// ...调用组件函数
}
// 正因如此:Hook 必须无条件、按固定顺序调用,否则索引对不上常用 Hooks 详解
useState - 状态管理基础:
useState 是最基础的 Hook,用于在函数组件中添加状态管理能力。它接收一个初始值或初始化函数,返回一个包含当前状态值和更新函数的数组。useState 支持函数式更新,可以在更新函数中接收前一个状态值,这在处理异步更新或基于前值连续计算时特别有用(避免闭包捕获旧值)。惰性初始化允许传入一个函数作为初始值,该函数只在首次渲染时执行一次,适合初始值计算昂贵的场景(如读取 localStorage、大数组构造)。
useEffect - 副作用处理核心:
useEffect 是处理副作用的核心 Hook,包括数据获取、订阅、DOM 操作、定时器等。它接收一个回调函数和一个可选的依赖数组,当依赖数组中的值发生变化时执行回调。空依赖数组表示只在挂载和卸载时执行;不传依赖数组则每次渲染都执行;有依赖则在依赖变化时执行。useEffect 的清理函数在组件卸载或下一次 effect 执行前调用,用于取消订阅、清除定时器等资源释放。注意 useEffect 是异步执行(在浏览器绘制之后),若需在绘制前同步读写 DOM 布局,用 useLayoutEffect。
useRef - 可变引用与 DOM 访问:
useRef 返回一个可变的 ref 对象,其 current 属性可以保存任意值,且在整个组件生命周期内保持同一个引用。useRef 的主要用途:访问 DOM 元素(把 ref 传给 JSX 元素)、保存可变值而不触发重新渲染(如定时器 id、上一次的值)。与 useState 不同,修改 ref.current 不会触发重新渲染——它是组件里的"抽屉",放东西进去不惊动界面。
useMemo - 计算结果缓存:
useMemo 用于缓存昂贵的计算结果,避免每次渲染重复计算。它接收一个计算函数和依赖数组,只有依赖变化时才重新计算。适合复杂的数据转换、过滤、排序。但 useMemo 本身也有比较依赖、缓存的开销,对廉价计算使用反而得不偿失——先测量再决定。
useCallback - 函数引用缓存:
useCallback 用于缓存函数引用,避免每次渲染都创建新的函数实例。它返回记忆化的回调,主要配合 React.memo 或作为其他 Hook 的依赖时使用。useCallback(fn, deps) 等价于 useMemo(() => fn, deps)——前者缓存函数本身,后者缓存返回值。
useReducer - 复杂状态的集中管理:
当多个状态相互关联、更新逻辑复杂时,useReducer 比多个 useState 更清晰。它把"如何更新状态"收敛到一个纯函数 reducer 里,组件只负责 dispatch 动作,逻辑可测试、可预测。
useContext - 跨层级共享:
useContext 读取最近 Provider 提供的值,解决 prop drilling。注意 Context value 变化会让所有消费组件重渲染,需配合拆分 Context 或 memo 优化。
| Hook | 用途 | 是否触发重渲染 |
| --- | --- | --- |
| useState | 组件局部状态 | 是 |
| useReducer | 复杂/关联状态 | 是 |
| useEffect | 副作用(异步,绘制后) | 否(自身) |
| useLayoutEffect | 同步布局副作用(绘制前) | 否(自身) |
| useRef | 可变引用/存 DOM | 否 |
| useMemo | 缓存计算结果 | 否 |
| useCallback | 缓存函数引用 | 否 |
| useContext | 消费 Context | 是(value 变时) |
代码示例
// useState 基础用法:函数式更新与惰性初始化
function Counter() {
const [count, setCount] = useState(0);
// 函数式更新:基于前值计算,避免闭包旧值问题
const incrementThrice = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1); // 连续 +3,若写 setCount(count+1) 只会 +1
};
// 惰性初始化:函数只在首次渲染执行一次
const [data, setData] = useState(() => {
const saved = localStorage.getItem('data');
return saved ? JSON.parse(saved) : [];
});
return (
<div>
<p>Count: {count}</p>
<button onClick={incrementThrice}>+3</button>
</div>
);
}// useEffect 数据获取与竞态处理(cancelled 标志防止旧请求覆盖新结果)
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false;
async function fetchUser() {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
if (!cancelled) setUser(data); // 组件已卸载或 userId 已变则不再 setState
}
fetchUser();
return () => { cancelled = true; }; // 清理:标记本次请求作废
}, [userId]);
if (!user) return <div>Loading...</div>;
return <div>{user.name}</div>;
}// useRef 的两种典型用途
function RefExamples() {
const inputRef = useRef(null); // 用途一:访问 DOM
const renderCount = useRef(0); // 用途二:存可变值而不触发渲染
renderCount.current++; // 记录渲染次数,改它不会引发新渲染
const focusInput = () => inputRef.current?.focus();
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={focusInput}>Focus Input</button>
<p>已渲染 {renderCount.current} 次</p>
</div>
);
}// useMemo 和 useCallback 优化:配合 React.memo 才有意义
const ChildList = React.memo(function ChildList({ items, onItemClick }) {
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onItemClick(item.id)}>{item.name}</li>
))}
</ul>
);
});
function ExpensiveComponent({ items, onItemClick }) {
// 缓存昂贵的排序结果,仅在 items 变化时重算
const sortedItems = useMemo(() => {
console.log('Sorting items...');
return [...items].sort((a, b) => a.name.localeCompare(b.name));
}, [items]);
// 缓存函数引用,避免每次渲染都给 memo 子组件传新函数导致其重渲染
const handleClick = useCallback((id) => {
onItemClick?.(id);
}, [onItemClick]);
return <ChildList items={sortedItems} onItemClick={handleClick} />;
}// useReducer:把复杂更新逻辑集中到 reducer,组件只负责 dispatch
const initialState = { count: 0, step: 1 };
function reducer(state, action) {
switch (action.type) {
case 'increment': return { ...state, count: state.count + state.step };
case 'decrement': return { ...state, count: state.count - state.step };
case 'setStep': return { ...state, step: action.payload };
case 'reset': return initialState;
default: throw new Error('Unknown action: ' + action.type);
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+{state.step}</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-{state.step}</button>
<input type="number" value={state.step}
onChange={e => dispatch({ type: 'setStep', payload: Number(e.target.value) })} />
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
);
}自定义 Hooks:逻辑复用的正确姿势
自定义 Hooks 是复用状态逻辑(而非 UI)的强大工具,以 use 开头命名是 React 的约定(也让 lint 规则能检查)。它把一段带状态的逻辑抽成函数,多个组件调用它各自拥有独立的状态——这正是 HOC / render props 想做却做得很笨拙的事。
// useLocalStorage:状态与 localStorage 双向同步
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
const setValue = useCallback((value) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
}, [key, storedValue]);
return [storedValue, setValue];
}
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(prev => prev === 'light' ? 'dark' : 'light')}>
Current theme: {theme}
</button>
);
}// useDebounce:延迟返回值,常用于搜索输入
function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer); // 每次 value 变化重置计时
}, [value, delay]);
return debounced;
}
function SearchBox() {
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebounce(keyword, 400);
useEffect(() => {
if (debouncedKeyword) fetch(`/api/search?q=${debouncedKeyword}`);
}, [debouncedKeyword]); // 用户停止输入 400ms 后才真正请求
return <input value={keyword} onChange={e => setKeyword(e.target.value)} />;
}// useFetch:通用数据请求 Hook,封装 loading/error/竞态
function useFetch(url) {
const [state, setState] = useState({ data: null, loading: true, error: null });
useEffect(() => {
let cancelled = false;
setState({ data: null, loading: true, error: null });
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(data => { if (!cancelled) setState({ data, loading: false, error: null }); })
.catch(error => {
if (!cancelled && error.name !== 'AbortError')
setState({ data: null, loading: false, error });
});
return () => { cancelled = true; controller.abort(); };
}, [url]);
return state;
}真实案例:从 class 到 Hooks 的逻辑复用重构
某项目里有三个组件都要监听窗口尺寸做响应式布局。在 class 时代,团队用一个 withWindowSize 高阶组件包裹,结果导致组件树多了三层 wrapper、props 命名冲突、DevTools 里满屏 (WithWindowSize(Foo)) 难以调试——这就是典型的"HOC 嵌套地狱"。
改用自定义 Hook 后,抽出一个 useWindowSize,三个组件各自调用一行即可,组件树扁平、无 props 冲突、逻辑一目了然。这就是 Hooks 相比 HOC/render props 在逻辑复用上的降维优势。
function useWindowSize() {
const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight });
useEffect(() => {
const handler = () => setSize({ width: window.innerWidth, height: window.innerHeight });
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler); // 卸载清理,防泄漏
}, []);
return size;
}
function Responsive() {
const { width } = useWindowSize();
return <div>{width < 768 ? '移动端布局' : '桌面布局'}</div>;
}性能与对比数据
| 对比项 | Class 组件 | 函数组件 + Hooks |
| --- | --- | --- |
| 逻辑复用 | HOC / render props(易嵌套地狱) | 自定义 Hook(扁平、无冲突) |
| 相关逻辑组织 | 分散在多个生命周期 | 同一 useEffect 内聚 |
| this 问题 | 需手动绑定,易出错 | 无 this |
| 代码量 | 较多样板 | 更精简 |
| 打包体积 | 相对更大 | 通常更小(利于压缩) |
> useMemo/useCallback 不是越多越好:它们本身有依赖比较与缓存开销。仅在计算确实昂贵、或需要维持引用稳定以配合 React.memo 时使用,务必"先用 Profiler 测量,再决定优化"。
常见坑
最佳实践
useEffect 依赖数组与闭包陷阱深入剖析
依赖数组是 useEffect(以及 useMemo、useCallback)里最容易踩坑的地方。要真正理解它,必须先理解一个事实:每次渲染都是一次独立的"快照"。函数组件在每次渲染时都会被完整地重新执行一遍,组件内的每个变量、每个函数、每个 effect 回调都是这一次渲染专属的,它们通过闭包"冻结"了当前这一帧的 props 和 state。React 16.8 之后的所有 Hooks 都建立在这个心智模型之上。
闭包陷阱是怎么产生的
看一个经典的错误例子:一个定时器每秒打印 count,但永远打印 0。
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// 这里的 count 永远是首次渲染时的 0
console.log('当前 count =', count);
}, 1000);
return () => clearInterval(id);
}, []); // 空依赖:effect 只在挂载时执行一次
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}原因:effect 只在挂载时执行了一次,那次执行捕获的 count 就是 0。之后即使 count 更新到 5、10,定时器回调里引用的仍然是第一帧闭包里的那个 count(值为 0)。这就是 stale closure(陈旧闭包)。
修复方式有三种,按推荐程度排序:
// 方案一(推荐):用函数式更新,彻底不依赖外部 count
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // prev 始终是最新值,无需 count 进依赖
}, 1000);
return () => clearInterval(id);
}, []); // 合法:内部不再引用 count
return <div>{count}</div>;
}
// 方案二:把 count 加入依赖,每次变化重建定时器(会有重建开销)
useEffect(() => {
const id = setInterval(() => console.log(count), 1000);
return () => clearInterval(id);
}, [count]); // count 变则清理旧定时器、建新的
// 方案三:用 ref 保存最新值,effect 仍只建一次(适合高频场景)
function Timer() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
countRef.current = count; // 每次渲染同步最新值到 ref
useEffect(() => {
const id = setInterval(() => console.log(countRef.current), 1000);
return () => clearInterval(id);
}, []);
return <div>{count}</div>;
}依赖数组的三种形态精确对照
| 写法 | 执行时机 | 典型用途 | 风险 |
| --- | --- | --- | --- |
| 不传第二参 | 每次渲染后都执行 | 极少数需要每帧同步 | 易引发无限循环 |
| 传空数组 [] | 仅挂载后执行一次,卸载时清理 | 一次性订阅、初始化 | 闭包捕获初值 |
| 传 [a, b] | a 或 b 变化时执行 | 依赖变化时同步 | 遗漏依赖读到旧值 |
依赖比较使用 Object.is(浅比较)。这意味着对象、数组、函数每次渲染都是新引用,会被判定为"变化"。这也是 useMemo/useCallback 存在的核心理由——保持引用稳定。
// 反例:options 每次渲染都是新对象,effect 每次都执行
function Bad({ id }: { id: number }) {
const options = { id, verbose: true }; // 每次渲染新引用
useEffect(() => {
doSomething(options);
}, [options]); // 等于没有优化,每帧都跑
return null;
}
// 正解:把真正的原始值放进依赖,或用 useMemo 稳定对象
function Good({ id }: { id: number }) {
const options = useMemo(() => ({ id, verbose: true }), [id]);
useEffect(() => {
doSomething(options);
}, [options]); // 仅 id 变时执行
return null;
}exhaustive-deps:不要盲目对抗 lint
eslint-plugin-react-hooks 的 exhaustive-deps 规则会提示你补全依赖。90% 的情况下它是对的。如果你觉得"我就是不想在某依赖变化时重跑",正确做法不是加 // eslint-disable,而是从根源上消除依赖(函数式更新、把函数移进 effect 内部、用 useReducer 合并状态、用 ref 承接不该触发重跑的值)。React 官方文档反复强调:与 lint 对抗几乎总是 bug 的信号。
useLayoutEffect 与 useEffect 的时机对比
两者 API 完全一致,区别只在执行时机,理解这一点能解决大量"闪烁"问题。
浏览器一帧的流程可以概括为:React 更新 DOM → useLayoutEffect 同步运行(此时可读写布局,修改会被合并进本次绘制)→ 浏览器绘制 → useEffect 异步运行。
// 场景:根据元素实际宽度动态定位 tooltip,若用 useEffect 会先闪一下再归位
function Tooltip({ targetRect }: { targetRect: DOMRect }) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
// 用 useLayoutEffect:测量后同步 setState,在绘制前完成,用户看不到中间态
useLayoutEffect(() => {
const rect = ref.current!.getBoundingClientRect();
setPos({
top: targetRect.top - rect.height - 8,
left: targetRect.left,
});
}, [targetRect]);
return <div ref={ref} style={{ position: 'fixed', ...pos }}>提示内容</div>;
}选型原则:默认永远用 useEffect,只有当你在 effect 里做 DOM 测量、且这个测量结果会立刻影响可见渲染(否则会看到闪烁)时,才升级到 useLayoutEffect。滥用 useLayoutEffect 会阻塞绘制、拖慢首屏。此外在 SSR 场景下,useLayoutEffect 不会在服务端执行,React 会警告,应使用 useEffect 或做同构降级(useIsomorphicLayoutEffect 模式)。
| 对比项 | useEffect | useLayoutEffect |
| --- | --- | --- |
| 执行时机 | 绘制后(异步) | 绘制前(同步) |
| 是否阻塞绘制 | 否 | 是 |
| 适用场景 | 数据请求、订阅、日志 | DOM 测量、防闪烁定位 |
| SSR 表现 | 正常 | 服务端警告、不执行 |
| 性能影响 | 小 | 用不好会卡首屏 |
useReducer 完整范式与最佳实践
当状态字段多、更新逻辑相互关联、或"下一个状态依赖多个动作"时,useReducer 比一堆 useState 清晰得多。它把状态转移逻辑收敛进纯函数,天然可测试、可回放、便于用 TypeScript 收窄类型。
// 一个带完整 TS 类型的表单 reducer
interface FormState {
values: { name: string; email: string };
errors: Record<string, string>;
submitting: boolean;
}
type FormAction =
| { type: 'change'; field: 'name' | 'email'; value: string }
| { type: 'submit_start' }
| { type: 'submit_success' }
| { type: 'submit_error'; errors: Record<string, string> }
| { type: 'reset' };
const initialForm: FormState = {
values: { name: '', email: '' },
errors: {},
submitting: false,
};
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'change':
return {
...state,
values: { ...state.values, [action.field]: action.value },
errors: { ...state.errors, [action.field]: '' }, // 输入即清错
};
case 'submit_start':
return { ...state, submitting: true, errors: {} };
case 'submit_success':
return { ...state, submitting: false };
case 'submit_error':
return { ...state, submitting: false, errors: action.errors };
case 'reset':
return initialForm;
default: {
// TS 穷尽性检查:新增 action 忘处理时编译期报错
const _exhaustive: never = action;
return state;
}
}
}
function SignupForm() {
const [state, dispatch] = useReducer(formReducer, initialForm);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
dispatch({ type: 'submit_start' });
try {
await api.signup(state.values);
dispatch({ type: 'submit_success' });
} catch (err) {
dispatch({ type: 'submit_error', errors: { email: '邮箱已被注册' } });
}
};
return (
<form onSubmit={onSubmit}>
<input
value={state.values.name}
onChange={e => dispatch({ type: 'change', field: 'name', value: e.target.value })}
/>
<input
value={state.values.email}
onChange={e => dispatch({ type: 'change', field: 'email', value: e.target.value })}
/>
{state.errors.email && <span>{state.errors.email}</span>}
<button disabled={state.submitting}>提交</button>
</form>
);
}useReducer 还支持惰性初始化:useReducer(reducer, initialArg, init),init(initialArg) 只在首次执行,适合从 props 派生初始状态或读取本地存储。
useReducer vs useState 选型
| 维度 | useState | useReducer |
| --- | --- | --- |
| 状态结构 | 简单、独立 | 复杂、字段关联 |
| 更新逻辑 | 分散在各事件处理 | 集中在 reducer |
| 可测试性 | 一般 | 高(纯函数可单测) |
| 与 Context 配合 | 需自行封装 | 天然(dispatch 引用稳定) |
| 调试 | 靠 console | 可打印 action 序列回放 |
一个隐藏优势:dispatch 的引用在组件整个生命周期内保持稳定,把它放进 Context 或 effect 依赖都不会引起额外重渲染,这在大型状态共享场景里非常有用。
useRef 高级用法全解
useRef 的本质是"一个在渲染之间保持不变的可变盒子",改 .current 不触发渲染。除了存 DOM,它还有几种高频高级用法。
// 用法一:usePrevious —— 记住上一次的值
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value; // 渲染提交后才更新,所以渲染期间读到的是"上一次"
}, [value]);
return ref.current;
}
function PriceDisplay({ price }: { price: number }) {
const prev = usePrevious(price);
const trend = prev === undefined ? '' : price > prev ? '↑' : price < prev ? '↓' : '→';
return <div>{price} {trend}</div>;
}// 用法二:保存最新回调,避免 effect 频繁重建(Dan Abramov 的 useEventCallback 模式)
function useEventCallback<A extends unknown[], R>(fn: (...args: A) => R) {
const ref = useRef(fn);
useLayoutEffect(() => {
ref.current = fn; // 每次渲染同步最新的 fn
});
return useCallback((...args: A) => ref.current(...args), []); // 返回引用永久稳定
}// 用法三:标记"是否首次渲染",跳过挂载时的 effect
function useUpdateEffect(effect: React.EffectCallback, deps?: React.DependencyList) {
const isFirst = useRef(true);
useEffect(() => {
if (isFirst.current) {
isFirst.current = false;
return; // 首次挂载跳过
}
return effect();
}, deps);
}注意 useRef 与 createRef 的区别:createRef 每次渲染都返回全新 ref(用于 class),而 useRef 在函数组件多次渲染间返回同一个 ref 对象。另外不要在渲染期间读写 ref.current 做渲染逻辑(除了惰性初始化那种一次性写入),因为它不受 React 数据流管理,会破坏并发渲染下的一致性。
useImperativeHandle 与 forwardRef
默认情况下函数组件不能接收 ref。要让父组件通过 ref 调用子组件暴露的方法(命令式 API,如 focus、scrollToTop、播放器 play),需要 forwardRef + useImperativeHandle 组合。React 19 起,ref 可作为普通 prop 传递,forwardRef 逐步不再必需,但存量代码里这套仍是主流。
// 封装一个只暴露 focus / clear 两个命令的输入框
interface FancyInputHandle {
focus: () => void;
clear: () => void;
}
const FancyInput = forwardRef<FancyInputHandle, { placeholder?: string }>(
function FancyInput(props, ref) {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => {
if (inputRef.current) inputRef.current.value = '';
},
}), []); // 依赖数组决定 handle 何时重建,一般为空
return <input ref={inputRef} placeholder={props.placeholder} />;
}
);
function Parent() {
const inputRef = useRef<FancyInputHandle>(null);
return (
<>
<FancyInput ref={inputRef} placeholder="搜索" />
<button onClick={() => inputRef.current?.focus()}>聚焦</button>
<button onClick={() => inputRef.current?.clear()}>清空</button>
</>
);
}原则:命令式句柄应当尽量少暴露方法,只暴露父组件真正需要的动作,不要把整个 DOM 节点透出去,否则封装形同虚设。
useMemo / useCallback 性能实测与常见误用
这两个 Hook 是最常被"为优化而优化"误用的。它们不是免费的:每次渲染都要保存依赖、做浅比较、维护缓存,这些开销对廉价计算而言可能超过它省下的成本。
什么时候真正需要
// 误用一:给一个加法加 useMemo,纯属负优化
const sum = useMemo(() => a + b, [a, b]); // ❌ 比较开销 > 计算开销
// 误用二:useCallback 但子组件没被 memo 包裹,缓存白搭
const onClick = useCallback(() => doSomething(), []); // 若 <Child onClick> 未 memo,无意义
// 误用三:依赖写错导致缓存永不更新,读到旧值
const filtered = useMemo(() => list.filter(x => x.type === type), []); // ❌ 漏了 [list, type]
// 正确:三者协同才有意义
const Child = React.memo(function Child({ onAction }: { onAction: () => void }) {
return <button onClick={onAction}>触发</button>;
});
function Parent({ list }: { list: Item[] }) {
const heavy = useMemo(() => expensiveTransform(list), [list]); // 昂贵计算才缓存
const onAction = useCallback(() => console.log(heavy.length), [heavy]); // 稳定引用
return <Child onAction={onAction} />;
}React 19 引入了 React Compiler(前身 React Forget),能在编译期自动插入记忆化,未来大量手写 useMemo/useCallback 有望被自动优化替代。但在 Compiler 全面普及前,理解手动记忆化的原理仍是必修课。
并发特性与 React 18 / 19 新 Hooks
React 18(2022 年发布)带来并发渲染,随之新增了一批 Hook;React 19(2024 年)进一步扩展。
// useTransition:把状态更新标记为"非紧急",避免大列表更新时输入卡顿
function SearchableList({ all }: { all: string[] }) {
const [query, setQuery] = useState('');
const [list, setList] = useState(all);
const [isPending, startTransition] = useTransition();
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value); // 紧急更新:输入框立刻响应
startTransition(() => {
// 非紧急更新:可被打断,不阻塞输入
setList(all.filter(s => s.includes(e.target.value)));
});
};
return (
<>
<input value={query} onChange={onChange} />
{isPending && <span>过滤中…</span>}
<ul>{list.map(s => <li key={s}>{s}</li>)}</ul>
</>
);
}// useDeferredValue:给某个值一个"延迟版本",效果类似防抖但由 React 调度
function Results({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query); // 高优先级更新时用旧值渲染
const list = useMemo(() => search(deferredQuery), [deferredQuery]);
const stale = query !== deferredQuery;
return <div style={{ opacity: stale ? 0.5 : 1 }}>{list.length} 条结果</div>;
}// useId:生成跨服务端/客户端一致的稳定 id,解决 SSR 水合不匹配
function Field({ label }: { label: string }) {
const id = useId();
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} />
</>
);
}// useSyncExternalStore:订阅外部数据源(Redux、浏览器 API),并发安全
function useOnlineStatus() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
},
() => navigator.onLine, // 客户端快照
() => true // 服务端快照(SSR)
);
}// React 19:useOptimistic —— 乐观更新,请求返回前先展示预期结果
function MessageBox({ messages, send }: { messages: string[]; send: (m: string) => Promise<void> }) {
const [optimistic, addOptimistic] = useOptimistic(
messages,
(state, newMsg: string) => [...state, newMsg + '(发送中…)']
);
const onSend = async (text: string) => {
addOptimistic(text); // 立刻显示
await send(text); // 真实请求,完成后 messages 更新覆盖乐观值
};
return <ul>{optimistic.map((m, i) => <li key={i}>{m}</li>)}</ul>;
}// React 19:use() —— 可在渲染中读取 Promise 或 Context,且可条件调用
function Comments({ commentsPromise }: { commentsPromise: Promise<string[]> }) {
const comments = use(commentsPromise); // 配合 <Suspense> 自动挂起
return <ul>{comments.map((c, i) => <li key={i}>{c}</li>)}</ul>;
}
// 注意:use 是唯一可以在条件语句里调用的 "use 开头" API,它不是传统 Hook| Hook | 引入版本 | 一句话用途 |
| --- | --- | --- |
| useTransition | React 18 | 标记非紧急更新,防卡顿 |
| useDeferredValue | React 18 | 延迟派生值,降优先级 |
| useId | React 18 | SSR 一致的唯一 id |
| useSyncExternalStore | React 18 | 并发安全订阅外部 store |
| useInsertionEffect | React 18 | CSS-in-JS 注入样式(库作者用) |
| useOptimistic | React 19 | 乐观 UI 更新 |
| useActionState | React 19 | 表单 action 状态管理 |
| use | React 19 | 渲染中读 Promise / Context |
自定义 Hook 实战大全
以下是工程中最常复用的一批自定义 Hook,均可直接落地。
// usePrevious 已在上文给出;这里补充 useToggle
function useToggle(initial = false): [boolean, () => void, (v: boolean) => void] {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn(v => !v), []);
return [on, toggle, setOn];
}// useEventListener:声明式绑定事件,自动清理,支持最新回调
function useEventListener<K extends keyof WindowEventMap>(
eventName: K,
handler: (event: WindowEventMap[K]) => void,
element: Window | HTMLElement = window
) {
const savedHandler = useRef(handler);
useEffect(() => { savedHandler.current = handler; }, [handler]);
useEffect(() => {
const listener = (e: Event) => savedHandler.current(e as WindowEventMap[K]);
element.addEventListener(eventName, listener);
return () => element.removeEventListener(eventName, listener);
}, [eventName, element]);
}// useIntersectionObserver:元素进入视口时触发(懒加载、无限滚动)
function useIntersectionObserver(options?: IntersectionObserverInit) {
const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
const ref = useRef<HTMLElement | null>(null);
useEffect(() => {
const node = ref.current;
if (!node) return;
const observer = new IntersectionObserver(([e]) => setEntry(e), options);
observer.observe(node);
return () => observer.disconnect();
}, [options]);
return { ref, isIntersecting: entry?.isIntersecting ?? false, entry };
}
function LazyImage({ src }: { src: string }) {
const { ref, isIntersecting } = useIntersectionObserver({ rootMargin: '200px' });
return (
<div ref={ref as React.RefObject<HTMLDivElement>}>
{isIntersecting ? <img src={src} alt="" /> : <div style={{ height: 200 }} />}
</div>
);
}// useMediaQuery:响应式断点判断,优于监听 resize(更省)
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() =>
typeof window !== 'undefined' ? window.matchMedia(query).matches : false
);
useEffect(() => {
const mql = window.matchMedia(query);
const onChange = () => setMatches(mql.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, [query]);
return matches;
}// useAsync:管理任意异步操作的状态机(idle / pending / success / error)
type AsyncState<T> =
| { status: 'idle' }
| { status: 'pending' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function useAsync<T>(asyncFn: () => Promise<T>, immediate = true) {
const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });
const run = useCallback(async () => {
setState({ status: 'pending' });
try {
const data = await asyncFn();
setState({ status: 'success', data });
} catch (error) {
setState({ status: 'error', error: error as Error });
}
}, [asyncFn]);
useEffect(() => {
if (immediate) run();
}, [run, immediate]);
return { ...state, run };
}自定义 Hook 设计三原则:单一职责(一个 Hook 只做一件事)、清晰的输入输出(参数明确、返回值用对象或元组约定好顺序)、内部自洽(自己管理并清理副作用,不把清理责任甩给调用方)。
Hooks 规则与 ESLint 插件
React 官方提供 eslint-plugin-react-hooks,包含两条核心规则:
// .eslintrc 配置片段
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}两条黄金规则的深层原因回到本文开头:Hooks 靠调用顺序在链表里定位状态。把 Hook 写进条件或循环,某次渲染顺序一变,React 就会把 A 的状态发给 B。因此:
// ❌ 条件调用,破坏顺序
function Bad({ show }: { show: boolean }) {
if (show) {
const [x] = useState(0); // 报错:顺序不稳定
}
}
// ✅ 无条件调用,把条件放进 Hook 内部或 JSX
function Good({ show }: { show: boolean }) {
const [x, setX] = useState(0); // 永远调用
return show ? <div>{x}</div> : null;
}Hooks 底层链表实现原理
前文的 mock 用数组解释了"顺序依赖",真实 React 内部用的是单向链表,挂在 Fiber 节点的 memoizedState 上。每个 Hook 是链表的一个节点,包含 memoizedState(该 Hook 的当前值)、queue(更新队列)、next(指向下一个 Hook)。
// 极简还原 React 内部的 Hook 链表结构
const fiber = {
memoizedState: null, // 指向第一个 Hook 节点
};
function mountWorkInProgressHook() {
const hook = { memoizedState: null, queue: null, next: null };
if (fiber.memoizedState === null) {
fiber.memoizedState = hook; // 链表头
} else {
let last = fiber.memoizedState;
while (last.next) last = last.next; // 找到尾部
last.next = hook; // 追加
}
return hook;
}
// 更新时则是沿着 next 指针"按序取节点",这正是顺序不能变的根本原因这也解释了几个现象:为什么 useState 返回的 setState 引用稳定(存在 hook.queue 上)、为什么依赖比较用 Object.is(存在 hook.memoizedState 里做浅比较)、为什么并发模式下 ref 不能在渲染期乱写(Fiber 有 current 树和 workInProgress 树双缓冲)。
Hooks 与 class 生命周期对照表
从 class 迁移到 Hooks 时,最常见的疑问是"某个生命周期用什么 Hook 替代"。下表给出精确映射。
| Class 生命周期 | Hooks 等价写法 | 说明 |
| --- | --- | --- |
| constructor | useState(初值) / 惰性初始化 | 初始化状态 |
| componentDidMount | useEffect(fn, []) | 挂载后执行一次 |
| componentDidUpdate | useEffect(fn, [deps]) | 依赖变化时执行 |
| componentWillUnmount | useEffect 返回的清理函数 | 卸载时清理 |
| getDerivedStateFromProps | 渲染中直接计算 / useMemo | 派生状态优先在渲染期算 |
| shouldComponentUpdate | React.memo | 浅比较 props 跳过渲染 |
| getSnapshotBeforeUpdate | useLayoutEffect | 绘制前读取布局 |
| componentDidCatch | 暂无 Hook,需 class ErrorBoundary | 错误边界仍靠 class |
要点:class 里"挂载 + 更新"分成 didMount / didUpdate 两个方法,Hooks 里合并成一个 useEffect(靠依赖数组区分);class 里"建立 + 清理"分散在 didMount / willUnmount,Hooks 里内聚在同一个 useEffect(返回清理函数)。这种"按关注点组织"而非"按时机组织"正是 Hooks 的核心优势。唯一至今没有 Hook 等价物的是错误边界(componentDidCatch / getDerivedStateFromError),仍必须用 class 组件实现。
总结
Hooks 让函数组件拥有了状态、副作用与逻辑复用能力,其底层依赖"按调用顺序维护的 Hooks 链表",这也是"必须在顶层无条件调用"规则的由来。掌握每个 Hook 的定位、依赖数组与清理机制、以及自定义 Hook 的抽象方法,就能写出内聚、可复用、性能可控的现代 React 代码。
| Hook | 一句话记忆 | 关键注意点 |
| --- | --- | --- |
| useState | 组件的记忆 | 函数式更新、惰性初始化 |
| useEffect | 与外部世界同步 | 依赖数组 + 清理函数 |
| useRef | 不触发渲染的抽屉 | 存 DOM 或可变值 |
| useMemo | 缓存计算结果 | 昂贵计算才用 |
| useCallback | 缓存函数引用 | 配合 React.memo |
| useReducer | 集中管理复杂状态 | 逻辑收敛进 reducer |
| useContext | 跨层级共享 | value 变化引发消费者重渲染 |
| 自定义 Hook | 复用状态逻辑 | use 开头、单一职责 |