Skip to content

React

React 的定位是「声明式 UI 库」:你描述"长什么样 + 数据怎么来",框架负责"何时以何种方式更新 DOM"。要驾驭 React,先建立渲染(render)心智模型,再学 Hooks 与并发特性,最后才谈状态管理。本文基于 React 19(2024-12-05 稳定,当前主线版本);示例为标准用法,可复制进 create-vite(react 模板)或官方模板直接运行。

一、渲染心智模型:先懂"什么时候重新渲染"

1.1 组件是一次函数调用

React 将 UI 描述为组件树。每次状态变化,React 会重新调用相关组件函数,拿新返回值与上次渲染结果做差异(reconcile),再把差异应用到 DOM:

jsx
function Counter() {
  const [count, setCount] = useState(0)
  // count 变化 → 本组件函数被重新调用 → 返回新的 <p> 描述 → React 只更新必要 DOM
  return <p>你点了 {count} 次</p>
}

关键推论:

  • render 必须纯净:不要在 render 中做副作用(发请求、改全局、写 localStorage);
  • 组件不是"模板":它每次都完整执行——console.log 出现在 render 里就会每次渲染都打(不优化时);
  • props 是快照:本次渲染的 props/state 在本次调用里恒定不变,事件回调闭包捕获的是"触发那一刻"的值。

1.2 什么时候会重新渲染

触发原因说明常见误解
自身 state 变化setState 后必然重渲染本组件把"改 props 不行就改 state"当银弹
父组件渲染默认向下传染:父 render → 所有子组件都 render(哪怕子组件没变化)"我 memo 了子组件就不会"——条件见 §3
context 值变化订阅该 context 的组件重渲染把整个 store 塞进一个 context
props 引用变化每次父渲染都新建对象/函数,memo 失效useCallback 依赖没写全

1.3 key 决定"身份"而非"位置"

列表 diff 靠 key 匹配新旧元素。索引当 key 在插入/删除/重排时会张冠李戴(状态错位、输入框串行):

jsx
// ❌ index 作 key:在头部插入一项,其后所有项都被当成"同一个组件换了内容"
{items.map((it, i) => <Row key={i} item={it} />)}

// ✅ 稳定唯一 id;没有就用内容哈希/库生成(crypto.randomUUID 仅在创建时生成一次)
{items.map((it) => <Row key={it.id} item={it} />)}

1.4 派生状态:别复制 state,渲染时算

React 官方推荐"derive state during render":能从 props/state 算出来的,就不要另存一份要手动同步的 state:

jsx
// ❌ 反模式:用 effect 把 props 同步进 state(双数据源,时序坑)
const [list, setList] = useState(props.items)
useEffect(() => setList(props.items), [props.items])

// ✅ 渲染期直接派生;真正"只读一遍"的历史值才需要存(见 §3.5 的 key 换组件)
const shown = props.items.filter((it) => it.active)

二、Hooks 的正确用法

2.1 规则与心智锚点

  1. 只在组件 / 自定义 Hook 顶层调用,不要在循环、条件里调(顺序 = 状态槽位);
  2. useState 的 setter 更新的是快照队列,连续多次 setCount(count + 1) 不会累加——用函数式更新 setCount(c => c + 1)
  3. 对象/数组状态要替换而非修改setUser({...user, name: 'x'}),否则引用不变不触发渲染。

2.2 useEffect:同步外部系统,不是"数据流"

大多数 state 联动都应写成"渲染时派生",effect 只留给与外部系统同步(订阅、DOM 操作、请求——请求本身最好交给数据请求库):

jsx
useEffect(() => {
  const onKey = (e) => { if (e.key === 'Escape') close() }
  window.addEventListener('keydown', onKey)
  return () => window.removeEventListener('keydown', onKey)  // 清理必须成对
}, [close])
  • 依赖数组要诚实:漏依赖是 bug(闭包拿旧值),把"我不希望它重跑"写进依赖是自欺——正确做法是重组依赖(用 ref 保存"只用于回调的最新值",或把不稳定函数放进 effect 内定义);
  • Strict Mode(开发环境)会双执行 effect:模拟"挂载→卸载→挂载",帮你暴露清理遗漏,不是 bug。

2.3 useMemo / useCallback:只挡"代价高或引用不稳定"的

jsx
const sorted = useMemo(() => heavySort(list), [list])        // 昂贵计算才用
const onClick = useCallback(() => doThing(id), [id])         // 传给 memo 子组件才用

心智锚点:memo 只是"props 引用没变就跳过子组件 render"。而父组件每次 render 都会新建函数/对象——所以 memo 必须配合 useCallback/useMemo 稳定引用才有效。纯计算不配 memo、useMemo 依赖每次都在变都是无效优化。React 19 起编译优化方向见 §6,别手工到处套。

2.4 useRef:跨渲染的可变值 + DOM 引用

jsx
const idRef = useRef(0)                    // 可变但不变不触发渲染
const inputRef = useRef(null)              // DOM 引用
// React 19:函数组件可直接接收 ref prop(不再必须 forwardRef)
function Field({ ref, ...props }) { return <input ref={ref} {...props} /> }

2.5 自定义 Hook 的组合纪律

把逻辑抽成 useXxx:内部用现有 hooks 组合,返回"值 + 操作"。命名以 use 开头(eslint 检查规则依赖它)。注意同一 hook 调用一次只服务一个逻辑——不要在 setter 里做两件事的编排,编排放调用方。

三、性能:把"重渲染"管住

3.1 自上而下的默认 vs 精确跳过

React 默认"宁可多渲染也不错漏"。优化顺序:

  1. 先确认慢在哪(Profiler / React DevTools Highlight updates)——多数场景根本不用动;
  2. 结构优化:把常变的 state 下沉到最小子树(状态放哪,影响范围就在哪);
  3. memo + 稳定引用:隔离低频子树;
  4. 极端场景(列表项成百上千且频繁更新)才考虑列表虚拟化或自行拆分。

3.2 两个"延迟/降级"并发 API

API效果典型场景
useTransition(返回 isPending把状态更新标为"可中断的低优先级"切换大表格/筛选,UI 立刻响应、完成前保留旧内容
useDeferredValue生成"延后版本"的值去渲染慢部分搜索框:输入即时高亮,结果列表用延后值渲染
jsx
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)   // 让出主线程渲染旧列表,直到不忙

并发渲染是 React 的"实现细节能力":渲染可中断、可丢弃低优先级结果。它不是把同步计算变快,而是让"慢更新"不再卡住"急更新"(输入框不丢字)。

3.3 Suspense 与数据获取

Suspense 让组件可以"挂起(suspend)"并显示 fallback,直到资源就绪:

jsx
<Suspense fallback={<Spinner />}>
  <ProfilePanel userId={id} />   {/* 内部通过 use()/数据库读取并挂起 */}
</Suspense>

配合服务端框架(Next.js App Router 等)可做流式 SSR:先发壳与关键内容,慢的数据以 HTML 流分块到达,客户端再补水合,LCP 显著改善。

3.4 库作者:useSyncExternalStore

从外部 store 读状态且要并发安全(避免 tearing),交给 useSyncExternalStore

jsx
const count = useSyncExternalStore(store.subscribe, store.getSnapshot)

应用层一般不需要直接写它——它是状态库(Zustand 等)的底层实现。

四、Actions:表单与异步更新的第一公民(React 19)

React 19 把"提交动作"标准化为 Actions——传给 transition / <form action> 的异步函数,React 自动跟踪 pending、自动重置受控表单、把错误抛给最近错误边界:

jsx
function useSubmit() {
  const [state, formAction, isPending] = useActionState(
    async (prev, formData) => {
      const res = await api.create({ title: formData.get('title') })
      return { ok: true, id: res.id }      // 返回值成为下一次 prev / state
    },
    { ok: false },
  )
  return { state, formAction, isPending }
}

function CreateForm() {
  const { state, formAction, isPending } = useSubmit()
  return (
    <form action={formAction}>
      <input name="title" required />
      <button disabled={isPending}>{isPending ? '提交中…' : '创建'}</button>
      {state.ok && <p>已创建 #{state.id}</p>}
    </form>
  )
}

配套:

  • useOptimistic:提交期间先渲染乐观值,成功后回落到服务端真实值;
  • useFormStatus表单后代组件读取父 form 的 pending(用于拆出按钮组件);
  • <form> action 是纯客户端能力;要服务端执行交给 Next.js 等的 Server Actions。

五、状态管理选型:先本地,再全局

5.1 演进阶梯(按需上,别一步到位)

层级手段何时用
1组件内 useState/useReducer + 渲染期派生状态只影响自身或子树
2状态提升 + props 下传少量兄弟/父子共享
3useContext + useReducer中等规模、读多写少(拆分 context:值与 dispatch 分开,避免全树重渲)
4外部库(Zustand / Redux Toolkit / Jotai)跨模块大范围共享、需要 devtools/持久化/时序工具
附加维度TanStack Query / SWR(服务端缓存)与后端交互的数据:请求去重、缓存、失效重取——与客户端状态正交

经验法则:多数 CRUD 界面里,真正需要"全局客户端状态"的往往只有"当前用户/主题/布局"。服务端数据请默认交给数据请求库,别用全局 store 当请求缓存(失效与并发难管)。

5.2 需要 devtools 与中间件:Redux Toolkit

项目已有成熟 RTK 基建、跨团队规范、需要时间旅行调试时选它。样板是它最大的争论点,createSlice 已大幅压缩:

js
// features/cartSlice.js(RTK 标准写法)
import { createSlice } from '@reduxjs/toolkit'
export const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [] },
  reducers: {
    add(state, action) { state.items.push(action.payload) },  // Immer 允许"写"语法
    clear() { return { items: [] } },
  },
})

5.3 轻量跨组件:Zustand(v5)

store 是普通 hook,无 Provider 包裹;基于 useSyncExternalStore,选择器化读取避免多余重渲染:

jsx
import { create } from 'zustand'
export const useCart = create((set) => ({
  count: 0,
  add: () => set((s) => ({ count: s.count + 1 })),
}))

function CartBadge() {
  const count = useCart((s) => s.count)   // 只订阅 count:变化才重渲染
  return <span>{count}</span>
}

:Zustand 不加选择器直接 useCart() 会订阅整个 store(任何字段变都重渲染);选择器返回新对象/数组(如 useCart((s) => s.items.filter(...)))时又可能死循环——用 v5 的 useShallow 或在 store 内维护派生字段。

六、React 19 特性速览与 19 之后的方向

特性一句话
Actions / useActionState / useOptimistic / useFormStatus异步更新内建 pending、乐观更新与错误处理
<form action>表单直接提交给函数(客户端动作)
ref 作为 prop函数组件免 forwardRef
use()在条件/循环里读 Context 或 Promise(配合 Suspense)
文档元数据提升组件里写的 <title>/<meta> 自动上移到 <head>
<Suspense> 为默认边界资源加载失败就近回退
React Compiler自动记忆化(memo/useMemo/useCallback 可逐步删除);以独立编译层启用,配合 ESLint 检查

未来方向一句话:Actions + Compiler 正在把"手动优化渲染"变成框架内建能力——状态管理、渲染优化的心智会继续往"默认快 + 少配置"收敛,但渲染模型与 Hooks 规则不会变。

七、常见坑速查

  1. 依赖数组漏写/乱写:闭包拿旧 state、effect 意外重跑——用 lint(exhaustive-deps)兜底,写不进去的依赖用 ref 方案而非注释;
  2. 派生状态用 effect 同步:双数据源闪屏/竞态——渲染期派生(§1.4);
  3. memo 无效:父组件每次新建 props 引用、或依赖没写全——配合 useCallback,先量再优;
  4. state 更新不生效:直接改了对象/数组没有替换引用(§2.1-3);
  5. 事件回调闭包过期:快速连续交互读到旧值——用函数式更新或 useEffectEvent(实验)/ref 保持最新;
  6. 把请求写在 useEffect + 手工取消防抖:重复挂载双发、竞态、无缓存——直接上 TanStack Query/SWR,把"取数"从手写逻辑里拿掉;
  7. setState 在 render 中同步调用:除非是官方支持的"渲染期重置派生状态"模式(比较 props 变化后 setState 并立即 return),否则死循环;
  8. 过度优化:万物 memo/useCallback,热路径之外净增心智负担——先 Profiler 定位,再动手;
  9. 把"全局 store"当唯一数据层:服务端数据进 store 导致缓存/失效自己造轮子——按 §5.1 阶梯分层。

状态与参考

下一步

  • [ ] 镜像 React 官方文档的分篇页面(类似 Vue 3 栏目),把 Hooks、并发、Actions 展开为独立子页
  • [ ] 用 TanStack Query + Zod 写一个"服务端状态管理"完整迷你示例(含失效与乐观更新)
  • [ ] 对照 VueSvelte 写一篇"信号三阵营(依赖收集/脏检查/信号)"的渲染机制对比

写作规范请参阅领域概览

基于 VitePress 构建 · 内容以知识共享方式沉淀