05-react
面向大厂 P6 前端面试的体系化答案。题目来源:快手、飞书、PDD 真实面经。 每题结构:【核心答案】(30 秒口述版)→ 【展开】(原理 + 可运行代码)→ 【追问 & 加分点】。
React
面向大厂 P6 前端面试的体系化答案。题目来源:快手、飞书、PDD 真实面经。 每题结构:【核心答案】(30 秒口述版)→ 【展开】(原理 + 可运行代码)→ 【追问 & 加分点】。
目录
- 1. React Hook 的规则,以及为什么有这些规则
- 2. useEffect 第二个参数的三种情况与清理函数时机
- 3. React.memo 与 useMemo 的区别,useCallback + memo 的正确姿势
- 4. 父子组件传值:props、回调、ref、Context
- 5. 自定义 Hook 的设计:useCountdown 与 useRequest
- 6. Fiber 架构:动机、数据结构、双缓存、更新流程
- 7. Concurrent 并发特性与 RSC 服务端组件
- 8. React 的 diff 策略与 key
1. React Hook 的规则,以及为什么有这些规则
【核心答案】
两条规则:只在最顶层调用 Hook(不能放在条件、循环、嵌套函数、return 之后);只在 React 函数组件或自定义 Hook 中调用 Hook(不能在普通函数、类组件、事件回调里调用)。
根本原因是:React 并没有把 Hook 的状态按"名字"存起来,而是把它们按调用顺序串成一条单向链表挂在当前组件对应的 Fiber 节点的 memoizedState 上。每次渲染时 React 只是从头到尾按顺序取链表节点,靠"第几次调用"来对应"第几个状态"。一旦条件分支让某次渲染少调用或多调用了一个 Hook,后面所有 Hook 的下标就整体错位,useState 拿到别人的 state、useEffect 拿到别人的依赖数组,行为完全错乱。
【展开】
1.1 状态存在哪里
函数组件本身没有实例,状态挂在 Fiber 上:
FiberNode (函数组件)
├── memoizedState ──> Hook1 ──next──> Hook2 ──next──> Hook3 ──next──> null
│ │ │
│ │ └── memoizedState: 依赖数组 + effect 对象
│ └── memoizedState: state 值
│ queue: 更新队列(环形链表)
└── ...每个 Hook 节点的简化结构(对应 React 源码 ReactFiberHooks.js):
const hook = {
memoizedState: null, // useState 存 state;useEffect 存 effect 对象;useMemo 存 [value, deps]
baseState: null, // 计算新 state 的基础值
baseQueue: null, // 上次渲染被跳过的更新(优先级不足时留下的)
queue: null, // 本次的更新队列
next: null, // 指向下一个 Hook,构成单向链表
};1.2 手写一个"迷你 Hook",直观解释为什么顺序不能变
// ===== 极简版实现:只演示"按下标取状态"这一核心机制 =====
let hookStates = []; // 相当于 fiber.memoizedState 链表
let hookIndex = 0; // 相当于遍历链表的游标
function useState(initial) {
const currentIndex = hookIndex; // 关键:靠"第几次调用"定位自己的槽位
hookStates[currentIndex] =
hookStates[currentIndex] === undefined ? initial : hookStates[currentIndex];
const setState = (action) => {
const prev = hookStates[currentIndex];
hookStates[currentIndex] = typeof action === 'function' ? action(prev) : action;
render(); // 触发重新渲染
};
hookIndex++; // 游标后移,下一个 Hook 用下一个槽位
return [hookStates[currentIndex], setState];
}
function render() {
hookIndex = 0; // 每次渲染都从头开始遍历,这就是"顺序必须稳定"的原因
App();
}
// ❌ 错误写法:条件调用
function App() {
const [count, setCount] = useState(0);
if (count > 0) {
// 第一次渲染 count===0,不执行;第二次 count===1,执行
// 于是 name 在第一次渲染时占用槽位 1,第二次渲染时槽位 1 被 name 占用、
// 而原本槽位 1 的 age 被挤到槽位 2 —— 整条链表错位
const [name] = useState('kwai');
}
const [age, setAge] = useState(18);
}React 真实实现里,更新阶段的 updateWorkInProgressHook 会沿着 currentHook.next 走。如果本次渲染多调用了一个 Hook,走到链表尾部会拿到 null,直接抛出经典报错:
Rendered more hooks than during the previous render.
1.3 第二条规则:只在 React 函数中调用
React 用一个全局变量 ReactCurrentDispatcher.current 指向当前的 dispatcher。只有在 renderWithHooks 执行组件函数期间,它才被赋值为 HooksDispatcherOnMount / HooksDispatcherOnUpdate;其余时间是 ContextOnlyDispatcher,调用任何 Hook 都会报 "Invalid hook call"。
// react-reconciler/src/ReactFiberHooks.js(简化)
function renderWithHooks(current, workInProgress, Component, props) {
currentlyRenderingFiber = workInProgress;
workInProgress.memoizedState = null; // 清空,准备重建链表
ReactCurrentDispatcher.current =
current === null ? HooksDispatcherOnMount : HooksDispatcherOnUpdate;
const children = Component(props); // ← 只有这一行执行期间,Hook 才是合法的
ReactCurrentDispatcher.current = ContextOnlyDispatcher; // 出了这里就报错
return children;
}这条规则还有一个副作用是保证 React 知道状态属于哪个组件:currentlyRenderingFiber 是全局的,在事件回调里调用 Hook 时这个变量指向的是上一次渲染的残留或 null,状态无处安放。
1.4 合法的"条件"写法
规则限制的是 Hook 的调用,不是它的行为。把条件挪进 Hook 内部即可:
function Panel({ visible, id }) {
// ✅ Hook 无条件调用,条件写在内部
const [detail, setDetail] = useState(null);
useEffect(() => {
if (!visible) return; // 条件写在 effect 内部
fetchDetail(id).then(setDetail);
}, [visible, id]);
// ✅ 提前 return 必须放在所有 Hook 之后
if (!visible) return null;
return <div>{detail?.name}</div>;
}如果确实需要"某个分支才有状态",正确做法是抽成子组件,让条件出现在 JSX 层面(组件的挂载/卸载),而不是 Hook 层面。
【追问 & 加分点】
- 为什么不用 key/名字而用顺序? Dan Abramov 在 RFC 里解释过:如果按名字,每个 Hook 都要传一个唯一字符串,自定义 Hook 组合时会出现命名冲突,且无法自动处理同一个自定义 Hook 被调用两次的情况。按调用顺序的方案让自定义 Hook 天然可组合、可嵌套、可复用,代价就是这两条规则。而这两条规则可以被
eslint-plugin-react-hooks静态检查覆盖,属于"用工具兜底换 API 简洁"。 - eslint-plugin-react-hooks 怎么判断"这是不是 React 函数"? 靠命名约定:大写字母开头(组件)或
use开头(自定义 Hook)。所以自定义 Hook 必须以use开头,不是审美要求,而是 lint 规则的判定依据。 - React 19 的
use()是个例外:use(promise)/use(context)可以在条件语句中调用,因为它不依赖 Hook 链表下标(不在链表上分配节点,靠 Suspense 机制和 context 栈工作)。面试时能主动提一句会加分。 - StrictMode 下组件函数会执行两次(开发环境),就是为了暴露 Hook 里的副作用和非幂等逻辑。
2. useEffect 第二个参数的三种情况与清理函数时机
【核心答案】
- 不传第二个参数:每次渲染(包括首次)完成后都执行 effect。
- 传空数组
[]:只在组件挂载后执行一次,卸载时执行清理。 - 传依赖数组
[a, b]:挂载后执行一次;之后每次渲染用Object.is逐项浅比较依赖,只要有一项变了就先执行上一次的清理函数、再执行新的 effect。
清理函数的执行时机有两个:下一次 effect 执行之前(先清理旧的再跑新的)和组件卸载时。注意 useEffect 是异步的,在浏览器绘制(paint)之后执行;useLayoutEffect 是同步的,在 DOM 变更后、浏览器绘制之前执行,会阻塞绘制。
【展开】
2.1 三种情况的完整行为表
| 第二参数 | 首次渲染 | 后续渲染 | 清理函数触发时机 |
|---|---|---|---|
不传(undefined) | 执行 | 每次都执行 | 每次 effect 前 + 卸载时 |
[] | 执行 | 不执行 | 仅卸载时 |
[a, b] | 执行 | 任一依赖变化时执行 | 依赖变化的那次 effect 前 + 卸载时 |
React 内部的比较逻辑就是浅比较:
// react-reconciler/src/ReactFiberHooks.js
function areHookInputsEqual(nextDeps, prevDeps) {
if (prevDeps === null) return false; // 上次没传依赖 → 永远不相等 → 每次都执行
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
if (is(nextDeps[i], prevDeps[i])) continue; // Object.is
return false;
}
return true;
}关键点:用的是 Object.is(引用比较),不是深比较。 所以依赖里放对象字面量、数组字面量、内联函数,每次渲染都是新引用,等价于"没传依赖"。
2.2 一个能跑起来的完整验证 Demo
import { useState, useEffect } from 'react';
function EffectDemo() {
const [count, setCount] = useState(0);
const [other, setOther] = useState(0);
// 情况一:不传依赖 —— 每次渲染后都跑
useEffect(() => {
console.log('[A] 每次渲染都执行, count =', count);
return () => console.log('[A] 清理, 上一次的 count =', count);
});
// 情况二:空数组 —— 只在挂载后跑一次
useEffect(() => {
console.log('[B] 只在挂载时执行一次');
const timer = setInterval(() => console.log('tick'), 1000);
// 卸载时清理,必须写,否则组件卸载后定时器还在跑 → 内存泄漏
return () => {
console.log('[B] 卸载时清理');
clearInterval(timer);
};
}, []);
// 情况三:有依赖 —— count 变才跑,other 变不跑
useEffect(() => {
console.log('[C] count 变化时执行, count =', count);
const controller = new AbortController();
fetch(`/api/item/${count}`, { signal: controller.signal })
.then((r) => r.json())
.catch((e) => {
if (e.name !== 'AbortError') throw e;
});
// 清理:取消上一次未完成的请求,避免竞态(老请求后返回覆盖新数据)
return () => controller.abort();
}, [count]);
return (
<>
<button onClick={() => setCount((c) => c + 1)}>count: {count}</button>
<button onClick={() => setOther((o) => o + 1)}>other: {other}</button>
</>
);
}点击 other 按钮时的控制台顺序是:
[A] 清理, 上一次的 count = 0 ← 先清理旧的
[A] 每次渲染都执行, count = 0 ← 再执行新的清理函数里拿到的是上一次渲染的闭包变量,这点非常容易被追问。
2.3 依赖数组的三类经典坑
坑一:空数组闭包陷阱(stale closure)
// ❌ 定时器里的 count 永远是 0
useEffect(() => {
const t = setInterval(() => {
setCount(count + 1); // 闭包捕获的是首次渲染的 count = 0
}, 1000);
return () => clearInterval(t);
}, []);
// ✅ 方案一:函数式更新,不依赖外部 count
useEffect(() => {
const t = setInterval(() => setCount((c) => c + 1), 1000);
return () => clearInterval(t);
}, []);
// ✅ 方案二:把 count 放进依赖(代价是每秒重建定时器)
useEffect(() => {
const t = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(t);
}, [count]);
// ✅ 方案三:用 ref 存最新值(适合"我要读最新值但不想重跑 effect")
const latest = useRef(count);
latest.current = count;
useEffect(() => {
const t = setInterval(() => console.log(latest.current), 1000);
return () => clearInterval(t);
}, []);坑二:对象/数组/函数依赖导致的无限循环
// ❌ options 每次渲染都是新对象 → effect 每次都执行 → setState → 再渲染 → 死循环
function List({ pageSize }) {
const options = { pageSize, sort: 'desc' };
useEffect(() => {
fetchList(options).then(setData);
}, [options]);
}
// ✅ 依赖原始值
useEffect(() => {
fetchList({ pageSize, sort: 'desc' }).then(setData);
}, [pageSize]);
// ✅ 或者用 useMemo 稳定引用
const options = useMemo(() => ({ pageSize, sort: 'desc' }), [pageSize]);坑三:useCallback 的依赖差异
useCallback(fn, deps) 本质就是 useMemo(() => fn, deps),它缓存的是函数引用,不是执行结果:
// 不传第二参数 —— 等于没优化,每次渲染都返回新函数
const f1 = useCallback(() => doSth(a)); // deps 为 undefined,永远判定"依赖变了"
// 空数组 —— 函数引用永远不变,但闭包里的 a 永远停留在首次渲染的值
const f2 = useCallback(() => doSth(a), []); // ⚠️ stale closure
// 有依赖 —— a 变了才生成新函数,闭包里的 a 是最新的(推荐)
const f3 = useCallback(() => doSth(a), [a]);useCallback 空数组和 useEffect 空数组的坑是同源的,都是闭包捕获问题;区别在于 useEffect 的表现是"副作用用了旧值",useCallback 的表现是"传给子组件的回调用了旧值",后者更隐蔽,因为它往往几次点击之后才暴露。
2.4 执行时机:useEffect vs useLayoutEffect
React 渲染完成
→ commit mutation 阶段:真实 DOM 被修改
→ useLayoutEffect 同步执行(DOM 已更新,但屏幕还没画) ← 可以读布局、改 DOM,不会闪烁
→ 浏览器 paint(用户看到画面)
→ useEffect 异步执行(下一帧或调度空闲时) ← 不阻塞绘制判断标准:如果 effect 会同步修改 DOM 并且用户可能看到中间态(比如测量宽高后调整位置),用 useLayoutEffect;其余一律用 useEffect。
【追问 & 加分点】
- React 18 StrictMode 开发环境下,
[]的 effect 会执行两次(mount → unmount → mount),这是刻意设计,用来检验清理函数是否写全,为将来的 Offscreen / 状态保留特性做准备。生产环境只执行一次。有候选人会误以为是 bug 去加useRef做"只跑一次"的标记,这是反模式。 - 依赖数组长度必须稳定:不能写
useEffect(fn, cond ? [a] : [a, b]),React 会警告并可能比较错位。 - effect 不是"渲染后的回调",而是"提交后的副作用"。React 官方文档明确表示:如果只是根据 props/state 算出新的渲染数据,应该在渲染期间直接算或者用
useMemo,不要useEffect+setState,那会多一次渲染并造成闪烁。 - React 19 新增
useEffectEvent(实验性):把"读最新值但不作为依赖"这件事标准化,解决前面坑一/坑三的场景,不用再手写 ref。 - 清理函数在并发渲染下的语义:React 只保证 commit 过的 effect 会被清理;被中断丢弃的 render 结果不会产生 effect,所以不用担心"渲染了但没提交"造成的泄漏。
3. React.memo 与 useMemo 的区别,useCallback + memo 的正确姿势
【核心答案】
三者作用对象不同:
React.memo(Component)是高阶组件,包裹一个组件,对新旧 props 做浅比较,相等则跳过这个组件的重新渲染(复用上次的 Fiber 与输出)。useMemo(fn, deps)是 Hook,缓存一次计算的结果值,依赖不变就不重新计算。useCallback(fn, deps)是useMemo(() => fn, deps)的语法糖,缓存的是函数本身的引用。
组合的正确姿势:React.memo 的浅比较,只要有一个 prop 是每次新建的对象/函数就会失效。所以给被 memo 包裹的子组件传函数时要用 useCallback 稳定引用,传对象/数组时要用 useMemo 稳定引用——三者必须成套使用,缺一个就白干。
不该用的时候:子组件本身很轻(几个 DOM 节点)、props 里必然含有每次都变的值、或者组件本来就很少重渲染。这时 memo 的浅比较开销 + useCallback/useMemo 的缓存开销 + 额外的内存占用,是净亏损。
【展开】
3.1 三者的区别一张表
| API | 类型 | 缓存的东西 | 比较方式 | 典型用途 |
|---|---|---|---|---|
React.memo | 高阶组件 | 组件的渲染输出 | 对 props 逐 key 做 Object.is 浅比较 | 阻止子组件被父组件的无关更新带着重渲染 |
useMemo | Hook | 任意计算结果 | 对 deps 浅比较 | 昂贵计算、稳定对象/数组引用 |
useCallback | Hook | 函数引用 | 对 deps 浅比较 | 稳定回调引用,配合 memo / effect 依赖 |
3.2 一个完整的、正确的优化示例
import { memo, useCallback, useMemo, useState } from 'react';
// ---------- 子组件:用 memo 包裹 ----------
const TodoItem = memo(function TodoItem({ todo, onToggle, style }) {
console.log('渲染 TodoItem:', todo.id);
return (
<li style={style} onClick={() => onToggle(todo.id)}>
{todo.done ? '✅' : '⬜'} {todo.text}
</li>
);
});
// ---------- 父组件 ----------
export default function TodoList() {
const [todos, setTodos] = useState(() =>
Array.from({ length: 1000 }, (_, i) => ({ id: i, text: `任务 ${i}`, done: false }))
);
const [keyword, setKeyword] = useState(''); // 与列表项渲染无关的高频状态
// ✅ 用 useCallback 稳定回调引用
// 依赖数组为空是安全的:这里用了函数式更新,没有捕获任何外部变量
const handleToggle = useCallback((id) => {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
}, []);
// ✅ 用 useMemo 稳定对象引用(否则 style 每次都是新对象,memo 直接失效)
const itemStyle = useMemo(() => ({ cursor: 'pointer', lineHeight: 1.8 }), []);
// ✅ 昂贵计算用 useMemo 缓存:keyword 变才重算
const filtered = useMemo(
() => todos.filter((t) => t.text.includes(keyword)),
[todos, keyword]
);
return (
<>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<ul>
{filtered.map((todo) => (
<TodoItem
key={todo.id}
todo={todo} // 引用只在这一项被修改时才变(map 只重建被改的那项?不,见下)
onToggle={handleToggle}
style={itemStyle}
/>
))}
</ul>
</>
);
}注意上面
setTodos里的map只对被点击的那一项创建了新对象,其他项保持原引用,所以 999 个未变的TodoItem的todoprop 引用不变,memo生效,只有 1 个重渲染。这就是"不可变更新 + memo"配套的意义:不可变更新保证"变的才变引用",memo 才能利用这一点。
3.3 常见的"白优化"反面教材
// ❌ 反例 1:memo 了,但每次传新函数
<TodoItem onToggle={(id) => handleToggle(id)} /> // 内联箭头函数,每次都是新引用
// ❌ 反例 2:memo 了,但每次传新对象
<TodoItem style={{ color: 'red' }} config={{ a: 1 }} />
// ❌ 反例 3:memo 了,但传了 children
<Card>{<Foo />}</Card> // children 是 React Element,每次 createElement 都是新对象
// ❌ 反例 4:useCallback 了,但父组件没有 memo 子组件
// useCallback 单独用对渲染性能没有任何帮助,反而多一次依赖比较3.4 React.memo 的第二个参数
const Row = memo(
function Row({ data }) {
return <div>{data.name}</div>;
},
// 自定义比较函数:返回 true 表示"相等,跳过渲染"(注意与 shouldComponentUpdate 语义相反)
(prevProps, nextProps) => prevProps.data.id === nextProps.data.id
);慎用:写深比较(如 lodash.isEqual)往往比直接重渲染更贵,而且很容易因为漏比某个字段导致 UI 不更新——这是线上非常难查的 bug。
3.5 什么时候不该用
- 子组件很轻:渲染一个
<span>{text}</span>的组件,浅比较 props 的成本和渲染成本在同一量级,memo 是纯负担。 - props 必然每次都变:比如子组件接收
Date.now()、每次新建的 children、来自上层 context 的变化值。memo 永远返回 false,只增加了一次比较。 - 组件本来就在叶子节点且父组件很少更新。
useMemo缓存的计算本身很便宜:useMemo(() => a + b, [a, b])的缓存开销(创建数组、浅比较、多一个 Hook 链表节点)比a + b贵得多。- Context 场景:
memo挡不住 context 更新。消费了某个 Context 的组件,只要 Provider 的 value 变了,即使被 memo 包裹也一定会重渲染。要优化只能拆 Context 或用useSyncExternalStore之类的订阅方案。
决策顺序(面试可以答这个方法论,比死记规则高一档):
先用 React DevTools Profiler 定位真实瓶颈
→ 能不能通过"状态下放 / 内容提升(children 作为 props 传入)"从结构上避免重渲染?
→ 能:用结构优化,零运行时成本,优先做
→ 不能:再上 memo + useCallback + useMemo,并用 Profiler 验证确实变快了"内容提升"这个技巧值得单独展开:
// ❌ keyword 每次变化,整棵子树都重渲染
function Page() {
const [keyword, setKeyword] = useState('');
return (
<>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<HeavyTree /> {/* 被迫跟着重渲染 */}
</>
);
}
// ✅ 把高频状态下放到子组件,或把重内容作为 children 传入
function Page() {
return (
<SearchBox>
<HeavyTree /> {/* HeavyTree 的 Element 在 Page 里创建,SearchBox 内部 state 变化不会重建它 */}
</SearchBox>
);
}
function SearchBox({ children }) {
const [keyword, setKeyword] = useState('');
return (
<>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
{children} {/* children 引用不变,React 复用整棵子树,无需 memo */}
</>
);
}【追问 & 加分点】
- React Compiler(原 React Forget):React 19 起提供的编译器会自动插入等价于
useMemo/useCallback/memo的缓存,目标就是让开发者不再手写这些。能提一句"这些手动优化在编译器普及后会逐步退场,但理解引用相等性依然是前提",是明显的加分项。 - memo 只做浅比较,跳过的是"这个组件自己的重渲染",不是整棵子树的更新。如果子树里有组件消费了变化的 Context,或者子组件自己有 state 更新,照样会渲染。
useMemo不保证一定缓存:React 文档明确说未来可能为了释放内存丢弃缓存,所以不能把useMemo当作语义保证(比如靠它来保证某个副作用只跑一次)。真正需要"引用永远稳定"的场景用useRef。useCallback更常见的价值其实不是 memo,而是 effect 依赖:把函数放进useEffect依赖数组时,不useCallback就会每次重跑 effect。
4. 父子组件传值:props、ref、回调、Context
【核心答案】
React 的数据流是单向的,四种主要通信方式:
- 父 → 子:props 下行,最常用,直接传值。
- 子 → 父:回调函数上行,父把 setter 或处理函数作为 prop 传下去,子调用它把数据传回来("状态提升")。
- 父调用子的方法:
ref+useImperativeHandle,命令式地暴露子组件的能力(focus、播放、校验表单等),是逃生舱,不是常规手段。 - 跨层级:Context,避免 props 逐层透传(prop drilling),适合主题、语言、当前用户这类"低频变化的全局值"。
兄弟组件通信 = 状态提升到共同父级 + 上面两种的组合;更复杂的场景交给状态管理库(Zustand / Redux Toolkit / Jotai)。
【展开】
4.1 props 下行 + 回调上行(受控组件的标准形态)
import { useState, useCallback } from 'react';
// ---------- 子组件:完全受控,自己不持有状态 ----------
function SearchInput({ value, placeholder, onChange, onSearch }) {
return (
<div>
<input
value={value} // 父 → 子:props 下行
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} // 子 → 父:回调上行
onKeyDown={(e) => e.key === 'Enter' && onSearch(value)}
/>
<button onClick={() => onSearch(value)}>搜索</button>
</div>
);
}
// ---------- 父组件:持有状态(状态提升) ----------
export default function SearchPage() {
const [keyword, setKeyword] = useState('');
const [list, setList] = useState([]);
const handleSearch = useCallback(async (kw) => {
const res = await fetch(`/api/search?q=${encodeURIComponent(kw)}`);
setList(await res.json());
}, []);
return (
<>
<SearchInput
value={keyword}
placeholder="请输入关键词"
onChange={setKeyword} // 直接把 setter 传下去
onSearch={handleSearch}
/>
<ul>{list.map((it) => <li key={it.id}>{it.name}</li>)}</ul>
</>
);
}4.2 ref + useImperativeHandle:父命令式调用子
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
// React 19 之前需要 forwardRef;React 19 起 ref 可以直接作为 props 传入函数组件
const FormModal = forwardRef(function FormModal(props, ref) {
const [visible, setVisible] = useState(false);
const inputRef = useRef(null);
// 只暴露想给外部的 API,而不是整个 DOM / 整个组件内部
useImperativeHandle(ref, () => ({
open() {
setVisible(true);
// 等 DOM 更新后聚焦
requestAnimationFrame(() => inputRef.current?.focus());
},
close() {
setVisible(false);
},
validate() {
return inputRef.current?.value.trim().length > 0;
},
}), []); // 依赖数组语义与 useMemo 一致
if (!visible) return null;
return (
<div className="modal">
<input ref={inputRef} />
<button onClick={() => setVisible(false)}>关闭</button>
</div>
);
});
function Parent() {
const modalRef = useRef(null);
return (
<>
<button onClick={() => modalRef.current.open()}>打开弹窗</button>
<button onClick={() => console.log(modalRef.current.validate())}>校验</button>
<FormModal ref={modalRef} />
</>
);
}什么时候该用 ref:命令式的一次性动作——focus、scrollIntoView、播放/暂停、触发动画、表单校验。什么时候不该用:任何可以用状态表达的 UI(弹窗开关、列表数据)都应该用 state + props,用 ref 会让数据流变得不可追踪。
4.3 Context:跨层级传值
import { createContext, useContext, useMemo, useState, useCallback } from 'react';
// 1. 拆分成"值 Context"和"操作 Context",避免值变化时连带 dispatch 消费者一起重渲染
const ThemeStateContext = createContext(null);
const ThemeActionsContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggle = useCallback(() => {
setTheme((t) => (t === 'light' ? 'dark' : 'light'));
}, []);
// 2. actions 引用永远稳定 → 只用 toggle 的组件永远不会因为 theme 变化而重渲染
const actions = useMemo(() => ({ toggle, setTheme }), [toggle]);
return (
<ThemeActionsContext.Provider value={actions}>
{/* 3. value 直接传原始值,避免 {{ theme }} 这种每次新建对象的写法 */}
<ThemeStateContext.Provider value={theme}>
{children}
</ThemeStateContext.Provider>
</ThemeActionsContext.Provider>
);
}
// 4. 封装自定义 Hook,顺带做"是否在 Provider 内"的校验
export function useTheme() {
const ctx = useContext(ThemeStateContext);
if (ctx === null) throw new Error('useTheme 必须在 ThemeProvider 内部使用');
return ctx;
}
export function useThemeActions() {
const ctx = useContext(ThemeActionsContext);
if (ctx === null) throw new Error('useThemeActions 必须在 ThemeProvider 内部使用');
return ctx;
}
// ---------- 深层组件直接消费,无需逐层传 props ----------
function DeepButton() {
const theme = useTheme();
const { toggle } = useThemeActions();
return <button onClick={toggle}>当前主题:{theme}</button>;
}Context 的性能陷阱:Provider 的 value 一变,所有消费该 Context 的组件都会重渲染,且 React.memo 拦不住(Context 更新走的是 Fiber 上的依赖列表,绕过了 props 比较)。所以:
value必须用useMemo稳定,绝不能写value={{ a, b }};- 高频变化的值和低频的值要拆成不同 Context;
- 状态和 dispatch 拆开(上例做法);
- 真的需要细粒度订阅时,用 Zustand / Jotai 或
useSyncExternalStore,它们支持 selector 级别的订阅。
4.4 四种方式的选型
| 场景 | 推荐方式 |
|---|---|
| 父传数据给子 | props |
| 子把用户输入/事件传给父 | 回调函数(状态提升) |
| 父需要触发子的命令式动作 | ref + useImperativeHandle |
| 兄弟组件共享状态 | 状态提升到最近公共父级 |
| 跨 3 层以上、低频变化的全局值 | Context |
| 跨页面、高频、复杂的共享状态 | Zustand / Redux Toolkit / Jotai |
【追问 & 加分点】
- React 19 移除了
forwardRef的必要性:函数组件可以直接从 props 里解构ref,forwardRef保留但标记为逐步废弃。 useImperativeHandle的第三个参数是依赖数组,忘了写会导致每次渲染都重建句柄对象(对 ref 本身影响不大,但如果外部对句柄做了 memo 判断就有问题)。- 子组件修改父组件传下来的对象是反模式:props 应视为只读。要改就调用父传下来的回调,让父自己用不可变方式更新。
- "回调上行"传的是事件语义还是数据语义? 好的组件设计是
onSearch(keyword)这种语义化事件,而不是setParentState,前者让子组件不依赖父组件的状态结构,可复用性更强。 - Context 不是状态管理库:它只解决"传递"问题,不解决"更新粒度"和"派生状态"问题。这是面试里区分"用过"和"想过"的关键点。
5. 自定义 Hook 的设计:useCountdown 与 useRequest
【核心答案】
自定义 Hook 的本质是逻辑复用的函数——把若干内置 Hook 组合起来、封装一段有状态的逻辑,对外暴露值和操作方法。它复用的是逻辑,不是状态:两个组件调用同一个自定义 Hook,各自拥有完全独立的状态。
设计原则:
use开头(lint 依据);- 只在顶层调用其他 Hook;
- 返回值设计:单值返回值本身,多值且需要重命名返回数组(如
useState),字段多且语义固定返回对象; - 返回的函数引用要稳定(
useCallback),否则会污染使用方的依赖数组; - 必须清理副作用:定时器、订阅、请求都要在卸载时取消;
- 关注"竞态"和"卸载后 setState"这两个高频线上问题。
【展开】
5.1 useCountdown:完整实现
需求:给一个目标时间戳或秒数,返回剩余时间、格式化结果,支持开始/暂停/重置,组件卸载自动清理。
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
/**
* 倒计时 Hook
* @param {number} initialSeconds 初始秒数
* @param {object} options
* @param {number} options.interval 刷新间隔(毫秒),默认 1000
* @param {boolean} options.autoStart 是否自动开始,默认 true
* @param {Function} options.onEnd 倒计时结束回调
* @returns {{ seconds:number, isRunning:boolean, formatted:string,
* start:Function, pause:Function, reset:Function }}
*/
export function useCountdown(initialSeconds, options = {}) {
const { interval = 1000, autoStart = true, onEnd } = options;
const [seconds, setSeconds] = useState(initialSeconds);
const [isRunning, setIsRunning] = useState(autoStart);
// 用 ref 存 onEnd:这样 onEnd 变化不会导致 effect 重跑(否则使用方传内联函数就每次重建定时器)
const onEndRef = useRef(onEnd);
onEndRef.current = onEnd;
// 用「结束时间戳」而不是「每次减 1」来计算,避免 setInterval 累积误差、
// 以及页面切到后台被浏览器节流后时间不准的问题
const endTimeRef = useRef(0);
useEffect(() => {
if (!isRunning || seconds <= 0) return;
// 每次进入运行态,用当前剩余秒数重新锚定结束时间
endTimeRef.current = Date.now() + seconds * 1000;
const timer = setInterval(() => {
const rest = Math.max(0, Math.ceil((endTimeRef.current - Date.now()) / 1000));
setSeconds(rest);
if (rest <= 0) {
clearInterval(timer);
setIsRunning(false);
onEndRef.current?.(); // 读 ref 拿到最新回调,规避 stale closure
}
}, interval);
// 清理:暂停、依赖变化、组件卸载时都会走到这里
return () => clearInterval(timer);
// 注意:这里故意不把 seconds 放进依赖,否则每秒重建定时器。
// isRunning 从 false → true 时才需要重新锚定 endTime,这已由 isRunning 依赖覆盖。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isRunning, interval]);
const start = useCallback(() => setIsRunning(true), []);
const pause = useCallback(() => setIsRunning(false), []);
const reset = useCallback(
(next = initialSeconds) => {
setIsRunning(false);
setSeconds(next);
},
[initialSeconds]
);
// 派生数据用 useMemo,而不是再开一个 state + useEffect
const formatted = useMemo(() => {
const m = String(Math.floor(seconds / 60)).padStart(2, '0');
const s = String(seconds % 60).padStart(2, '0');
return `${m}:${s}`;
}, [seconds]);
return { seconds, isRunning, formatted, start, pause, reset };
}
// ---------- 使用:短信验证码按钮 ----------
function SmsButton({ phone }) {
const { seconds, isRunning, start } = useCountdown(60, { autoStart: false });
const handleSend = async () => {
await fetch('/api/sms', { method: 'POST', body: JSON.stringify({ phone }) });
start();
};
return (
<button disabled={isRunning} onClick={handleSend}>
{isRunning ? `${seconds} 秒后重发` : '获取验证码'}
</button>
);
}这个实现里值得在面试中主动讲的三个设计点:
- 用
Date.now()锚定结束时间,而不是每次seconds - 1。因为setInterval会漂移,且浏览器在后台标签页会把定时器节流到 ≥1s 甚至更久,纯累减会明显不准。 - 用
onEndRef存回调,避免使用方传内联函数导致定时器被反复销毁重建。 - 派生值
formatted用useMemo而不是额外的 state,避免多一次渲染。
5.2 useRequest:带竞态处理、取消、缓存的请求 Hook
import { useState, useRef, useCallback, useEffect } from 'react';
/**
* 请求 Hook
* @param {(params:any, signal:AbortSignal) => Promise<any>} service 请求函数
* @param {object} options
* @param {boolean} options.manual 是否手动触发,默认 false(挂载即请求)
* @param {any[]} options.deps 自动重新请求的依赖
* @param {any} options.defaultParams 首次自动请求的参数
* @param {Function} options.onSuccess / options.onError
*/
export function useRequest(service, options = {}) {
const {
manual = false,
deps = [],
defaultParams,
onSuccess,
onError,
} = options;
const [state, setState] = useState({
data: undefined,
error: undefined,
loading: !manual,
});
// 只保留最新一次请求的"令牌",用于竞态判定
const requestIdRef = useRef(0);
const controllerRef = useRef(null);
const mountedRef = useRef(true);
// 把可能变化的外部依赖存进 ref,保证 run 的引用可以稳定
const serviceRef = useRef(service);
serviceRef.current = service;
const onSuccessRef = useRef(onSuccess);
onSuccessRef.current = onSuccess;
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
controllerRef.current?.abort(); // 卸载时取消在途请求
};
}, []);
const run = useCallback(async (params) => {
// 1. 取消上一次未完成的请求(省流量、也防止服务端压力)
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
// 2. 自增请求 id,只有 id 最大的那次结果才允许写入 state(解决竞态)
const id = ++requestIdRef.current;
setState((s) => ({ ...s, loading: true, error: undefined }));
try {
const data = await serviceRef.current(params, controller.signal);
// 3. 三重防护:组件已卸载 / 已被更新的请求覆盖 / 已被取消
if (!mountedRef.current || id !== requestIdRef.current) return;
setState({ data, error: undefined, loading: false });
onSuccessRef.current?.(data, params);
return data;
} catch (error) {
if (error.name === 'AbortError') return; // 主动取消,不算错误
if (!mountedRef.current || id !== requestIdRef.current) return;
setState({ data: undefined, error, loading: false });
onErrorRef.current?.(error, params);
}
}, []); // 依赖为空,run 引用永久稳定,使用方可以安心放进 effect 依赖
const refresh = useCallback(() => run(defaultParams), [run, defaultParams]);
const cancel = useCallback(() => {
controllerRef.current?.abort();
requestIdRef.current++; // 让在途结果失效
setState((s) => ({ ...s, loading: false }));
}, []);
// 自动请求
useEffect(() => {
if (manual) return;
run(defaultParams);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return { ...state, run, refresh, cancel };
}
// ---------- 使用 ----------
function UserDetail({ userId }) {
const { data, loading, error, refresh } = useRequest(
(id, signal) => fetch(`/api/user/${id}`, { signal }).then((r) => r.json()),
{ deps: [userId], defaultParams: userId }
);
if (loading) return <Skeleton />;
if (error) return <Error onRetry={refresh} />;
return <div>{data.name}</div>;
}竞态(race condition)是这道题的核心考点:用户快速切换 userId 从 1 到 2,如果请求 1 比请求 2 后返回,不做处理的话页面会显示用户 1 的数据。上面用了两层保护——AbortController 主动取消 + 自增 requestId 丢弃过期结果。只答其中一个也能过,两个都答出来是加分。
5.3 自定义 Hook 的设计 checklist
□ 命名以 use 开头
□ 内部 Hook 只在顶层调用,无条件分支
□ 返回的函数用 useCallback 包裹,引用稳定
□ 派生数据用 useMemo,不用额外 state + useEffect
□ 所有副作用(定时器 / 订阅 / 事件监听 / 请求)都有对应清理
□ 处理竞态:异步结果落地前校验"我还是最新的那次吗"
□ 回调型参数(onSuccess 等)用 ref 存,避免污染依赖数组
□ 对外 API 语义化,隐藏内部实现细节
□ SSR 安全:不在渲染期间访问 window / document【追问 & 加分点】
- 自定义 Hook 复用的是逻辑不是状态:两个组件各调一次
useCountdown,两份独立状态。要共享状态得靠 Context 或外部 store。这是一个非常高频的追问点。 - 为什么不用
isMounted判断来"防止卸载后 setState"? React 18 起卸载后 setState 已经不再警告(不算内存泄漏),官方推荐的做法是用AbortController取消请求,而不是加 mounted 标志位。我上面保留mountedRef是为了兼容"取消不了的 Promise",但要能说清这个演变。 useSyncExternalStore:如果自定义 Hook 要订阅外部数据源(window.matchMedia、localStorage、WebSocket),并发模式下用useState+useEffect会有"撕裂"(tearing)问题,正确 API 是useSyncExternalStore。- 和 ahooks 的对比:能说出 ahooks 的
useRequest还支持轮询、防抖节流、缓存与 SWR、依赖 ready、loading delay,说明你读过工业级实现。
6. Fiber 架构:动机、数据结构、双缓存、更新流程
【核心答案】
为什么需要 Fiber:React 15 的协调过程是基于虚拟 DOM 树的同步递归(mountComponent 递归调用子组件)。递归一旦开始就不能停,调用栈由 JS 引擎管理,React 无法保存进度、无法中断。组件树一大(几千个节点),一次更新的计算就可能占用几十上百毫秒,超过一帧的 16.6ms,主线程被独占,用户的输入、动画全部卡住(掉帧)。
Fiber 的解法:把递归改成链表遍历 + 循环。用 child / sibling / return 三个指针把树变成可以用循环遍历的结构,把每个节点作为一个"工作单元",React 自己维护遍历进度(workInProgress 指针)。这样就可以在每个工作单元之间检查"这一帧的时间片用完了吗",用完就保存进度、把主线程还给浏览器,下一帧再从断点继续——这就是**时间切片(可中断渲染)**的基础。
Element vs Fiber:Element 是 createElement 的返回值,是描述 UI 长什么样的不可变的、轻量的普通对象(type / props / key / ref),每次渲染都重新创建;Fiber 是可变的工作单元 + 组件实例的持久载体,保存了 stateNode(真实 DOM 或类实例)、Hook 链表、副作用标记、优先级、指针关系,跨渲染复用。
双缓存:内存中同时最多存在两棵 Fiber 树——屏幕上正在显示的 current 树和正在内存中构建的 workInProgress 树,两者的同名节点通过 alternate 互相指向。新树在内存中完整构建好之后,一次性把 fiberRoot.current 指向它完成切换,避免用户看到构建到一半的中间状态。
更新流程:触发更新(setState)→ Render / Reconciler 阶段(构建 workInProgress 树,可中断、可丢弃、可重来,纯计算无副作用)→ Commit 阶段(把标记的副作用一次性刷到真实 DOM,同步不可中断)。
【展开】
6.1 React 15 的问题
// React 15 的 mountComponent 本质是这样的递归(示意)
function reconcile(element, parentInstance) {
const instance = instantiate(element);
const dom = instance.mount(); // 创建 DOM
parentInstance.appendChild(dom);
element.props.children.forEach((child) =>
reconcile(child, instance) // ← 递归,调用栈由 JS 引擎管理,无法中断
);
}问题不在"慢",而在"不可中断"。哪怕总耗时 100ms,如果能拆成 20 个 5ms 的片段,中间让浏览器响应用户输入,体验就完全不同。
6.2 Element 与 Fiber 的对比
// ---------- React Element:createElement 的返回值 ----------
// <div className="box"><span key="a">hi</span></div> 编译后
const element = {
$$typeof: Symbol.for('react.element'), // 防 XSS 的标记,JSON 无法伪造 Symbol
type: 'div', // 'div' | 函数组件 | 类组件 | Symbol(Fragment)
key: null,
ref: null,
props: { className: 'box', children: { /* 又一个 element */ } },
};
// 特点:不可变、轻量、每次 render 都重新创建、只描述"要渲染什么",不含任何状态// ---------- Fiber Node:工作单元 ----------
function FiberNode(tag, pendingProps, key, mode) {
// ===== 静态属性:描述"我是什么" =====
this.tag = tag; // FunctionComponent=0 / ClassComponent=1 / HostComponent=5 ...
this.key = key;
this.elementType = null;
this.type = null; // 对应 element.type
this.stateNode = null; // HostComponent → 真实 DOM;ClassComponent → 类实例
// ===== 链表指针:把树变成可循环遍历的结构 =====
this.return = null; // 父 Fiber("归"的时候回到哪里)
this.child = null; // 第一个子 Fiber
this.sibling = null; // 下一个兄弟 Fiber
this.index = 0; // 在兄弟中的位置,diff 时用来判断是否需要移动
// ===== 工作单元属性:描述"这次要干什么" =====
this.pendingProps = pendingProps; // 本次新的 props
this.memoizedProps = null; // 上次渲染完成后的 props
this.updateQueue = null; // 更新队列(类组件的 setState / 函数组件的 effect 链表)
this.memoizedState = null; // 类组件 → state;函数组件 → Hook 单向链表的头节点
this.dependencies = null; // 消费的 Context 列表
// ===== 副作用与调度 =====
this.flags = NoFlags; // 本节点的副作用标记:Placement/Update/Deletion/Passive...
this.subtreeFlags = NoFlags; // 子树是否有副作用(用于 commit 阶段快速跳过整棵干净子树)
this.deletions = null; // 需要删除的子 Fiber
this.lanes = NoLanes; // 本节点待处理更新的优先级
this.childLanes = NoLanes; // 子树待处理更新的优先级
// ===== 双缓存 =====
this.alternate = null; // 指向另一棵树中的对应节点
}| 维度 | React Element | Fiber Node |
|---|---|---|
| 产生方式 | createElement()(JSX 编译结果) | createFiberFromElement() 或从 alternate 复用 |
| 可变性 | 不可变,每次渲染新建 | 可变,跨渲染复用(双缓存两个实例交替) |
| 是否含状态 | 否 | 是(memoizedState、Hook 链表、stateNode) |
| 结构 | 通过 props.children 嵌套 | 通过 child / sibling / return 组成链表 |
| 职责 | 描述 UI 的形状 | 工作单元 + 状态载体 + 副作用记录 + 优先级载体 |
6.3 树 → 链表的遍历
App
/ \
Header List
|
Item ── Item ── ItemFiber 结构中的指针:
App.child = Header
Header.sibling = List, Header.return = App
List.child = Item1, List.return = App
Item1.sibling = Item2, Item1.return = List遍历算法是"深度优先 + 递归改循环",分"递"(beginWork)和"归"(completeWork)两个方向:
// 可中断的工作循环:每处理完一个 Fiber 就检查是否该让出主线程
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
// ↑ 关键:Scheduler 判断当前时间片(5ms)是否用完
performUnitOfWork(workInProgress);
}
}
// 同步模式没有 shouldYield,一口气做完
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
function performUnitOfWork(unitOfWork) {
const current = unitOfWork.alternate;
// "递"阶段:根据新 props 计算,创建/复用子 Fiber,返回第一个子节点
let next = beginWork(current, unitOfWork, renderLanes);
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// 没有子节点了 → 进入"归"阶段
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}
function completeUnitOfWork(unitOfWork) {
let completedWork = unitOfWork;
do {
// "归"阶段:创建真实 DOM(离屏)、处理 props、把子树的 flags 向上冒泡
completeWork(completedWork.alternate, completedWork, renderLanes);
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
workInProgress = siblingFiber; // 有兄弟,转去处理兄弟的"递"
return;
}
completedWork = completedWork.return; // 没兄弟,回到父节点继续"归"
workInProgress = completedWork;
} while (completedWork !== null);
}中断为什么安全:因为 workInProgress 是一个普通变量,中断时它就停在某个 Fiber 上;恢复时从这个变量继续 workLoopConcurrent 即可。而且 Render 阶段是纯计算,构建的是内存中的新树,没有碰真实 DOM,所以即使被高优先级更新打断、整棵 workInProgress 树被丢弃重来,用户也感知不到。
6.4 双缓存机制
首屏(mount):
fiberRoot.current ──> HostRootFiber(current,空)
↕ alternate
HostRootFiber(workInProgress) ──> 构建整棵新树
commit 后:fiberRoot.current = workInProgress 树
更新(update):
fiberRoot.current ──> 树 A(屏幕上显示的)
↕ alternate
树 B(复用上一次的那棵,作为新的 workInProgress)
构建完成 commit 后:fiberRoot.current = 树 B
下一次更新时,树 A 又成为 workInProgress —— 两棵树交替复用,避免每次都新建全部对象// 创建/复用 workInProgress 节点
function createWorkInProgress(current, pendingProps) {
let workInProgress = current.alternate;
if (workInProgress === null) {
// 首次:新建一个,并与 current 建立 alternate 双向指向
workInProgress = createFiber(current.tag, pendingProps, current.key, current.mode);
workInProgress.stateNode = current.stateNode; // 复用真实 DOM,不重新创建
workInProgress.alternate = current;
current.alternate = workInProgress;
} else {
// 复用上一棵树的节点对象,只重置本次需要重算的字段(对象复用,减少 GC 压力)
workInProgress.pendingProps = pendingProps;
workInProgress.type = current.type;
workInProgress.flags = NoFlags;
workInProgress.subtreeFlags = NoFlags;
workInProgress.deletions = null;
}
// 从 current 同步不变的状态
workInProgress.child = current.child;
workInProgress.memoizedProps = current.memoizedProps;
workInProgress.memoizedState = current.memoizedState;
workInProgress.updateQueue = current.updateQueue;
workInProgress.lanes = current.lanes;
workInProgress.childLanes = current.childLanes;
return workInProgress;
}双缓存的两个价值:一是避免中间态(新树没构建完之前,屏幕上的 current 树完全不受影响;如果被中断丢弃,用户什么都不会看到);二是对象复用(两棵树的 Fiber 节点反复复用,避免每次更新都创建成千上万个对象)。这个思路和图形学的 double buffering、canvas 的离屏渲染是同一个。
6.5 完整更新流程
① 触发更新
this.setState / useState 的 dispatch / root.render
→ 创建 Update 对象,挂到对应 Fiber 的 updateQueue(环形链表)
→ markUpdateLaneFromFiberToRoot:从触发节点沿 return 指针向上,
把 lane 记录到每一层的 childLanes,一直到 FiberRoot
→ scheduleUpdateOnFiber → ensureRootIsScheduled
② 调度(Scheduler)
根据 lane 算出优先级 → Scheduler.scheduleCallback(priority, performConcurrentWorkOnRoot)
Scheduler 用 MessageChannel 产生宏任务(不是 requestIdleCallback:兼容性差、触发不稳定)
同步优先级(如离散事件、flushSync)走 performSyncWorkOnRoot,不可中断
③ Render / Reconciler 阶段 ★可中断★
renderRootConcurrent → workLoopConcurrent
beginWork(递):
- 判断能否 bailout(props 全等 && 没有本节点的更新 && 子树无更新 lane)
- 不能则调用组件函数 / render(),得到新的 children Element
- reconcileChildren:diff,生成/复用子 Fiber,打上 Placement / Update / Deletion 标记
completeWork(归):
- HostComponent:createInstance 创建离屏 DOM,appendAllChildren 拼接子 DOM
- diffProperties 计算属性差异,存入 updateQueue
- bubbleProperties:把子树的 flags 冒泡到 subtreeFlags
结束后得到一棵带完整副作用标记的 workInProgress 树(finishedWork)
▲ 这个阶段是纯计算,没有操作真实 DOM,所以中断/丢弃/重跑都安全
▲ 也正因为可能重跑,render 阶段的代码必须是纯的(这就是 StrictMode 双调用要暴露的问题)
④ Commit 阶段 ★同步、不可中断★
commitRoot 分三个子阶段:
a. before mutation:读取变更前的 DOM 状态
- 类组件 getSnapshotBeforeUpdate
- 调度 useEffect 的异步执行(scheduleCallback(NormalPriority, flushPassiveEffects))
b. mutation:真正操作 DOM
- Placement:插入/移动节点 - Update:更新属性、文本
- Deletion:删除节点,同时执行 useLayoutEffect 的清理函数、componentWillUnmount
- 【这一步之后切换 fiberRoot.current = finishedWork】
切换点选在 mutation 之后、layout 之前:保证 componentWillUnmount 里读到的是旧树,
而 componentDidMount/Update 与 useLayoutEffect 里读到的是新树
c. layout:DOM 已更新但浏览器还没绘制
- componentDidMount / componentDidUpdate
- useLayoutEffect 的回调(同步执行,会阻塞绘制)
- ref 的赋值
→ 浏览器 paint
→ useEffect 的回调此时才被异步执行为什么 Commit 必须同步不可中断:这一步在直接改真实 DOM,如果中途被打断,用户就会看到一半旧一半新的破碎界面。所以 React 的设计是"把耗时的可中断部分(计算)和快速的不可中断部分(DOM 写入)分离"。
【追问 & 加分点】
- Fiber 这个名字的含义:借用操作系统"纤程"的概念——比线程更轻量的、由用户态自己调度的执行单元。React 用它表达"一个可以被调度、可以暂停恢复的最小工作单元"。
- 为什么用 MessageChannel 而不是
requestIdleCallback?requestIdleCallback存在兼容性问题(Safari 长期不支持)、触发频率不稳定(浏览器认为空闲才调,可能几百毫秒不触发)、且回调优先级太低。React 自己实现了 Scheduler,用MessageChannel生成宏任务(比setTimeout少 4ms 的最小延迟钳制),并固定 5ms 的时间切片。 shouldYield()判断的不只是时间:新版本还会检查是否有待处理的输入事件(isInputPending,Chrome 支持)。- React 17 移除了 effectList(副作用链表),改为
subtreeFlags冒泡:老版本用一条链表串起所有有副作用的 Fiber,commit 时遍历链表;新版本改为在 completeWork 时把子树 flags 冒泡到父节点,commit 时递归遍历但可以靠subtreeFlags === NoFlags整棵跳过。原因是可中断渲染下维护单链表更容易出错。 - "Fiber 让 React 变快了吗":严格说单次更新的总计算量没变,甚至因为调度开销略有增加。Fiber 提升的是响应性/交互流畅度(perceived performance),不是吞吐量。能澄清这一点是很强的信号。
- bailout 的条件:
oldProps === newProps(引用相等)且!includesSomeLane(renderLanes, workInProgress.lanes)且 context 没变,则可以复用整棵子树,直接bailoutOnAlreadyFinishedWork。这解释了为什么"内容提升"(把 children 作为 props 传入)能优化性能——children 的 Element 引用没变,触发了 bailout。
7. Concurrent 并发特性与 RSC 服务端组件
【核心答案】
Concurrent(并发渲染) 不是一个开关或一个 API,而是 React 18 起由 createRoot 启用的一套可中断渲染能力,包含三块:
- 时间切片:Render 阶段每处理若干工作单元就检查时间片(5ms),超时就把主线程还给浏览器,下一个宏任务继续,保证用户输入和动画不被长渲染阻塞。
- 优先级(Lane 模型):每个更新被赋予一条 lane(31 位位掩码),离散事件(点击、输入)优先级高于连续事件(滚动、拖动),高于 transition,高于 offscreen。高优先级更新可以打断正在进行的低优先级渲染,做完之后低优先级的再重头来一遍。
- 暴露给开发者的 API:
useTransition(把更新标记为"非紧急")、useDeferredValue(延迟某个值的更新)、<Suspense>(声明式加载态)、startTransition。
RSC(React Server Components) 是"组件在服务端执行"的模型:服务端组件在服务器上渲染成一段序列化的 RSC Payload(不是 HTML 字符串,而是描述 UI 树的特殊格式),流式发送到客户端由 React 渲染。价值有三:组件代码不进客户端 bundle(用 markdown 解析、日期库这类重依赖时体积收益巨大);可以直接 async/await 访问数据库/文件系统,数据获取贴近数据源,消除客户端的请求瀑布;与 Suspense 配合可以流式渲染,先出骨架再逐块填充。代价是服务端组件不能有状态、不能用 effect 和浏览器 API,需要通过 'use client' 显式划出客户端边界。
【展开】
7.1 从 Legacy 到 Concurrent
// React 17 / React 18 legacy 模式:同步渲染,无并发能力
ReactDOM.render(<App />, container);
// React 18 concurrent 模式:开启可中断渲染 + 自动批处理
import { createRoot } from 'react-dom/client';
createRoot(container).render(<App />);仅仅换成 createRoot 就会得到的两个变化:
- 自动批处理(Automatic Batching):React 17 只在 React 事件处理函数内批处理 setState;
setTimeout、Promise、原生事件里的多次 setState 会各触发一次渲染。React 18 concurrent 模式下所有场景都批处理。需要立刻同步刷新时用flushSync。 - 渲染可被中断,为下面的 API 提供基础。
7.2 Lane 优先级模型
// react-reconciler/src/ReactFiberLane.js(节选)
export const SyncLane = 0b0000000000000000000000000000010;
export const InputContinuousLane = 0b0000000000000000000000000001000;
export const DefaultLane = 0b0000000000000000000000000100000;
export const TransitionLane1 = 0b0000000000000000000000001000000;
// ... TransitionLane1..16
export const RetryLane1 = 0b0000000010000000000000000000000;
export const IdleLane = 0b0100000000000000000000000000000;
export const OffscreenLane = 0b1000000000000000000000000000000;用位掩码而不是数字优先级的好处:一个 Fiber 可以同时携带多条 lane(多个不同优先级的待处理更新),用位运算 & | 就能高效地做批量判断和合并,这是 React 16 的 expirationTime(单一数字)做不到的。
高优先级打断低优先级的过程:
低优先级 transition 渲染进行到一半(workInProgress 停在某个节点)
→ 用户点击按钮,产生 SyncLane 更新
→ ensureRootIsScheduled 发现新 lane 优先级更高
→ 丢弃当前的 workInProgress 树(不是保存,是丢弃!因为状态可能已经不一致)
→ 从根节点重新开始,以高优先级 lane 渲染
→ 高优先级 commit 完成
→ 剩余的 transition lane 重新调度,从头再渲染一次注意"重头再来"这个细节:这就是为什么 render 阶段的函数必须是纯的——它可能被执行任意多次。
7.3 useTransition:标记非紧急更新
经典场景:输入框实时过滤一个上万条的大列表。输入必须立刻响应,列表可以慢一点。
import { useState, useTransition, memo } from 'react';
const HugeList = memo(function HugeList({ keyword, items }) {
// 故意做一个昂贵的渲染
const filtered = items.filter((it) => it.name.includes(keyword));
return (
<ul>
{filtered.map((it) => (
<li key={it.id}>{it.name}</li>
))}
</ul>
);
});
export default function SearchPage({ items }) {
const [input, setInput] = useState(''); // 紧急状态:输入框的值
const [keyword, setKeyword] = useState(''); // 非紧急状态:用于过滤列表
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
const value = e.target.value;
setInput(value); // 紧急更新:立刻反映到输入框,绝不能卡
startTransition(() => {
setKeyword(value); // 非紧急更新:可被打断、可被丢弃
});
};
return (
<>
<input value={input} onChange={handleChange} />
{/* isPending 让我们能给一个"正在更新"的视觉反馈,而不是白屏或卡死 */}
<div style={{ opacity: isPending ? 0.5 : 1 }}>
<HugeList keyword={keyword} items={items} />
</div>
</>
);
}useTransition 和防抖的区别(高频追问):
| 防抖(debounce) | useTransition | |
|---|---|---|
| 机制 | 延迟触发计算,等用户停下来 | 立刻开始计算,但可被更高优先级打断 |
| 快速输入时 | 中间的输入完全不计算 | 中间的渲染会开始、被打断、丢弃 |
| 用户停止输入后 | 还要等一个防抖时长 | 立刻拿到结果(可能已经算了一部分) |
| 适用 | 请求节流(省网络) | 纯渲染开销(省不了,只能让出主线程) |
结论:如果昂贵的是网络请求,用防抖;如果昂贵的是渲染,用 transition;两者可以叠加。
7.4 useDeferredValue
useTransition 需要你能控制 setState 的调用处;如果值是从 props 传下来的,用 useDeferredValue:
function Results({ keyword }) {
// deferredKeyword 会"滞后"于 keyword:紧急渲染用旧值,空闲时再用新值渲染一次
const deferredKeyword = useDeferredValue(keyword);
const isStale = keyword !== deferredKeyword;
return (
<div style={{ opacity: isStale ? 0.6 : 1 }}>
<HugeList keyword={deferredKeyword} />
</div>
);
}可以理解为:useDeferredValue 是"接收端"的 transition,useTransition 是"发送端"的 transition。
7.5 Suspense 与流式 SSR
<Suspense fallback={<Skeleton />}>
<Comments postId={id} />
</Suspense>React 18 的 SSR 配合 Suspense 提供两个能力:
- Streaming SSR(
renderToPipeableStream):服务端不必等全部数据就绪才发 HTML,可以先发出骨架,慢的部分后续通过<script>补丁流式插入。 - Selective Hydration:客户端不必等全部 JS 加载完才能交互;某个 Suspense 边界的代码到了就先水合它。而且如果用户点击了一个尚未水合的区域,React 会提高该区域的水合优先级,插队处理。
7.6 RSC:服务端组件
// app/posts/[id]/page.jsx —— 默认就是服务端组件(Next.js App Router)
// 没有 'use client',所以这段代码永远不会出现在浏览器的 bundle 里
import { db } from '@/lib/db';
import { marked } from 'marked'; // 一个 100KB 的 markdown 库,客户端零负担
import LikeButton from './LikeButton'; // 客户端组件
export default async function PostPage({ params }) {
// ✅ 服务端组件可以是 async 函数,直接 await
// ✅ 可以直接访问数据库 / 文件系统 / 内部服务,无需暴露 API 接口
// ✅ 密钥、连接串留在服务端,不会泄漏
const post = await db.post.findUnique({ where: { id: params.id } });
const html = marked(post.content);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: html }} />
{/* 把服务端取到的数据作为 props 传给客户端组件(props 必须可序列化) */}
<LikeButton postId={post.id} initialCount={post.likes} />
</article>
);
}// app/posts/[id]/LikeButton.jsx
'use client'; // ← 边界指令:这个文件及其 import 的模块会进客户端 bundle
import { useState } from 'react';
export default function LikeButton({ postId, initialCount }) {
const [count, setCount] = useState(initialCount); // 只有客户端组件能有状态
return <button onClick={() => setCount((c) => c + 1)}>👍 {count}</button>;
}服务端组件与客户端组件的能力对照:
| 能力 | 服务端组件 | 客户端组件 |
|---|---|---|
useState / useReducer / useEffect | ❌ | ✅ |
| 事件处理(onClick 等) | ❌ | ✅ |
| 浏览器 API(window / localStorage) | ❌ | ✅ |
async/await 直接取数 | ✅ | ⚠️(需 use() 或 effect) |
| 直接访问数据库 / 文件系统 / 密钥 | ✅ | ❌ |
| 代码进入客户端 bundle | ❌(零字节) | ✅ |
| 可以 import 客户端组件 | ✅ | — |
| 可以 import 服务端组件 | — | ❌(只能通过 children 插槽传入) |
RSC Payload 不是 HTML:它是一种流式的、类 JSON 的序列化格式,描述"渲染出的元素树 + 客户端组件的占位符和 chunk 引用 + props"。客户端 React 拿到后与已有的客户端树做协调,因此导航时不会丢失客户端组件的状态——这是 RSC 相比传统 SSR 的关键优势。
RSC 的价值总结:
- bundle 体积:只在服务端使用的库(markdown、语法高亮、日期格式化、ORM)完全不进客户端。
- 数据获取:组件级别的取数,天然并行(同级 async 组件并发执行),消除
useEffect取数造成的请求瀑布和 loading 闪烁。 - 安全:密钥和数据访问逻辑留在服务端。
- 首屏:配合流式 SSR,TTFB 和 FCP 更好。
代价:心智模型变复杂(要时刻分清代码跑在哪一侧)、必须有 Node 服务端(不能纯静态部署)、跨边界的 props 必须可序列化(不能传函数,除非是 Server Action)、生态和调试工具还在成熟中。
【追问 & 加分点】
- "React 18 升级到 createRoot 就自动变快了吗":不会。并发能力只是"可用",真正的收益需要你主动用
useTransition/useDeferredValue/Suspense去标记优先级。这是一个很好的辨析题。 useTransition里能不能放异步操作? React 18 的startTransition回调必须是同步的(await 之后的 setState 不再处于 transition 上下文)。React 19 支持了异步 transition(Actions),useActionState、useOptimistic就是配套 API。- Server Actions:RSC 的另一半,用
'use server'标记的函数可以从客户端直接调用,React 帮你做 RPC,配合useActionState和useOptimistic做表单和乐观更新。 - RSC 与 SSR 的区别:SSR 是"把组件渲染成 HTML 字符串发给客户端,然后客户端再下载同一份 JS 水合一遍";RSC 是"这个组件的 JS 根本不发给客户端"。SSR 解决首屏,RSC 解决 bundle 体积和数据获取。两者正交、可以叠加使用。
- 撕裂(tearing):并发渲染下,一次渲染可能被中断,中断期间外部 store 的数据变了,导致同一次渲染的不同部分读到不同的值。这就是
useSyncExternalStore存在的原因,也是 Redux/Zustand 等库必须适配 React 18 的原因。
8. React 的 diff 策略与 key
【核心答案】
完整的树 diff 算法复杂度是 O(n³),不可接受。React 基于三个前提假设把它降到 O(n):
- 同层比较(tree diff):只比较同一层级的节点,不做跨层级的移动匹配。一个节点如果跨层级移动了,React 会把它在旧位置删除、在新位置重建,而不是复用。
- 不同类型直接重建(component diff):
type不同(div→p,或组件 A → 组件 B)就卸载整棵旧子树、挂载新子树,不再向下比较。 - 同层列表用 key 匹配(element diff):同一层的多个子节点,通过
key来标识"是不是同一个节点",从而实现移动复用而非删除重建。
key 的作用:告诉 React "本次的这个节点和上次的哪个节点是同一个"。有了稳定的 key,React 才能在列表增删改序时复用已有的 DOM 和组件状态。
用数组下标做 key 的问题:下标是"位置"而不是"身份"。在列表头部插入、删除中间项、排序、过滤时,同一个下标在前后两次渲染中对应的是不同的数据项,导致:(a) 性能退化——本可以只插一个节点,变成从插入点开始逐个更新所有节点的内容;(b) 状态错位——组件内部的 state、非受控输入框的值、ref、CSS 过渡动画会跟着"位置"走而不是跟着"数据"走,出现输入框内容串行、勾选状态跑到别的行这类真实的 UI bug。
【展开】
8.1 三大策略的具体表现
// ① 同层比较:跨层级移动不会复用
// 更新前
<div>
<Counter /> {/* 有内部 state */}
</div>
// 更新后
<div>
<span>
<Counter /> {/* 层级变了 → 旧的 Counter 被卸载(state 丢失),新的重新挂载 */}
</span>
</div>
// ② 类型不同直接重建
<div><Counter /></div> → <p><Counter /></p>
// div 和 p 的 type 不同 → 整棵子树销毁重建,Counter 的 state 丢失
// ③ 类型相同则复用 DOM,只更新变化的属性
<div className="a" title="x" /> → <div className="b" title="x" />
// 复用同一个 DOM 节点,只执行 dom.className = 'b'顺带一个高频陷阱:
// ❌ 在组件内部定义组件
function Parent() {
function Child() { return <input />; } // 每次 Parent 渲染,Child 都是一个全新的函数
return <Child />; // type 每次都不同 → 每次都卸载重建 → input 失焦、state 丢失
}8.2 React 的列表 diff 实现(reconcileChildrenArray)
React 的实现和 Vue 的双端 diff 不同,是从左到右单向遍历 + 两轮:
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
let resultingFirstChild = null;
let previousNewFiber = null;
let oldFiber = currentFirstChild;
let lastPlacedIndex = 0; // 「最后一个可以保持不动的旧节点」在旧列表中的下标
let newIdx = 0;
// ===== 第一轮:从左到右逐个比较同下标的新旧节点 =====
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
if (newFiber === null) break; // key 不同 → 无法就地复用,跳出第一轮
// ... 挂接链表
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
previousNewFiber = newFiber;
oldFiber = oldFiber.sibling;
}
// ===== 第二轮的三种收尾情况 =====
// 情况 A:新节点遍历完了 → 剩下的旧节点全部删除
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
// 情况 B:旧节点遍历完了 → 剩下的新节点全部插入
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// ... 挂接
}
return resultingFirstChild;
}
// 情况 C:新旧都有剩余(说明发生了移动/乱序)
// 把剩余旧节点放进 Map<key ?? index, fiber>,然后遍历剩余新节点去 Map 里捞
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);
if (newFiber !== null) {
if (newFiber.alternate !== null) {
existingChildren.delete(newFiber.key === null ? newIdx : newFiber.key);
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
}
// ... 挂接
}
// Map 里没被复用的旧节点全部删除
existingChildren.forEach((child) => deleteChild(returnFiber, child));
return resultingFirstChild;
}
// 判断是否需要移动:React 只标记"向右移动"
function placeChild(newFiber, lastPlacedIndex, newIndex) {
newFiber.index = newIndex;
const current = newFiber.alternate;
if (current !== null) {
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// 这个节点在旧列表中的位置,比"已确定不动的最右边界"还靠左 → 需要移动
newFiber.flags |= Placement;
return lastPlacedIndex;
}
return oldIndex; // 不需要移动,更新右边界
}
newFiber.flags |= Placement; // 全新节点,需要插入
return lastPlacedIndex;
}关键点:React 的移动判定是单向的——只处理"把节点往右挪",不做"往左挪"的优化。所以 [A,B,C,D] → [D,A,B,C] 这种把最后一个移到最前的情况,React 会移动 A、B、C 三个(3 次移动),而 Vue3 用 LIS 只需移动 D(1 次)。这是 React diff 相对 Vue diff 的一个已知取舍:实现更简单、常数更小,但极端乱序场景移动次数更多。
8.3 下标 key 的问题,用具体例子说清
场景一:头部插入(性能退化)
// 旧:['B', 'C'] 新:['A', 'B', 'C']
// 用 index 作 key:
// key=0: 'B' → 'A' 复用节点,改文本
// key=1: 'C' → 'B' 复用节点,改文本
// key=2: 无 → 'C' 新建节点
// 结果:2 次文本更新 + 1 次插入
// 用 id 作 key:
// 'B'、'C' 完全复用,只在头部插入一个 'A'
// 结果:1 次插入场景二:状态错位(真正的 bug)
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: '写周报' },
{ id: 2, text: '改 bug' },
]);
const addFirst = () =>
setTodos((prev) => [{ id: 3, text: '开会' }, ...prev]); // 插到最前面
return (
<>
<button onClick={addFirst}>头部插入</button>
{todos.map((todo, index) => (
// ❌ 用 index 做 key
<div key={index}>
{todo.text}
{/* 非受控输入框,值存在真实 DOM 里,不受 React 数据流管理 */}
<input defaultValue="" placeholder="备注" />
{/* 子组件有自己的 state */}
<Checkbox />
</div>
))}
</>
);
}操作步骤:在"写周报"那行的备注框里输入"周五前",然后点"头部插入"。
结果:新插入的"开会"这一行,备注框里显示的是"周五前"。原因是 index=0 这个 key 前后两次渲染都存在,React 认为是同一个节点,于是复用了那个 DOM <input>(浏览器保存的用户输入值跟着 DOM 走),只把兄弟节点的文本改成了"开会"。同理,<Checkbox /> 的内部 state 也留在了原来的位置上。
结论:只要列表项内部持有"不由 props 决定的状态",下标 key 就会造成真正的渲染错乱,而不只是性能问题。 这类状态包括:
- 子组件的
useState/useReducerstate - 非受控表单元素的用户输入(
<input defaultValue>、<textarea>、文件选择) - 焦点、光标位置、滚动位置
ref指向的 DOM- CSS transition / animation 的进行状态
<video>/<audio>的播放进度
场景三:删除中间项
// 旧:[{id:1,'A'}, {id:2,'B'}, {id:3,'C'}] 删除 id=2
// 新:[{id:1,'A'}, {id:3,'C'}]
// 用 index:key=0 'A'→'A'(不变);key=1 'B'→'C'(复用了 B 的 DOM 和状态,只改文本!);key=2 删除
// → B 的 input 内容、勾选状态跑到了 C 这一行
// 用 id: 精确删除 id=2 的节点,A、C 完全不动场景四:排序 / 过滤
// 点击表头排序:[A,B,C] → [C,B,A]
// 用 index:三行的 DOM 全部原地复用,只把文本换了一遍
// → 每行的展开状态、勾选状态、动画完全错乱;有过渡动画的话看不到"移动"效果,只看到内容突变
// 过滤同理:筛选条件一变,下标与数据的对应关系整体错位什么时候用 index 是安全的(要能说出边界,才显得是理解而不是背诵):
同时满足以下全部条件时,index 作 key 没有问题:
- 列表是静态的,永远不会增删;
- 列表项永远不重新排序或过滤;
- 列表项内部没有任何本地状态(纯展示,所有内容都来自 props);
- 列表项没有非受控输入、没有 transition 动画。
典型场景:渲染一个固定的表头、一个静态的 tab 配置数组。
为什么不能用 Math.random() 做 key:那比 index 更糟。每次渲染 key 都不同 → React 认为所有节点都是新的 → 全部销毁重建 → 性能最差,且所有状态、焦点、动画每次都丢。
8.4 key 的正确选取
// ✅ 最好:后端返回的稳定唯一 id
{list.map((item) => <Row key={item.id} data={item} />)}
// ✅ 次选:能唯一标识业务实体的字段组合
{list.map((item) => <Row key={`${item.type}-${item.code}`} data={item} />)}
// ✅ 数据本身没有 id 时:在数据进入 state 的那一刻生成并固化 id,而不是在渲染时生成
const [list, setList] = useState(() => raw.map((it) => ({ ...it, _key: crypto.randomUUID() })));
// ⚠️ key 只需在「同一父节点的兄弟之间」唯一,不需要全局唯一8.5 key 的另一个用法:主动重置组件状态
// 切换用户时,希望表单内部的所有状态完全重置
<ProfileForm key={userId} userId={userId} />
// userId 变化 → key 变化 → React 认为是不同的组件 → 卸载旧的、挂载新的 → state 全部重置
// 这比在组件内写 useEffect(() => resetAllStates(), [userId]) 干净得多,也是官方推荐做法【追问 & 加分点】
- React diff 与 Vue3 diff 的差异:React 单向遍历 +
lastPlacedIndex判定右移;Vue3 是双端预处理(先同步头部、再同步尾部)+ 中间乱序段用newIndexToOldIndexMap+ 最长递增子序列求最少移动。Vue 的移动次数更优,React 的实现更简单且更契合其可中断渲染的需求。 - 为什么 React 不做跨层级的移动优化? 因为 DOM 跨层级移动在真实业务里极其罕见,为它付出 O(n³) 的匹配成本不划算。这是一个典型的"用假设换复杂度"的工程决策。
- key 变化会触发什么? 完整的卸载 + 挂载:
useEffect清理函数执行、componentWillUnmount、ref 置 null、DOM 销毁重建。所以用 key 重置状态虽然干净,但成本不低,不要在高频变化的值上用。 <Fragment key={...}>:<>...</>短语法不支持 key,需要 key 时必须写成<React.Fragment key={id}>。- key 不会作为 props 传给组件:
props.key是undefined(读取时 React 还会警告),需要在组件内用到这个值时必须额外传一个idprop。注意 React 19 把ref变成了普通 prop,但key依然是 React 保留的、不下传的。
反向链接(0)
还没有笔记链接到这里。在 Obsidian 里用 [[笔记名]] 引用它,这里就会出现,关系图谱上也会多出一条连线。