FORMA

渲染与协调

React 将组件渲染为虚拟 DOM 树,再**协调(reconciliation)**到浏览器 DOM。理解该过程有助于解释 keymemo 与性能优化。见 React 基础

核心概念

React 应用的每次更新都会经历「重新计算应该长什么样」和「把差异应用到真实 DOM」两件事。React 把这两件事拆成明确的两阶段:先在内存里算出新的虚拟 DOM 树并与旧树 diff(协调),再把差异一次性 commit 到真实 DOM。这个设计让 React 可以在 Render 阶段暂停、重新开始、甚至丢弃计算结果而不影响用户看到的界面,是并发特性的基础。

渲染流程(简化)

  1. 触发更新setStatedispatch、父组件重渲染、Context value 变化等。
  2. Render 阶段:调用组件函数,得到新的 React 元素树(可中断,React 18 并发特性在此阶段调度)。
  3. Commit 阶段:将变更应用到 DOM,执行 useLayoutEffect、浏览器绘制,再执行 useEffect
text
setState() → 调度更新 → Render(可中断/可丢弃) → Diff → Commit(不可中断)→ useLayoutEffect → 浏览器绘制 → useEffect

协调与 key

当同一层级的子节点类型或 key 变化时,React 会卸载旧节点、挂载新节点;key 相同则尝试复用实例并更新 props。

  • 列表使用稳定唯一 key(如数据库 id)。
  • 用下标作 key 在插入/重排时会导致错误的状态保留。

示例:下标 key 导致的状态错位

tsx
function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <ul>
      {todos.map((todo, index) => (
        // 反例:用 index 作 key,在列表开头插入新项后,
        // 原来第 0 项的 <input> 内部状态(如已聚焦、已输入的临时值)会被错误地复用到新的第 0 项上
        <li key={index}>
          <TodoItem todo={todo} />
        </li>
      ))}
    </ul>
  );
}

// 正确:用稳定的业务 id
{todos.map((todo) => <li key={todo.id}><TodoItem todo={todo} /></li>)}

React 通过 key 判断「这是不是同一个逻辑实体」,而不是「这是不是同一个位置」——理解这一点就能明白为什么下标 key 在增删排序场景下容易出问题。

Fiber 架构(概念)

React 16+ 使用 Fiber 将渲染工作拆为可调度的小单元,支持:

  • 可中断的渲染(为高优先级更新让路)。
  • Suspense、并发渲染、startTransition 等(React 18)。

应用开发通常只需知道「渲染可被打断、分优先级」,无需实现 Fiber 细节。可以把 Fiber 类比成「渲染任务的调度单元」:每个组件对应一个 Fiber 节点,React 可以在处理完一个节点后检查「是否有更紧急的任务插队」,而不必等整棵树处理完。

startTransition

非紧急更新标记为 transition,保持输入等交互流畅:

tsx
import { startTransition, useState } from "react";

function Search() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<Item[]>([]);

  function handleChange(value: string) {
    setQuery(value); // 紧急更新:输入框要立刻响应
    startTransition(() => {
      setResults(filterHugeList(value)); // 非紧急:可以稍后计算,不阻塞输入
    });
  }
  // ...
}

配合 useTransition 可以获得 isPending 状态,用于展示加载指示:

tsx
function Search() {
  const [isPending, startTransition] = useTransition();
  const [results, setResults] = useState<Item[]>([]);

  function handleChange(value: string) {
    startTransition(() => setResults(filterHugeList(value)));
  }

  return (
    <div>
      <input onChange={(e) => handleChange(e.target.value)} />
      {isPending && <Spinner />}
      <ResultList items={results} />
    </div>
  );
}

Suspense 与数据获取

Suspense 让组件在等待异步资源(懒加载组件、支持 Suspense 的数据获取库)时展示 fallback,而不必手写 if (loading) return <Spinner />

tsx
<Suspense fallback={<Spinner />}>
  <UserProfile userId={id} />
</Suspense>

结合支持 Suspense 的数据请求方案(如部分 Relay、use() API 或框架内置能力),可以让加载状态的处理更统一,具体接入方式因所用数据层而异。

StrictMode 与双重渲染

开发模式下 StrictMode额外执行一次部分逻辑以帮助发现副作用问题,不代表生产环境会渲染两次。见 React 基础。这个「双重调用」专门用于暴露不纯的组件函数或未正确清理的 Effect——如果一个 Effect 执行两次就出现异常行为(如重复发起请求且没有取消机制),往往说明清理逻辑本身有问题,而不是 StrictMode 的锅。

最佳实践

  • 列表 key 一律使用稳定的业务标识(id),不要用数组下标,尤其是列表可能重排、插入、删除的场景。
  • 筛选、搜索等「输入即触发大量计算」的交互,用 startTransition/useTransition 保持输入响应性。
  • 理解「Render 阶段可能被多次调用/丢弃」,因此组件函数体必须是纯函数,不要在渲染过程中直接产生副作用(如修改外部变量、发起请求)。
  • Suspense 边界的粒度要适当:太粗会导致整块 UI 一起等待,太细会导致 fallback 闪烁过多。
  • 排查「明明数据变了但界面没更新」问题时,先检查是否是 key 导致组件被错误复用/重建。

常见坑

现象常见原因处理
列表重排后输入框内容跑到别的行用了下标作 key改用稳定业务 id
组件函数体内直接调用了 console.log/发请求造成重复执行不了解 Render 阶段可能被多次调用(尤其 StrictMode 开发下)副作用移入 useEffect,保持渲染函数纯净
startTransition 里的更新看起来「没生效」把同步的紧急更新也包进了 transition,导致响应变慢只把「非紧急、可以稍后计算」的更新放进 transition
Suspense fallback 一直不消失内部资源的 Promise 未正确 resolve,或数据层未支持 Suspense 协议确认所用数据获取方案是否真正支持 Suspense
生产环境「感觉」也渲染了两次与 StrictMode 无关,可能是父组件本身多次触发更新用 Profiler 确认渲染次数与触发源

延伸阅读

参考文献

以下链接在编写时均可正常访问:

资料说明
React:渲染与提交两阶段
React:preserving statekey 与状态
React:useTransition过渡更新
React:Suspense异步边界
React:StrictMode开发模式检查

Series

react

10 / 16