React Hooks 深度掌握

useState/useEffect/useRef/useCallback/useMemo/useContext/useReducer 完全掌握,custom Hooks 设计模式,Hooks 规则背后的原理,以及 Hooks 与类组件生命周期的对应关系。

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:复杂状态逻辑的组织方式

基本模式

useReduceruseState 的替代品,适合状态逻辑复杂、有多个子值或状态变化有内在关联的场景:

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 配合

useReduceruseContext 组合起来,就实现了一个简化版的 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 上的东西。


延展阅读