Skip to content

Day6 React 核心:Fiber、Diff 算法、Hooks 原理 ​

📖 基础知识讲解【基础原理 + 高级深挖知识点】 ​

基础原理 ​

1. JSX 到真实 DOM 的完整链路 ​

JSX 不是浏览器能识别的语法,它是 React.createElement 的语法糖,由 Babel 在编译期转换:

jsx
// 你写的 JSX
const el = (
  <div className="box" onClick={handleClick}>
    <span>hello</span>
    {count}
  </div>
);

// React 17 之前:Babel 编译产物
const el = React.createElement(
  "div",
  { className: "box", onClick: handleClick },
  React.createElement("span", null, "hello"),
  count
);

// React 17+ 新 JSX 转换(自动引入,不用再 import React)
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
const el = _jsxs("div", {
  className: "box",
  onClick: handleClick,
  children: [_jsx("span", { children: "hello" }), count],
});

createElement 返回的就是 ReactElement(虚拟 DOM):

js
{
  $$typeof: Symbol(react.element), // 防 XSS,JSON 无法伪造 Symbol
  type: 'div',                     // 字符串=原生标签,函数/类=组件
  key: null,
  ref: null,
  props: { className: 'box', onClick: f, children: [...] },
  _owner: FiberNode               // 创建它的那个组件 Fiber
}

完整链路(ASCII 图):

JSX
 │ Babel 编译
 ▼
React.createElement() / jsx()
 │
 ▼
ReactElement 树(虚拟 DOM,只描述「长什么样」,轻量普通对象)
 │ beginWork:根据 Element 创建 / 复用 Fiber
 ▼
Fiber 树(workInProgress,带 return/child/sibling 指针 + 副作用标记)
 │ completeWork:自下而上创建离屏 DOM、收集 effectList
 ▼
Commit 阶段(同步不可中断):before mutation → mutation → layout
 │
 ▼
真实 DOM
概念是什么关键区别
ReactElementcreateElement 的返回值不可变,每次 render 都新建;只描述 UI 结构
Fiber真实的工作单元节点可变,尽量复用;带 stateNode、memoizedState、flags
DOM浏览器真实节点挂在 Fiber 的 stateNode 上

为什么要虚拟 DOM? 不是因为「虚拟 DOM 比直接操作 DOM 快」—— 手写最优的原生 DOM 操作一定比虚拟 DOM 快。虚拟 DOM 的真正价值是:① 提供声明式编程模型,让你只关心状态不关心 DOM 操作;② 用可接受的性能换来跨平台能力(ReactDOM / ReactNative / 自定义渲染器);③ 批量 diff 后一次性提交,避免手写代码里常见的多次回流重绘。

2. React Diff 的三大原则 ​

React 把 O(n³) 的树 diff 问题,通过三条启发式假设降到了 O(n):

原则具体做法带来的代价
① 同层级比较(Tree Diff)只比较同一父节点下的同层节点,不做跨层级移动节点跨层级移动会被「删除 + 重建」,状态和 DOM 全丢
② 类型不同直接销毁重建(Component Diff)type 不同(div→p、A→B)就卸载整棵子树重建即使子树内容完全一样也会重建
③ key 作为同层唯一标识(Element Diff)同层列表用 key 判断是否可复用key 不稳定(如 index)会导致大量无效重建

单节点 diff 的判断流程:

新旧节点对比
   │
   ├─ key 不同? ────────────► 直接标记删除旧节点,创建新节点
   │
   └─ key 相同
        │
        ├─ type 不同? ──────► 删除旧节点(及整棵子树),创建新节点
        │
        └─ type 相同  ──────► ✅ 复用 Fiber,只更新 props,继续 diff children

多节点(列表)diff —— 两轮遍历:

第一轮:从左到右逐个比对(处理「更新」这种最常见的情况)
  旧: A B C D
  新: A B C E
       ✓ ✓ ✓ ✗ ← key 或 type 不匹配,立即跳出第一轮

第二轮:把剩余旧节点存进 Map(key → fiber),再遍历剩余新节点
  existingChildren = { D: fiber }
  新节点 E:Map 里找不到 → 新建
  遍历结束,Map 里剩下的 D → 标记删除

移动判断:用 lastPlacedIndex 记录「上一个可复用节点在旧列表中的位置」
  若 oldIndex < lastPlacedIndex → 标记 Placement(需要右移)
  若 oldIndex >= lastPlacedIndex → 不动,更新 lastPlacedIndex = oldIndex

React 的 diff 只做「向右移动」,不做向左移动。所以把最后一个元素移到最前面(ABCD → DABC),React 需要移动 A、B、C 三个节点;而移到最后(ABCD → BCDA)只需移动 A 一个。这是 React diff 相比 Vue3 最长递增子序列算法的一个短板,面试讲出来很加分。

3. 为什么不能用 index 作为 key ​

jsx
// ❌ 用 index:在头部插入一条数据
// 旧: [{id:1,'A'}, {id:2,'B'}]  →  key: 0, 1
// 新: [{id:3,'C'}, {id:1,'A'}, {id:2,'B'}]  →  key: 0, 1, 2
//
// React 认为:key=0 的节点内容从 A 变成了 C → 更新
//            key=1 的节点内容从 B 变成了 A → 更新
//            key=2 是新增 → 创建
// 结果:3 个节点全部重新渲染,而实际只需要插入 1 个!
{
  list.map((item, index) => <Item key={index} {...item} />);
}

// ✅ 用业务唯一 id
{
  list.map((item) => <Item key={item.id} {...item} />);
}
// React 认为:id=3 是新节点 → 插入;id=1、id=2 原样复用
危害表现
性能下降本可复用的节点被判定为「内容变化」,触发大面积更新
非受控组件状态错乱<input> 的值、<video> 播放进度、CSS 动画状态跟着「位置」而不是「数据」走
组件内部 state 串位子组件自己的 useState 被错误复用到了另一条数据上
动画异常过渡动画作用在错误的元素上

什么时候 index 当 key 是安全的? 同时满足三个条件:① 列表是纯静态的,不会增删改排序;② 列表项没有内部 state、没有非受控输入;③ 列表项不是可拖拽的。实际业务里很难同时满足,所以默认就该用业务 id。

另一个常见错误是用 Math.random() 当 key —— 每次 render 都生成新 key,等于告诉 React「所有节点都是全新的」,整个列表每次都销毁重建,比 index 还糟糕。

4. 常用 Hooks 速查表 ​

Hook作用返回值是否触发重渲染
useState声明局部状态[state, setState]✅ 值变化时
useReducer复杂状态逻辑(state 机)[state, dispatch]✅
useEffect副作用,异步执行(浏览器绘制后)——
useLayoutEffect副作用,同步执行(DOM 变更后、绘制前)—会阻塞绘制
useRef跨渲染持久化的可变容器{ current }❌ 改 .current 不触发
useMemo缓存计算结果缓存值—
useCallback缓存函数引用缓存函数—
useContext消费 Contextcontext 值✅ Provider 值变化时
useImperativeHandle配合 forwardRef 定制暴露给父组件的实例——
useId (18+)生成 SSR 一致的唯一 idstring—
useTransition (18+)标记非紧急更新[isPending, startTransition]✅
useDeferredValue (18+)延迟派生值,类似防抖延迟后的值✅
useSyncExternalStore (18+)订阅外部数据源(给状态库用)快照值✅

Hooks 的两条铁律:

jsx
// ❌ 条件调用:Hook 数量/顺序在不同渲染间不一致
function Bad({ show }) {
  const [a, setA] = useState(0);
  if (show) {
    const [b, setB] = useState(1); // 报错:Rendered fewer hooks than expected
  }
  useEffect(() => {}, []); // 这个 Hook 在两次渲染中对应到了不同的链表位置
}

// ✅ 正确:Hook 永远在顶层,条件写在 Hook 内部
function Good({ show }) {
  const [a, setA] = useState(0);
  const [b, setB] = useState(1);
  useEffect(() => {
    if (!show) return; // 条件判断放进去
    doSomething();
  }, [show]);
}
规则原因
只能在函数组件顶层调用Hook 用单向链表 + 顺序定位,不能有 if/for/return 之后的调用
只能在函数组件或自定义 Hook 中调用Hook 依赖全局 currentlyRenderingFiber,普通函数里调用取不到对应 Fiber

强烈建议开启 eslint-plugin-react-hooks,rules-of-hooks 和 exhaustive-deps 两条规则能在编译期拦掉 90% 的 Hooks 问题。

✨ 高级深挖知识点 ​

1. Fiber 架构:React 16 为什么要重写 ​

React 15(Stack Reconciler)的痛点:

递归遍历虚拟 DOM 树(函数调用栈),一旦开始无法中断
 │
 ├─ 组件树很深 / 节点很多 → 单次调和耗时 100ms+
 │
 └─ 期间主线程被完全占满
      ├─ 用户点击/输入无响应
      ├─ 动画掉帧
      └─ 浏览器无法绘制 → 页面「卡死」

浏览器一帧约 16.6ms(60fps),留给 JS 的时间只有几毫秒。React 15 的递归调和没有「暂停点」,因为函数调用栈一旦展开,中断就意味着丢失执行上下文。

Fiber 的解法:把递归改成「可中断的链表遍历」

Fiber 节点本质是一个虚拟的调用栈帧,用对象的属性保存了原本在函数调用栈里的信息,所以可以随时存盘、随时恢复:

js
function FiberNode(tag, pendingProps, key) {
  // ===== 1. 节点自身属性 =====
  this.tag = tag; // 类型:FunctionComponent=0 / ClassComponent=1 / HostComponent=5
  this.key = key;
  this.type = null; // 函数组件就是函数本身,原生标签是 'div'
  this.stateNode = null; // 对应的真实 DOM 实例 / 类组件实例

  // ===== 2. 链表指针(取代递归调用栈)=====
  this.return = null; // 父 Fiber(为什么叫 return?因为它是"归还/返回"的目标)
  this.child = null; // 第一个子 Fiber
  this.sibling = null; // 下一个兄弟 Fiber
  this.index = 0;

  // ===== 3. 工作单元数据 =====
  this.pendingProps = pendingProps; // 新的 props
  this.memoizedProps = null; // 上次渲染的 props
  this.memoizedState = null; // 上次渲染的 state(函数组件里是 Hook 链表头!)
  this.updateQueue = null; // 更新队列(环状链表)

  // ===== 4. 副作用 =====
  this.flags = NoFlags; // 自身副作用标记:Placement/Update/Deletion...
  this.subtreeFlags = NoFlags; // 子树是否有副作用(用于快速跳过整棵子树)
  this.deletions = null;

  // ===== 5. 优先级(并发特性的基础)=====
  this.lanes = NoLanes; // 本节点待处理更新的优先级
  this.childLanes = NoLanes;

  // ===== 6. 双缓存 =====
  this.alternate = null; // 指向另一棵树的对应节点
}

遍历顺序:深度优先 + 「归」阶段(这是 Fiber 最精髓的设计)

        App
       /   \
      A     B
     / \
    C   D

遍历路径(递 → 归):
  beginWork  (递):  App → A → C
  completeWork(归):  C 没有 child → 完成 C → 找 sibling D
  beginWork  (递):  D
  completeWork(归):  完成 D → 没有 sibling → 回到 return A → 完成 A
                    → A 的 sibling 是 B → beginWork(B)
  completeWork(归):  完成 B → 回到 App → 完成 App → 结束

实际顺序:App↓ A↓ C↓ C↑ D↓ D↑ A↑ B↓ B↑ App↑
阶段函数干了什么
递(beginWork)自顶向下根据新 Element 创建/复用子 Fiber,调用组件函数拿到 children,diff 打 flags
归(completeWork)自底向上创建离屏 DOM 节点、设置属性、把子树的 flags 冒泡到父节点(subtreeFlags)

关键:每处理完一个 Fiber,React 都会检查 shouldYield() —— 如果当前时间切片(5ms)用完了,就保存 workInProgress 指针并 return,把主线程交还给浏览器;下一次调度再从断点继续。这就是「时间切片」。

2. 两大阶段:Render(可中断) vs Commit(不可中断) ​

┌──────────── Render / Reconciliation 阶段(可中断、可重做、可丢弃)────────────┐
│                                                                          │
│  performConcurrentWorkOnRoot                                                 │
│    └─ workLoopConcurrent:  while (workInProgress && !shouldYield())          │
│         ├─ beginWork(fiber)      ← 创建/复用子 Fiber,执行组件函数            │
│         └─ completeWork(fiber)   ← 创建离屏 DOM,冒泡 flags                   │
│                                                                              │
│  ⚠️ 这里不能有副作用!因为可能被中断后重新执行多次                              │
└──────────────────────────────────────────────────────────────────────────────┘
                                   │ 构建完成,得到带 flags 的 workInProgress 树
                                   ▼
┌──────────── Commit 阶段(同步执行,不可中断)──────────────────────────────────┐
│                                                                              │
│  ① before mutation:getSnapshotBeforeUpdate / 调度 useEffect(异步)           │
│  ② mutation       :真正操作 DOM(插入/更新/删除)                             │
│       └─ 中间切换指针:root.current = finishedWork  ⭐ 双缓存切换点             │
│  ③ layout         :componentDidMount/Update、useLayoutEffect 回调(同步)     │
│                                                                              │
│  浏览器绘制 ──► 之后异步执行 useEffect 回调                                     │
└──────────────────────────────────────────────────────────────────────────────┘
维度Render 阶段Commit 阶段
能否中断✅ 可中断、可丢弃重做❌ 必须一次性完成
为什么工作在内存中进行,用户看不到操作真实 DOM,中断会让用户看到不完整的 UI
执行什么beginWork / completeWork、组件函数、diffDOM 增删改、生命周期、ref 挂载、effect 调度
副作用限制必须纯净(可能执行多次)可以有副作用
对应钩子render、getDerivedStateFromProps、函数组件体componentDidMount/Update、useLayoutEffect、useEffect

为什么 React 18 的严格模式(StrictMode)会故意把函数组件执行两次? 就是为了帮你发现「Render 阶段不纯净」的 bug —— 如果你在组件体里改了外部变量、发了请求、操作了 DOM,两次执行会暴露出不一致。因为并发模式下 Render 阶段真的可能被执行多次。

3. 双缓存 Fiber 树:指针切换一步到位 ​

React 同时维护两棵 Fiber 树,通过 alternate 互相指向:

          ┌──────────────┐
          │  FiberRoot   │ (整个应用唯一)
          └──────┬───────┘
                 │ current  ← 指向当前屏幕上的那棵树
                 ▼
   ┌─────────────────────┐  alternate   ┌──────────────────────────┐
   │   current 树         │ ◄──────────► │  workInProgress 树        │
   │  (屏幕上正在显示)    │              │ (内存中正在构建的新 UI)    │
   │                     │              │                          │
   │   HostRoot          │              │   HostRoot'              │
   │     └─ App          │              │     └─ App'              │
   │          └─ ...     │              │          └─ ...          │
   └─────────────────────┘              └──────────────────────────┘

构建完成后,在 commit 的 mutation 阶段结束时:
   root.current = workInProgressTree;   ← ⭐ 一行代码切换整棵树
原来的 current 树变成下次更新的 workInProgress(复用对象,避免反复 new)
好处说明
避免 UI 闪烁 / 撕裂新树在内存里构建,没构建完屏幕上一直是旧 UI;切换是原子操作
可以随时丢弃高优先级任务插队时,直接扔掉半成品 workInProgress 树重新构建,current 树毫发无损
对象复用,减少 GC两棵树的 Fiber 节点通过 alternate 来回复用,不用每次都新建一整棵树
便于对比fiber.alternate 就是上一次的自己,memoizedProps vs pendingProps 直接比

面试加分: 双缓存这个思路和图形渲染里的 double buffering 完全一致 —— 后台缓冲区画好了再 swap 到前台,避免看到绘制过程。

4. Hooks 底层:挂在 Fiber 上的单向链表 ​

Hooks 的数据不存在组件里,而是存在当前组件对应的 FiberNode 上:

js
// Hook 对象结构(简化)
const hook = {
  memoizedState: null, // 当前状态值(useState 存 state,useEffect 存 effect 对象,useRef 存 {current})
  baseState: null,     // 更新前的基础状态(用于优先级跳过时重算)
  baseQueue: null,     // 被跳过的 update 链表
  queue: null,         // 更新队列(环状链表)
  next: null,          // 指向下一个 Hook —— 关键!
};

// fiber.memoizedState 指向 Hook 链表的【头节点】
fiber.memoizedState = hook1 -> hook2 -> hook3 -> null;
function Counter() {
  const [a, setA] = useState(0);    ┐
  const [b, setB] = useState('x');  │  调用顺序 = 链表顺序
  const ref = useRef(null);         │
  useEffect(() => {}, []);          ┘
}

fiber.memoizedState
      │
      ▼
 ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐
 │ hook1    │───▶│ hook2    │───▶│ hook3    │───▶│ hook4    │──▶ null
 │ state:0  │    │ state:'x'│    │{current} │    │ effect   │
 └──────────┘    └──────────┘    └──────────┘    └──────────┘
   useState        useState         useRef         useEffect

mount 与 update 走的是两套 dispatcher:

阶段dispatcher内部函数行为
首次渲染HooksDispatcherOnMountmountWorkInProgressHook创建 Hook 对象并 next 串到链表尾部
更新渲染HooksDispatcherOnUpdateupdateWorkInProgressHook从 current.memoizedState 按顺序取下一个 Hook 复用
非法调用ContextOnlyDispatcher直接抛错在组件外调 Hook 报 "Invalid hook call"
js
// 为什么顺序不能变(本质:链表没有 key,只能靠「第几个」定位)
function Bad({ show }) {
  if (show) {
    const [a] = useState(0); // ❌ show 为 false 时这个 Hook 消失
  }
  const [b] = useState(1); // 第二次渲染时它会去读 hook1 的数据 → 错位
}

面试话术: 类组件的 state 是一个对象,用 key 取值所以顺序无关;函数组件的 Hook 是链表 + 下标,只能靠调用顺序对齐,所以 React 要求 Hook 必须在函数体顶层无条件调用。

5. useRef:跨渲染周期的「可变容器」 ​

js
// 本质极其简单
function mountRef(initialValue) {
  const hook = mountWorkInProgressHook();
  const ref = { current: initialValue };
  hook.memoizedState = ref; // 对象存在 Hook 链表上,每次渲染拿到的是【同一个引用】
  return ref;
}
方案跨渲染保持修改是否触发重渲染典型用途
普通变量 let x = 0❌ 每次渲染重置❌仅本次渲染内的临时计算
useState✅✅ 触发需要体现在 UI 上的数据
useRef✅❌ 不触发DOM 实例、定时器 id、上一次的值、是否首次渲染标记
js
// 场景 1:拿 DOM
const inputRef = useRef(null);
<input ref={inputRef} />;
inputRef.current.focus();

// 场景 2:存定时器/请求标记,避免因重渲染丢失
const timerRef = useRef(null);
useEffect(() => {
  timerRef.current = setInterval(tick, 1000);
  return () => clearInterval(timerRef.current); // 清理拿得到同一个 id
}, []);

// 场景 3:破解闭包陷阱 —— 永远拿到最新 state
const countRef = useRef(count);
countRef.current = count; // 每次渲染同步最新值
useEffect(() => {
  const t = setInterval(() => console.log(countRef.current), 1000); // ✅ 不是 0
  return () => clearInterval(t);
}, []);

// 场景 4:记录上一次的 props/state
function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  }); // effect 在渲染后执行,所以渲染中读到的是旧值
  return ref.current;
}
js
// 函数组件暴露方法给父组件:forwardRef + useImperativeHandle
const Input = forwardRef((props, ref) => {
  const inner = useRef(null);
  useImperativeHandle(
    ref,
    () => ({
      focus: () => inner.current.focus(),
      clear: () => {
        inner.current.value = "";
      },
    }),
    []
  ); // 只暴露受控的方法,而不是整个 DOM
  return <input ref={inner} />;
});

注意: 不要在渲染过程中读写 ref.current(除了初始化赋值),因为 React 并发模式下渲染可能被中断重做,会破坏纯函数假设;读写应放在 effect 或事件回调里。

6. useMemo / useCallback:缓存不等于优化 ​

js
// 两者底层几乎一样,只差「存什么」
function updateMemo(create, deps) {
  const hook = updateWorkInProgressHook();
  const prev = hook.memoizedState; // [value, deps]
  if (prev !== null && deps !== null && areHookInputsEqual(deps, prev[1])) {
    return prev[0]; // 依赖浅比较相等 → 直接复用
  }
  const value = create(); // 否则重新计算
  hook.memoizedState = [value, deps];
  return value;
}
// useCallback 存的是 [callback, deps],不调用 callback,只缓存函数引用
维度useMemouseCallback
缓存内容函数执行结果函数本身的引用
等价写法—useMemo(() => fn, deps)
适用昂贵计算、作为子组件 props 的对象/数组传给 React.memo 子组件的回调、作为其他 Hook 依赖的函数

三条判断是否该用的标准:

情况该不该用原因
子组件没包 React.memo❌ 不该用父组件重渲染子组件必然重渲染,缓存引用完全无意义
计算量很小(如 a + b)❌ 不该用缓存本身要存数组、比较依赖,成本可能大于收益
昂贵计算 / memo 子组件回调 / 作为 deps✅ 该用真正避免了重复计算或连锁重渲染
jsx
// ❌ 无效优化:Child 没有 memo,每次父更新都重渲染
const onClick = useCallback(() => {}, []);
<Child onClick={onClick} />;

// ✅ 有效:memo + 稳定引用,才能真正跳过
const Child = React.memo(({ onClick }) => <button onClick={onClick} />);

// ❌ 依赖里是每次新建的对象 —— 缓存永远失效
const style = { color: "red" };
const v = useMemo(() => calc(style), [style]);

// ✅ 把对象也缓存起来,或者只依赖原始值
const style = useMemo(() => ({ color }), [color]);

闭包陷阱的三种解法:

jsx
const [count, setCount] = useState(0);

// ❌ 陷阱:effect 只跑一次,闭包里的 count 永远是 0
useEffect(() => {
  const t = setInterval(() => setCount(count + 1), 1000);
  return () => clearInterval(t);
}, []);

// ✅ 解法 1:函数式更新(最推荐,不依赖外部变量)
setCount((c) => c + 1);

// ✅ 解法 2:useRef 同步最新值(适合要读多个最新 state 的场景)
const latest = useRef(count);
latest.current = count;

// ✅ 解法 3:补全依赖,让 effect 重建(代价是定时器反复销毁重建)
useEffect(() => {
  /* ... */
}, [count]);

面试加分: React 官方曾提过 useEvent(后改名 useEffectEvent)提案,用来得到「引用稳定但始终能读到最新值」的回调,本质就是解法 2 的官方封装。另外 React 19 的编译器(React Compiler)会自动插入 memo,手写 useMemo/useCallback 的必要性会大幅下降。

7. useEffect vs useLayoutEffect:时机差一帧 ​

Commit 阶段
┌─────────────────────────────────────────────────────────────────┐
│ before mutation  →  mutation(改真实 DOM)  →  layout            │
│                                                 │               │
│                                    useLayoutEffect 在此【同步】执行 │
│                                    (此时 DOM 已更新但【未绘制】)  │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
                     浏览器 Paint 绘制到屏幕
                              │
                              ▼
                 useEffect 在此【异步】执行(下一个宏/微任务)
维度useEffectuseLayoutEffect
执行时机浏览器绘制之后,异步DOM 变更后、绘制之前,同步
是否阻塞绘制❌ 不阻塞✅ 阻塞,耗时操作会掉帧
典型场景请求数据、订阅事件、埋点、打印日志读取 DOM 尺寸后同步改样式、避免闪烁(如 Tooltip 定位)
SSR正常(不执行)⚠️ 服务端不执行并告警,需降级为 useEffect
js
// 依赖数组三种形态
useEffect(fn); // 每次渲染后都执行
useEffect(fn, []); // 只在 mount 执行一次,unmount 执行清理
useEffect(fn, [a, b]); // a 或 b 浅比较变化时执行(先跑上次的清理函数)

// 清理函数的执行时机:① 下一次 effect 执行前 ② 组件卸载时
useEffect(() => {
  const ctrl = new AbortController();
  fetch(url, { signal: ctrl.signal }).then(setData);
  return () => ctrl.abort(); // ✅ 避免竞态 + 卸载后 setState 警告
}, [url]);

// SSR 安全写法
const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? useLayoutEffect : useEffect;

8. React 18:并发特性全景 ​

(1) 自动批处理 Automatic Batching

jsx
// React 17:只有 React 合成事件里才批处理
setTimeout(() => {
  setA(1); // 触发一次渲染
  setB(2); // 再触发一次渲染 → 共 2 次
}, 0);

// React 18(createRoot 后):Promise / setTimeout / 原生事件里也自动批处理
setTimeout(() => {
  setA(1);
  setB(2); // ✅ 合并为 1 次渲染
}, 0);

// 需要立即刷新时手动退出批处理
import { flushSync } from "react-dom";
flushSync(() => setA(1)); // 同步渲染完再往下走
对比项React 17 (ReactDOM.render)React 18 (createRoot)
合成事件内批处理✅✅
setTimeout / Promise / 原生事件❌ 逐次渲染✅ 自动批处理
并发特性❌ 不支持✅ startTransition 等生效

(2) 优先级:Lane 车道模型

Lane 用 31 位二进制表示优先级,位数越低优先级越高
SyncLane            0b0000000000000000000000000000001  离散事件(click/input)
InputContinuousLane 0b0000000000000000000000000000100  连续事件(drag/scroll)
DefaultLane         0b0000000000000000000000000010000  普通更新 / 网络请求
TransitionLane      0b0000000000000000000001000000000  startTransition 标记
IdleLane            0b0100000000000000000000000000000  空闲任务

优势:可以用位运算同时表达【一批】优先级 → 支持批量判断与饥饿问题处理

(3) 过渡更新:startTransition / useTransition / useDeferredValue

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

function onChange(e) {
  setKeyword(e.target.value); // 紧急更新:输入框必须立刻响应
  startTransition(() => {
    setList(search(e.target.value)); // 过渡更新:可被打断、可延后
  });
}
// isPending 可直接用来渲染 loading 态

// useDeferredValue:拿不到 setState 时用(比如值来自 props)
const deferredKeyword = useDeferredValue(keyword);
const list = useMemo(() => search(deferredKeyword), [deferredKeyword]);
对比防抖 DebouncestartTransition
原理定时器延迟触发,固定等待时间低优先级渲染,可被高优先级打断并随时恢复
设备快时依然要等固定时间立刻完成,无延迟
设备慢时仍可能卡顿自动让出主线程,输入始终跟手

(4) 其他变化

特性说明
createRoot入口 API 变更,不用它则降级为 Legacy 模式,并发特性全部失效
Suspense SSR支持流式 SSR + 选择性 Hydration,首屏不再被最慢的组件阻塞
useSyncExternalStore为外部状态库(Redux/Zustand)提供并发安全的订阅方式,避免撕裂(tearing)
useId生成 SSR/CSR 一致的唯一 id,解决 hydration 不匹配
StrictMode开发环境额外「挂载 → 卸载 → 再挂载」一次,提前暴露副作用未清理的问题

面试加分: 并发(Concurrent)不是多线程,JS 仍是单线程。它是把渲染任务切成小片,在每片之间通过 MessageChannel 把控制权还给浏览器,让高优先级更新插队 —— 本质是可中断 + 优先级调度。

❓ 高频面试题【基础简答 + 连环追问链 + 精简口述答案 + 项目关联话术】 ​

题 1:React Diff 算法的原理是什么?为什么不要用 index 作为 key? ​

精简口述答案:

完整的树 diff 是 O(n³),React 基于三个假设把它降到了 O(n):第一,不同类型的元素产生不同的树,type 变了就整棵子树删除重建;第二,只做同层比较,不做跨层级移动;第三,用 key 标识同层节点,帮助复用。列表 diff 分两轮:第一轮从左往右按位置比对,key 相同就复用、不同就跳出;第二轮把剩下的旧节点放进一个 key → fiber 的 Map,再遍历新节点去 Map 里找复用,用 lastPlacedIndex 判断是否需要向右移动,最后删掉 Map 里剩下的节点。index 作为 key 的问题在于它跟数据本身没有绑定关系:列表头部插入或排序后,同一个 index 对应的数据变了,React 会认为这是「同一个节点内容变了」,于是复用了旧 Fiber 和旧 DOM,导致非受控组件的输入框内容、CSS 动画状态、组件内部 state 全部错位,而且本该是移动的操作变成了大量属性更新,性能反而更差。

连环追问链:

  1. 追问:key 的最佳实践是什么?
    • 答:优先用业务唯一且稳定的 id(后端主键);没有 id 时可以用能唯一标识该条数据的字段组合;只有当列表是纯静态展示、不增删不排序、无内部状态时才可以用 index。绝对不能用 Math.random(),那样每次渲染 key 都变,所有节点全部卸载重建,等于关掉了 diff。
  2. 追问:key 只需要在兄弟节点间唯一还是全局唯一?
    • 答:只需要在同一个父节点的兄弟之间唯一即可,因为 diff 是同层进行的,React 用的是一个局部的 Map。
  3. 追问:节点跨层级移动 React 怎么处理?
    • 答:React 不处理,直接把原位置的子树删除、在新位置重新创建。所以要避免设计出会导致 DOM 层级变化的结构,比如用条件渲染包裹一层 div 把子树整体挪走,应改成用 CSS 控制或者保持层级不变。
  4. 追问:React 和 Vue3 的列表 diff 有什么区别?
    • 答:Vue3 是双端 + 最长递增子序列(LIS)。先处理头尾相同的部分,中间乱序部分建立 key 索引表,求出 LIS,不在 LIS 里的节点才移动,移动次数是理论最优。React 只有一轮从左到右的遍历,用 lastPlacedIndex 单调判断,只能向右移动,所以把最后一个元素移到第一个这种场景,React 会移动前面所有元素,而 Vue3 只移动一个。
  5. 追问:不写 key 会怎样?
    • 答:React 会用 index 兜底并在控制台告警,行为等价于 key={index},同样有上面说的错位风险。

✅ 项目关联话术(按真实经历替换):

我们商品列表支持拖拽排序,最早用的 index 作为 key,结果拖拽后每一项里的「数量输入框」内容串位了 —— 因为输入框是非受控的,DOM 被复用但数据换了。改成用商品 skuId 作为 key 之后问题消失,同时因为 React 走的是「移动」而不是「全量更新属性」,一次拖拽的 commit 耗时从 18ms 降到了 4ms 左右。

题 2:Fiber 架构解决了什么痛点?Render 和 Commit 阶段有什么区别? ​

精简口述答案:

React 15 的 Stack Reconciler 是递归遍历虚拟 DOM 树,一旦开始就无法中断,组件树一深,单次更新占用主线程几百毫秒,用户输入和动画全部卡死。Fiber 做了两件事:一是把树形递归改成了链表结构的循环,每个 FiberNode 有 return、child、sibling 三个指针,可以把「下一个要处理的节点」保存在 workInProgress 变量里,随时中断随时恢复;二是配合 Scheduler 做时间切片,每处理完一个 Fiber 就调用 shouldYield() 检查这一帧的 5ms 时间片有没有用完,用完就把控制权还给浏览器。整个更新分两个阶段:Render 阶段做 diff、打 flags、构建 workInProgress 树,它是纯计算、不碰真实 DOM,所以可中断、可丢弃、可重做;Commit 阶段遍历 effectList 真正操作 DOM,分 before mutation、mutation、layout 三个子阶段,这个阶段同步不可中断,否则用户会看到渲染到一半的界面。

连环追问链:

  1. 追问:双缓存 Fiber 树的作用是什么?
    • 答:内存中同时存在 current 树(屏幕上的)和 workInProgress 树(正在构建的),两棵树通过 alternate 互相指向。新树在内存里慢慢构建,构建失败可以直接丢弃,不影响屏幕;构建完成后 Commit 阶段只需要一句 root.current = workInProgress 完成切换,避免了用户看到中间态。跟图形学的 double buffering 是一个思路。
  2. 追问:为什么 Commit 阶段不能中断?
    • 答:Commit 阶段在真实操作 DOM,如果中断,用户会看到一部分旧 UI 一部分新 UI 的撕裂画面;而且 getSnapshotBeforeUpdate、componentDidMount、useLayoutEffect 都要求在同一帧内、DOM 更新后绘制前同步完成,中断会破坏这个语义。
  3. 追问:Render 阶段可能被重复执行,有什么实际影响?
    • 答:这就是为什么 Render 阶段必须是纯函数:不能有副作用、不能直接 setState、不能直接 mutate。React 18 的 StrictMode 在开发环境故意把渲染和 effect 执行两次,就是为了提前把不纯的代码暴露出来。
  4. 追问:Fiber 如何实现中断后恢复?
    • 答:全局变量 workInProgress 保存当前处理到的 Fiber 节点,中断时只是退出 workLoop 循环,恢复时从这个变量继续 performUnitOfWork。遍历用的是深度优先的「递」beginWork 和「归」completeWork,沿着 child → sibling → return 走,是纯指针跳转,不依赖调用栈。
  5. 追问:优先级是怎么表达的?
    • 答:React 17 之前用 expirationTime 过期时间,18 换成了 Lane 模型 —— 31 位二进制位,位越低优先级越高,可以用位运算同时表示一批优先级,便于做批量判断和解决低优先级任务饥饿的问题。

✅ 项目关联话术(按真实经历替换):

我们有个数据看板,筛选条件变化后要重渲染一张几千行的表格,输入框打字会明显掉帧。升级 React 18 用 createRoot 之后,把表格的 setState 包进 startTransition,输入框的更新保持高优先级立即响应,表格渲染变成可中断的低优先级任务,输入延迟从 200ms 左右降到了 16ms 以内,而且这不是防抖 —— 设备性能好的时候表格几乎是瞬时更新的。

题 3:为什么 Hooks 不能写在条件语句、循环里? ​

精简口述答案:

因为 Hook 的数据是以单向链表的形式挂在组件对应的 FiberNode 的 memoizedState 上的,链表节点之间只有 next 指针,没有任何名字或 key 来标识身份。首次渲染时 React 按调用顺序依次创建 Hook 节点并串成链表;更新渲染时 React 只是顺着链表按顺序往下取,取第几个完全依赖调用顺序。如果 Hook 写在 if 里,某次渲染这个 Hook 没执行,后面所有 Hook 的取值就整体错位一位,拿到的就是别人的 state,表现出来可能是报错 "Rendered fewer hooks than expected",也可能是更隐蔽的数据串位。

连环追问链:

  1. 追问:为什么类组件没有这个限制?
    • 答:类组件的 state 是一个对象,this.state.count 是按 key 取值的,跟声明顺序无关;Hook 是链表 + 隐式下标,只能靠顺序对齐。
  2. 追问:React 是怎么检测到这个错误的?
    • 答:更新阶段 updateWorkInProgressHook 发现要取下一个 Hook 但链表已经走到 null,就会抛 "Rendered more hooks than during the previous render";反之渲染结束后发现链表还有剩余,就提示 fewer hooks。另外 eslint-plugin-react-hooks 的 rules-of-hooks 会在编译期静态检查出条件调用。
  3. 追问:那我确实需要条件性的逻辑怎么办?
    • 答:把条件移到 Hook 内部,而不是包在外面。比如 useEffect(() => { if (!show) return; ... }, [show]);或者把这部分逻辑抽成一个独立子组件,用条件渲染控制子组件的挂载。
  4. 追问:自定义 Hook 里的 state 在多个组件间是共享的吗?
    • 答:不共享。自定义 Hook 只是函数复用,调用它时 Hook 节点是挂在调用方组件的 Fiber 上的,每个组件实例各有一份独立状态。要共享必须靠 Context、状态管理库,或者把 state 提到共同的父组件。
  5. 追问:同一个组件里调用两次同一个自定义 Hook 会冲突吗?
    • 答:不会,它们在链表上是两段独立的 Hook 节点,互不影响 —— 这也正说明了「顺序即身份」。

✅ 项目关联话术(按真实经历替换):

我们有个表单组件早期为了「省性能」,把 useMemo 写在了 if (isEdit) 里,结果编辑态和新增态来回切换时报了 hooks 数量不一致的错。排查完之后我们在项目里强制开启了 eslint-plugin-react-hooks,并把 rules-of-hooks 设为 error 接入 CI 的 lint 卡点,之后这类问题就彻底拦在提交前了。

题 4:useMemo / useCallback 一定能优化性能吗?闭包陷阱怎么解决? ​

精简口述答案:

不一定,用错了反而是负优化。它们本质就是把 [值, 依赖数组] 存在 Hook 链表上,每次渲染都要做一次依赖的浅比较,这个比较本身和额外的内存占用都是成本。只有三种情况值得用:一是计算确实昂贵;二是结果作为 props 传给了 React.memo 包裹的子组件,需要保持引用稳定;三是这个值本身要作为其他 Hook 的依赖项。如果子组件没包 memo,父组件重渲染时子组件照样重渲染,缓存引用毫无意义。闭包陷阱是另一个常见坑:函数组件每次渲染都会生成一个全新的作用域,如果 effect 或回调的依赖数组是空的,它捕获的就永远是首次渲染那一刻的 state。解法有三种:优先用函数式更新 setX(prev => ...),彻底不依赖外部变量;需要读多个最新值时用 useRef 在每次渲染同步最新值;或者干脆补全依赖让 effect 重建,代价是副作用会反复创建销毁。

连环追问链:

  1. 追问:React.memo 的比较规则是什么?能自定义吗?
    • 答:默认对 props 做浅比较(Object.is 逐个比较第一层)。可以传第二个参数 areEqual(prevProps, nextProps) 自定义,返回 true 表示相等、跳过渲染 —— 注意它的返回值语义和 shouldComponentUpdate 是相反的。
  2. 追问:依赖数组漏写会有什么后果?不写依赖数组呢?
    • 答:漏写会读到过期的变量(stale closure),表现为「点了没反应」或者拿到旧值;完全不写依赖数组则每次渲染后都执行,如果里面还 setState 就会死循环。
  3. 追问:依赖里是对象或数组,每次都是新引用怎么办?
    • 答:三种办法:把依赖拆成用到的原始值([obj.id, obj.name]);把对象本身也用 useMemo 缓存;或者在 effect 内部做深比较/用 JSON.stringify 后的字符串作为依赖(仅限小对象)。
  4. 追问:怎么拿到「引用稳定但总是最新」的回调?
    • 答:用 useRef 存最新的函数,再用 useCallback 返回一个只调用 ref.current(...) 的稳定包装(也就是社区的 useEvent / useLatest 方案)。React 官方的 useEffectEvent 提案就是它的标准化版本。
  5. 追问:React 19 的编译器会改变这些吗?
    • 答:会。React Compiler 会在编译期自动插入等价的 memo 逻辑,手写 useMemo / useCallback 的必要性会大幅降低,但理解依赖和闭包的原理依然必要,因为编译器也依赖你的代码是纯的。

✅ 项目关联话术(按真实经历替换):

我接手的一个页面几乎每个变量都包了 useMemo,但子组件一个 memo 都没加,profiler 上看渲染耗时没有任何改善,反而多了一堆依赖比较。我的做法是:先用 React DevTools Profiler 定位真正的渲染热点,只给确认是瓶颈的长列表子组件加 React.memo,再给传给它的回调和对象加 useCallback / useMemo,其余全部删掉。最终列表区域的重渲染次数从每次输入 1 次全量变成了 0 次,代码也简洁了很多。

题 5:useEffect 和 useLayoutEffect 的区别?什么时候必须用后者? ​

精简口述答案:

两者的 API 和依赖规则完全一样,区别只在执行时机。useLayoutEffect 在 Commit 阶段的 layout 子阶段同步执行,此时真实 DOM 已经更新但浏览器还没绘制,所以在里面读 DOM 尺寸、再同步修改样式,用户看不到中间状态;代价是它会阻塞绘制,里面放耗时逻辑会直接掉帧。useEffect 是在浏览器完成绘制之后异步执行的,不阻塞渲染,适合请求数据、订阅事件、埋点这类不影响布局的副作用。判断标准很简单:如果副作用会改变布局且用户可能看到闪烁,就用 useLayoutEffect,比如 Tooltip 根据目标元素位置定位、滚动容器恢复 scrollTop;其他情况默认用 useEffect。另外 useLayoutEffect 在 SSR 下不执行并会告警,需要降级处理。

连环追问链:

  1. 追问:清理函数(return 的函数)什么时候执行?
    • 答:两个时机 —— 下一次该 effect 执行之前(先清理旧的再执行新的),以及组件卸载时。所以定时器、事件监听、AbortController、WebSocket 都必须在清理函数里释放。
  2. 追问:StrictMode 下 effect 执行两次是 bug 吗?
    • 答:不是,是 React 18 开发环境故意的「挂载 → 卸载 → 再挂载」,用来检验你的 effect 是否可重复执行、清理函数是否写全。生产环境只执行一次。如果执行两次出问题,说明代码本身有副作用泄漏。
  3. 追问:在 useEffect 里 setState 会不会死循环?
    • 答:如果没有依赖数组,或者依赖项每次渲染都是新引用(对象/数组字面量),就会「渲染 → effect → setState → 渲染」无限循环。正确做法是补全并收窄依赖,或者加 if (next !== cur) return; 这样的短路判断。
  4. 追问:如何在 effect 里安全地发请求?
    • 答:用 AbortController 在清理函数里 abort,或者用一个 let cancelled = false 的闭包标记,在 then 回调里判断后再 setState,避免竞态(后发的请求先返回导致数据错乱)和卸载后 setState。
  5. 追问:SSR 里怎么处理?
    • 答:服务端不存在布局,React 不会执行 useLayoutEffect 并会在控制台告警。常见做法是封装 useIsomorphicLayoutEffect,根据 typeof window 判断选择 useLayoutEffect 还是 useEffect。

✅ 项目关联话术(按真实经历替换):

我们做了个气泡提示组件,需要读取触发元素的 getBoundingClientRect() 再计算气泡位置。最初用 useEffect,气泡会先在左上角 (0,0) 闪一下再跳到正确位置。换成 useLayoutEffect 之后定位在绘制前就完成了,闪烁彻底消失。同时因为项目有 SSR,我们封装了一个同构版本的 hook 来消除服务端的告警。

✍️ 手撕代码【核心实现 + 边界 Case 自测清单 + 变种拓展题】 ​

题 1:实现简易版 useState(Hook 链表版) ​

核心实现:

js
// ============ 模拟 Fiber 与调度 ============
const fiber = {
  memoizedState: null, // Hook 链表头
  stateNode: null, // 组件函数
};
let workInProgressHook = null; // 当前正在处理的 Hook
let isMount = true; // 是否首次挂载

// 取出(或创建)当前这个 Hook —— 顺序即身份
function getHook() {
  if (isMount) {
    const hook = {
      memoizedState: null,
      queue: { pending: null }, // 环状更新链表
      next: null,
    };
    if (fiber.memoizedState === null) {
      fiber.memoizedState = hook; // 链表头
    } else {
      workInProgressHook.next = hook; // 串到尾部
    }
    workInProgressHook = hook;
    return hook;
  }
  // 更新阶段:按顺序往下取
  const hook =
    workInProgressHook === null ? fiber.memoizedState : workInProgressHook.next;
  workInProgressHook = hook;
  return hook;
}

// ============ useState ============
function useState(initialState) {
  const hook = getHook();

  if (isMount) {
    // 惰性初始化:支持传函数
    hook.memoizedState =
      typeof initialState === "function" ? initialState() : initialState;
  } else {
    // 消费更新队列
    let newState = hook.memoizedState;
    const pending = hook.queue.pending;
    if (pending !== null) {
      let update = pending.next; // 环状链表的第一个
      do {
        const action = update.action;
        // 支持函数式更新 setState(prev => next)
        newState = typeof action === "function" ? action(newState) : action;
        update = update.next;
      } while (update !== pending.next);
      hook.queue.pending = null; // 清空队列
    }
    hook.memoizedState = newState;
  }

  return [hook.memoizedState, dispatchAction.bind(null, hook.queue)];
}

function dispatchAction(queue, action) {
  const update = { action, next: null };
  // 维护成环状链表,pending 指向最后一个
  if (queue.pending === null) {
    update.next = update;
  } else {
    update.next = queue.pending.next;
    queue.pending.next = update;
  }
  queue.pending = update;

  schedule(); // 触发重新渲染
}

// ============ 渲染入口 ============
function schedule() {
  workInProgressHook = null; // 关键:每次渲染前重置指针
  const app = fiber.stateNode();
  isMount = false;
  return app;
}

// ============ 使用 ============
function App() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState(() => "hi"); // 惰性初始化
  console.log("render:", count, text);
  return {
    add: () => setCount((c) => c + 1),
    setText: (v) => setText(v),
  };
}
fiber.stateNode = App;
let app = schedule(); // render: 0 hi
app.add(); // render: 1 hi

✅ 边界 Case 自测:

  • 一个组件里调用多个 useState,更新其中一个,其余状态是否保持正确?
  • useState(() => expensive()) 惰性初始化:初始函数是否只在 mount 执行一次?
  • setCount(5) 直接传值 与 setCount(c => c + 1) 函数式更新都要支持。
  • 同一次事件中连续调用三次 setCount(c => c + 1),结果应是 +3 而不是 +1(环状队列按序消费)。
  • 新旧 state 用 Object.is 判断相等时应跳过本次渲染(eagerState 优化)。
  • 每次渲染前必须把 workInProgressHook 重置为 null,否则第二次渲染会从链表尾部继续取,直接取到 null。
  • 在条件语句里调用会导致链表错位 —— 可以手动验证一下报错现象。
  • 组件卸载后调用 setState 应被忽略,不应报错或内存泄漏。

🔁 变种拓展题:

js
// 变种 1:useReducer —— useState 其实就是它的特例
function useReducer(reducer, initialArg, init) {
  const hook = getHook();
  if (isMount) {
    hook.memoizedState = init ? init(initialArg) : initialArg;
  } else {
    let newState = hook.memoizedState;
    const pending = hook.queue.pending;
    if (pending !== null) {
      let update = pending.next;
      do {
        newState = reducer(newState, update.action); // 唯一区别:交给 reducer
        update = update.next;
      } while (update !== pending.next);
      hook.queue.pending = null;
    }
    hook.memoizedState = newState;
  }
  return [hook.memoizedState, dispatchAction.bind(null, hook.queue)];
}
// useState = useReducer(basicStateReducer, initialState)
// basicStateReducer = (state, action) => typeof action === 'function' ? action(state) : action;

// 变种 2:批量更新(模拟 React 18 自动批处理)
let isBatching = false;
const dirtyQueue = new Set();

function batchedUpdates(fn) {
  isBatching = true;
  try {
    fn();
  } finally {
    isBatching = false;
    flush();
  }
}

function requestUpdate(fiber) {
  dirtyQueue.add(fiber);
  if (isBatching) return; // 批处理中:只收集,不渲染
  queueMicrotask(flush); // React 18 用微任务兜底,让同步代码里的多次更新合并
}

function flush() {
  dirtyQueue.forEach((f) => render(f));
  dirtyQueue.clear();
}

题 2:实现简易版 useRef / useMemo / useCallback / useEffect ​

核心实现:

js
// 复用题 1 的 getHook()、isMount

// ============ 依赖浅比较 ============
function areDepsEqual(nextDeps, prevDeps) {
  if (prevDeps === null || nextDeps === null) return false;
  if (nextDeps.length !== prevDeps.length) return false;
  for (let i = 0; i < nextDeps.length; i++) {
    if (!Object.is(nextDeps[i], prevDeps[i])) return false; // 注意是浅比较
  }
  return true;
}

// ============ useRef ============
function useRef(initialValue) {
  const hook = getHook();
  if (isMount) {
    hook.memoizedState = { current: initialValue }; // 只创建一次
  }
  return hook.memoizedState; // 每次返回【同一个】对象引用
}

// ============ useMemo ============
function useMemo(create, deps) {
  const hook = getHook();
  const nextDeps = deps === undefined ? null : deps;

  if (!isMount) {
    const prev = hook.memoizedState; // [value, deps]
    if (prev !== null && nextDeps !== null && areDepsEqual(nextDeps, prev[1])) {
      return prev[0]; // 命中缓存
    }
  }
  const nextValue = create(); // 未命中 or 首次:重新计算
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

// ============ useCallback:只是不执行 create ============
function useCallback(callback, deps) {
  const hook = getHook();
  const nextDeps = deps === undefined ? null : deps;

  if (!isMount) {
    const prev = hook.memoizedState;
    if (prev !== null && nextDeps !== null && areDepsEqual(nextDeps, prev[1])) {
      return prev[0];
    }
  }
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

// ============ useEffect:收集 effect,渲染后统一执行 ============
const effectQueue = [];

function useEffect(create, deps) {
  const hook = getHook();
  const nextDeps = deps === undefined ? null : deps;
  const prev = hook.memoizedState; // { create, destroy, deps }

  if (!isMount && prev !== null && areDepsEqual(nextDeps, prev.deps)) {
    hook.memoizedState = { ...prev, deps: nextDeps }; // 依赖未变,跳过
    return;
  }

  const effect = {
    create,
    destroy: prev ? prev.destroy : undefined,
    deps: nextDeps,
  };
  hook.memoizedState = effect;
  effectQueue.push(effect); // 本次需要执行
}

// 渲染提交后调用:先清理上一次,再执行本次
function flushEffects() {
  effectQueue.forEach((effect) => {
    if (typeof effect.destroy === "function") effect.destroy();
    effect.destroy = effect.create(); // 返回值作为下次的清理函数
  });
  effectQueue.length = 0;
}

✅ 边界 Case 自测:

  • deps 传 undefined(不传)→ 每次渲染都重新计算 / 每次都执行 effect。
  • deps 传 [] → 只在首次计算一次,之后永远命中缓存。
  • deps 长度变化(如从 [a] 变成 [a, b])→ 应判定为不相等并重算。
  • 依赖项是 NaN → 必须用 Object.is 而不是 ===,否则 NaN !== NaN 导致缓存永久失效。
  • 依赖项是对象字面量 → 每次都是新引用,缓存必然失效,需要提醒使用者。
  • useRef 返回的对象在多次渲染间必须是同一引用;修改 .current 不应触发渲染。
  • useEffect 的清理函数要在下次执行前和卸载时都被调用,且只调用一次。
  • useCallback 不应执行传入的函数,只缓存引用。

🔁 变种拓展题:

jsx
// 变种 1:手写 React.memo 的浅比较
function shallowEqual(objA, objB) {
  if (Object.is(objA, objB)) return true;
  if (typeof objA !== "object" || objA === null) return false;
  if (typeof objB !== "object" || objB === null) return false;

  const keysA = Object.keys(objA);
  const keysB = Object.keys(objB);
  if (keysA.length !== keysB.length) return false;

  return keysA.every(
    (key) =>
      Object.prototype.hasOwnProperty.call(objB, key) &&
      Object.is(objA[key], objB[key])
  );
}

function memo(Component, areEqual = shallowEqual) {
  let prevProps = null;
  let prevResult = null;
  return function MemoComponent(props) {
    if (prevProps !== null && areEqual(prevProps, props)) {
      return prevResult; // 命中:复用上次的渲染结果
    }
    prevProps = props;
    prevResult = Component(props);
    return prevResult;
  };
}

// 变种 2:usePrevious / useLatest / useDebounce
function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  }); // 渲染后才赋值,所以渲染中读到的是上一次的值
  return ref.current;
}

function useLatest(value) {
  const ref = useRef(value);
  ref.current = value; // 每次渲染同步,专治闭包陷阱
  return ref;
}

function useDebounce(fn, delay) {
  const fnRef = useLatest(fn);
  const timer = useRef(null);
  useEffect(() => () => clearTimeout(timer.current), []); // 卸载清理
  return useCallback(
    (...args) => {
      clearTimeout(timer.current);
      timer.current = setTimeout(() => fnRef.current(...args), delay);
    },
    [delay]
  ); // 引用稳定,但永远调用最新的 fn
}

题 3:实现时间切片(Fiber 调度的核心思想) ​

核心实现:

js
// React 没用 requestIdleCallback(兼容性差、触发频率不稳定),
// 而是用 MessageChannel 产生【宏任务】,优于 setTimeout(0) 的 4ms 最小延迟

const channel = new MessageChannel();
const port = channel.port2;

let nextUnitOfWork = null; // 保存中断点 —— 可恢复的关键
let taskQueue = [];
let deadline = 0;
const FRAME_BUDGET = 5; // React 默认一帧让出前最多占用 5ms

function shouldYield() {
  return performance.now() >= deadline; // 时间片用尽就让出主线程
}

function performUnitOfWork(work) {
  work.doWork(); // 执行一个最小工作单元(对应一个 Fiber 的 beginWork)
  return work.next; // 返回下一个单元;返回 null 表示整棵树处理完
}

function workLoop() {
  while (nextUnitOfWork !== null && !shouldYield()) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }
  if (nextUnitOfWork !== null) {
    schedulePerformWork(); // 还没做完 → 下一帧继续(恢复)
  } else {
    commitRoot(); // 全部完成 → 进入不可中断的 commit
  }
}

channel.port1.onmessage = () => {
  deadline = performance.now() + FRAME_BUDGET;
  workLoop();
};

function schedulePerformWork() {
  port.postMessage(null); // 作为宏任务排到下一轮,浏览器有机会绘制与响应输入
}

function commitRoot() {
  console.log("commit: 同步操作 DOM,不可中断");
}

// ============ 使用:构造一条 10000 个单元的链表 ============
function buildWork(n) {
  let head = null;
  for (let i = n - 1; i >= 0; i--) {
    head = { doWork: () => heavyCalc(i), next: head };
  }
  return head;
}
nextUnitOfWork = buildWork(10000);
schedulePerformWork(); // 页面不会卡死,输入依然跟手

✅ 边界 Case 自测:

  • 工作单元数量极大(如 10 万)时,页面仍能响应点击和输入(对比一下 for 循环同步执行的卡死效果)。
  • 单个工作单元本身超过 5ms(如一次 50ms 的计算)→ 时间切片无法拆分单个任务,仍会掉帧,这正是 Fiber 要求把工作拆到足够细的原因。
  • 中断后恢复时,nextUnitOfWork 必须准确指向上次的断点,不能重复执行也不能漏掉。
  • 高优先级任务插入时,应能打断当前低优先级循环(可扩展一个按 priority 排序的小顶堆任务队列)。
  • commitRoot 期间不应检查 shouldYield,必须一次性同步做完。

🔁 变种拓展题:

js
// 变种:用 requestIdleCallback 的降级版(并说明为何 React 弃用它)
const scheduleWork =
  typeof MessageChannel !== "undefined"
    ? schedulePerformWork
    : (cb) =>
        requestIdleCallback((idle) => {
          // ① 触发频率最低只有 20FPS,不足以支撑动画
          // ② 不同浏览器实现不一致,Safari 长期不支持
          while (idle.timeRemaining() > 1 && nextUnitOfWork) {
            nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
          }
        });

⚠️ 当日踩坑实录 ​

常见坑解决方案
❌ Hook 写在 if / for / return 之后,报 "Rendered fewer hooks than expected"✅ Hook 必须在函数体顶层无条件调用;把条件判断移到 Hook 内部,或抽成子组件用条件渲染控制
❌ 列表用 index 作为 key,排序/插入后输入框内容、勾选状态串位✅ 用业务唯一且稳定的 id;只有纯静态、不增删不排序的列表才可用 index
❌ 用 Math.random() 当 key,列表每次全量重建、动画闪烁✅ key 必须稳定,随机值等于主动关闭 diff 复用
❌ 给子组件加了 useCallback 但没发现性能提升✅ 必须配合 React.memo 才有意义;没有 memo 时父更新子必更新
❌ 给所有变量无脑包 useMemo,反而变慢✅ 只在「昂贵计算 / memo 子组件 props / 作为其他 Hook 依赖」三种场景使用
❌ useEffect(fn, []) 里读到的 state 永远是初始值(闭包陷阱)✅ 用函数式更新 setX(prev => ...),或 useRef 同步最新值,或补全依赖
❌ useEffect 依赖项是对象/数组字面量,每次都是新引用导致死循环✅ 依赖拆成原始值 [obj.id],或用 useMemo 缓存该对象
❌ useEffect 里 setState 且没写依赖数组 → 无限渲染✅ 补全依赖数组;或在 setState 前做 Object.is 短路判断
❌ useState(someFunction) 本意是存函数,结果被当成惰性初始化执行了✅ 存函数要写 useState(() => someFunction)
❌ 直接 state.push(x) / obj.a = 1 后 setState,界面不更新✅ state 必须不可变更新:setList([...list, x])、setObj({ ...obj, a: 1 })
❌ setCount(count + 1) 连写三次只加了 1✅ 同一批次里 count 是同一个闭包值,改用 setCount(c => c + 1)
❌ setState 之后立刻 console.log(state) 打印的还是旧值✅ state 在下次渲染才更新;要拿新值放到 useEffect 里,或用局部变量先算好
❌ 改了 ref.current 但界面没变✅ ref 的设计就是不触发渲染;需要驱动 UI 请用 useState
❌ 在渲染过程中读写 ref.current 导致并发模式下行为诡异✅ 只在 effect 或事件回调中读写 ref
❌ useLayoutEffect 在 SSR 下报 warning✅ 封装 useIsomorphicLayoutEffect,服务端降级为 useEffect
❌ Tooltip / 弹层定位先闪到 (0,0) 再归位✅ 改用 useLayoutEffect,在浏览器绘制前完成位置计算
❌ 组件卸载后 setState,报 "Can't perform a React state update on an unmounted component"✅ 在清理函数里 abort() 请求、clearInterval、取消订阅
❌ 快速切换筛选条件,后发请求先返回导致数据错乱(竞态)✅ 用 AbortController 或 let cancelled = false 标记,在清理函数中置位
❌ StrictMode 下 effect 执行两次,以为是 bug✅ 这是 React 18 故意的,用来暴露未清理的副作用;写好清理函数即可,生产环境只执行一次
❌ 升级 React 18 后仍没有自动批处理和并发特性✅ 入口必须换成 createRoot,ReactDOM.render 会降级为 Legacy 模式
❌ React.memo 的 areEqual 返回值写反,组件完全不更新✅ 返回 true 表示相等、跳过渲染,与 shouldComponentUpdate 语义相反
❌ 用防抖来解决大列表输入卡顿,设备好时依然有固定延迟✅ 用 startTransition / useDeferredValue,让渲染可被打断而不是无脑延迟

📌 当日复盘任务 ​

  • [ ] 能手绘 JSX → React.createElement → ReactElement → Fiber → 真实 DOM 的完整链路图
  • [ ] 能说清 Diff 三大原则,以及列表 diff 两轮遍历与 lastPlacedIndex 的作用
  • [ ] 能对比 React 与 Vue3 的列表 diff 差异(单向移动 vs 双端 + LIS)
  • [ ] 能解释为什么不能用 index / 随机数作为 key,并举出一个真实的错位场景
  • [ ] 能默写 FiberNode 的核心字段(return / child / sibling / flags / lanes / alternate)
  • [ ] 能讲清 Render 阶段可中断、Commit 阶段不可中断的原因,以及 commit 三个子阶段
  • [ ] 能画出双缓存 Fiber 树并说明 root.current 切换的时机
  • [ ] 能画出 Hook 单向链表结构,并解释「顺序即身份」导致的调用限制
  • [ ] 能区分 useState / useRef / 普通变量的持久化与触发渲染特性
  • [ ] 能说出useMemo / useCallback 真正有收益的三种场景及其成本
  • [ ] 能说出闭包陷阱的三种解法,并手写一个 useLatest
  • [ ] 能画出 useEffect 与 useLayoutEffect 相对于浏览器绘制的时序
  • [ ] 能解释 React 18 自动批处理与 17 的差异,以及 startTransition 为什么不是防抖
  • [ ] 手写:Hook 链表版 useState(支持多 Hook、惰性初始化、函数式更新)
  • [ ] 手写:useMemo / useCallback / useRef / useEffect 与依赖浅比较
  • [ ] 手写:基于 MessageChannel 的时间切片 workLoop + shouldYield
  • [ ] 用 React DevTools Profiler 在自己项目里定位一次真实的重渲染热点并优化