Skip to content

第 12 章 · React 进阶

一句话开篇:理解 Hooks 的高级用法 + 性能优化 + 虚拟 DOM/Fiber 原理,从"会用 React"晋级为"能讲明白 React"。


0. 生活类比

类比一:自定义 Hooks = 自配的厨房工具包

useState useEffect 是 React 给你的标准锅碗瓢盆,但日常做饭你会发现"切水果 + 装盘 + 拍照"这套动作总是一起出现。自定义 Hook 就是把这套动作打包成一个 useFruitPlate 工具,下次直接拿来用,不用重写。

类比二:Fiber 架构 = 厨师的"工单管理系统"

老的 React Reconciler 像"老厨师":菜单一来就闷头炒,必须做完才能停。客人插队(用户输入)只能干等。

Fiber 像"现代化厨房工单系统":把大菜单拆成一个个工单(Fiber 节点),中间可以暂停、看看有没有 VIP 插队(高优先级任务),处理完急单再回来继续——这就是可中断渲染 + 时间切片

类比三:虚拟 DOM Diff = 装修对比图

老房子(旧虚拟 DOM)和新设计稿(新虚拟 DOM)摆一起对比:

  • 同一位置类型不同(沙发变成床)→ 直接拆掉重装
  • 同一位置同类型(沙发换了颜色)→ 只换布套
  • 多个相同位置(一排椅子)→ 看每个椅子的"标签"(key)来识别

1. 自定义 Hooks

是什么?

自定义 Hook 是一个use 开头的函数,内部可以调用其他 Hooks,把"组件中重复的状态逻辑"抽离出来复用。

例子:useToggle(开关)

jsx
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn(o => !o), []);
  return [on, toggle];
}

// 使用
function App() {
  const [show, toggleShow] = useToggle(false);
  return (
    <>
      <button onClick={toggleShow}>{show ? '隐藏' : '显示'}</button>
      {show && <p>Hi</p>}
    </>
  );
}

例子:useFetch(数据请求)

jsx
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    fetch(url)
      .then(r => r.json())
      .then(d => !cancelled && (setData(d), setLoading(false)))
      .catch(e => !cancelled && (setError(e), setLoading(false)));
    return () => { cancelled = true; };
  }, [url]);

  return { data, loading, error };
}

自定义 Hook 三原则

  1. 必须以 use 开头(ESLint 才能识别成 Hook)
  2. 遵守 Hooks 规则(只能在顶层、组件/Hook 中调用)
  3. 返回值无强制约定(数组、对象、单值都可以)

2. useMemo 和 useCallback —— 缓存

共同点:都用来避免重复计算/重复创建。区别:useMemo 缓存"计算结果",useCallback 缓存"函数引用"。

useMemo

jsx
const expensive = useMemo(() => {
  return list.filter(...).sort(...).reduce(...); // 重计算
}, [list]); // 依赖不变就用上次结果

useCallback

jsx
const handleClick = useCallback(() => {
  console.log(id);
}, [id]); // 依赖不变就用同一个函数引用

等价关系

jsx
useCallback(fn, deps) === useMemo(() => fn, deps)

useCallback 真正解决的问题

单独用 useCallback 没用! 它必须配合 React.memo 才有意义:

jsx
const Child = React.memo(({ onClick }) => {
  console.log('Child render');
  return <button onClick={onClick}>click</button>;
});

function Parent() {
  const [n, setN] = useState(0);

  // ❌ 每次渲染都新建 fn,Child 还是会重渲染
  // const handleClick = () => console.log('click');

  // ✅ 配合 memo,Child 不再重复渲染
  const handleClick = useCallback(() => console.log('click'), []);

  return (
    <>
      <button onClick={() => setN(n+1)}>{n}</button>
      <Child onClick={handleClick} />
    </>
  );
}

⚠️ 不要无脑用 memo / useMemo / useCallback

它们本身有缓存开销。只在以下情况使用:

  • 计算成本高(大数组排序、复杂过滤)
  • 子组件被 React.memo 包裹,需要稳定引用
  • 作为其他 Hook 的依赖项需要稳定

3. useReducer —— 复杂状态的"小型 Redux"

适合:多状态联动状态更新逻辑复杂

jsx
const initial = { count: 0, step: 1 };

function reducer(state, action) {
  switch (action.type) {
    case 'add':   return { ...state, count: state.count + state.step };
    case 'sub':   return { ...state, count: state.count - state.step };
    case 'step':  return { ...state, step: action.payload };
    case 'reset': return initial;
    default: throw new Error('未知 action');
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initial);
  return (
    <>
      <p>{state.count}(step {state.step})</p>
      <button onClick={() => dispatch({ type: 'add' })}>+</button>
      <button onClick={() => dispatch({ type: 'sub' })}>-</button>
      <input
        type="number"
        value={state.step}
        onChange={e => dispatch({ type: 'step', payload: +e.target.value })}
      />
      <button onClick={() => dispatch({ type: 'reset' })}>重置</button>
    </>
  );
}

useState vs useReducer

维度useStateuseReducer
适合场景简单状态、独立字段多字段联动、状态机
更新方式setX(val) 直接更新dispatch(action) 经过 reducer
可测试性一般好(reducer 是纯函数,可单独测)
调试console 看 setX可中央打印 action 流
与 Context 配合也可以配合 Context 实现"轻量 Redux"

4. useContext —— 跨层级数据传递

解决什么问题?

Props Drilling(属性钻取):祖先要把数据传给孙子,必须层层透传。

App ──(theme)──> Layout ──(theme)──> Sidebar ──(theme)──> Avatar

                                                          其实只有这里用

中间组件被迫成为"传话筒"。

用 Context 直接打通

jsx
const ThemeContext = React.createContext('light');

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Layout />
    </ThemeContext.Provider>
  );
}

function Avatar() {
  const theme = useContext(ThemeContext); // 直接拿
  return <div className={theme}>...</div>;
}

Context + useReducer = 轻量 Redux

jsx
const CartContext = createContext();

function CartProvider({ children }) {
  const [cart, dispatch] = useReducer(cartReducer, []);
  return (
    <CartContext.Provider value={{ cart, dispatch }}>
      {children}
    </CartContext.Provider>
  );
}

⚠️ Context 一变化,所有消费它的组件都会重渲染。频繁变化的状态用 Context 性能不佳,要么拆 Context,要么上 Redux/Zustand。


5. 性能优化全景

5.1 React.memo —— 浅比较 props

jsx
const Child = React.memo(({ name }) => <p>{name}</p>);
// props 浅比较相等 → 跳过渲染

5.2 useMemo / useCallback —— 稳定引用

见第 2 节。

5.3 拆分组件 —— 把状态局部化

jsx
// ❌ 输入框输入会让整个 App 重渲染
function App() {
  const [text, setText] = useState('');
  return <>
    <input value={text} onChange={e => setText(e.target.value)} />
    <BigList />  {/* 也跟着 render */}
  </>;
}

// ✅ 把输入下沉到子组件
function App() {
  return <><Input /><BigList /></>;
}

5.4 虚拟列表 —— 只渲染可视区

10000 条数据全渲染 = 卡爆。只渲染屏幕能看到的 ~20 条 + 上下缓冲:

全量数据:[0...9999]
                   ┌─────────────────┐
                   │   可视区域(20)   │ ← 只渲染这段
                   └─────────────────┘
滚动 → 改变 startIndex → 重新渲染窗口

主流方案:react-window / react-virtual

5.5 懒加载

jsx
const Heavy = React.lazy(() => import('./Heavy'));

<Suspense fallback={<Loading />}>
  <Heavy />
</Suspense>

5.6 useTransition / useDeferredValue(React 18)

把"不紧急的更新"标记为低优先级:

jsx
const [isPending, startTransition] = useTransition();

const onChange = (e) => {
  setInput(e.target.value);          // 紧急(输入框立即响应)
  startTransition(() => {
    setList(filter(e.target.value));  // 不紧急(万级列表过滤)
  });
};

6. 虚拟 DOM 与 Diff 算法

虚拟 DOM 是什么?

是用 JS 对象描述真实 DOM 结构的轻量数据结构。React 通过对比新旧两棵虚拟 DOM 树,得出最小变更集,再批量应用到真实 DOM。

js
// <div className="box"><h1>Hi</h1></div>
const vdom = {
  type: 'div',
  props: {
    className: 'box',
    children: [{ type: 'h1', props: { children: 'Hi' } }]
  }
};

Diff 算法三大启发式

  1. 同层比较:只比较同层级节点,不跨层(O(n) 而非 O(n³))
  2. type 不同直接销毁重建<div><span> → 不复用
  3. 列表用 key 标识:避免误判"修改"为"新增/删除"
旧:<ul>             新:<ul>
      <li key=A>      ↓     <li key=B>   ← B 复用旧 B
      <li key=B>            <li key=A>   ← A 复用旧 A
      <li key=C>            <li key=D>   ← D 新建
    </ul>                 </ul>          ← C 删除

Diff 复杂度

传统树 Diff 是 O(n³),React 通过上述三个假设把复杂度降到 O(n)


7. Fiber 架构

为什么要 Fiber?(React 16 重构)

老 Reconciler 是递归的,一旦开始无法中断。如果组件树很大(几千节点),渲染会卡住主线程几百毫秒,用户输入毫无响应。

Fiber 是什么

把"虚拟 DOM 树"从"递归"变成链表:每个组件对应一个 Fiber 节点,节点之间通过 child sibling return 指针连接。

        App (Fiber)
         │ child

       Header ─sibling→ Main ─sibling→ Footer

                          ▼ child
                        Card ─sibling→ Card ─sibling→ Card

Fiber 带来的能力

  1. 可中断:每处理一个 Fiber 节点就检查"还有时间吗",没时间就让出主线程,下次再继续
  2. 优先级调度:用户输入(高) > 网络数据(中) > 数据预取(低)
  3. 双缓存(current / workInProgress):渲染失败可以丢弃 wip 树,回到 current
  4. 副作用收集:先在内存中算好所有变更,再一次性提交(Commit Phase)

渲染两阶段(ASCII 图)

┌────────────────────────────────────────────────┐
│ Render Phase(可中断、可丢弃)                  │
│  - 构建/更新 Fiber 树                           │
│  - 执行 reconcile(diff)                        │
│  - 标记需要变更的 effect tag                    │
│  时间切片:每 5ms 检查一次有没有更高优先级       │
└────────────────────┬───────────────────────────┘
                     │ 完成(无中断)

┌────────────────────────────────────────────────┐
│ Commit Phase(同步、不可中断)                  │
│  - 执行 DOM 操作                                │
│  - 调用 useLayoutEffect / componentDidMount    │
│  - 调度 useEffect                              │
└────────────────────────────────────────────────┘

8. 受控组件 vs 非受控组件

维度受控非受控
数据源React stateDOM 自身
写法value + onChangedefaultValue + ref
实时校验容易
适合场景大多数表单集成第三方 DOM 库、文件上传
jsx
// 受控
<input value={name} onChange={e => setName(e.target.value)} />

// 非受控
<input defaultValue="" ref={inputRef} />

9. forwardRef 与 useImperativeHandle

函数组件默认不能接收 ref。要用 forwardRef 转发:

jsx
const Input = forwardRef((props, ref) => (
  <input ref={ref} {...props} />
));

// 父组件
const ref = useRef();
<Input ref={ref} />
ref.current.focus();

自定义暴露的方法(封装内部细节)

jsx
const FancyInput = forwardRef((props, ref) => {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current.focus(),
    clear: () => { inputRef.current.value = ''; }
  }));
  return <input ref={inputRef} />;
});

10. Portal —— 渲染到组件树之外

jsx
ReactDOM.createPortal(
  <Modal>...</Modal>,
  document.body  // 渲染到 body 下,逃出父容器的 overflow / z-index 限制
)

典型场景:弹窗、Toast、Tooltip。事件冒泡按 React 树冒泡,不是按 DOM 树。


11. Suspense 入门

jsx
<Suspense fallback={<Loading />}>
  <LazyComponent />
</Suspense>
  • 代码分割:配合 React.lazy 异步加载组件
  • 数据获取(实验/部分库支持):组件读取数据时如果还没加载好,"挂起"自身,让 Suspense 显示 fallback

12. ⚠️ 易踩的坑

  1. 滥用 useMemo / useCallback:本身有开销,没有 memo 接住时几乎无收益。
  2. memo 失效:传入了对象/数组/函数字面量 → 每次都是新引用,浅比较失败。
  3. Context 频繁更新引发全树重渲染:把"频繁变"和"很少变"的状态拆开放在两个 Context。
  4. 依赖数组里放对象/数组[obj] 每次都是新引用 → effect 一直触发。要么拆成原始值,要么用 useMemo 稳定。
  5. useReducer 里写副作用:reducer 必须是纯函数,副作用放在 useEffect
  6. 闭包陷阱useEffectsetTimeoutuseCallback 中读到的 state 是创建时的值。用函数式更新 setX(prev => ...) 或加依赖。
  7. Strict Mode 下 useEffect 执行两次:是有意为之,提示你检查清理函数是否完整。
  8. forwardRef 忘了:函数组件直接绑 ref 拿不到东西,<MyComp ref={...}> 会报警告。

13. 实战案例

详见 examples/ 目录:

  • custom-hook.jsx — 自定义 useLocalStorage
  • context-demo.jsx — Context + useReducer 实现轻量主题切换
  • useReducer.jsx — 复杂状态机(购物车)
  • memo-optimize.jsx — memo + useCallback 阻止子组件重渲染

demo/index.html主题切换 + 全局购物车(useContext + useReducer + useMemo 综合演练)


14. 一句话总结

React 进阶 = 用 Hooks 抽象逻辑(自定义 Hook),用缓存避免无效渲染(memo / useMemo / useCallback),用 Context 跨层传值,理解 Fiber 让你看懂调度的"为什么"。


15. 延伸阅读