React 原理
🔥 Fiber 架构
要解决什么问题:React 15 的 Stack Reconciler 用递归遍历虚拟 DOM 树,递归无法中断。组件树一深,一次更新可能占用主线程几百毫秒,期间浏览器无法渲染和响应输入 —— 页面卡死。
Fiber 的解法:
- 把递归改成链表遍历 —— 每个 Fiber 节点有
child(第一个子节点)、sibling(下一个兄弟)、return(父节点)三个指针,可以用循环代替递归 - 于是可以随时中断 —— 处理完一个 Fiber 节点就检查「这一帧还有时间吗」,没时间就记下当前位置,把主线程还给浏览器,下一帧继续
- 优先级调度 —— 高优先级更新(用户输入)可以插队,打断低优先级的渲染(数据加载)
Fiber 节点是什么 —— 既是「虚拟 DOM 节点」(描述 UI),也是「工作单元」(记录要做什么),还存了组件的状态(hooks 链表、effect 链表)。
两个阶段:
| 阶段 | 做什么 | 能否中断 |
|---|---|---|
| Render / Reconcile | 遍历 Fiber 树,diff,标记需要的 DOM 操作(flags) | ✅ 可中断、可丢弃、可重做 |
| Commit | 把标记的 DOM 操作一次性应用到真实 DOM | ❌ 同步且不可中断(否则会看到不完整的 UI) |
双缓冲(double buffering) —— 内存里同时存在两棵树:current(当前屏幕上的)和 workInProgress(正在构建的)。构建完成后只需切换根节点指针(fiberRoot.current = workInProgress),这个切换是原子的。两棵树通过 alternate 属性互相指向,可以复用节点对象减少 GC。
:::warning 为什么 Render 阶段的副作用是危险的
Render 阶段可能被打断后重新执行,所以在 render 函数或 useMemo 里做副作用(请求、修改外部变量、console.log 计数)可能执行多次。
这也是 StrictMode 在开发环境故意双调用 render、useState 初始化函数、useEffect 的原因 —— 帮你提前暴露这类问题。很多人以为是 bug,实际是刻意设计。
:::
🔥 Diff 算法
虚拟 DOM 树的完整 diff 是 O(n³),React 用三条启发式规则降到 O(n):
- 只做同层比较 —— 不跨层级移动节点。如果一个节点换了父节点,React 会删除旧的、创建新的,而不是移动
- 类型不同直接重建 ——
div变成span,整棵子树卸载重建(组件状态全丢) - 用
key识别同层节点 —— 这是列表 diff 的关键
为什么不能用 index 作为 key(必答题):
原列表:[A, B, C] key = 0, 1, 2
删除 A:[B, C] key = 0, 1
React 会认为「key=0 的内容从 A 变成了 B」「key=1 的从 B 变成 C」「key=2 被删除」—— 实际上是三次更新而不是一次删除。后果:
- 性能变差(本该复用的节点被更新)
- 组件内部状态错位 —— 输入框的值、动画状态、未提交的表单会跟错行
列表有增删或排序时必须用稳定的唯一 ID。纯静态列表用 index 无妨。
React 的列表 diff 策略 —— 先用 key 建 map,然后遍历新列表:能复用就复用(必要时记录移动),不能就新建;最后删除旧列表里没被复用的。注意 React 只做「向右移动」的优化,所以把最后一个元素移到最前面的代价比反过来大。
🔥 Hooks 原理
Hooks 存在哪 —— 每个函数组件对应的 Fiber 节点上有 memoizedState 属性,指向一个单向链表,链表的每个节点是一个 hook:
fiber.memoizedState → hook1 → hook2 → hook3 → null
(useState)(useEffect)(useMemo)
为什么有「两条规则」:
只能在函数组件最顶层调用 Hooks,不能在条件、循环、嵌套函数里调用。
因为 Hooks 是靠「调用顺序」和链表位置对应的,没有名字。如果:
if (cond) {
const [a] = useState(1) // ❌ 第一次渲染 cond 为真,第二次为假
}
const [b] = useState(2) // 第二次渲染时 b 会读到 a 的位置
链表错位,状态全乱。这是 ESLint 规则 react-hooks/rules-of-hooks 存在的原因。
useState 的关键行为
更新是异步批处理的:
const [n, setN] = useState(0)
const onClick = () => {
setN(n + 1)
setN(n + 1)
console.log(n) // 0,不是 1 —— 本次渲染里 n 是闭包捕获的旧值
}
// 结果 n = 1,不是 2
修复用函数式更新:setN(prev => prev + 1) 两次 —— 它从队列里的最新值算,结果是 2。
React 18 的自动批处理 —— 17 及之前只在 React 事件处理器里批处理,setTimeout、Promise.then、原生事件里的多次 setState 会触发多次渲染。18 的并发特性下所有场景都批处理。要跳过批处理用 flushSync。
setState 后立刻读不到新值 —— 因为状态更新会触发重新渲染,新值在下一次渲染的闭包里。需要在更新后做事用 useEffect 监听该状态。
useEffect
useEffect(() => {
// 副作用
return () => { /* 清理 */ }
}, [deps])
执行时机:
| Hook | 时机 | 用途 |
|---|---|---|
useEffect | DOM 更新后、浏览器绘制后(异步) | 大部分副作用:请求、订阅、日志 |
useLayoutEffect | DOM 更新后、绘制前(同步,阻塞绘制) | 需要读取布局并同步修改(避免闪烁),如测量元素位置后调整 |
清理函数什么时候执行 —— ① 组件卸载时;② 下一次 effect 执行之前(依赖变化时先清理上次的)。这是避免「订阅泄漏」和「竞态请求」的关键。
闭包陷阱是 Hooks 最常见的坑:
useEffect(() => {
const id = setInterval(() => console.log(count), 1000) // 永远打印初始值
return () => clearInterval(id)
}, []) // 空依赖导致闭包捕获的 count 永远是第一次的值
三种解法:把 count 加进依赖(但会重建定时器)、用函数式更新 setCount(c => c + 1)、用 useRef 存最新值。
竞态请求:
useEffect(() => {
let cancelled = false
fetch(url).then(r => { if (!cancelled) setData(r) })
return () => { cancelled = true } // 或用 AbortController
}, [url])
不加这个清理,快速切换 url 时先发的请求可能后返回,覆盖掉正确的数据。
useMemo / useCallback / memo
| 作用 | |
|---|---|
useMemo(fn, deps) | 缓存计算结果 |
useCallback(fn, deps) | 缓存函数引用 |
React.memo(Comp) | props 浅比较相同时跳过子组件重渲染 |
三者通常要配合使用才有效:
// ❌ memo 失效:每次父组件渲染都生成新的 onClick 引用
<Child onClick={() => {}} />
// ✅
const onClick = useCallback(() => {}, [])
<Child onClick={onClick} /> // Child 用 React.memo 包裹
:::warning 不要滥用
useMemo/useCallback 本身有成本(存储 + 依赖比较)。对于简单计算和没有被 memo 子组件消费的函数,加了只是增加开销和代码噪音。
判断标准:① 计算确实昂贵;② 或者引用稳定性被下游依赖(memo 子组件的 props、其他 hook 的依赖项)。
React 19 的编译器(React Compiler)会自动做这件事,手动 memo 化在新项目里会变得不必要。面试提到这个能显示对版本演进的跟进。 :::
useRef
两个用途:① 访问 DOM(ref={myRef});② 存一个不触发重渲染的可变值(像实例变量)。
useRef 的 .current 改变不会触发渲染,这既是优点(存定时器 ID、上一次的值)也是陷阱(改了它页面不更新)。
合成事件
React 不把事件绑在具体元素上,而是在根容器上做事件委托(React 17 之前绑在 document,17 之后绑在 React 树的根 DOM 容器)。
为什么这么做 —— 减少内存占用、统一跨浏览器差异、便于实现批处理和优先级调度。
和原生事件的区别:
- 执行顺序 —— 原生事件先于 React 合成事件(因为合成事件要冒泡到根容器才触发)
e.stopPropagation()阻止不了原生监听器(反之原生的stopPropagation能阻止合成事件 —— 事件没冒泡到根容器)- 17 之前有事件池(
e会被复用,异步访问要e.persist()),17 已移除事件池,不需要了
状态管理对比
| 方案 | 适合 | 特点 |
|---|---|---|
useState / useReducer | 组件内状态 | 最简单,优先用 |
| Context | 低频变化的全局状态(主题、用户信息、i18n) | ⚠️ Context 值变化会让所有消费者重渲染,不适合高频更新 |
| Redux (Toolkit) | 大型应用的复杂状态流转 | 可预测、DevTools 时间旅行、中间件生态。样板代码多(RTK 已大幅简化) |
| Zustand | 中小型应用 | 极简 API,不需要 Provider,按需订阅避免无关渲染 |
| Jotai / Recoil | 原子化状态,细粒度更新 | 自底向上,适合状态间有派生关系的场景 |
| TanStack Query / SWR | 服务端数据 | 这是独立的一类 —— 缓存、重验证、乐观更新、竞态处理都内置了 |
:::tip 一个值得主动说的观点 「服务端数据」和「客户端状态」应该分开管理。 过去把 API 数据塞进 Redux 是常见做法,但它需要手写 loading/error/缓存/失效逻辑。
现在的共识是:服务端数据用 TanStack Query / SWR(它们专门解决缓存和同步问题),真正的客户端状态(表单草稿、UI 开关、主题)才用 Zustand / Context。
这个区分能显著减少状态管理的复杂度,也是面试里能体现工程判断的点。 :::
其他高频点
受控 vs 非受控组件 —— 受控(值由 React state 驱动,value + onChange)便于校验和联动;非受控(用 defaultValue + ref 读取)性能好、代码少。大表单可以用非受控 + 库(react-hook-form)避免每次输入都重渲染。
为什么需要虚拟 DOM —— 常见答案是「操作真实 DOM 慢」,但更准确的是:① 提供了声明式的编程模型(你描述 UI 应该是什么样,而不是怎么一步步改);② 跨平台(同一套描述可以渲染到 DOM、Native、Canvas);③ 批量和最小化 DOM 操作。注意虚拟 DOM 不一定比手写的精确 DOM 操作快,它换来的是可维护性。
React 18 的并发特性 —— useTransition(把更新标记为低优先级,不阻塞输入)、useDeferredValue(延迟一个值的更新)、Suspense 配合数据获取、startTransition。核心价值是「让紧急更新(输入)插队到非紧急更新(列表过滤)前面」。
RSC(React Server Components) —— 组件在服务端执行并把结果序列化传给客户端,不占客户端 bundle。详见Next.js 与 SSR。
React 和 Vue 的核心差异 —— React 是「不可变 + 重新渲染 + diff」,Vue 是「可变 + 依赖追踪 + 精确更新」(Vue 3 用 Proxy 做响应式,知道具体哪个数据变了、哪些组件依赖它)。所以 Vue 通常不需要手动 memo 化,React 需要(直到有编译器)。代价是 Vue 的响应式系统有运行时开销和一些需要注意的边界(数组/新增属性的处理,Vue 3 用 Proxy 后已大幅改善)。