React核心原理
JSX 的本质
面试中经常问"JSX 是什么",很多人回答"模板语法"——这是不准确的。JSX 既不是字符串,也不是 HTML,它是 JavaScript 的语法扩展,本质上是 React.createElement 的语法糖。
从 JSX 到虚拟 DOM
一段 JSX 代码在编译阶段会被 Babel(或 SWC、esbuild)转换为函数调用:
// 你写的 JSX
const element = <h1 className="title">Hello</h1>;
// 编译后的结果(React 17 之前)
const element = React.createElement('h1', { className: 'title' }, 'Hello');
// React 17+ 使用新的 JSX Transform,不再需要手动引入 React
import { jsx as _jsx } from 'react/jsx-runtime';
const element = _jsx('h1', { className: 'title', children: 'Hello' });
createElement 执行后返回一个普通 JavaScript 对象,这就是所谓的虚拟 DOM 节点(VNode):
// createElement 的返回值结构
{
$$typeof: Symbol(react.element),
type: 'h1',
key: null,
ref: null,
props: {
className: 'title',
children: 'Hello'
}
}
$$typeof 是一个 Symbol,用于防止 XSS 攻击——因为 JSON 无法序列化 Symbol,即使攻击者注入了一个看起来像 React Element 的 JSON 对象,React 也不会渲染它。
面试追问:为什么不直接操作真实 DOM?
两个原因:
- 性能:真实 DOM 节点的属性非常多(一个 div 有 200+ 属性),创建和修改代价高。虚拟 DOM 是轻量的 JS 对象,创建和对比成本远低于真实 DOM 操作。
- 跨平台:虚拟 DOM 是与平台无关的中间表示。同一份虚拟 DOM 描述,交给
react-dom渲染到浏览器,交给react-native渲染到移动端,交给react-three-fiber渲染到 3D 场景。
但要注意,虚拟 DOM 不一定比直接操作 DOM 快。它的核心价值是提供了声明式编程模型——开发者只描述"UI 应该是什么样",框架负责算出最小变更并执行。在大量节点的细粒度更新场景下,Svelte 那样编译时生成精确 DOM 操作的方案可能更快。
虚拟 DOM 与协调(Reconciliation)
虚拟 DOM 本身只是数据结构,真正让它发挥作用的是 React 的协调过程:当状态变化时,React 生成新的虚拟 DOM 树,和旧树进行对比(Diff),找出需要变更的最小操作集合,然后批量更新真实 DOM。
这个"对比 → 找差异 → 更新"的过程就是 Reconciliation。React 16 之前使用递归的 Stack Reconciler,16 之后切换到了基于 Fiber 架构的 Fiber Reconciler。
Fiber 架构
为什么需要 Fiber
React 15 的 Stack Reconciler 采用递归方式遍历组件树,一旦开始就无法中断,直到整棵树处理完毕。当组件树很大时(比如一个包含上千行的长列表),这个递归过程可能占用主线程几十甚至上百毫秒,导致浏览器无法响应用户输入、动画掉帧。
核心矛盾:JS 执行和页面渲染共享同一个主线程。长时间的同步计算会阻塞渲染,用户感知到卡顿。
Fiber 架构的设计目标就是解决这个问题——把原本不可中断的递归更新,拆成可中断、可恢复、可设置优先级的增量更新。
Fiber 节点的数据结构
每个 React 元素对应一个 Fiber 节点,Fiber 节点之间通过链表连接:
{
tag: 0, // 组件类型标记(FunctionComponent=0, ClassComponent=1, HostComponent=5...)
type: App, // 组件函数/类,或 DOM 标签字符串如 'div'
key: null,
stateNode: null, // 对应的真实 DOM 节点或类组件实例
// 链表指针——构成树结构
return: parentFiber, // 父节点
child: firstChild, // 第一个子节点
sibling: nextFiber, // 右边的兄弟节点
// 工作单元
pendingProps: {}, // 本次更新待处理的 props
memoizedProps: {}, // 上次渲染使用的 props
memoizedState: {}, // 上次渲染使用的 state(Hooks 链表也挂在这里)
updateQueue: null, // 更新队列
// 副作用
flags: 0, // 标记该节点需要执行的操作(Placement/Update/Deletion)
subtreeFlags: 0, // 子树的副作用标记(React 18 优化)
// 双缓冲
alternate: null, // 指向另一棵树中对应的 Fiber 节点
}
注意三个关键指针:return(父)、child(第一个子节点)、sibling(兄弟)。React 不再使用传统的树结构(children 数组),而是用链表串联,这样就可以通过循环代替递归来遍历整棵树。
时间切片与可中断渲染
Fiber 架构的核心思路是:把整棵树的渲染工作拆成一个个小的工作单元(每个 Fiber 节点就是一个工作单元),每处理完一个单元后,检查是否还有剩余时间,如果浏览器需要处理更高优先级的任务(如用户输入),就暂停渲染,把控制权交还给浏览器。
处理 Fiber A → 还有时间?→ 处理 Fiber B → 还有时间?→ 处理 Fiber C → 时间用完
↓
交还主线程 → 浏览器处理用户输入/绘制
↓
下一帧继续从 Fiber D 开始
判断"是否还有时间"依赖 shouldYield() 函数,底层使用 MessageChannel(而非 requestIdleCallback,因为后者触发频率太低且不稳定)来实现帧级别的调度。每个时间切片大约 5ms。
优先级调度
React 18 引入了 Lane 模型来管理优先级。不同类型的更新被分配到不同的 Lane:
| 优先级 | 触发场景 | Lane |
|---|---|---|
| 同步(最高) | flushSync 调用 | SyncLane |
| 连续输入 | 文本输入、拖拽 | InputContinuousLane |
| 默认 | setState、网络请求回调 | DefaultLane |
| 过渡(较低) | useTransition、useDeferredValue | TransitionLane |
| 空闲(最低) | offscreen、预渲染 | IdleLane |
高优先级更新可以打断低优先级更新的渲染过程。被打断的低优先级更新不会丢失,而是等高优先级任务完成后重新开始。
双缓冲机制
React 同时维护两棵 Fiber 树:
- current 树:当前屏幕上显示的内容对应的 Fiber 树。
- workInProgress 树:正在构建中的新 Fiber 树。
更新时,React 基于 current 树创建 workInProgress 树(尽可能复用节点)。所有计算都在 workInProgress 树上完成,不影响当前显示。构建完成后,React 将 workInProgress 树一次性切换为 current 树,这就是"双缓冲"——类似于显卡的前后缓冲区交换,避免用户看到中间状态。
Diff 算法
理论上,对比两棵树的差异是 O(n³) 的复杂度。React 通过三条策略将其优化到 O(n):
策略一:同层对比
React 只对比同一层级的节点,不会跨层级移动节点。如果一个节点从树的第二层移到了第三层,React 不会尝试"移动",而是直接销毁旧节点、创建新节点。
实际开发中,跨层级移动 DOM 的场景极少,这个假设在绝大多数情况下成立。
策略二:类型判断
对比同层的两个节点时,先比较 type:
- type 不同:直接销毁旧节点及其整棵子树,创建新节点。比如
<div>变成<span>,不会复用。 - type 相同:保留 DOM 节点,只更新变化的属性。然后递归对比子节点。
组件也一样:<ComponentA> 变成 <ComponentB>,即使渲染结果相似,React 也会卸载 A 并挂载 B。
策略三:key 标识
对比同层的子节点列表时,React 使用 key 来识别节点身份。这是列表渲染性能的关键。
没有 key 时,React 按顺序逐个对比,遇到差异就更新。在列表头部插入一项会导致后续所有项都被认为"变了",逐个更新。
有 key 时,React 通过 key 建立旧节点的映射,精确找到可复用的节点,只需移动位置而非重新创建。
// 差:用 index 做 key,在列表头部插入时所有项都会重新渲染
{items.map((item, index) => <Item key={index} data={item} />)}
// 好:用唯一标识做 key
{items.map(item => <Item key={item.id} data={item} />)}
为什么 index 做 key 有问题? 当列表项的顺序发生变化(插入、删除、排序)时,index 和实际数据的对应关系会错位。React 会认为同一个 key 对应的是同一个组件,复用其状态和 DOM,导致输入框内容错位、动画异常等 Bug。只有在列表不会重排且没有组件状态的静态展示场景下,用 index 做 key 才是安全的。
Hooks 原理
React 16.8 引入 Hooks,让函数组件拥有了状态和副作用能力。理解 Hooks 的底层实现,能帮你搞清楚很多"奇怪"的限制背后的原因。
Hooks 的链表存储
每个函数组件对应的 Fiber 节点上有一个 memoizedState 字段,它指向一条单向链表,链表中的每个节点对应组件内的一个 Hook 调用。
// 组件代码
function Counter() {
const [count, setCount] = useState(0); // Hook 1
const [name, setName] = useState('React'); // Hook 2
useEffect(() => { /* ... */ }, [count]); // Hook 3
return <div>{count} - {name}</div>;
}
// Fiber.memoizedState 指向的链表结构
Hook1(useState:0) → Hook2(useState:'React') → Hook3(useEffect) → null
每个 Hook 节点的结构大致如下:
{
memoizedState: any, // 当前值(useState 存 state,useEffect 存 effect 对象,useRef 存 ref 对象)
baseState: any, // 基础 state(用于中断恢复)
baseQueue: null, // 未处理的低优先级更新
queue: { // 更新队列(useState 的 dispatch 产生的更新会挂到这里)
pending: null,
dispatch: null,
lastRenderedReducer: null,
lastRenderedState: null,
},
next: nextHook, // 指向下一个 Hook 节点
}
首次渲染(mount)时,React 依次执行每个 Hook 调用,创建 Hook 节点并依次追加到链表末尾。后续更新(update)时,React 按同样的顺序遍历链表,将每个 Hook 调用和对应的节点匹配起来,读取旧状态并计算新状态。
Rules of Hooks:为什么不能条件调用
这是面试高频题。答案直接来源于链表结构:React 靠调用顺序来匹配 Hook 和链表节点,没有任何"名字"或"ID"来标识。
// ❌ 错误:条件调用导致顺序不稳定
function Bad({ showName }) {
const [count, setCount] = useState(0);
if (showName) {
const [name, setName] = useState('React'); // 条件 Hook
}
useEffect(() => { /* ... */ }, []);
}
// 第一次渲染 showName=true:Hook1(count) → Hook2(name) → Hook3(effect)
// 第二次渲染 showName=false:Hook1(count) → Hook2(effect) ← 错位!
// React 把 effect 的链表节点当成 useState 来处理,直接崩溃
所以 React 的规则很明确:
- 只在函数组件的顶层调用 Hooks——不要在循环、条件或嵌套函数中调用。
- 只在 React 函数组件或自定义 Hook 中调用 Hooks——普通函数中不能调用。
eslint-plugin-react-hooks 的 rules-of-hooks 规则就是在编译期检查这两条约束。
useState 原理
useState 本质是 useReducer 的语法糖,内部使用 basicStateReducer:
function basicStateReducer(state, action) {
return typeof action === 'function' ? action(state) : action;
}
调用 setCount(1) 时,React 创建一个 update 对象挂到当前 Hook 节点的 queue.pending 上,然后触发调度。下次渲染时,React 遍历 update 链表,依次计算最终 state。
需要注意的是:setState 不会立即修改 state。在同一个事件处理函数中连续调用多次 setState,React 会进行批量更新(batching),只触发一次重渲染。React 18 中,这种批量更新行为扩展到了所有场景(setTimeout、Promise 回调等),不再仅限于事件处理函数。
function handleClick() {
setCount(count + 1); // 此时 count 仍是旧值
setCount(count + 1); // 还是基于同一个旧值,结果只加 1
// 如果要基于最新值更新,使用函数式写法:
setCount(prev => prev + 1);
setCount(prev => prev + 1); // 基于上一次的结果,正确加 2
}
useEffect 执行时机
useEffect 的回调不是同步执行的,而是在浏览器完成 DOM 绘制之后异步执行。具体时序:
setState → Render Phase(计算新虚拟DOM)
→ Commit Phase(同步更新真实DOM)
→ 浏览器绘制(用户看到新画面)
→ useEffect 回调执行
这意味着 useEffect 回调中拿到的 DOM 已经是更新后的,但执行时机不会阻塞浏览器渲染。如果需要在 DOM 变更后、浏览器绘制前同步执行副作用(比如测量 DOM 尺寸后立即调整布局),应该使用 useLayoutEffect。
useEffect 的清除函数(return 的函数)在下一次 effect 执行前或组件卸载时调用:
useEffect(() => {
const timer = setInterval(() => console.log('tick'), 1000);
return () => clearInterval(timer); // 清除上一次的定时器
}, []);
闭包陷阱
这是实际开发和面试中的高频坑。每次渲染都会创建一个新的函数作用域,useEffect 和事件处理函数中捕获的是那一次渲染时的 state 和 props,而不是最新值。
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // 永远打印 0!因为闭包捕获的是初始渲染时的 count
setCount(count + 1); // 永远是 0 + 1 = 1
}, 1000);
return () => clearInterval(timer);
}, []); // 空依赖数组,effect 只在 mount 时执行一次
return <div>{count}</div>;
}
解决方案有三种:
// 方案一:使用函数式更新,不依赖闭包中的 count
setCount(prev => prev + 1);
// 方案二:将 count 加入依赖数组(但每次 count 变化都会重新创建定时器)
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, [count]);
// 方案三:用 useRef 保存最新值
const countRef = useRef(count);
countRef.current = count; // 每次渲染都更新 ref
useEffect(() => {
const timer = setInterval(() => {
setCount(countRef.current + 1); // ref.current 始终是最新值
}, 1000);
return () => clearInterval(timer);
}, []);
常用 Hooks 详解
useState
管理组件内部状态,返回 [state, setState]。初始值只在首次渲染时生效。如果初始值计算成本高,可以传入函数做惰性初始化:
// ❌ 每次渲染都会执行 computeExpensiveValue,即使值被忽略
const [data, setData] = useState(computeExpensiveValue());
// ✅ 只在首次渲染时执行
const [data, setData] = useState(() => computeExpensiveValue());
useEffect
处理副作用(数据获取、订阅、手动 DOM 操作)。依赖数组决定 effect 何时重新执行:
| 依赖数组 | 行为 |
|---|---|
| 不传 | 每次渲染后都执行 |
[] | 仅在 mount 后执行一次,unmount 时调用清除函数 |
[a, b] | 当 a 或 b 变化时执行,执行前先调用上一次的清除函数 |
依赖的比较使用 Object.is,引用类型(对象、数组)每次渲染都会创建新引用,导致 effect 每次都触发。解决办法是用 useMemo 稳定引用,或把依赖拆成基本类型。
useRef
useRef 返回一个 { current: initialValue } 对象,这个对象在组件的整个生命周期内保持不变(同一个引用)。修改 ref.current 不会触发重渲染。
两种典型用途:
// 用途一:引用 DOM 节点
function TextInput() {
const inputRef = useRef(null);
const focusInput = () => inputRef.current.focus();
return <input ref={inputRef} />;
}
// 用途二:保存"最新值",解决闭包陷阱
function LatestValue({ value }) {
const latestRef = useRef(value);
latestRef.current = value; // 每次渲染都更新,但不触发重渲染
useEffect(() => {
const timer = setTimeout(() => {
console.log(latestRef.current); // 永远拿到最新的 value
}, 3000);
return () => clearTimeout(timer);
}, []);
}
useMemo 与 useCallback
两者都是性能优化手段,核心目的是避免不必要的重复计算或引用变化:
useMemo(() => computeValue(a, b), [a, b]):缓存计算结果,只在依赖变化时重新计算。useCallback((args) => doSomething(a, b), [a, b]):缓存函数引用,只在依赖变化时重新创建。
useCallback(fn, deps) 等价于 useMemo(() => fn, deps)。
function ParentComponent({ items }) {
// ✅ items 不变时,filteredList 不会重新计算
const filteredList = useMemo(
() => items.filter(item => item.active),
[items]
);
// ✅ 引用稳定,传给子组件不会导致子组件因 props 变化而重渲染
const handleClick = useCallback(
(id) => { console.log('clicked', id); },
[] // 没有外部依赖,函数引用永远不变
);
return <ChildList items={filteredList} onClick={handleClick} />;
}
// 配合 React.memo 才有意义——React.memo 浅比较 props,引用不变则跳过渲染
const ChildList = React.memo(({ items, onClick }) => {
return items.map(item => <div key={item.id} onClick={() => onClick(item.id)}>{item.name}</div>);
});
滥用警告:不要给每个值和函数都加 useMemo/useCallback。缓存本身有成本(额外的内存、依赖比较开销)。只在以下场景使用:
- 计算确实昂贵(大数组过滤、复杂序列化等)。
- 值作为
useEffect的依赖——引用不稳定会导致 effect 频繁执行。 - 函数/对象作为 props 传给
React.memo包裹的子组件。
useContext
跨层级传递数据,避免 prop drilling。useContext 接收 createContext 的返回值,读取最近的 Provider 提供的 value:
const ThemeContext = createContext('light');
function App() {
return (
<ThemeContext.Provider value="dark">
<Toolbar />
</ThemeContext.Provider>
);
}
function ThemedButton() {
const theme = useContext(ThemeContext); // "dark"
return <button className={theme}>Click</button>;
}
Context 的一个关键性能问题:Provider 的 value 变化时,所有消费该 Context 的组件都会重渲染,即使组件只用了 value 中的一部分。这正是 Context 在状态管理场景下的局限性,后面会展开。
React 状态管理
Context 的局限性
Context + useReducer 可以实现一套简易的全局状态管理,但在中大型项目中会暴露两个问题:
- 粒度过粗导致无效渲染:把多个状态塞进同一个 Context,其中任何一个字段变化,所有 Consumer 都重渲染。拆成多个 Context 可以缓解,但 Context 数量多了嵌套层级会很深,维护成本高。
- 缺乏中间件和异步流程支持:Context 本身没有 middleware、devtools、persist 这类能力,异步数据获取需要自己在 useEffect 里手写,缺乏统一的模式。
Context 更适合低频变化的全局配置(主题、语言、用户认证状态),不适合频繁变化的业务状态。
Redux 核心思想
Redux 的设计遵循三个原则:
- 单一数据源:整个应用的状态存储在一棵 state 树中,由唯一的 Store 持有。
- 状态只读:唯一改变 state 的方式是 dispatch 一个 Action(描述"发生了什么"的普通对象)。
- 纯函数修改:Reducer 是纯函数,接收旧 state 和 action,返回新 state,不产生副作用。
// Action
const increment = { type: 'counter/increment', payload: 1 };
// Reducer
function counterReducer(state = { value: 0 }, action) {
switch (action.type) {
case 'counter/increment':
return { value: state.value + action.payload };
default:
return state;
}
}
// Store
const store = createStore(counterReducer);
store.dispatch(increment);
Redux 的优势在于可预测性——任何状态变化都有明确的 action 触发链路,配合 Redux DevTools 可以做时间旅行调试。劣势是样板代码多,现代项目一般使用 Redux Toolkit(RTK)简化:
import { createSlice, configureStore } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: (state, action) => { state.value += action.payload; },
// RTK 内部使用 Immer,可以"直接修改" state(实际上是生成新对象)
},
});
const store = configureStore({ reducer: { counter: counterSlice.reducer } });
轻量方案:Zustand / Jotai / Recoil
Redux 之外,社区涌现了多种更轻量的状态管理方案,各有不同的心智模型:
| 库 | 心智模型 | 核心特点 |
|---|---|---|
| Zustand | 单一 Store,类似简化版 Redux | API 极简,不需要 Provider,基于发布-订阅,组件只订阅用到的字段,天然避免无效渲染 |
| Jotai | 原子化(Atom),自底向上 | 每个状态是一个独立的 atom,组件直接读写 atom,派生状态通过 atom 组合实现,类似 Recoil 但更轻量 |
| Recoil | 原子化 + Selector,Facebook 出品 | atom 定义最小状态单元,selector 定义派生状态,支持异步 selector,与 React 并发模式深度集成 |
Zustand 的典型用法——对比 Redux 的简洁程度一目了然:
import { create } from 'zustand';
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));
// 组件中使用——只订阅 count,其他字段变化不会触发重渲染
function Counter() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}
选型建议:小项目或局部状态用 Zustand/Jotai 足够;需要严格的单向数据流、中间件生态和团队规范的大型项目,Redux Toolkit 仍然是主流选择。
React 渲染流程
React 的一次更新分为两个阶段,理解它们的边界和特性是回答很多面试题的基础。
Render Phase(调度 + 协调)
这个阶段的任务是计算变更,不涉及任何真实 DOM 操作。
- 从触发更新的组件开始,向下遍历 Fiber 树。
- 对每个 Fiber 节点调用组件的 render 函数(函数组件就是组件函数本身),生成新的 React Element。
- 将新的 Element 和旧的 Fiber 节点进行 Diff,决定是复用、更新还是删除。
- 在需要变更的 Fiber 节点上打上 flags 标记(Placement、Update、Deletion)。
关键特性:Render Phase 是可中断的。在并发模式下,React 可以在处理完几个 Fiber 节点后暂停,让出主线程给更高优先级的任务,之后再恢复。这也意味着 Render Phase 中的代码(组件函数体、render 方法)可能被执行多次,所以必须是纯函数,不能有副作用。
Commit Phase(提交)
Render Phase 完成后,React 拿到一棵打好标记的 Fiber 树,进入 Commit Phase,同步地将变更应用到真实 DOM。这个阶段不可中断,分三个子阶段:
- Before Mutation:DOM 变更前。读取 DOM 快照(
getSnapshotBeforeUpdate),调度useEffect的异步执行。 - Mutation:执行真实 DOM 操作——插入、更新、删除节点。
useLayoutEffect的清除函数在此阶段执行。 - Layout:DOM 已经更新但浏览器还未绘制。
useLayoutEffect回调在此阶段同步执行,componentDidMount/componentDidUpdate也在这个时机调用。
浏览器绘制完成后,React 再异步执行 useEffect 的回调。
完整时序:
setState
→ Render Phase(可中断,计算 Diff,打标记)
→ Commit Phase
→ Before Mutation(读 DOM 快照)
→ Mutation(操作真实 DOM,执行 useLayoutEffect 清除函数)
→ Layout(执行 useLayoutEffect 回调)
→ 浏览器绘制
→ useEffect 异步执行
面试追问:为什么 Commit Phase 不能中断?
如果 Commit Phase 中途暂停,用户会看到 DOM 更新了一半的中间状态——比如列表删了前三项但后面的还没移上来。这会导致严重的视觉闪烁和交互异常。所以 Commit Phase 必须同步执行到底。
并发模式(Concurrent Mode)
什么是并发模式
React 18 正式引入并发特性(不再叫"Concurrent Mode",而是按需启用的并发特性)。核心理念:渲染是可中断的,高优先级更新可以插队。
传统的同步渲染模型下,一旦开始渲染就必须走完,无论耗时多久。并发模式允许 React "同时"准备多个版本的 UI——不是真正的多线程,而是通过时间切片在单线程上交替执行不同优先级的任务。
启用方式:使用 createRoot 替代 ReactDOM.render:
// React 17
ReactDOM.render(<App />, document.getElementById('root'));
// React 18 — 启用并发特性
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
Suspense
Suspense 让组件在等待异步数据时"暂停"渲染,显示 fallback UI,数据就绪后恢复。
import { Suspense, lazy } from 'react';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</Suspense>
);
}
Suspense 的工作原理:当子组件在渲染过程中抛出一个 Promise(是的,throw 一个 Promise),React 捕获它,暂停该子树的渲染,显示最近的 Suspense 边界的 fallback。Promise resolve 后,React 重新渲染该子树。
React 18 中,Suspense 不仅用于代码分割(lazy),还可以配合数据获取框架(如 Next.js、Relay)实现 Streaming SSR——服务端可以先发送页面骨架,数据就绪的部分逐步流式注入。
useTransition
useTransition 把一次状态更新标记为"过渡更新"(低优先级),不会阻塞用户交互。典型场景:搜索输入框实时过滤大列表。
function SearchPage() {
const [query, setQuery] = useState('');
const [filteredItems, setFilteredItems] = useState(allItems);
const [isPending, startTransition] = useTransition();
function handleChange(event) {
const value = event.target.value;
setQuery(value); // 高优先级:立即更新输入框
startTransition(() => {
// 低优先级:过滤计算可以被中断,不阻塞输入
setFilteredItems(allItems.filter(item => item.name.includes(value)));
});
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <span>Filtering...</span>}
<ItemList items={filteredItems} />
</>
);
}
isPending 标志位让你可以在过渡期间显示 loading 状态。用户连续快速输入时,React 会中断未完成的低优先级渲染,直接开始最新输入对应的渲染——用户感知到输入框始终流畅响应。
useDeferredValue
useDeferredValue 的思路和 useTransition 类似,但角度不同:它接收一个值,返回该值的"延迟版本"。React 会先用旧值渲染(保持界面响应),再在后台用新值重新渲染。
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
// deferredQuery 可能"滞后"于 query
// React 先用旧的 deferredQuery 渲染,保持界面不卡顿
// 空闲时再用新值重新渲染
const isStale = query !== deferredQuery; // 可以据此显示加载状态
return (
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<ExpensiveList query={deferredQuery} />
</div>
);
}
useTransition vs useDeferredValue:前者用于你能控制状态更新的地方(包裹 setState 调用),后者用于你只能拿到值但无法控制更新源头的地方(比如值来自 props)。
总结
React 的核心可以用一条主线串起来:
JSX 语法糖 → 编译为 createElement → 生成虚拟 DOM(轻量 JS 对象)→ 通过 Fiber 架构(链表 + 时间切片 + 优先级调度)实现可中断的协调 → Diff 算法(同层对比 + 类型判断 + key 标识)找出最小变更 → 分为 Render Phase 和 Commit Phase 执行更新 → Hooks 通过链表为函数组件提供状态和副作用能力 → 并发特性让高优先级更新不被阻塞。
面试回答建议:
- 虚拟 DOM:重点讲声明式编程模型的价值,而非"比真实 DOM 快"。
- Fiber:围绕"为什么需要 → 怎么实现可中断 → 优先级怎么调度"三层递进。
- Diff:三条策略能清晰说出来,再加 key 的实际影响。
- Hooks:链表存储 → Rules of Hooks 的原因 → 闭包陷阱,这是追问链条。
- 并发特性:结合
useTransition的搜索场景讲清楚"可中断渲染"的实际意义。
每个知识点都能和实际项目结合:虚拟 DOM 的跨平台能力在 React Native 中体现,Fiber 的优先级调度在输入框实时搜索中体现,Hooks 的闭包陷阱在定时器和事件监听中体现。把原理和工程经验串联起来,面试表达会更有说服力。