Chapter 04

React Hooks 深度解析

Hooks 是 React 16.8 最重要的革新——让函数组件具备状态和副作用能力

1. 什么是 Hooks

Hooks 是一组以 use 开头的函数,让你在函数组件中"钩入"(hook into)React 的状态管理和生命周期能力。在 Hooks 出现之前,这些能力只有类组件才有。

Hooks 解决的类组件痛点

Hooks 的两条铁律(Rules of Hooks)

🚫

规则一:只在函数组件顶层调用 Hook,不能在条件语句、循环或嵌套函数中调用。
规则二:只在 React 函数组件自定义 Hook 中调用 Hook,不能在普通 JS 函数中调用。

违反这两条规则会导致难以排查的 bug。ESLint 插件 eslint-plugin-react-hooks 会自动检测。

2. useState

useState 是最基础的 Hook,让函数组件持有响应式状态——当 state 改变时,React 自动重新渲染组件。

state 的本质

每次组件渲染时,useState 返回当前这次渲染的快照值。state 不是普通变量——它在两次渲染之间被 React 持久化,而普通变量在每次渲染时都会重置。

import { useState } from 'react';

function Counter() {
  // 解构:[当前值, setter 函数]
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      {/* setter 调用后,React 调度重渲染 */}
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}

函数式更新(基于上次值)

当新 state 依赖于上一个 state 时,应该用函数式更新,避免闭包陷阱(读到过期的 state 值)。

// ❌ 直接用变量 —— 在异步中可能读到过期值
setCount(count + 1);
setCount(count + 1); // count 仍是旧值,结果只 +1,不是 +2

// ✅ 函数式更新 —— 始终基于最新值
setCount(prev => prev + 1);
setCount(prev => prev + 1); // 正确 +2

// 常见场景:toggle
setIsOpen(prev => !prev);

对象/数组 state 的不可变更新

React 通过对比引用判断 state 是否变化。如果直接修改对象/数组(改变内部属性但引用不变),React 不会触发重渲染。必须创建新对象/数组。

const [user, setUser] = useState({ name: 'Alice', age: 25, skills: ['JS'] });

// ❌ 直接修改 —— React 不会重渲染!
user.age = 26;
setUser(user); // 引用未变,React 认为没有变化

// ✅ 展开创建新对象
setUser({ ...user, age: 26 });

// ✅ 数组:不要 push/splice,要创建新数组
const [items, setItems] = useState<string[]>([]);

// 新增
setItems(prev => [...prev, 'new item']);

// 删除(filter 返回新数组)
setItems(prev => prev.filter(item => item !== 'old item'));

// 更新某一项
setItems(prev => prev.map(item =>
  item === 'old' ? 'new' : item
));

初始化函数(避免重复计算)

// ❌ 这个函数每次渲染都执行(即使初始值只用一次)
const [state, setState] = useState(computeExpensiveValue());

// ✅ 传入函数 —— 只在首次渲染时执行
const [state, setState] = useState(() => computeExpensiveValue());
const [items, setItems] = useState(() =>
  JSON.parse(localStorage.getItem('items') ?? '[]')
);

3. useEffect

useEffect 用于执行副作用(Side Effects)——那些与渲染无关的操作,如数据获取、DOM 操作、订阅事件、设置定时器等。

依赖数组三种情况对比

// 情况1:无依赖数组 —— 每次渲染后都执行
useEffect(() => {
  console.log('每次渲染后执行');
}); // ← 没有第二个参数

// 情况2:空数组 —— 只在组件挂载时执行一次
useEffect(() => {
  console.log('只在 mount 时执行');
}, []); // ← 空数组

// 情况3:有依赖值 —— 依赖变化时执行
useEffect(() => {
  console.log('userId 变化时执行');
}, [userId]); // ← 只在 userId 变化时重新运行

清理函数(Cleanup)

Effect 返回的函数是清理函数——在组件卸载或 Effect 重新运行前执行,用于清理副作用(取消订阅、清除定时器、取消请求等)。

useEffect(() => {
  // 订阅事件
  const handler = (e: Event) => console.log(e);
  window.addEventListener('resize', handler);

  // 返回清理函数
  return () => {
    window.removeEventListener('resize', handler);
  };
}, []);

// 数据请求的清理(防止组件卸载后设置 state)
useEffect(() => {
  let cancelled = false;

  const fetchData = async () => {
    const data = await fetchUser(userId);
    if (!cancelled) setUser(data); // 已卸载时不设置 state
  };

  fetchData();
  return () => { cancelled = true; };
}, [userId]);

常见错误:deps 缺失

⚠️

如果 Effect 内部使用了某个变量(state/props),但没有把它加入依赖数组,Effect 会读到过期的旧值——这是"闭包陷阱"。eslint-plugin-react-hooksexhaustive-deps 规则会警告这种情况。

4. useRef

useRef 返回一个 { current: T } 对象,有两种截然不同的用途。

用途一:持有 DOM 引用

import { useRef, useEffect } from 'react';

function AutoFocusInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    // 组件挂载后,DOM 元素已存在,可以操作
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} placeholder="自动聚焦" />;
}

用途二:持有可变值(不触发重渲染)

有时你需要在渲染之间保存一个值,但改变它时不想触发重渲染(如计时器 ID、上一次的 props 值)。这时用 useRef,而不是 useState。

function Timer() {
  const [count, setCount] = useState(0);
  const timerIdRef = useRef<ReturnType<typeof setInterval> | null>(null);

  const start = () => {
    timerIdRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
  };

  const stop = () => {
    if (timerIdRef.current) clearInterval(timerIdRef.current);
  };

  return (
    <div>
      <p>{count}</p>
      <button onClick={start}>开始</button>
      <button onClick={stop}>停止</button>
    </div>
  );
}
特性useStateuseRef
触发重渲染✅ 每次更新都重渲染❌ 更改 current 不触发
渲染时的值每次渲染的快照值始终是最新的可变值
适用场景UI 依赖的数据定时器 ID、DOM 引用、计数器

5. useMemo 与 useCallback

记忆化(Memoization):缓存计算结果或函数引用,只有依赖变化时才重新计算,避免不必要的重计算或重渲染。

useMemo — 缓存计算结果

import { useMemo, useState } from 'react';

function ProductList({ products, searchQuery }: Props) {
  // 每次渲染都会重新过滤(如果 products 很多,很慢)
  // const filtered = products.filter(p => p.name.includes(searchQuery));

  // ✅ useMemo 缓存:只有 products 或 searchQuery 变化时才重新计算
  const filtered = useMemo(
    () => products.filter(p => p.name.includes(searchQuery)),
    [products, searchQuery] // 依赖数组
  );

  return <ul>{filtered.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

useCallback — 缓存函数引用

每次渲染都会创建新的函数对象,导致接收该函数作为 prop 的子组件总是重渲染(引用不同了)。useCallback 让函数引用稳定。

import { useCallback, memo } from 'react';

// 子组件用 React.memo 包裹 —— 只有 props 变化才重渲染
const Button = memo(({ onClick, label }: ButtonProps) => {
  console.log(`渲染 ${label}`);
  return <button onClick={onClick}>{label}</button>;
});

function Parent() {
  const [count, setCount] = useState(0);

  // ❌ 每次 Parent 渲染,handleClick 都是新函数 → Button 也重渲染
  // const handleClick = () => console.log('clicked');

  // ✅ useCallback 缓存函数 —— 依赖不变时,返回同一个函数引用
  const handleClick = useCallback(() => {
    console.log('clicked, count is', count);
  }, [count]);

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
      <Button onClick={handleClick} label="Click me" />
    </div>
  );
}
⚠️

过度优化的危害:useMemo/useCallback 本身有开销(缓存存储、依赖比较)。不要无脑加。只在实测存在性能问题时再加,或者:计算非常昂贵(如大数据排序),以及需要配合 React.memo 使用时。

6. useContext

Context 用于在组件树中共享数据,避免逐层传递 Props(Props Drilling)。主题、语言设置、当前登录用户等适合放 Context。

import { createContext, useContext, useState } from 'react';

// 1. 创建 Context(提供默认值)
interface ThemeContextType {
  theme: 'light' | 'dark';
  toggleTheme(): void;
}

const ThemeContext = createContext<ThemeContextType>({
  theme: 'dark',
  toggleTheme: () => {},
});

// 2. Provider 包裹子树,提供值
function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('dark');
  const toggleTheme = () => setTheme(t => t === 'light' ? 'dark' : 'light');

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

// 3. 在任意子组件中消费
function Header() {
  const { theme, toggleTheme } = useContext(ThemeContext);
  return (
    <header className={`header-${theme}`}>
      <button onClick={toggleTheme}>切换主题</button>
    </header>
  );
}

7. useReducer

当组件状态逻辑复杂(多个子值、下一状态依赖上一状态)时,用 useReducer 比多个 useState 更清晰。它的模式与 Redux 相同:action → reducer → new state

import { useReducer } from 'react';

// 定义 state 和 action 类型
interface CartState {
  items: CartItem[];
  totalAmount: number;
}

type CartAction =
  | { type: 'ADD_ITEM';    payload: CartItem }
  | { type: 'REMOVE_ITEM'; payload: string }
  | { type: 'CLEAR' };

// Reducer 函数:(currentState, action) => newState
function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        items: [...state.items, action.payload],
        totalAmount: state.totalAmount + action.payload.price,
      };
    case 'REMOVE_ITEM':
      const removed = state.items.find(i => i.id === action.payload);
      return {
        items: state.items.filter(i => i.id !== action.payload),
        totalAmount: state.totalAmount - (removed?.price ?? 0),
      };
    case 'CLEAR':
      return { items: [], totalAmount: 0 };
    default:
      return state;
  }
}

function ShoppingCart() {
  const [cart, dispatch] = useReducer(cartReducer, { items: [], totalAmount: 0 });

  return (
    <div>
      <p>总价: ¥{cart.totalAmount}</p>
      <button onClick={() => dispatch({ type: 'CLEAR' })}>清空</button>
    </div>
  );
}

8. 自定义 Hook

自定义 Hook 是以 use 开头的普通函数,内部可以使用其他 Hook。它是提取可复用逻辑的最佳方式,比 HOC 和 Render Props 更简洁直观。

useFetch — 数据获取

import { useState, useEffect } from 'react';

interface FetchState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}

function useFetch<T>(url: string): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({
    data: null, loading: true, error: null
  });

  useEffect(() => {
    let cancelled = false;
    setState({ data: null, loading: true, error: null });

    fetch(url)
      .then(r => r.json())
      .then(data => { if (!cancelled) setState({ data, loading: false, error: null }); })
      .catch(err => { if (!cancelled) setState({ data: null, loading: false, error: err.message }); });

    return () => { cancelled = true; };
  }, [url]);

  return state;
}

// 使用 —— 逻辑完全复用,组件非常简洁
function UserProfile({ userId }: { userId: string }) {
  const { data: user, loading, error } = useFetch<User>(
    `/api/users/${userId}`
  );

  if (loading) return <div>加载中...</div>;
  if (error)   return <div>错误: {error}</div>;
  return <div>{user?.name}</div>;
}

useLocalStorage — 持久化状态

function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    try {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch { return initialValue; }
  });

  const setStoredValue = (newValue: T) => {
    setValue(newValue);
    localStorage.setItem(key, JSON.stringify(newValue));
  };

  return [value, setStoredValue] as const;
}

// 用起来和 useState 一样,但数据会持久化到 localStorage
const [theme, setTheme] = useLocalStorage('theme', 'dark');

自定义 Hook vs 工具函数:如果逻辑内部用了 Hook(useState/useEffect 等),就必须封装为自定义 Hook(use 前缀);如果只是普通逻辑计算(不用 Hook),封装为普通工具函数即可。

9. Hook 的底层原理

理解 Hook 的底层,才能真正明白"为什么不能在条件语句中使用 Hook"这条规则。

链表结构

React 在每个组件实例上维护一条Hook 链表。每次调用 useStateuseEffect 等 Hook,都会按顺序在这条链表上追加一个节点。

// 第一次渲染:依次创建链表节点
const [name, setName] = useState('');     // Hook #1
const [age, setAge]   = useState(0);      // Hook #2
useEffect(() => { /* ... */ }, []);        // Hook #3

// 第二次渲染:按相同顺序读取链表节点
const [name, setName] = useState('');     // 读 Hook #1 → 'Alice'
const [age, setAge]   = useState(0);      // 读 Hook #2 → 25
useEffect(() => { /* ... */ }, []);        // 读 Hook #3

React 完全依赖 Hook 调用的顺序来关联节点。如果把 Hook 放在条件语句中,某次渲染时跳过了某个 Hook,链表顺序就乱了——后续所有 Hook 都会读到错误的节点。这就是为什么有"只能在顶层调用"这条规则。

🚫

永远不要这样做

if (isLoggedIn) {
  const [user, setUser] = useState(null); // ❌ 条件中的 Hook!
}

本章小结:Hooks 是现代 React 的核心。useState 管理 UI 状态,useEffect 处理副作用(记得清理),useRef 持有 DOM 引用或可变值,useMemo/useCallback 是性能优化工具(按需使用),自定义 Hook 是逻辑复用的最佳方案。