React Hooks 深入
前言
React Hooks 自 React 16.8 版本正式发布以来,彻底改变了 React 组件的开发范式。它允许开发者在函数组件中使用状态和其他 React 特性,无需编写 class 组件。Hooks 解决了 class 组件中逻辑复用困难、复杂组件难以理解、this 指向困扰等痛点。本文将深入探讨 React 核心 Hooks 的原理、使用技巧、常见陷阱以及自定义 Hooks 的最佳实践。
一、useState — 状态管理的基础
1.1 状态初始化
useState 是 React 中最基础的 Hook,用于在函数组件中声明状态变量。其签名如下:
const [state, setState] = useState<S>(initialState: S | (() => S)): [S, Dispatch<S>]状态初始化可以接受一个直接值或一个初始化函数。使用初始化函数时,React 仅在组件首次渲染时调用该函数,后续渲染会忽略它,这称为惰性初始化(Lazy Initialization)。
// 直接值初始化
const [count, setCount] = useState(0);
// 惰性初始化 — 适合计算开销较大的场景
const [items, setItems] = useState(() => {
const initialData = expensiveComputation(); // 仅在首次渲染执行
return initialData;
});惰性初始化特别适合从 localStorage 读取数据、执行复杂计算或解析大量 JSON 数据的场景。
1.2 更新函数
setState 有两种使用方式:
- 直接传入新值:
setCount(count + 1),适合新值不依赖旧值的场景。 - 传入更新函数:
setCount(prev => prev + 1),适合新值依赖旧值的场景,尤其在连续多次更新时至关重要。
const [count, setCount] = useState(0);
// ❌ 错误:连续三次调用,结果仍为 1
const handleWrong = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1); // count 最终为 1
};
// ✅ 正确:使用函数式更新,结果为 3
const handleCorrect = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1); // count 最终为 3
};1.3 状态更新的异步性与批处理
在 React 18 之前,仅在事件处理函数中进行状态批处理;在 setTimeout、Promise 回调中则不会批处理。React 18 引入了自动批处理(Automatic Batching),所有更新都会进行批处理。
const [a, setA] = useState(0);
const [b, setB] = useState(0);
// React 18 中,以下两个 setState 会合并为一次渲染
fetch('/api/data').then(() => {
setA(1);
setB(2); // 合并渲染,只触发一次 re-render
});1.4 Closure 陷阱
由于 JavaScript 闭包特性,事件处理函数或异步回调中捕获的状态值可能是"过时"的。这是 React Hooks 开发中最常见的陷阱之一。
function Counter() {
const [count, setCount] = useState(0);
// ❌ Closure 陷阱:点击时 count 仍然是创建时的值
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // 始终为 0
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
// ✅ 正确:使用函数式更新或添加依赖
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // 不依赖闭包中的 count
}, 1000);
return () => clearInterval(timer);
}, []);
}对于函数式更新无法解决的场景(如异步请求后的状态处理),应确保依赖数组正确。
二、useEffect — 副作用处理
2.1 基本概念
useEffect 用于在函数组件中处理副作用,包括数据获取、订阅、DOM 操作、计时器等。它接受两个参数:一个副作用函数和一个可选的依赖数组。
useEffect(effectFunction, dependencies);2.2 依赖数组
依赖数组决定了副作用函数的执行时机:
- 不传依赖数组:每次渲染后都执行。
- 空数组
[]:仅在组件挂载时执行一次(类似componentDidMount)。 - 指定依赖项:当依赖项发生变化时执行。
// 每次渲染后执行
useEffect(() => {
document.title = `点击 ${count} 次`;
});
// 仅在挂载时执行
useEffect(() => {
fetchData();
}, []);
// 依赖项变化时执行
useEffect(() => {
fetchUserData(userId);
}, [userId]);2.3 清理函数
当副作用函数返回一个函数时,该函数会在组件卸载或重新执行副作用前被调用,用于清理订阅、取消请求、清除计时器等。
useEffect(() => {
const subscription = someAPI.subscribe(data => {
setData(data);
});
// 清理函数
return () => {
subscription.unsubscribe();
};
}, []);清理函数是防止内存泄漏的关键手段,尤其在 SPA 应用中,组件频繁挂载卸载时尤为重要。
2.4 执行时机
与 componentDidMount 和 componentDidUpdate 不同,useEffect 中的副作用函数在浏览器完成布局与绘制之后才会执行。这意味着副作用不会阻塞视觉更新,页面对用户响应更快。
如果需要同步执行副作用(即在浏览器绘制前执行),可以使用 useLayoutEffect,但应谨慎使用,因为它会阻塞渲染。
2.5 常见模式
数据获取模式:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!cancelled) {
setUser(data);
setLoading(false);
}
});
return () => {
cancelled = true; // 防止竞态条件
};
}, [userId]);
if (loading) return <Spinner />;
return <div>{user.name}</div>;
}事件监听模式:
useEffect(() => {
const handleScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);防抖/节流模式:
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, 500);
return () => clearTimeout(timer);
}, [value]);三、useRef — 可变引用
3.1 DOM 引用
useRef 最常见的用途是获取 DOM 节点的引用:
function TextInput() {
const inputRef = useRef(null);
const handleFocus = () => {
inputRef.current.focus();
};
return (
<>
<input ref={inputRef} type="text" />
<button onClick={handleFocus}>聚焦输入框</button>
</>
);
}3.2 可变值存储
useRef 返回的 ref 对象在整个组件生命周期内保持不变,且 .current 属性可以修改而不会触发重新渲染。这使得 useRef 成为存储"不需要触发渲染的可变值"的理想工具。
function Timer() {
const [seconds, setSeconds] = useState(0);
const timerRef = useRef(null);
const start = () => {
timerRef.current = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
};
const stop = () => {
clearInterval(timerRef.current);
};
useEffect(() => {
return () => clearInterval(timerRef.current); // 清理
}, []);
return (
<div>
<p>{seconds}s</p>
<button onClick={start}>开始</button>
<button onClick={stop}>停止</button>
</div>
);
}3.3 与 useState 的对比
| 特性 | useRef | useState |
|---|---|---|
| 值变更是否触发渲染 | 否 | 是 |
| 跨渲染周期保持引用 | 是 | 是(state 值本身不变) |
| 适用场景 | DOM 引用、计时器 ID、不需要渲染的中间值 | 直接影响 UI 的数据 |
| 读取方式 | ref.current | 直接使用变量 |
选择原则:如果某个值的变化需要更新 UI,使用 useState;如果只需要在事件处理或副作用中读取最新值而不需要渲染,使用 useRef。
四、useMemo — 性能记忆化
4.1 基本用法
useMemo 用于记忆化(memoize)计算结果,避免在每次渲染时都执行昂贵的计算。它接受一个"创建"函数和一个依赖数组,仅当依赖项发生变化时才重新计算。
const memoizedValue = useMemo(() => {
return computeExpensiveValue(a, b);
}, [a, b]);4.2 实际应用
function SearchResults({ query, items }) {
// 过滤大列表 — 使用 useMemo 避免每次渲染都重新过滤
const filteredItems = useMemo(() => {
console.log('过滤中...');
return items.filter(item =>
item.name.toLowerCase().includes(query.toLowerCase())
);
}, [items, query]);
return (
<ul>
{filteredItems.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}4.3 性能优化注意事项
useMemo 不是免费的——它本身也需要内存存储和依赖比对。以下情况不适用 useMemo:
- 计算非常简单(如数组长度判断)。
- 组件很少重新渲染。
- 依赖数组频繁变化(每次都会重新计算,记忆化无意义)。
React 团队建议:先不加 useMemo 编写代码,当真正遇到性能瓶颈时,通过 Profiler 定位后再优化。
4.4 保持引用稳定
useMemo 的另一个重要用途是保持引用稳定性,这对于传递给子组件的对象、数组特别重要,可以配合 React.memo 阻止不必要的子组件渲染。
const config = useMemo(() => ({
url: `/api/users/${userId}`,
retryCount: 3,
}), [userId]);五、useCallback — 函数记忆化
5.1 基本用法
useCallback 本质上是 useMemo 的函数特化版本,用于记忆化函数引用:
const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]);
// 等价于
const memoizedCallback = useMemo(() => {
return () => doSomething(a, b);
}, [a, b]);5.2 与 useMemo 的关系
useCallback(fn, deps) 等价于 useMemo(() => fn, deps)。区别在于语法层面:useCallback 返回记忆化的函数本身,而 useMemo 返回记忆化的计算结果。
| useMemo | useCallback | |
|---|---|---|
| 返回值 | 任意值(计算结果) | 函数 |
| 等价形式 | useMemo(() => fn, deps) | useCallback(fn, deps) |
| 主要用途 | 避免重复计算 | 稳定函数引用 |
5.3 子组件优化
useCallback 常与 React.memo 配合使用,避免父组件重新渲染时创建新的函数引用,导致子组件不必要的重新渲染。
const Child = React.memo(({ onClick, name }) => {
console.log(`${name} 重新渲染`);
return <button onClick={onClick}>{name}</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const [other, setOther] = useState(0);
// ❌ 每次渲染创建新函数,Child 会跟着重新渲染
const handleClick = () => setCount(c => c + 1);
// ✅ 记忆化函数引用,仅当依赖变化时才更新
const handleClickMemo = useCallback(() => {
setCount(c => c + 1);
}, []); // 函数式更新不需要依赖 count
return (
<div>
<p>Count: {count}</p>
<Child onClick={handleClickMemo} name="优化后" />
<button onClick={() => setOther(o => o + 1)}>更新 Other: {other}</button>
</div>
);
}5.4 何时使用 useCallback
useCallback 并非银弹,滥用同样会带来性能问题。推荐在以下场景使用:
- 传递给使用
React.memo优化的子组件。 - 作为其他 Hook 的依赖项(如
useEffect、useMemo)。 - 自定义 Hook 的返回值(确保下游使用者的依赖数组稳定)。
六、自定义 Hooks — 逻辑复用
6.1 提取逻辑复用
自定义 Hook 是 React Hooks 的核心优势之一,它将组件逻辑提取为可复用的函数。自定义 Hook 是一个以 use 开头的函数,内部可以调用其他 Hooks。
6.2 命名约定
- 必须以
use开头(React 通过这个约定判断函数是否包含 Hook 调用)。 - 遵循驼峰命名法,如
useWindowSize、useOnlineStatus。 - 名称应清晰表达功能,如
useLocalStorage表示操作 localStorage 的 Hook。
6.3 示例:useDebounce
import { useState, useEffect } from 'react';
function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// 使用
function SearchComponent() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
searchAPI(debouncedQuery);
}
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}6.4 示例:useLocalStorage
import { useState, useCallback } from 'react';
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error('读取 localStorage 失败:', error);
return initialValue;
}
});
const setValue = useCallback(value => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error('写入 localStorage 失败:', error);
}
}, [key, storedValue]);
return [storedValue, setValue];
}6.5 示例:useFetch
import { useState, useEffect } from 'react';
function useFetch(url, options) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(url, options)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(json => {
if (!cancelled) {
setData(json);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err.message);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}该 Hook 包含了竞态条件处理(通过 cancelled 标记)、错误处理和加载状态,是一个生产可用的数据请求方案。
七、Hooks 使用规则
7.1 顶层调用
规则一:只在最顶层调用 Hooks
不要在循环、条件语句或嵌套函数中调用 Hooks。这是因为 React 依赖 Hooks 的调用顺序来正确关联状态。如果同一个 Hook 在不同渲染中有条件地执行,React 就无法确定哪个状态对应哪个 Hook。
// ❌ 禁止:在条件语句中使用 Hook
if (condition) {
useEffect(() => { /* ... */ });
}
// ❌ 禁止:在循环中使用 Hook
for (const item of items) {
useMemo(() => item, [item]);
}
// ✅ 正确:所有 Hook 在顶层无条件调用
useEffect(() => {
if (condition) { /* 条件逻辑放在内部 */ }
}, [condition]);7.2 条件与循环中禁止
这条规则背后的原因是 React 内部使用链表来存储 Hooks 状态。每个 Hook 按照调用顺序依次存入链表。如果某个渲染中跳过了某个 Hook,后续所有 Hook 的位置都会错乱,导致状态错乱或内存泄漏。
7.3 ESLint 插件
React 官方提供了 eslint-plugin-react-hooks 插件,包含两个核心规则:
- rules-of-hooks:强制执行 Hooks 的调用规则。
- exhaustive-deps:检查
useEffect、useMemo、useCallback的依赖数组是否完整。
强烈建议在项目中启用这两个规则:
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}exhaustive-deps 规则尤其重要——它会在你忘记在依赖数组中添加某些依赖项时发出警告,避免因闭包陷阱导致难以排查的 bug。
八、常见 Hooks 设计模式
8.1 组合模式
复杂功能可以通过组合多个基础 Hooks 实现:
function useUserData(userId) {
const user = useFetch(`/api/users/${userId}`);
const posts = useFetch(`/api/users/${userId}/posts`);
const recentPosts = useMemo(() => {
return posts.data?.slice(0, 5) ?? [];
}, [posts.data]);
return { user, posts, recentPosts };
}8.2 状态机模式
使用 useReducer 实现有限状态机,适合复杂的状态流转:
const initialState = { status: 'idle', data: null, error: null };
function reducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return { ...state, status: 'loading' };
case 'FETCH_SUCCESS':
return { status: 'success', data: action.payload, error: null };
case 'FETCH_ERROR':
return { status: 'error', data: null, error: action.payload };
default:
return state;
}
}8.3 上下文 + Reducer 模式
结合 useContext 和 useReducer 实现轻量级状态管理:
const TodoContext = createContext();
function TodoProvider({ children }) {
const [todos, dispatch] = useReducer(todoReducer, []);
return (
<TodoContext.Provider value={{ todos, dispatch }}>
{children}
</TodoContext.Provider>
);
}
function useTodos() {
const context = useContext(TodoContext);
if (!context) throw new Error('useTodos 必须在 TodoProvider 内使用');
return context;
}8.4 事件管理器模式
用于需要手动管理事件监听器的场景:
function useEventListener(eventName, handler, element = window) {
const savedHandler = useRef(handler);
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
const isSupported = element && element.addEventListener;
if (!isSupported) return;
const eventListener = event => savedHandler.current(event);
element.addEventListener(eventName, eventListener);
return () => element.removeEventListener(eventName, eventListener);
}, [eventName, element]);
}九、总结
React Hooks 为函数组件带来了强大的能力,但也引入了新的思考方式。以下是关键要点:
- useState:惰性初始化节省首次渲染性能;函数式更新确保基于最新状态;React 18 的自动批处理简化了性能优化。
- useEffect:合理管理依赖数组;始终在需要时返回清理函数;注意数据获取时的竞态条件。
- useRef:不触发渲染的可变容器;DOM 引用的标准方式;适合存储计时器 ID、前一次值等。
- useMemo / useCallback:只在性能瓶颈处使用;优先保证代码可读性;配合
React.memo发挥最大效果。 - 自定义 Hooks:以
use开头;提取组件间共享逻辑;组合多个 Hooks 实现复杂功能。 - 使用规则:顶层调用、禁止条件/循环中调用、使用 ESLint 插件确保依赖完整性。
Hooks 的核心哲学是声明式副作用和逻辑组合,理解这些理念将帮助你写出更简洁、更可维护的 React 代码。