跳到主要内容

React 原理

🔥 Fiber 架构​

要解决什么问题:React 15 的 Stack Reconciler 用递归遍历虚拟 DOM 树,递归无法中断。组件树一深,一次更新可能占用主线程几百毫秒,期间浏览器无法渲染和响应输入 —— 页面卡死。

Fiber 的解法:

  1. 把递归改成链表遍历 —— 每个 Fiber 节点有 child(第一个子节点)、sibling(下一个兄弟)、return(父节点)三个指针,可以用循环代替递归
  2. 于是可以随时中断 —— 处理完一个 Fiber 节点就检查「这一帧还有时间吗」,没时间就记下当前位置,把主线程还给浏览器,下一帧继续
  3. 优先级调度 —— 高优先级更新(用户输入)可以插队,打断低优先级的渲染(数据加载)

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):

  1. 只做同层比较 —— 不跨层级移动节点。如果一个节点换了父节点,React 会删除旧的、创建新的,而不是移动
  2. 类型不同直接重建 —— div 变成 span,整棵子树卸载重建(组件状态全丢)
  3. 用 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时机用途
useEffectDOM 更新后、浏览器绘制后(异步)大部分副作用:请求、订阅、日志
useLayoutEffectDOM 更新后、绘制前(同步,阻塞绘制)需要读取布局并同步修改(避免闪烁),如测量元素位置后调整

清理函数什么时候执行 —— ① 组件卸载时;② 下一次 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 容器)。

为什么这么做 —— 减少内存占用、统一跨浏览器差异、便于实现批处理和优先级调度。

和原生事件的区别:

  1. 执行顺序 —— 原生事件先于 React 合成事件(因为合成事件要冒泡到根容器才触发)
  2. e.stopPropagation() 阻止不了原生监听器(反之原生的 stopPropagation 能阻止合成事件 —— 事件没冒泡到根容器)
  3. 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 后已大幅改善)。