--- id: frontend.react.hooks-rendering title: React 渲染机制、Hooks 规则、状态管理和 React 19 新特性 type: lesson domain: frontend stack: [react] tags: [React, Hooks, useEffect, useReducer, 渲染, memo, 并发特性, React 19, React Compiler] level: intermediate prerequisites: [frontend.typescript.type-system] est_hours: 3 related: [frontend.react.scenario.long-task-ui, frontend.ssr.nextjs-app-router, frontend.react.interview] week: [7] order: 1 --- # React 渲染机制、Hooks 规则、状态管理和 React 19 新特性 > 基于 React 19 编写,以官方文档为准:https://zh-hans.react.dev/ 。如果你主要用 Vue,这一课帮你建立 React 的心智模型,面试时能对比两者。 ## 解释说明 ### 渲染机制 - **组件每次渲染都会重新执行函数体**。状态变化(`setState`)、父组件渲染、Context 变化都会触发渲染。 - 渲染 → 生成新的元素树 → 和上一次比较(协调,reconciliation)→ 提交最小的 DOM 变更。列表的 `key` 决定元素的身份。 - 状态更新是**批量的**(React 18 起在 Promise、定时器中也自动批量)。 - 状态是**快照**:在一次渲染中读取的 state 是固定的,`setCount(count + 1)` 连续调用三次只加 1,要用函数式更新 `setCount(c => c + 1)`。 ### Hooks 规则和常见坑 | Hook | 要点 | | --- | --- | | `useState` / `useReducer` | 复杂状态转换用 reducer,便于测试(见示例) | | `useEffect` | 用于**与外部系统同步**(订阅、请求、DOM 操作),返回清理函数;依赖数组要完整;开发模式下严格模式会执行“挂载 → 卸载 → 挂载”帮助发现缺少清理的问题 | | `useMemo` / `useCallback` / `memo` | 性能优化手段,不是语义保证;React Compiler 可以自动做记忆化(以官方文档为准) | | `useRef` | 保存不触发渲染的可变值、DOM 引用 | | 自定义 Hook | 复用有状态逻辑;必须以 `use` 开头,只在顶层调用 | **规则**:只在组件或自定义 Hook 的顶层调用 Hook,不能放在条件、循环里——因为 React 按调用顺序识别每个 Hook 的状态。 **很多 `useEffect` 是不需要的**:从 props 计算出来的值直接在渲染中计算;用户事件引起的逻辑放在事件处理函数里。 ### 状态管理选择 局部状态 `useState`;跨组件共享且变化不频繁用 Context;复杂的客户端状态用 Zustand / Redux Toolkit;**服务端数据**(请求、缓存、重试、失效)用 TanStack Query 等,而不是放进全局 store。 ### React 19 和并发特性(了解) - 并发特性:`useTransition`、`useDeferredValue` 把非紧急更新标记为可中断,保持输入流畅。 - React 19:Actions(异步函数作为 `action`,配合 `useActionState`、`useOptimistic` 处理表单提交、待定状态和乐观更新)、`use` 读取 Promise / Context、`ref` 可以作为普通 prop 传递。 - 服务端组件(Server Components)主要通过 Next.js 等框架使用,见 [Next.js 课程](../ssr/02-nextjs-app-router.md)。 ## 案例 报表 Agent 的任务面板:任务有排队、运行、等待输入、成功、失败、取消等状态,服务端通过 SSE 推送事件,网络重连后可能收到重复或乱序的事件。用 `useReducer` 集中管理状态转换,事件带序号,旧事件和终态之后的事件直接丢弃;订阅在 `useEffect` 中建立并在任务切换 / 卸载时取消。 ## 代码示例 reducer(完整版本 [examples/task-panel/taskReducer.ts](examples/task-panel/taskReducer.ts),`node taskReducer-demo.ts` 自检): ```ts export function taskReducer(s: TaskState, e: TaskEvent): TaskState { switch (e.type) { case "submitted": return { ...initialState, id: e.id, status: "queued" }; case "cancel": return FINAL.has(s.status) ? s : { ...s, status: "cancelled" }; case "retry": return s.status === "failed" ? { ...s, status: "queued", error: null } : s; default: if (FINAL.has(s.status) || e.seq <= s.version) return s; // 丢弃重复、乱序、迟到的事件 } ... } ``` 组件([TaskPanel.tsx](examples/task-panel/TaskPanel.tsx),严格模式 `tsc` 检查通过): ```tsx const [state, dispatch] = useReducer(taskReducer, initialState); useEffect(() => { dispatch({ type: "submitted", id: taskId }); const unsubscribe = subscribe(taskId, dispatch); // dispatch 引用稳定 return unsubscribe; // taskId 变化或卸载时取消订阅 }, [taskId, subscribe]); ``` ## 易错点 - `useEffect` 依赖数组漏写,读到陈旧的值;或者为了“只执行一次”故意写空数组。 - 订阅、定时器、请求没有清理,切换页面后旧数据覆盖新数据(竞态)。 - 在渲染中直接修改 state 对象(`state.list.push(x)`),引用没变,不触发渲染。 - 用数组下标作为可重排列表的 `key`。 - 父组件每次渲染都创建新的对象 / 函数作为 props,导致 `memo` 子组件失效。 - 把服务端数据放进 Redux,自己处理缓存、失效、重试,代码复杂且容易出错。 ## 面试常问 - React 组件什么时候会重新渲染?怎么避免不必要的渲染? - 为什么 Hooks 不能写在条件语句里? - `useEffect` 的执行时机和清理时机? - React 和 Vue 的响应式 / 更新机制有什么区别?