React核心原理

33 分钟

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?

两个原因:

  1. 性能:真实 DOM 节点的属性非常多(一个 div 有 200+ 属性),创建和修改代价高。虚拟 DOM 是轻量的 JS 对象,创建和对比成本远低于真实 DOM 操作。
  2. 跨平台:虚拟 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
过渡(较低)useTransitionuseDeferredValueTransitionLane
空闲(最低)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 的规则很明确:

  1. 只在函数组件的顶层调用 Hooks——不要在循环、条件或嵌套函数中调用。
  2. 只在 React 函数组件或自定义 Hook 中调用 Hooks——普通函数中不能调用。

eslint-plugin-react-hooksrules-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 可以实现一套简易的全局状态管理,但在中大型项目中会暴露两个问题:

  1. 粒度过粗导致无效渲染:把多个状态塞进同一个 Context,其中任何一个字段变化,所有 Consumer 都重渲染。拆成多个 Context 可以缓解,但 Context 数量多了嵌套层级会很深,维护成本高。
  2. 缺乏中间件和异步流程支持: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,类似简化版 ReduxAPI 极简,不需要 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 操作。

  1. 从触发更新的组件开始,向下遍历 Fiber 树。
  2. 对每个 Fiber 节点调用组件的 render 函数(函数组件就是组件函数本身),生成新的 React Element。
  3. 将新的 Element 和旧的 Fiber 节点进行 Diff,决定是复用、更新还是删除。
  4. 在需要变更的 Fiber 节点上打上 flags 标记(Placement、Update、Deletion)。

关键特性:Render Phase 是可中断的。在并发模式下,React 可以在处理完几个 Fiber 节点后暂停,让出主线程给更高优先级的任务,之后再恢复。这也意味着 Render Phase 中的代码(组件函数体、render 方法)可能被执行多次,所以必须是纯函数,不能有副作用。

Commit Phase(提交)

Render Phase 完成后,React 拿到一棵打好标记的 Fiber 树,进入 Commit Phase,同步地将变更应用到真实 DOM。这个阶段不可中断,分三个子阶段:

  1. Before Mutation:DOM 变更前。读取 DOM 快照(getSnapshotBeforeUpdate),调度 useEffect 的异步执行。
  2. Mutation:执行真实 DOM 操作——插入、更新、删除节点。useLayoutEffect 的清除函数在此阶段执行。
  3. 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 的闭包陷阱在定时器和事件监听中体现。把原理和工程经验串联起来,面试表达会更有说服力。