React Hooks 深度掌握
Hooks 是什么:解决什么问题
React 16.8 引入 Hooks 是一次重大范式转变。在 Hooks 之前,React 组件要复用状态逻辑只有两种方式:render props 和高阶组件。这两种模式都会产生「包装地狱」——组件树里嵌套着一层又一层的提供者或渲染函数,实际代码逻辑被埋在回调深处。
Hooks 的核心思路是:让函数组件具有状态和生命周期能力,同时让状态逻辑可以被复用,而不需要改变组件层级。
这不只是一个语法便利。它重新定义了「什么是组件」——组件不再需要是 class 才能拥有 state,函数本身就是组件,而 Hooks 是连接状态逻辑和函数组件的桥梁。
理解 Hooks,不能只记住「怎么用」,还要理解「React 怎么记住每次渲染的状态」「为什么依赖数组决定 effect 什么时候运行」「为什么 ref 的变化不会触发重新渲染」。
useState:状态是什么,React 怎么记住它
基本用法
const [count, setCount] = useState(0);
useState 返回一个数组,第一个元素是当前状态值,第二个是更新函数。传入 useState 的参数是状态的初始值。
React 怎么记住状态
每个组件实例的 Hooks 调用顺序是固定的。React 内部维护一个「hooks 链表」,每次渲染时按顺序读取。这就是为什么 Hooks 不能写在条件语句里——如果条件跳过了一次调用,链表的顺序就会乱掉,后续 Hooks 的状态就对不上了。
// 错误写法:条件调用 Hook
function Component({ showAge }) {
const [name, setName] = useState('Tom');
if (showAge) {
const [age, setAge] = useState(25); // 条件调用,破坏链表结构
}
const [gender, setGender] = useState('male');
}
这不只是一个 lint 规则,是 React 内部实现的基础。Linter 帮你捕获这类错误。
函数式更新
setCount 可以接受一个新值,也可以接受一个函数:
setCount(5); // 直接赋值
setCount(prev => prev + 1); // 函数式更新
函数式更新的场景是新状态依赖于旧状态。如果你写 setCount(count + 1) 且连续快速调用三次,由于 count 的闭包值可能已经过时(因为状态更新是异步批量的),结果可能只加了 1 而不是 3。用函数式更新 setCount(prev => prev + 1) 能保证每次都基于最新状态计算。
懒初始化
useState 的初始值可以是计算代价较高的结果。用函数形式可以实现懒初始化:
// 每次渲染都执行 — 不推荐
const [data, setData] = useState(parseExpensiveData(initialData));
// 只在首次渲染执行一次
const [data, setData] = useState(() => parseExpensiveData(initialData));
useEffect:副作用的正确管理方式
什么是副作用
副作用(side effect)是指那些影响组件外部世界的操作:网络请求、DOM 操作、订阅事件、设置定时器。组件的渲染应该是纯粹的——给定相同的 props,应该返回相同的 UI。副作用不应该发生在渲染期间。
useEffect 是把副作用从渲染阶段分离出来的机制。Effect 在 React 完成 DOM 更新后运行。
基本模式
useEffect(() => {
// 副作用操作
document.title = `You clicked ${count} times`;
return () => {
// 清理操作
};
}, [count]); // 依赖数组
依赖数组决定 Effect 何时重新运行:数组里的值变化了,Effect 的清理函数先运行(清理上一次的效果),然后新 Effect 运行。没有依赖数组,每次渲染后都运行。有空依赖数组 [],只在首次渲染后运行一次。
为什么依赖数组很重要
一个常见的错误是在 Effect 里用了渲染期间才有的变量,但没有把它放进依赖数组:
function Component({ userId }) {
const [userData, setUserData] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUserData);
}); // 没有依赖数组 — bug!
// userId 变了,Effect 不会重新运行,拿到的还是旧 userId 的数据
}
正确写法是 useEffect(() => { ... }, [userId])。ESLint 的 exhaustive-deps 规则会帮你捕获这类问题。
清理函数做什么
清理函数在两种情况下运行:Effect 重新运行之前(因为依赖变化),和组件卸载时。
useEffect(() => {
const subscription = subscribeToData(id);
return () => {
subscription.unsubscribe(); // 防止内存泄漏
};
}, [id]);
如果 Effect 不返回清理函数,组件卸载后订阅依然存在,就会导致内存泄漏和潜在错误(比如在已卸载组件上调用 setState)。
常见 useEffect 场景
数据获取:
useEffect(() => {
let cancelled = false; // 防止竞态条件
async function fetchData() {
const data = await api.getData(id);
if (!cancelled) setData(data);
}
fetchData();
return () => { cancelled = true; };
}, [id]);
cancelled 标记解决了「先发起请求 A,id 变成 B,请求 A 的响应比 B 的响应更晚到达」的问题——这是一种 race condition,是异步数据获取里最常见的 bug 之一。
事件监听:
useEffect(() => {
function handleResize() {
setWindowWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
副作用清理和 React 18 严格模式: React 18 的开发模式下,组件会「假装」卸载再重新挂载一次,来暴露缺少清理函数的 Effect。所以如果你看到 Effect 运行了两次,不一定是 bug——可能是严格模式在帮你发现问题。
useRef:不触发渲染的状态容器
核心特性
useRef 返回一个 ref 对象,其 .current 属性可以存放任何值,且修改它不会触发组件重新渲染。
const timerRef = useRef(null);
function startTimer() {
timerRef.current = setInterval(() => {
setCount(c => c + 1);
}, 1000);
}
function stopTimer() {
clearInterval(timerRef.current);
}
ref 的两种用途
第一种是引用 DOM 节点:
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus(); // 直接操作 DOM
}, []);
return <input ref={inputRef} />;
ref 接收一个 ref 对象后,React 会把对应的 DOM 节点赋值给 ref.current。这和 document.getElementById 的效果相同,但不依赖 DOM API 的字符串查找。
第二种是存储会在渲染间保持的可变值。因为 ref 的 .current 变化不会触发重新渲染,它适合存放那些「变化时不需要更新 UI」的值,比如定时器 ID、previous value 等。
为什么修改 ref 不会触发重新渲染
因为 ref 的变化被认为是不重要的副作用,不是状态。React 的渲染是由 state/props 变化驱动的,ref 不在这个驱动链里。这是一把双刃剑——ref 变化了 UI 不会自动更新,所以不要用 ref 来驱动界面变化,只用它来存不需要反映到界面上的值。
useCallback 与 useMemo:性能优化的工具
它们解决什么问题
在 JavaScript 里,函数是对象。每次组件渲染,如果你在 JSX 里直接写 onClick={() => handleClick(id)},每次渲染都会创建一个新的函数引用。对于 React.memo 包装的子组件,这意味着即使 props 没变,父组件渲染也会导致子组件认为 props 变了(因为函数引用不同)。
// 每次渲染都会创建新函数
<ChildComponent onClick={() => handleClick(id)} />
// 稳定引用
const handleClick = useCallback(() => handleClick(id), [id]);
<ChildComponent onClick={handleClick} />
useCallback vs useMemo
useCallback(fn, deps) 等价于 useMemo(() => fn, deps)。一个是缓存函数,一个是缓存计算结果。
// 缓存函数
const onClick = useCallback(() => dispatch({ type: 'CLICK', id }), [id]);
// 缓存计算结果
const expensiveValue = useMemo(() => computeExpensive(a, b), [a, b]);
什么时候用
不是所有地方都需要 useCallback。过度使用反而会增加代码复杂度。大多数组件不需要这些优化。真正需要用的场景是:
- 组件被
React.memo包裹,且传递的回调函数依赖了可能频繁变化的变量 - 传递给深层组件树的回调(深层 props 变化会导致整棵子树重新渲染)
- useMemo 适合那些计算代价高的场景(复杂计算、大数据排序等)
一个实际经验法则:先不用,等发现性能问题再优化。过早优化往往增加复杂度却得不到明显收益。
useContext:跨层级数据传递
Context 解决了什么问题
在 Hooks 之前,跨层级的数据传递需要一层层 prop drilling——把数据从顶层通过每一层组件传递下去,即使中间层根本不需要这个数据。
Context 提供了「Provider 模式」:在某个层级提供数据,该层级的所有子组件都可以直接读取,不需要层层传递。
// ThemeContext.js
const ThemeContext = createContext(null);
// 顶层提供数据
function App() {
const [theme, setTheme] = useState('dark');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Component />
</ThemeContext.Provider>
);
}
// 任意子组件直接消费
function Button() {
const { theme } = useContext(ThemeContext);
return <button className={theme}>Click</button>;
}
Context 的性能特性
Context 的问题在于:Provider 的 value 变化时,所有消费这个 Context 的组件都会重新渲染。
// 每次 ThemeContext 重新渲染,这个对象都是新的引用
<ThemeContext.Provider value={{ theme, setTheme }}>
解决方法是 value 用 useMemo 缓存:
<ThemeContext.Provider value={useMemo(() => ({ theme, setTheme }), [theme, setTheme])}>
或者把 Context 拆分成多个,避免一个 value 变化导致所有消费者重渲染。
useContext vs Redux
Context 只是数据传递机制,不等于状态管理。Redux/Zustand/Jotai 除了传递数据,还提供了:单一数据源、可预测的状态变化(reducer/action 模式)、时间旅行调试、中间件体系。Context 没有这些。
什么时候用 Context 什么时候用 Redux:Context 适合真正的「全局配置」(主题、语言设置、用户信息),状态管理库适合「全局状态且状态间有复杂交互逻辑」的场景。
useReducer:复杂状态逻辑的组织方式
基本模式
useReducer 是 useState 的替代品,适合状态逻辑复杂、有多个子值或状态变化有内在关联的场景:
const reducer = (state, action) => {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + 1 };
case 'set':
return { ...state, count: action.payload };
default:
return state;
}
};
const [state, dispatch] = useReducer(reducer, { count: 0 });
dispatch({ type: 'increment' });
useReducer 和 useContext 配合
把 useReducer 和 useContext 组合起来,就实现了一个简化版的 Redux:
const CounterContext = createContext(null);
function CounterProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<CounterContext.Provider value={{ state, dispatch }}>
{children}
</CounterContext.Provider>
);
}
子组件通过 useContext(CounterContext) 拿到 dispatch,不需要 prop drilling。这比完整 Redux 轻量得多,但对很多中小型应用已经足够。
custom Hooks:逻辑复用的正确方式
custom Hook 是一个函数,其名称以 use 开头,内部可以调用其他 Hooks。它的本质是把可复用的状态逻辑抽离出来,而不是抽离 UI。
几个实用的 custom Hook 例子
数据获取 Hook:
function useUser(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchUser(userId)
.then(data => !cancelled && setUser(data))
.catch(err => !cancelled && setError(err))
.finally(() => !cancelled && setLoading(false));
return () => { cancelled = true; };
}, [userId]);
return { user, loading, error };
}
订阅窗口宽度:
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return width;
}
custom Hook 的设计原则
一个设计良好的 custom Hook 应该:关注点单一(只管一件事)、返回的结构稳定(不要有时候返回三个值有时候返回两个)、处理好清理逻辑。
Hooks 规则:原理与实践
React 的 Hooks 规则有两条硬性规则,不是 eslint-plugin 强加的,而是 React 本身实现的假设:
规则一:只在顶层调用 Hooks。不要在循环、条件、嵌套函数里调用 Hooks。这是因为 React 靠调用顺序来关联 Hook 和状态。
规则二:只在 React 函数里调用 Hooks。在普通 JavaScript 函数里、class 组件里调用 Hooks 是不允许的。
这两条规则背后的原理是:React 靠 Hooks 的调用顺序来「记住」每个 Hook 对应的状态。每次渲染,Hooks 按相同顺序被调用,React 就能把每次调用的结果和上一次对应起来。
面试中的表达
Hooks 的面试问题通常从「useEffect 和 useLayoutEffect 有什么区别」或者「如何取消一个 useEffect 里的异步请求」开始,但真正展示深度的是理解背后的原理:
React Hooks 的本质是把「有状态的逻辑」从组件里抽离出来,通过 hooks 链表按调用顺序关联每个 Hook 和它的状态。useEffect 的清理函数对应了类组件生命周期的 mount/unmount 和 update 阶段,但比类组件的生命周期更精确——它让你可以针对同一个 effect 的更新阶段和卸载阶段分别做处理,而不是混在一个生命周期方法里。useRef 之所以修改不触发渲染,是因为 ref 的变化不在 React 的驱动渲染链里,它只是一个 mutable container,这使它适合存定时器 ID、previous value 这些不需要反映到 UI 上的东西。
延展阅读
- React Docs: Hooks Overview — 官方 Hooks 参考,最权威的 API 文档
- A Complete Guide to useEffect — Dan Abramov 写的 useEffect 完全指南,被认为是理解 useEffect 最好的文章
- Dan Abramov: You Might Not Need an Effect — 官方最新指南,讲解哪些逻辑不需要放在 useEffect 里
- React Hooks API Reference — 每个 Hook 的详细 API 文档