React Hooks 深度解析

中等 🟡React 生态
6 个标签
预计阅读时间:61 分钟
ReactHooksuseStateuseEffectuseReducer自定义Hook

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 或循环里,某次渲染少调用一个,整个链表就"错位"了,状态会张冠李戴。

javascriptCode
// 用一个极简实现理解"顺序依赖"的本质
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 变时) |

代码示例

javascriptCode
// 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>
  );
}
javascriptCode
// 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>;
}
javascriptCode
// 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>
  );
}
javascriptCode
// 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} />;
}
javascriptCode
// 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 想做却做得很笨拙的事。

javascriptCode
// 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>
  );
}
javascriptCode
// 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)} />;
}
javascriptCode
// 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 在逻辑复用上的降维优势。

javascriptCode
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 测量,再决定优化"。

常见坑

依赖数组遗漏:effect 里用了某变量却没写进依赖,会读到旧值(stale closure)。用 eslint-plugin-react-hooks 的 exhaustive-deps 规则兜底。
在条件/循环里调用 Hook:破坏调用顺序,直接报错或状态错乱。
useEffect 里直接写 async 函数:useEffect(async () => {}) 返回 Promise 而非清理函数,应在内部定义 async 函数再调用。
setState 用了旧闭包值:连续更新或异步回调里应改用函数式更新 setX(prev => ...)。
忘记清理副作用:定时器、订阅、事件监听不清理导致内存泄漏。
滥用 useMemo/useCallback:给廉价计算加缓存,收益为负还增加代码复杂度。
把可变值放进 useState 却不想触发渲染:应该用 useRef。

最佳实践

只在最顶层调用 Hooks:不要在循环、条件或嵌套函数中调用,保证每次渲染调用顺序一致。
只在 React 函数中调用 Hooks:函数组件或自定义 Hook 内,不要在普通 JS 函数里调用。
合理管理依赖数组:包含 effect 中用到的所有外部变量,开启 exhaustive-deps 规则。
优先函数式更新:基于前值的更新一律用 setX(prev => ...)。
正确清理副作用:在 useEffect 返回清理函数,防泄漏、防竞态。
有针对性地优化:React.memo + useCallback/useMemo 组合使用,先测量后优化。
复用逻辑抽自定义 Hook:单一职责、清晰的参数与返回、良好的类型定义。

useEffect 依赖数组与闭包陷阱深入剖析

依赖数组是 useEffect(以及 useMemo、useCallback)里最容易踩坑的地方。要真正理解它,必须先理解一个事实:每次渲染都是一次独立的"快照"。函数组件在每次渲染时都会被完整地重新执行一遍,组件内的每个变量、每个函数、每个 effect 回调都是这一次渲染专属的,它们通过闭包"冻结"了当前这一帧的 props 和 state。React 16.8 之后的所有 Hooks 都建立在这个心智模型之上。

闭包陷阱是怎么产生的

看一个经典的错误例子:一个定时器每秒打印 count,但永远打印 0。

tsxCode
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(陈旧闭包)。

修复方式有三种,按推荐程度排序:

tsxCode
// 方案一(推荐):用函数式更新,彻底不依赖外部 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 存在的核心理由——保持引用稳定。

tsxCode
// 反例: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 完全一致,区别只在执行时机,理解这一点能解决大量"闪烁"问题。

useEffect:异步执行。React 完成 DOM 变更并让浏览器绘制到屏幕之后才运行回调。不阻塞绘制。
useLayoutEffect:同步执行。在 DOM 变更之后、浏览器绘制之前运行回调。会阻塞绘制。

浏览器一帧的流程可以概括为:React 更新 DOM → useLayoutEffect 同步运行(此时可读写布局,修改会被合并进本次绘制)→ 浏览器绘制 → useEffect 异步运行。

tsxCode
// 场景:根据元素实际宽度动态定位 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 收窄类型。

tsxCode
// 一个带完整 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,它还有几种高频高级用法。

tsxCode
// 用法一: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>;
}
tsxCode
// 用法二:保存最新回调,避免 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), []); // 返回引用永久稳定
}
tsxCode
// 用法三:标记"是否首次渲染",跳过挂载时的 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 逐步不再必需,但存量代码里这套仍是主流。

tsxCode
// 封装一个只暴露 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 是最常被"为优化而优化"误用的。它们不是免费的:每次渲染都要保存依赖、做浅比较、维护缓存,这些开销对廉价计算而言可能超过它省下的成本。

什么时候真正需要

1.计算确实昂贵(大数组排序/过滤、复杂派生数据)——用 useMemo。
2.需要保持引用稳定,以便传给 React.memo 子组件、或作为其它 Hook 的依赖——用 useCallback / useMemo。
3.其它情况:先别用,用 React Profiler 测出瓶颈再说。
tsxCode
// 误用一:给一个加法加 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 年)进一步扩展。

tsxCode
// 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>
    </>
  );
}
tsxCode
// 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>;
}
tsxCode
// useId:生成跨服务端/客户端一致的稳定 id,解决 SSR 水合不匹配
function Field({ label }: { label: string }) {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} />
    </>
  );
}
tsxCode
// 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)
  );
}
tsxCode
// 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>;
}
tsxCode
// 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,均可直接落地。

tsxCode
// 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];
}
tsxCode
// 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]);
}
tsxCode
// 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>
  );
}
tsxCode
// 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;
}
tsxCode
// 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,包含两条核心规则:

rules-of-hooks:强制"只在顶层调用""只在 React 函数中调用"。它无法被绕过,违反直接报错。
exhaustive-deps:检查 useEffect/useMemo/useCallback 依赖数组完整性,给出 warning 与自动修复建议。
jsonCode
// .eslintrc 配置片段
{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

两条黄金规则的深层原因回到本文开头:Hooks 靠调用顺序在链表里定位状态。把 Hook 写进条件或循环,某次渲染顺序一变,React 就会把 A 的状态发给 B。因此:

tsxCode
// ❌ 条件调用,破坏顺序
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)。

首次渲染(mount):走 mountXxx 系列函数,依次创建 Hook 节点并用 next 串成链表。
更新渲染(update):走 updateXxx 系列,沿着链表按相同顺序取出对应节点,复用其状态、消费更新队列算出新值。
javascriptCode
// 极简还原 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 开头、单一职责 |