Appearance
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| 概念 | 是什么 | 关键区别 |
|---|---|---|
| ReactElement | createElement 的返回值 | 不可变,每次 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 = oldIndexReact 的 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 | 消费 Context | context 值 | ✅ Provider 值变化时 |
useImperativeHandle | 配合 forwardRef 定制暴露给父组件的实例 | — | — |
useId (18+) | 生成 SSR 一致的唯一 id | string | — |
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、组件函数、diff | DOM 增删改、生命周期、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 useEffectmount 与 update 走的是两套 dispatcher:
| 阶段 | dispatcher | 内部函数 | 行为 |
|---|---|---|---|
| 首次渲染 | HooksDispatcherOnMount | mountWorkInProgressHook | 创建 Hook 对象并 next 串到链表尾部 |
| 更新渲染 | HooksDispatcherOnUpdate | updateWorkInProgressHook | 从 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,只缓存函数引用| 维度 | useMemo | useCallback |
|---|---|---|
| 缓存内容 | 函数执行结果 | 函数本身的引用 |
| 等价写法 | — | 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 在此【异步】执行(下一个宏/微任务)| 维度 | useEffect | useLayoutEffect |
|---|---|---|
| 执行时机 | 浏览器绘制之后,异步 | 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]);| 对比 | 防抖 Debounce | startTransition |
|---|---|---|
| 原理 | 定时器延迟触发,固定等待时间 | 低优先级渲染,可被高优先级打断并随时恢复 |
| 设备快时 | 依然要等固定时间 | 立刻完成,无延迟 |
| 设备慢时 | 仍可能卡顿 | 自动让出主线程,输入始终跟手 |
(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 全部错位,而且本该是移动的操作变成了大量属性更新,性能反而更差。
连环追问链:
- 追问:key 的最佳实践是什么?
- 答:优先用业务唯一且稳定的 id(后端主键);没有 id 时可以用能唯一标识该条数据的字段组合;只有当列表是纯静态展示、不增删不排序、无内部状态时才可以用 index。绝对不能用
Math.random(),那样每次渲染 key 都变,所有节点全部卸载重建,等于关掉了 diff。
- 答:优先用业务唯一且稳定的 id(后端主键);没有 id 时可以用能唯一标识该条数据的字段组合;只有当列表是纯静态展示、不增删不排序、无内部状态时才可以用 index。绝对不能用
- 追问:key 只需要在兄弟节点间唯一还是全局唯一?
- 答:只需要在同一个父节点的兄弟之间唯一即可,因为 diff 是同层进行的,React 用的是一个局部的 Map。
- 追问:节点跨层级移动 React 怎么处理?
- 答:React 不处理,直接把原位置的子树删除、在新位置重新创建。所以要避免设计出会导致 DOM 层级变化的结构,比如用条件渲染包裹一层 div 把子树整体挪走,应改成用 CSS 控制或者保持层级不变。
- 追问:React 和 Vue3 的列表 diff 有什么区别?
- 答:Vue3 是双端 + 最长递增子序列(LIS)。先处理头尾相同的部分,中间乱序部分建立 key 索引表,求出 LIS,不在 LIS 里的节点才移动,移动次数是理论最优。React 只有一轮从左到右的遍历,用
lastPlacedIndex单调判断,只能向右移动,所以把最后一个元素移到第一个这种场景,React 会移动前面所有元素,而 Vue3 只移动一个。
- 答:Vue3 是双端 + 最长递增子序列(LIS)。先处理头尾相同的部分,中间乱序部分建立 key 索引表,求出 LIS,不在 LIS 里的节点才移动,移动次数是理论最优。React 只有一轮从左到右的遍历,用
- 追问:不写 key 会怎样?
- 答:React 会用 index 兜底并在控制台告警,行为等价于
key={index},同样有上面说的错位风险。
- 答:React 会用 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 三个子阶段,这个阶段同步不可中断,否则用户会看到渲染到一半的界面。
连环追问链:
- 追问:双缓存 Fiber 树的作用是什么?
- 答:内存中同时存在 current 树(屏幕上的)和 workInProgress 树(正在构建的),两棵树通过
alternate互相指向。新树在内存里慢慢构建,构建失败可以直接丢弃,不影响屏幕;构建完成后 Commit 阶段只需要一句root.current = workInProgress完成切换,避免了用户看到中间态。跟图形学的 double buffering 是一个思路。
- 答:内存中同时存在 current 树(屏幕上的)和 workInProgress 树(正在构建的),两棵树通过
- 追问:为什么 Commit 阶段不能中断?
- 答:Commit 阶段在真实操作 DOM,如果中断,用户会看到一部分旧 UI 一部分新 UI 的撕裂画面;而且
getSnapshotBeforeUpdate、componentDidMount、useLayoutEffect都要求在同一帧内、DOM 更新后绘制前同步完成,中断会破坏这个语义。
- 答:Commit 阶段在真实操作 DOM,如果中断,用户会看到一部分旧 UI 一部分新 UI 的撕裂画面;而且
- 追问:Render 阶段可能被重复执行,有什么实际影响?
- 答:这就是为什么 Render 阶段必须是纯函数:不能有副作用、不能直接 setState、不能直接 mutate。React 18 的 StrictMode 在开发环境故意把渲染和 effect 执行两次,就是为了提前把不纯的代码暴露出来。
- 追问:Fiber 如何实现中断后恢复?
- 答:全局变量
workInProgress保存当前处理到的 Fiber 节点,中断时只是退出workLoop循环,恢复时从这个变量继续performUnitOfWork。遍历用的是深度优先的「递」beginWork和「归」completeWork,沿着 child → sibling → return 走,是纯指针跳转,不依赖调用栈。
- 答:全局变量
- 追问:优先级是怎么表达的?
- 答: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",也可能是更隐蔽的数据串位。
连环追问链:
- 追问:为什么类组件没有这个限制?
- 答:类组件的 state 是一个对象,
this.state.count是按 key 取值的,跟声明顺序无关;Hook 是链表 + 隐式下标,只能靠顺序对齐。
- 答:类组件的 state 是一个对象,
- 追问:React 是怎么检测到这个错误的?
- 答:更新阶段
updateWorkInProgressHook发现要取下一个 Hook 但链表已经走到 null,就会抛 "Rendered more hooks than during the previous render";反之渲染结束后发现链表还有剩余,就提示 fewer hooks。另外eslint-plugin-react-hooks的rules-of-hooks会在编译期静态检查出条件调用。
- 答:更新阶段
- 追问:那我确实需要条件性的逻辑怎么办?
- 答:把条件移到 Hook 内部,而不是包在外面。比如
useEffect(() => { if (!show) return; ... }, [show]);或者把这部分逻辑抽成一个独立子组件,用条件渲染控制子组件的挂载。
- 答:把条件移到 Hook 内部,而不是包在外面。比如
- 追问:自定义 Hook 里的 state 在多个组件间是共享的吗?
- 答:不共享。自定义 Hook 只是函数复用,调用它时 Hook 节点是挂在调用方组件的 Fiber 上的,每个组件实例各有一份独立状态。要共享必须靠 Context、状态管理库,或者把 state 提到共同的父组件。
- 追问:同一个组件里调用两次同一个自定义 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 重建,代价是副作用会反复创建销毁。
连环追问链:
- 追问:
React.memo的比较规则是什么?能自定义吗?- 答:默认对 props 做浅比较(
Object.is逐个比较第一层)。可以传第二个参数areEqual(prevProps, nextProps)自定义,返回 true 表示相等、跳过渲染 —— 注意它的返回值语义和shouldComponentUpdate是相反的。
- 答:默认对 props 做浅比较(
- 追问:依赖数组漏写会有什么后果?不写依赖数组呢?
- 答:漏写会读到过期的变量(stale closure),表现为「点了没反应」或者拿到旧值;完全不写依赖数组则每次渲染后都执行,如果里面还 setState 就会死循环。
- 追问:依赖里是对象或数组,每次都是新引用怎么办?
- 答:三种办法:把依赖拆成用到的原始值(
[obj.id, obj.name]);把对象本身也用useMemo缓存;或者在 effect 内部做深比较/用JSON.stringify后的字符串作为依赖(仅限小对象)。
- 答:三种办法:把依赖拆成用到的原始值(
- 追问:怎么拿到「引用稳定但总是最新」的回调?
- 答:用
useRef存最新的函数,再用useCallback返回一个只调用ref.current(...)的稳定包装(也就是社区的useEvent/useLatest方案)。React 官方的useEffectEvent提案就是它的标准化版本。
- 答:用
- 追问:React 19 的编译器会改变这些吗?
- 答:会。React Compiler 会在编译期自动插入等价的 memo 逻辑,手写
useMemo/useCallback的必要性会大幅降低,但理解依赖和闭包的原理依然必要,因为编译器也依赖你的代码是纯的。
- 答:会。React Compiler 会在编译期自动插入等价的 memo 逻辑,手写
✅ 项目关联话术(按真实经历替换):
我接手的一个页面几乎每个变量都包了
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 下不执行并会告警,需要降级处理。
连环追问链:
- 追问:清理函数(return 的函数)什么时候执行?
- 答:两个时机 —— 下一次该 effect 执行之前(先清理旧的再执行新的),以及组件卸载时。所以定时器、事件监听、AbortController、WebSocket 都必须在清理函数里释放。
- 追问:StrictMode 下 effect 执行两次是 bug 吗?
- 答:不是,是 React 18 开发环境故意的「挂载 → 卸载 → 再挂载」,用来检验你的 effect 是否可重复执行、清理函数是否写全。生产环境只执行一次。如果执行两次出问题,说明代码本身有副作用泄漏。
- 追问:在
useEffect里 setState 会不会死循环?- 答:如果没有依赖数组,或者依赖项每次渲染都是新引用(对象/数组字面量),就会「渲染 → effect → setState → 渲染」无限循环。正确做法是补全并收窄依赖,或者加
if (next !== cur) return;这样的短路判断。
- 答:如果没有依赖数组,或者依赖项每次渲染都是新引用(对象/数组字面量),就会「渲染 → effect → setState → 渲染」无限循环。正确做法是补全并收窄依赖,或者加
- 追问:如何在 effect 里安全地发请求?
- 答:用
AbortController在清理函数里 abort,或者用一个let cancelled = false的闭包标记,在 then 回调里判断后再 setState,避免竞态(后发的请求先返回导致数据错乱)和卸载后 setState。
- 答:用
- 追问:SSR 里怎么处理?
- 答:服务端不存在布局,React 不会执行
useLayoutEffect并会在控制台告警。常见做法是封装useIsomorphicLayoutEffect,根据typeof window判断选择useLayoutEffect还是useEffect。
- 答:服务端不存在布局,React 不会执行
✅ 项目关联话术(按真实经历替换):
我们做了个气泡提示组件,需要读取触发元素的
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 在自己项目里定位一次真实的重渲染热点并优化