React Hooks 出来好几年了,真正用对的人并不多。useState 加错位置导致死循环、useEffect 依赖数组写漏触发陈旧闭包、useMemo 加了反而更慢 —— 这些都不是 Hooks 的问题,是没理解它背后的机制。这篇文章把 Hooks 的实现原理、最容易踩的坑、以及自定义 Hook 的设计套路一次讲透。
Hooks 为什么是这种长相
函数组件每次渲染就是函数被调用一次。这意味着函数本身不能"持有状态"—— 每次都是新的局部变量。React 通过 Fiber 节点上的链表把状态挂在外部:Fiber 节点维护一个 hooks 链表,每次 render 时按调用顺序逐个取出。
// 极简版 useState 实现示意(真实实现复杂得多)
let currentFiber;
let hookIndex;
function useState(initial) {
const hooks = currentFiber.hooks;
const i = hookIndex++;
if (hooks[i] === undefined) {
hooks[i] = typeof initial === 'function' ? initial() : initial;
}
const setState = (next) => {
hooks[i] = typeof next === 'function' ? next(hooks[i]) : next;
scheduleRerender(currentFiber);
};
return [hooks[i], setState];
}
这就解释了两条"Hooks 黄金规则":
- 只能在组件最顶层调用 Hook,不能放在 if / for / 函数嵌套里。
- 每次渲染调用顺序必须相同。
违反任何一条,链表对应位置就错了,React 取到的状态会张冠李戴 —— 这就是 ESLint 那条 react-hooks/rules-of-hooks 必须开的根本原因。
useState:函数式更新与惰性初始化
function Counter() {
const [count, setCount] = useState(0);
// 坑:这样写,连续 setCount 三次只会 +1
const addThreeWrong = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1); // 三次都基于同一个 count(闭包里的快照)
};
// 对:用函数式更新,每次拿到的是最新值
const addThreeRight = () => {
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);
};
return <button onClick={addThreeRight}>{count}</button>;
}
另一个常被忽略的细节:初始值的惰性求值。如果初始值是个昂贵的计算,直接传函数:
// 坏:每次 render 都执行 heavyCompute(),只是结果被忽略
const [data, setData] = useState(heavyCompute());
// 好:只在挂载时执行一次
const [data, setData] = useState(() => heavyCompute());
useEffect:依赖数组才是核心
useEffect 的语义是:"在 render 提交到 DOM 后,如果依赖数组里的某个值变了,先跑清理函数,再跑 effect。"理解这三个时机和"依赖数组"的语义,能避免 80% 的 Hooks bug。
useEffect(() => {
const timer = setInterval(() => console.log(count), 1000);
return () => clearInterval(timer); // 清理,在下次 effect 前或卸载时执行
}, [count]); // 依赖数组:[] 表示只挂载一次,省略表示每次 render 都跑
陈旧闭包:Hooks 最大的坑
function Stale() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // 永远打印 0 —— 闭包捕获了挂载时的 count
}, 1000);
return () => clearInterval(id);
}, []); // 空依赖数组,effect 只跑一次
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
修复有三种思路:
// 1. 把 count 加进依赖数组 —— 每次 count 变都重建 interval
useEffect(() => {
const id = setInterval(() => console.log(count), 1000);
return () => clearInterval(id);
}, [count]);
// 2. 用函数式更新避开 count
useEffect(() => {
const id = setInterval(() => setCount((c) => {
console.log(c);
return c;
}), 1000);
return () => clearInterval(id);
}, []);
// 3. 用 useRef 保存最新值
const ref = useRef(count);
useEffect(() => { ref.current = count; });
useEffect(() => {
const id = setInterval(() => console.log(ref.current), 1000);
return () => clearInterval(id);
}, []);
ESLint 的 react-hooks/exhaustive-deps 规则会自动报漏掉的依赖,务必开启。遇到它报红时不要直接 disable,那是在埋雷。
useReducer:状态多了就该升级
多个 useState 之间有联动逻辑时,useReducer 更清晰:
type State = { count: number; history: number[] };
type Action = { type: 'inc' } | { type: 'dec' } | { type: 'reset' };
const reducer = (s: State, a: Action): State => {
switch (a.type) {
case 'inc': return { count: s.count + 1, history: [...s.history, s.count + 1] };
case 'dec': return { count: s.count - 1, history: [...s.history, s.count - 1] };
case 'reset': return { count: 0, history: [] };
}
};
function CounterAdv() {
const [state, dispatch] = useReducer(reducer, { count: 0, history: [] });
return (
<>
<span>{state.count}</span>
<button onClick={() => dispatch({ type: 'inc' })}>+</button>
<button onClick={() => dispatch({ type: 'reset' })}>重置</button>
</>
);
}
useMemo / useCallback:不是越多越好
useMemo 缓存计算结果,useCallback 缓存函数引用。它们的目的不是"快",而是"稳定引用",从而避免下游 React.memo / useEffect 误触发。
// 滥用:简单计算 + 没下游消费,加 useMemo 反而拖累
const sum = useMemo(() => a + b, [a, b]); // 不值得
// 合理:计算昂贵 + 依赖不常变
const sortedList = useMemo(() => bigList.sort(byScore), [bigList]);
// 合理:函数会被作为依赖传给 useEffect / 子组件 props
const onSearch = useCallback((q) => api.search(q), []);
useEffect(() => { onSearch(q); }, [q, onSearch]);
记住:useMemo / useCallback 都有开销(对比依赖、维护缓存)。给"显然不慢"的计算加,是用复杂度换不到性能。
useRef:不只是 DOM 引用
useRef 返回一个可变对象 { current: ... },它有两个用途:
- 引用 DOM 节点 —— 这是大家最熟悉的。
- 跨 render 保存任意可变值,且修改它不会触发重渲。
function Profile() {
const inputRef = useRef<HTMLInputElement>(null);
const renderCountRef = useRef(0);
// 每次渲染都执行,但不触发重渲
renderCountRef.current += 1;
return (
<>
<input ref={inputRef} />
<button onClick={() => inputRef.current?.focus()}>聚焦</button>
<div>渲染次数:{renderCountRef.current}</div>
</>
);
}
useRef 是"逃出 React 重渲机制"的安全后门。常见用法:存定时器 ID、存上一次的值、存"是否首次渲染"的标志。
useContext:跨层级传值
const ThemeContext = createContext<'light' | 'dark'>('light');
function App() {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
return (
<ThemeContext.Provider value={theme}>
<Toolbar />
<button onClick={() => setTheme((t) => t === 'light' ? 'dark' : 'light')}>
切换
</button>
</ThemeContext.Provider>
);
}
function DeepChild() {
const theme = useContext(ThemeContext);
return <div className={theme}>...</div>;
}
陷阱:Provider 的 value 每次 render 都是新对象时,所有消费它的组件都会重渲。解决方法:把 value 用 useMemo 包起来。
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}>...</ThemeContext.Provider>;
自定义 Hook:逻辑复用的正确姿势
把"使用一组 Hook 的逻辑"抽成一个以 use 开头的函数,就是自定义 Hook。它继承 React 的 Hooks 调用规则,但能在多个组件间复用逻辑(不复用状态)。
// useDebounce:防抖一个值
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
}
// 使用
function Search() {
const [q, setQ] = useState('');
const dq = useDebounce(q, 300);
useEffect(() => { if (dq) api.search(dq); }, [dq]);
return <input value={q} onChange={(e) => setQ(e.target.value)} />;
}
// useLocalStorage:状态与 localStorage 同步
function useLocalStorage<T>(key: string, initial: T) {
const [v, setV] = useState<T>(() => {
try {
const raw = localStorage.getItem(key);
return raw === null ? initial : JSON.parse(raw);
} catch {
return initial;
}
});
useEffect(() => { localStorage.setItem(key, JSON.stringify(v)); }, [key, v]);
return [v, setV] as const;
}
// useAsync:封装一次性的异步数据获取
function useAsync<T>(fn: () => Promise<T>, deps: any[]) {
const [state, setState] = useState<{
loading: boolean; data?: T; error?: Error;
}>({ loading: true });
useEffect(() => {
let cancelled = false;
setState({ loading: true });
fn().then(
(data) => !cancelled && setState({ loading: false, data }),
(error) => !cancelled && setState({ loading: false, error }),
);
return () => { cancelled = true; }; // 卸载时取消,避免内存泄漏
}, deps);
return state;
}
这三个 Hook 涵盖了"防抖、持久化、异步"三个最高频场景。useAsync 里 cancelled 标志非常关键 —— 它防止"组件已卸载,但 promise 才回来"时去 setState,触发 React 警告。
性能优化的优先级
面试常考的"如何优化 React 性能":
- 先用 React DevTools Profiler 定位真实瓶颈,不要凭感觉优化。
- 把组件拆小,让 state 变化只触发该重渲的部分。
- 用
React.memo包"贵的、props 不常变的"子组件。 - 必要时用 useMemo / useCallback 稳定引用,但不要无脑加。
- 长列表用 react-window / react-virtualized 虚拟滚动。
- 避免在 render 里创建新对象/新函数作为 props 传给被 memo 的子组件 —— 这会让 memo 失效。
写在最后
Hooks 不是更短的语法糖,它是 React 心智模型的一次根本转变:组件从"实例 + 生命周期"变成"渲染函数 + 副作用声明"。一旦接受这个模型,你写代码的方式会改变 —— 不再问"什么时候做这件事",而是问"这件事依赖什么,依赖变了就重做"。
检查你 Hooks 代码质量的三道闸:Lint 规则没 disable;依赖数组没漏没多;effect 都有对应的清理。这三条做到位,你的组件就能稳定地跑在生产环境里,而不是某天用户切个 tab 回来发现页面卡死。
—— 别看了 · 2026