React 基础体系 · 第 16/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 并发渲染:Transition、Deferred Value、Suspense 和一致性

React 的“并发渲染”不是指 JavaScript 同时在多个线程执行,也不是指组件可以在后台修改已经提交到 DOM 的内容。它描述的是一种渲染调度模型:当一次更新包含大量计算,或者依赖尚未准备好的异步数据时,React 可以先处理更紧急的更新,暂停、丢弃或重新开始较低优先级的渲染,最后仍以一次完整提交的方式更新界面。

本文围绕四个概念展开:

  • Transition:把一组状态更新标记为非紧急更新;
  • Deferred Value:让某个值在后台渲染中逐步追上最新值;
  • Suspense:声明组件树在依赖未准备好时应显示的边界状态;
  • 一致性:说明并发渲染如何避免用户看到由不同状态拼接出的半成品界面,以及哪些外部数据访问方式会破坏这一保证。

示例以 React 19、现代 TypeScript 和客户端组件为基础。涉及服务端渲染、Server Components 和服务端数据获取时,会单独说明边界。


一、先建立模型:渲染、提交与并发

1. 渲染不是直接修改 DOM

一次 React 更新大致可以分为两个阶段:

  1. Render 阶段:调用组件函数,计算新的 React 元素树;
  2. Commit 阶段:把计算结果应用到 DOM,并执行相应的布局或副作用处理。

简化表示为:

状态更新
  │
  ▼
Render:计算新的 UI
  │
  ├─ 被打断、重试或丢弃
  │
  ▼
Commit:一次性提交到 DOM

并发渲染主要改变的是 Render 阶段。React 可以在计算低优先级树时暂停,让出主线程;也可以发现更新过时后放弃当前计算,重新基于更新后的状态渲染。

但一次 Commit 不应只提交“算到一半”的结果。用户通常看到的是某个完整的已提交 UI,而不是:

标题来自 query = "react"
列表来自 query = "re"
加载状态来自 query = "react 19"

这种不同时间点状态拼接出来的界面,就是并发场景中要避免的 tearing,中文常译为“撕裂”或“一致性破坏”。

2. “并发”不等于多线程

浏览器中的 React 通常仍运行在主线程。所谓并发,主要是:

  • 可中断的渲染;
  • 不同优先级的更新;
  • 对过时渲染结果的丢弃;
  • 对 Suspense 边界的协调;
  • 在多个状态更新之间选择更合适的提交时机。

因此,并发渲染不能消除 JavaScript 计算本身的成本。如果一个组件在渲染期间执行了很长的同步循环,浏览器主线程仍然会被阻塞。并发调度只能让 React 有机会在可中断的工作之间让出控制权。

3. React 的一致性目标

可以把一个已提交界面抽象为状态快照:

UIk=f(Sk)UI_k = f(S_k)

其中:

  • SkS_k 是第 kk 次提交所使用的状态快照;
  • ff 是组件树的纯渲染函数;
  • UIkUI_k 是提交后的完整界面。

React 希望满足:

已提交界面 UIk,UIk=f(Sk)\forall \text{已提交界面 } UI_k,\quad UI_k = f(S_k)

而不是让 DOM 中的一部分来自 SkS_k,另一部分来自 Sk+1S_{k+1}

并发渲染允许存在未提交的中间计算:

f(Sk+1) 正在计算f(S_{k+1}) \text{ 正在计算}

但这个中间结果在完成并通过 React 的协调后,才会作为一个整体提交。若计算期间又出现了更高优先级更新,旧计算可以被丢弃。

这就是后面 Transition、Deferred Value 和 Suspense 共同依赖的基础。


二、更新优先级:什么是紧急更新,什么是 Transition

1. 紧急更新和非紧急更新

用户输入通常需要立即响应。例如:

  • 文本框中的字符应立即显示;
  • 按钮按下后的视觉状态应尽快反馈;
  • 光标位置不能因为复杂列表渲染而长时间停滞。

而以下更新通常可以延后:

  • 根据搜索关键字重新计算大列表;
  • 切换复杂页面或选项卡;
  • 加载并渲染大量内容;
  • 重新渲染包含很多子组件的结果区域。

React 并不根据“操作名称”自动理解这些更新的业务优先级。开发者需要通过 Transition 明确告诉 React:

这组更新重要,但不应该阻塞当前的紧急交互。

2. startTransition

startTransition 用于把状态更新标记为 Transition:

import { startTransition, useState } from "react";

export function Tabs() {
  const [tab, setTab] = useState<"overview" | "details">("overview");

  function selectTab(nextTab: "overview" | "details") {
    startTransition(() => {
      setTab(nextTab);
    });
  }

  return (
    <div>
      <button onClick={() => selectTab("overview")}>概览</button>
      <button onClick={() => selectTab("details")}>详情</button>

      {tab === "overview" ? <Overview /> : <Details />}
    </div>
  );
}

这里的因果关系是:

  1. 点击事件发生;
  2. setTab 更新被标记为 Transition;
  3. React 可以优先完成其他更紧急的更新;
  4. Details 的渲染可以被暂停或重新开始;
  5. 新选项卡准备好后,React 再整体提交。

startTransition 不会自动显示加载状态,也不会自动取消网络请求,更不会让 Details 的计算成本消失。它只改变相关更新的调度优先级。

3. useTransition

如果组件需要知道 Transition 是否仍在进行,可以使用 useTransition

import { useState, useTransition } from "react";

export function TabPanel() {
  const [isPending, startTransition] = useTransition();
  const [tab, setTab] = useState<"overview" | "details">("overview");

  function selectTab(nextTab: "overview" | "details") {
    startTransition(() => {
      setTab(nextTab);
    });
  }

  return (
    <section>
      <nav aria-busy={isPending}>
        <button
          disabled={isPending}
          onClick={() => selectTab("overview")}
        >
          概览
        </button>
        <button
          disabled={isPending}
          onClick={() => selectTab("details")}
        >
          详情
        </button>
        {isPending && <span>正在准备内容…</span>}
      </nav>

      <div>
        {tab === "overview" ? <Overview /> : <Details />}
      </div>
    </section>
  );
}

isPending 表示相关 Transition 仍处于等待状态。它不是网络请求状态的通用替代品:

  • 请求可能已经结束,但组件仍在渲染;
  • Transition 可能在等待 Suspense 内容;
  • 多个更新可能被 React 合并;
  • isPending 的生命周期由 React 调度决定,不等于某个具体 Promise 的生命周期。

4. 文本输入不能直接作为 Transition 控制状态

文本输入有一个重要限制:控制输入框 value 的更新不能只放进 Transition。

下面的写法不正确:

function SearchBox() {
  const [query, setQuery] = useState("");

  return (
    <input
      value={query}
      onChange={(event) => {
        startTransition(() => {
          setQuery(event.target.value);
        });
      }}
    />
  );
}

原因是输入框的受控值需要立即更新。若它被当成非紧急更新,输入可能出现延迟、光标跳动或丢失字符。

正确方式是拆分两个状态:

function SearchBox() {
  const [inputValue, setInputValue] = useState("");
  const [query, setQuery] = useState("");
  const [, startTransition] = useTransition();

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    const nextValue = event.target.value;

    setInputValue(nextValue);

    startTransition(() => {
      setQuery(nextValue);
    });
  }

  return (
    <>
      <input value={inputValue} onChange={handleChange} />
      <p>用于搜索的关键字:{query}</p>
    </>
  );
}

这里:

  • inputValue 是紧急状态,保证输入框立即响应;
  • query 是非紧急状态,可以驱动复杂结果区域;
  • 二者短暂不同步是有意设计,而不是数据撕裂;
  • 只要结果区域基于同一个已提交的 query 快照渲染,界面仍然是一致的。

不过,这种手动维护两个状态也容易产生同步逻辑。对于“一个值只是希望延后使用”的场景,useDeferredValue 通常更直接。

5. Transition 的异步边界

React 19 支持将异步函数交给 Transition:

const [isPending, startTransition] = useTransition();

function handleSubmit() {
  startTransition(async () => {
    await saveData();
    setStatus("saved");
  });
}

这里必须区分三件事:

  1. await saveData() 的网络请求本身不是由 React 调度的;
  2. React 可以追踪这次 Transition 的等待状态;
  3. 异步函数中在 await 之后发生的状态更新,具体是否仍被视为同一个 Transition,受 React 版本和当前能力限制影响。

在需要明确控制的代码中,尤其是跨越多个异步边界时,可以在 await 后再次显式标记更新:

startTransition(async () => {
  const result = await saveData();

  startTransition(() => {
    setResult(result);
  });
});

这不是为了“加速请求”,而是为了明确后续状态更新的优先级。实际项目应以所使用 React 版本和框架对 Actions 的支持方式为准。


三、useDeferredValue:延后使用一个值,而不是延后产生它

1. API 语义

useDeferredValue 接收一个值,返回一个可能暂时落后的值:

const deferredQuery = useDeferredValue(query);

它的核心语义不是:

等待固定的 300 毫秒再更新。

而是:

当最新值对应的渲染较昂贵时,允许 React 先继续使用旧值,并在后台尝试让结果追上最新值。

因此,useDeferredValue 没有防抖时间参数,也不等价于 setTimeout

2. 一个完整的输入与结果例子

import { useDeferredValue, useState } from "react";

type Product = {
  id: string;
  name: string;
};

function filterProducts(products: Product[], query: string) {
  const normalized = query.trim().toLowerCase();

  if (!normalized) {
    return products;
  }

  return products.filter((product) =>
    product.name.toLowerCase().includes(normalized),
  );
}

export function ProductSearch({
  products,
}: {
  products: Product[];
}) {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);

  const filteredProducts = filterProducts(products, deferredQuery);
  const isStale = query !== deferredQuery;

  return (
    <section>
      <label>
        搜索:
        <input
          value={query}
          onChange={(event) => setQuery(event.target.value)}
        />
      </label>

      <div
        style={{
          opacity: isStale ? 0.6 : 1,
          transition: "opacity 120ms ease",
        }}
      >
        <p>结果关键字:{deferredQuery || "全部"}</p>
        <ul>
          {filteredProducts.map((product) => (
            <li key={product.id}>{product.name}</li>
          ))}
        </ul>
      </div>
    </section>
  );
}

逐步观察输入 "react" 的过程:

用户输入 r
  query = "r"
  deferredQuery 可能仍为 ""

用户继续输入 e
  query = "re"
  deferredQuery 可能仍为 "r"

后台渲染完成
  query = "re"
  deferredQuery = "re"

query !== deferredQuery 时,结果区域显示的是旧查询对应的内容。这个旧内容不是错误,而是一个明确的“正在追赶”状态。

3. useDeferredValuestartTransition 的差异

二者都可以降低某些更新对紧急交互的阻塞,但抽象层次不同。

机制 作用对象 常见场景
startTransition 标记一组状态更新 切换页面、更新选项卡、提交后刷新区域
useDeferredValue 延后某个已存在的值在下游的使用 输入框保持即时,列表使用较慢的关键字

可以用数据流表示:

startTransition:
事件 ──更新状态 A、B──> React 将这组更新作为非紧急工作

useDeferredValue:
状态 query ──> deferredQuery ──> 慢速结果组件
       └──────────────────────> 输入框立即使用 query

如果多个状态必须一起更新,例如切换选项卡同时更新 URL、选中项和内容区域,startTransition 更符合意图。

如果只有一个值需要让下游组件延后消费,useDeferredValue 更自然。

4. 它不是防抖,也不是请求取消

以下代码不会限制网络请求频率:

const deferredQuery = useDeferredValue(query);
const results = useResults(deferredQuery);

如果 useResults 每次看到新关键字都会发请求,那么关键字变化仍可能触发多次请求。Deferred Value 解决的是 React 渲染优先级,不是请求调度。

网络请求仍需要单独考虑:

  • 请求缓存;
  • 相同关键字复用 Promise;
  • 对不再需要的请求使用 AbortController
  • 忽略过期响应;
  • 处理错误和重试。

尤其是共享缓存 Promise 时,不能无条件取消某个请求,因为另一个组件可能仍然依赖同一个 Promise。


四、Suspense:组件如何声明“当前还不能完成渲染”

1. Suspense 不是通用的异步组件语法

Suspense 的基本形式是:

import { Suspense } from "react";

<Suspense fallback={<Loading />}>
  <Content />
</Suspense>

Content 的渲染路径依赖的数据尚未准备好时,React 可以让该子树暂停,并显示 fallback

但 Suspense 不是自动识别所有异步操作的机制。下面的代码不会被 Suspense 捕获:

function Content() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch("/api/data")
      .then((response) => response.json())
      .then(setData);
  }, []);

  if (!data) {
    return <p>加载中</p>;
  }

  return <pre>{JSON.stringify(data)}</pre>;
}

这里组件第一次渲染时直接返回了 <p>加载中</p>;网络请求发生在 Effect 中,React 没有收到一个“当前渲染需要等待”的信号。

Suspense 数据读取通常依赖框架或库提供的机制,或者 React 19 的 use API 读取一个 Promise。

2. React 19 的 use

React 19 提供:

import { use } from "react";

const data = use(promise);

它的行为是:

  • Promise 已完成:返回结果;
  • Promise 仍在等待:当前组件树暂停,最近的 Suspense 边界显示 fallback;
  • Promise 被拒绝:错误交给最近的错误边界。

use 与普通 Hook 有一个特殊区别:它可以在条件分支中使用,但仍必须遵守 React 对组件渲染的其他规则,不能在事件处理函数或 Effect 中直接调用。

3. 为什么必须缓存 Promise

下面的实现存在严重问题:

function Results({ query }: { query: string }) {
  const data = use(
    fetch(`/api/search?q=${encodeURIComponent(query)}`)
      .then((response) => response.json()),
  );

  return <pre>{JSON.stringify(data)}</pre>;
}

每次 Results 渲染都会创建一个新的 Promise。若 Promise 尚未完成,组件暂停;重试时又创建新的 Promise;这可能形成持续请求、重复挂起甚至无法稳定完成的循环。

因此,Promise 应由稳定的缓存管理:

type SearchResult = {
  items: Array<{
    id: string;
    name: string;
  }>;
};

const resultCache = new Map<string, Promise<SearchResult>>();

function getSearchResult(query: string): Promise<SearchResult> {
  const key = query.trim();

  let promise = resultCache.get(key);

  if (!promise) {
    promise = fetch(`/api/search?q=${encodeURIComponent(key)}`)
      .then((response) => {
        if (!response.ok) {
          throw new Error(`搜索请求失败:${response.status}`);
        }
        return response.json() as Promise<SearchResult>;
      });

    resultCache.set(key, promise);
  }

  return promise;
}

这个缓存的性质是:

同一个 query
  └─> 同一个 Promise

不同 query
  ├─> 不同 Promise
  └─> 可以并发请求

生产实现还需要限制缓存大小、设置失效策略,并根据业务决定是否缓存错误结果。

4. 一个可运行结构:输入、Deferred Value、Suspense 和错误边界

下面的例子展示四个机制如何组合。它假设应用已经有一个能够返回如下 JSON 的接口:

{
  "items": [
    { "id": "1", "name": "React" }
  ]
}

完整组件如下:

import {
  Component,
  Suspense,
  use,
  useDeferredValue,
  useState,
  type ChangeEvent,
  type ErrorInfo,
  type ReactNode,
} from "react";

type SearchResult = {
  items: Array<{
    id: string;
    name: string;
  }>;
};

const resultCache = new Map<string, Promise<SearchResult>>();

function getSearchResult(query: string): Promise<SearchResult> {
  const key = query.trim();

  if (!key) {
    return Promise.resolve({ items: [] });
  }

  const cached = resultCache.get(key);
  if (cached) {
    return cached;
  }

  const promise = fetch(`/api/search?q=${encodeURIComponent(key)}`)
    .then((response) => {
      if (!response.ok) {
        throw new Error(`搜索请求失败:HTTP ${response.status}`);
      }
      return response.json() as Promise<SearchResult>;
    });

  resultCache.set(key, promise);
  return promise;
}

function SearchResults({ query }: { query: string }) {
  const result = use(getSearchResult(query));

  if (result.items.length === 0) {
    return <p>没有匹配结果。</p>;
  }

  return (
    <ul>
      {result.items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

type ErrorBoundaryProps = {
  children: ReactNode;
  resetKey: string;
};

type ErrorBoundaryState = {
  error: Error | null;
};

class ErrorBoundary extends Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = { error: null };

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { error };
  }

  componentDidUpdate(previousProps: ErrorBoundaryProps) {
    if (previousProps.resetKey !== this.props.resetKey) {
      this.setState({ error: null });
    }
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    console.error("搜索区域渲染失败", error, info.componentStack);
  }

  render() {
    if (this.state.error) {
      return (
        <div role="alert">
          <p>{this.state.error.message}</p>
          <button onClick={() => this.setState({ error: null })}>
            重试
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

export function SearchPage() {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  function handleChange(event: ChangeEvent<HTMLInputElement>) {
    setQuery(event.target.value);
  }

  return (
    <main>
      <label>
        搜索:
        <input value={query} onChange={handleChange} />
      </label>

      <section
        aria-busy={isStale}
        style={{
          opacity: isStale ? 0.6 : 1,
          transition: "opacity 120ms ease",
        }}
      >
        <ErrorBoundary resetKey={deferredQuery}>
          <Suspense fallback={<p>正在加载搜索结果…</p>}>
            <SearchResults query={deferredQuery} />
          </Suspense>
        </ErrorBoundary>
      </section>
    </main>
  );
}

5. 这个例子的完整生命周期

假设用户依次输入 rre

第一步:输入 r

query = "r"
deferredQuery = "" 或 "r"

输入框立即使用 query,因此字符不会延迟。

如果 deferredQuery 仍是空字符串,结果区域仍然显示空查询的结果;如果后台渲染已经完成,也可能直接开始请求 r

第二步:输入 re

query = "re"
deferredQuery = "r"

这时:

  • 输入框显示 re
  • 结果区域继续显示 r 的结果;
  • 结果区域可以通过透明度表示内容正在过渡;
  • React 在后台尝试渲染 deferredQuery = "re" 的树。

第三步:re 的 Promise 完成

query = "re"
deferredQuery = "re"

use(getSearchResult("re")) 获得数据,Suspense 边界可以提交新结果。

第四步:请求失败

Promise 被拒绝后,use 把错误交给错误边界,而不是交给 Suspense。于是:

Suspense 处理:Promise 尚未完成
Error Boundary 处理:Promise 已拒绝或渲染抛出错误

两者职责不同,不能用 Suspense 的 fallback 代替错误界面。


五、Suspense 边界如何与 Transition 协作

1. 没有 Transition 时的行为

假设当前页面已经显示 Overview,切换到 Details 后,Details 需要异步数据:

function App() {
  const [tab, setTab] = useState<"overview" | "details">("overview");

  return (
    <>
      <button onClick={() => setTab("overview")}>概览</button>
      <button onClick={() => setTab("details")}>详情</button>

      <Suspense fallback={<PageSpinner />}>
        {tab === "overview" ? <Overview /> : <Details />}
      </Suspense>
    </>
  );
}

切换到 details 后,新的子树暂停。React 可能显示整个边界的 PageSpinner,因此用户暂时看不到原来的 Overview

这是一种合理行为:当前状态要求显示 Details,但 Details 尚未准备好。

2. 使用 Transition 保留已显示内容

function App() {
  const [tab, setTab] = useState<"overview" | "details">("overview");
  const [isPending, startTransition] = useTransition();

  function selectTab(nextTab: "overview" | "details") {
    startTransition(() => {
      setTab(nextTab);
    });
  }

  return (
    <>
      <button onClick={() => selectTab("overview")}>概览</button>
      <button onClick={() => selectTab("details")}>详情</button>

      {isPending && <span>正在切换…</span>}

      <Suspense fallback={<PageSpinner />}>
        {tab === "overview" ? <Overview /> : <Details />}
      </Suspense>
    </>
  );
}

当 Transition 内部的更新导致已显示内容再次 Suspend 时,React 可以继续保留当前已提交的内容,等待新内容准备好,而不是立刻把整个边界替换为 fallback。

简化时序如下:

当前:
  tab = overview
  Overview 已提交

点击 Details:
  tab = details 被标记为 Transition
  Details 开始渲染
  Details 暂停等待数据
  Overview 继续显示
  Details 数据完成
  Details 渲染完成
  React 提交 Details

这不是 Suspense 自动“保留旧内容”的普遍保证,而是 Transition 为这次更新提供了非紧急语义。若更新是紧急更新,React 可能更快展示 fallback。

3. Suspense 边界的位置决定用户看到什么

下面两个边界的用户体验不同:

<Suspense fallback={<PageSpinner />}>
  <Header />
  <MainContent />
  <Sidebar />
</Suspense>

只要 MainContent 暂停,整个边界的内容都可能进入 fallback。

更细的划分是:

<Header />

<Suspense fallback={<MainSpinner />}>
  <MainContent />
</Suspense>

<Suspense fallback={<SidebarSkeleton />}>
  <Sidebar />
</Suspense>

此时主内容和侧栏可以独立等待。边界不是性能魔法,而是 UI 状态协调单元:

  • 哪些内容必须一起出现;
  • 哪些内容可以独立加载;
  • 用户在等待期间保留哪些已显示内容;
  • 错误恢复应该重置哪一块。

4. Boundary 内部不要随意制造新的等待源

如果一次 Transition 会让同一个边界内部多个组件分别暂停,用户可能看到边界反复 fallback,或者等待时间变得难以解释。合理的边界设计通常围绕“用户能理解的内容单位”,而不是围绕每个请求机械拆分。


六、Deferred Value 与 Suspense 的组合:旧内容、后台新内容

useDeferredValue 特别适合搜索场景。与纯本地过滤不同,远程搜索可能触发 Suspense:

function SearchPage() {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);

  return (
    <>
      <input
        value={query}
        onChange={(event) => setQuery(event.target.value)}
      />

      <Suspense fallback={<p>首次加载结果…</p>}>
        <div style={{ opacity: query === deferredQuery ? 1 : 0.6 }}>
          <SearchResults query={deferredQuery} />
        </div>
      </Suspense>
    </>
  );
}

状态变化可以分为两种情况。

首次加载

query = ""
deferredQuery = ""
SearchResults("") 尚未准备好

没有可复用的旧结果,Suspense 显示 fallback。

已有旧结果后输入新关键字

query = "react 19"
deferredQuery = "react"
SearchResults("react") 已显示
SearchResults("react 19") 在后台等待

这时可以继续显示旧结果,并降低透明度。等新 Promise 完成后,再提交新结果。

这里的关键点是:

  • 输入框读取最新值;
  • 结果组件读取延后的值;
  • 结果区域内部仍然基于单个 deferredQuery 快照;
  • 并没有把新关键字和旧结果误认为已经匹配;
  • UI 明确通过样式或状态告诉用户结果正在更新。

如果不希望用户看到旧结果,也可以在 query !== deferredQuery 时显示独立的加载指示器。但不能仅仅依赖 Suspense fallback 来表达所有加载阶段,因为 Deferred Value 可能让旧树继续显示。


七、一致性:为什么并发渲染不会把界面“撕裂”

1. React 树内的一致提交

考虑一个列表筛选器:

function List({ query, items }: Props) {
  const visibleItems = items.filter((item) =>
    item.name.includes(query),
  );

  return (
    <>
      <h2>关键字:{query}</h2>
      <p>数量:{visibleItems.length}</p>
      <ul>
        {visibleItems.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </>
  );
}

标题、数量和列表都来自同一次组件调用中的 query。即使 React 在后台中断并重试渲染,它也不会把一次渲染调用中计算出的部分结果单独提交。

因此,一次提交满足:

标题 query = q
数量 = filter(items, q).length
列表 = filter(items, q)

而不是:

标题 query = q₂
数量 = filter(items, q₁).length
列表 = filter(items, q₁)

2. 违反一致性的常见来源:直接读取可变外部变量

下面的模式不安全:

let currentStore = {
  query: "",
  items: [],
};

function BadComponent() {
  return (
    <div>
      <h2>{currentStore.query}</h2>
      <List items={currentStore.items} />
    </div>
  );
}

如果 currentStore 在 React 渲染过程中被其他代码原地修改,组件的不同读取可能观察到不同版本的数据。React 无法管理这个普通可变对象的快照边界。

外部状态应通过适配器订阅。对于需要并发安全读取的外部 Store,应使用 useSyncExternalStore

import { useSyncExternalStore } from "react";

type Store = {
  getSnapshot(): StoreSnapshot;
  subscribe(listener: () => void): () => void;
};

type StoreSnapshot = {
  query: string;
  items: string[];
};

const store: Store = createStore();

function StoreView() {
  const snapshot = useSyncExternalStore(
    store.subscribe,
    store.getSnapshot,
  );

  return (
    <>
      <h2>{snapshot.query}</h2>
      <ul>
        {snapshot.items.map((item) => (
          <li key={item}>{item}</li>
        ))}
      </ul>
    </>
  );
}

useSyncExternalStore 的重点不是“让 Store 更方便”,而是为 React 提供:

  • 如何订阅变化;
  • 如何读取当前快照;
  • 在并发渲染期间如何验证快照是否仍然一致。

3. 外部 Store 在 Transition 中的特殊边界

如果外部 Store 在一个非紧急 Transition 期间发生不可变更且无法一致读取的变化,React 可能放弃并发渲染,改用阻塞方式重新渲染,以避免用户看到不一致内容。

这意味着:

  • Transition 不保证任何更新都能保持可中断;
  • 外部 Store 的实现质量会影响并发效果;
  • getSnapshot 应返回稳定、可比较的快照;
  • 不应在渲染期间原地修改 Store 数据。

4. 组件渲染必须保持纯

渲染函数中不应执行会产生外部可见副作用的操作:

function BadComponent() {
  analytics.send("rendered"); // 不应在渲染期间发送
  mutateGlobalState();        // 不应在渲染期间修改全局状态

  return <div />;
}

因为 React 可能:

  • 重新调用组件;
  • 中断后再次调用;
  • 渲染但最终不提交;
  • 在开发模式中额外检查。

副作用应放到 Effect、事件处理函数或框架提供的明确生命周期中。否则同一个“未提交渲染”可能产生重复写入。


八、异步数据、缓存、过期响应与一致性

Suspense 只描述“渲染时等待数据”的 UI 协调方式,不能自动解决异步数据的所有问题。

1. 不缓存请求会导致重复工作

function DataView({ id }: { id: string }) {
  const data = use(fetch(`/api/items/${id}`).then((r) => r.json()));
  return <pre>{JSON.stringify(data)}</pre>;
}

每次渲染创建新的 Promise,可能造成重复请求和无限重试。必须使用稳定的资源缓存,或者使用框架的数据层。

2. 过期响应不应覆盖新状态

即使不使用 Suspense,异步请求也可能按错误顺序完成:

输入 "r"  ──请求 A──────────────完成
输入 "re" ──请求 B────完成

如果 B 先完成,A 后完成,而代码无条件执行 setResults(A),界面就会被旧响应覆盖。

正确的策略包括:

  • 以 query 为缓存键;
  • 只从当前请求对应的资源读取;
  • 使用请求序列号验证响应是否仍然有效;
  • 使用 AbortController 取消已经不需要的请求;
  • 让数据层管理去重、失效和错误。

“Transition 会丢弃旧渲染”不等于“浏览器会自动取消旧网络请求”。渲染工作和网络工作是两个生命周期。

3. Abort 与共享缓存的取舍

如果一个 Promise 只属于一个请求实例,可以在新请求开始时取消旧请求:

const controller = new AbortController();

fetch(url, { signal: controller.signal });

// 不再需要时:
controller.abort();

但如果多个组件共享同一个缓存 Promise,就不能因为其中一个消费者不再需要而直接取消请求。否则其他消费者也会收到 AbortError

因此,取消策略必须与资源所有权一致:

独占请求:可以由拥有者取消
共享 Promise:需要引用计数或由缓存层统一管理

九、服务端渲染与 Server Components 的边界

1. Transition 是客户端交互概念

startTransitionuseTransition 主要用于客户端状态更新和交互调度。服务端渲染过程没有浏览器输入事件,也没有客户端 DOM 提交,因此不能把服务端渲染理解成“在服务器上运行 Transition”。

服务端可以生成 HTML、流式发送 Suspense 边界,并把客户端需要的内容逐步传输;客户端收到后仍由 React 负责水合和交互。

2. 服务端 Suspense 与流式渲染

在支持 React Server Components 或流式 SSR 的框架中,服务端可以先输出页面中已经准备好的部分:

服务器开始渲染
  ├─ Header 已完成,先发送
  ├─ Main 数据未完成,保留 Suspense 边界
  └─ Main 完成后再发送对应内容

浏览器先看到 fallback 或占位内容,数据准备好后再替换边界内部内容。这里的 Suspense 是服务端传输协议和客户端渲染协调的一部分,具体行为由框架的 SSR 实现决定。

3. Server Components 与客户端组件

Server Components 可以在服务端读取数据,但不能使用浏览器事件、客户端状态或客户端 Hook。需要交互的部分必须位于客户端组件边界内。

可以把职责划分为:

Server Component:
  获取服务端数据
  生成可序列化的组件输出
  不直接处理浏览器交互

Client Component:
  useState / useTransition / useDeferredValue
  事件处理
  浏览器 API
  客户端 Suspense 和交互更新

服务端传给客户端的数据必须满足框架规定的序列化条件。不能把任意 Promise、数据库连接、函数闭包或不可序列化的类实例直接当作客户端 Props 使用。

4. 服务端缓存不等于客户端缓存

服务端请求缓存、RSC Payload 缓存、浏览器 HTTP 缓存和客户端 Promise 缓存处于不同层次:

服务端数据缓存
  ≠ RSC / HTML 缓存
  ≠ 浏览器 HTTP 缓存
  ≠ 客户端 React 资源缓存

某一层命中缓存,并不意味着其他层也会命中。调试“为什么仍然请求了两次”时,需要确认请求发生在哪一层,以及是否存在开发模式、预加载、重新水合或框架重验证行为。


十、错误处理:Suspense 只处理等待,不处理失败

一个完整的数据边界通常同时需要 Suspense 和 Error Boundary:

<ErrorBoundary resetKey={query}>
  <Suspense fallback={<ResultsSkeleton />}>
    <Results query={query} />
  </Suspense>
</ErrorBoundary>

状态转移可以表示为:

资源不存在
  │
  ▼
Promise pending ──────────────┐
  │                           │
  │ resolve                   │ reject
  ▼                           ▼
正常内容                     Error Boundary

需要注意以下边界:

  • Suspense fallback 用于等待;
  • Error Boundary 用于渲染错误和 Promise rejection;
  • 错误边界不会捕获事件处理函数中的异常;
  • 错误边界也不自动捕获服务端请求的所有错误;
  • 错误恢复通常需要重置边界状态,或者改变资源键。

上面的 resetKey={deferredQuery} 是一种常见恢复方式:当搜索关键字变化时,错误边界清除旧错误,允许新资源重新渲染。重试按钮则清除当前错误状态;如果缓存永久保存了 rejected Promise,重试前还必须删除对应缓存项,否则会立即再次抛出同一个错误。


十一、常见误解与失败表现

误解一:Transition 会让代码执行得更快

不会。Transition 主要改变“何时做、是否可被打断”,不改变单次计算的算法复杂度。

如果列表过滤是:

O(n)O(n)

Transition 不会把它变成:

O(logn)O(\log n)

如果每次渲染都创建大量对象,仍应通过合理的数据结构、组件拆分和 memoization 减少实际工作。

误解二:Deferred Value 是防抖

防抖的模型是:

输入停止一段固定时间后,才触发一次操作

Deferred Value 的模型是:

输入立即更新
下游渲染由 React 按优先级推进

它没有固定等待时间,也不保证变化次数减少。

误解三:加上 Suspense 就自动拥有数据缓存

Suspense 需要一个能在渲染时提供“完成、等待或失败”状态的资源。它不负责决定:

  • 请求是否去重;
  • 数据缓存多久;
  • 如何失效;
  • 是否取消请求;
  • 如何重试;
  • 哪些错误应该展示给用户。

这些职责属于数据层或框架。

误解四:fallback 等于全局加载状态

Fallback 只代表对应 Suspense 边界中的内容尚未准备好。如果页面有多个边界,一个边界进入 fallback 不代表整个应用正在加载。

因此,加载提示应放在表达正确范围的位置。把整个应用包在一个巨大 Suspense 边界中,容易导致局部数据缺失时整页闪烁。

误解五:旧内容显示就是数据不一致

Deferred Value 或 Transition 下继续显示旧内容可能是有意的“过渡状态”,不等于撕裂。关键区别在于:

可接受:
输入框显示新 query,结果区域明确显示旧 query 的完整结果

不可接受:
标题显示新 query,数量和列表分别来自不同 query

前者是显式的延迟策略,后者是同一界面内部的快照混合。


十二、诊断并发问题的方法

1. 先判断问题发生在哪个阶段

遇到“输入卡顿”“页面闪烁”“结果错乱”时,先区分:

输入事件处理很慢?
  └─ 可能是同步计算或事件处理函数过重

Render 很慢?
  └─ 可能是组件树过大、重复计算、对象不稳定

Commit 后闪烁?
  └─ 可能是 Suspense 边界、布局副作用或加载状态设计问题

请求顺序错误?
  └─ 可能是缓存、取消或过期响应处理错误

外部状态撕裂?
  └─ 可能是直接读取可变 Store

2. 记录逻辑快照,而不是只记录 DOM

可以在组件中记录:

console.log({
  query,
  deferredQuery,
});

并在数据层记录:

console.log("request start", query);
console.log("request resolve", query);
console.log("request reject", query);

观察以下关系:

组件提交的 deferredQuery
是否对应实际读取的资源键?

请求完成后是否覆盖了当前查询?

错误边界重试时是否创建了新的资源?

3. 使用 React DevTools Profiler

Profiler 可以帮助确认:

  • 哪些组件在一次输入中重新渲染;
  • 哪些渲染是由状态、Props 还是 Context 触发;
  • Transition 是否真的把工作延后;
  • 是否存在本可以避免的重复渲染。

但 Profiler 不能代替数据一致性检查。一个渲染很快的错误缓存仍然可能返回过期数据。


十三、工程取舍:四种机制如何选择

可以按状态来源和问题类型选择:

只需要让复杂下游落后

使用:

const deferredValue = useDeferredValue(value);

适合:

  • 搜索结果;
  • 本地大列表;
  • 预览区域;
  • 图表过滤。

需要把多个更新作为一次非紧急操作

使用:

startTransition(() => {
  setPage(nextPage);
  setSelectedId(nextId);
});

适合:

  • 选项卡切换;
  • 路由切换;
  • 页面级状态转换;
  • 提交后刷新非关键区域。

渲染依赖尚未完成的数据

使用:

<Suspense fallback={<Loading />}>
  <AsyncContent />
</Suspense>

前提是数据读取机制真正支持 Suspense,例如框架提供的资源、React 19 的 use 配合稳定 Promise,或兼容的第三方数据层。

需要处理外部可变状态的一致快照

使用:

useSyncExternalStore(subscribe, getSnapshot)

不要在组件渲染期间直接读取和修改未受 React 管理的可变对象。


十四、最终检查:一条完整的数据流

一个较完整的搜索交互可以按下面的路径组织:

flowchart LR
    A[用户输入] --> B[紧急状态 query]
    B --> C[输入框立即更新]
    B --> D[useDeferredValue]
    D --> E[资源缓存按 query 查找]
    E --> F{Promise 状态}
    F -->|pending| G[Suspense fallback 或保留旧内容]
    F -->|fulfilled| H[结果组件读取数据]
    F -->|rejected| I[Error Boundary]
    H --> J[基于同一 query 快照提交]
    I --> K[重试或更换 query]

这条链路中每个机制负责不同问题:

  • query 保证用户输入立即反馈;
  • useDeferredValue 允许慢速结果落后;
  • 资源缓存避免每次重试都创建新请求;
  • Suspense 协调等待中的 UI;
  • Error Boundary 处理失败;
  • React 的原子提交保证结果区域不会由不同渲染快照拼接而成。

并发渲染的核心不是“让所有事情后台执行”,而是把更新分成可以被用户感知的优先级,并在渲染可能暂停、重试和重排的前提下,仍然只提交一致的界面快照。Transition 负责表达更新意图,Deferred Value 负责延后下游消费,Suspense 负责声明等待边界,而缓存、取消、错误恢复和外部 Store 适配则决定这套机制能否在真实应用中稳定工作。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。