Skip to content

深入理解 React 并发渲染

useTransition、Suspense、Selective Hydration、流式 SSR 与并发模式内部机制

什么是并发渲染?

定义:并发渲染(Concurrent Rendering)是 React 18 引入的渲染模式,允许 React 同时准备多个版本的 UI,根据优先级决定哪个版本先展示给用户。它不是多线程——JavaScript 仍然是单线程的——而是通过时间切片优先级调度实现"看起来同时进行"的效果。

涉及场景

  • 搜索输入:用户输入时立即响应键盘,搜索结果延迟渲染
  • 页面导航:切换标签时保持旧页面显示,新页面准备好后一次性切换
  • 大列表过滤:过滤操作不阻塞输入框交互
  • Suspense 数据加载:组件挂起时展示 fallback,数据就绪后无缝切换
  • 流式 SSR:服务端逐步发送 HTML,客户端逐步水合

作用

  1. UI 始终响应:紧急更新(输入、点击)不被耗时渲染阻塞
  2. 更好的加载体验:Suspense + 流式渲染让页面逐步呈现
  3. 减少不必要的中间状态:Transition 保持旧 UI 直到新 UI 就绪
  4. 面试重点:理解并发渲染是区分初级和高级 React 开发者的关键

一、并发模式 vs 同步模式

同步模式(React 17 及之前):
  setState() → 开始渲染 → 渲染完成 → 更新 DOM

           不可中断,必须一次性完成
           如果渲染耗时 100ms,用户输入延迟 100ms

并发模式(React 18+):
  setState() → 开始渲染 → [用户输入!] → 暂停渲染 → 处理输入 → 恢复渲染 → 更新 DOM

                    高优先级更新中断低优先级渲染

启用方式

jsx
// React 18+:使用 createRoot 自动启用并发特性
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(<App />);

// React 17:同步模式(不支持并发特性)
// ReactDOM.render(<App />, document.getElementById('root'));

二、useTransition 深入

基本原理

jsx
import { useTransition, useState } from 'react';

function FilterableList({ items }) {
  const [query, setQuery] = useState('');
  const [filtered, setFiltered] = useState(items);
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    const value = e.target.value;
    // 紧急更新:立即反映输入
    setQuery(value);

    // 非紧急更新:可被中断
    startTransition(() => {
      const result = items.filter(item =>
        item.name.toLowerCase().includes(value.toLowerCase())
      );
      setFiltered(result);
    });
  }

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isPending && <div className="loading-bar" />}
      <ul>
        {filtered.map(item => <li key={item.id}>{item.name}</li>)}
      </ul>
    </div>
  );
}

内部机制

startTransition(() => setState(newValue))

1. React 将这次 setState 标记为 TransitionLane(低优先级)
2. 调度器发现有 TransitionLane 的更新
3. 开始在 WIP 树上渲染这个更新
4. 如果渲染过程中收到 SyncLane 的更新(如用户输入):
   a. 暂停 Transition 的渲染
   b. 优先处理 SyncLane 更新
   c. SyncLane 处理完后,恢复 Transition 渲染
5. Transition 渲染完成 → Commit
6. isPending: true → false

startTransition(非 Hook 版本)

jsx
import { startTransition } from 'react';

// 在非组件代码中使用(如路由库)
function navigate(url) {
  startTransition(() => {
    setCurrentPage(url);
  });
}
// 注意:非 Hook 版本没有 isPending

三、useDeferredValue 深入

基本原理

jsx
function SearchResults({ query }) {
  // query 立即更新,deferredQuery 延迟到空闲时更新
  const deferredQuery = useDeferredValue(query);

  // 检测是否正在显示旧值
  const isStale = query !== deferredQuery;

  // deferredQuery 变化时才重新计算
  const results = useMemo(() => {
    return expensiveSearch(deferredQuery);
  }, [deferredQuery]);

  return (
    <div style={{ opacity: isStale ? 0.7 : 1 }}>
      {results.map(r => <ResultCard key={r.id} data={r} />)}
    </div>
  );
}

内部机制

useDeferredValue(value):

首次渲染:
  返回 value 本身(无延迟)

后续更新:
  1. React 先用旧的 deferredValue 完成一次渲染(紧急)
  2. 然后在后台用新的 value 重新渲染(Transition 优先级)
  3. 后台渲染完成后,切换到新值

本质上等价于:
  const [deferredValue, setDeferredValue] = useState(value);
  useEffect(() => {
    startTransition(() => setDeferredValue(value));
  }, [value]);

useTransition vs useDeferredValue

场景选择
你能控制 setStateuseTransition
值来自 props,你无法控制useDeferredValue
需要 isPending 状态useTransition
包裹第三方组件useDeferredValue

四、Suspense 深入

基本用法

jsx
import { Suspense, lazy, use } from 'react';

// 1. 代码分割
const LazyComponent = lazy(() => import('./HeavyComponent'));

// 2. 数据获取
function UserProfile({ userPromise }) {
  const user = use(userPromise); // 自动 suspend
  return <h1>{user.name}</h1>;
}

// 3. 嵌套 Suspense
function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Header />
      <Suspense fallback={<ContentSkeleton />}>
        <MainContent />
      </Suspense>
      <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar />
      </Suspense>
    </Suspense>
  );
}

Suspense 的工作原理

组件树渲染过程中,某个组件 throw 了一个 Promise:

1. React 捕获这个 Promise(不是普通的 try-catch,是 Fiber 层面的捕获)
2. 向上查找最近的 <Suspense> 边界
3. 显示该 Suspense 的 fallback
4. 同时"订阅"这个 Promise
5. Promise resolve 后:
   a. 重新渲染 Suspense 包裹的子树
   b. 这次组件不再 throw(因为数据已就绪)
   c. 替换 fallback 为正式内容

关键:组件不需要知道 Suspense 的存在
      数据获取库负责 throw Promise
      React 负责协调 fallback 显示

SuspenseList(实验性)

jsx
// 控制多个 Suspense 子项的显示顺序
<SuspenseList revealOrder="forwards" tail="collapsed">
  <Suspense fallback={<Skeleton />}>
    <Section1 />  {/* 先显示 */}
  </Suspense>
  <Suspense fallback={<Skeleton />}>
    <Section2 />  {/* 等 Section1 显示后再显示 */}
  </Suspense>
  <Suspense fallback={<Skeleton />}>
    <Section3 />  {/* 等 Section2 显示后再显示 */}
  </Suspense>
</SuspenseList>

// revealOrder: 'forwards' | 'backwards' | 'together'
// tail: 'collapsed'(只显示下一个 fallback)| 'hidden'(不显示 fallback)

五、流式 SSR(Streaming SSR)

传统 SSR 的问题

传统 SSR 流程(瀑布式):
  服务器获取所有数据 → 渲染完整 HTML → 发送 → 加载所有 JS → 水合所有组件
       ↑                                                          ↑
  最慢的数据决定首屏时间              必须全部水合完才能交互

流式 SSR + Selective Hydration

React 18 流式 SSR:
  1. 服务器遇到 <Suspense> 时:
     a. 先发送 fallback 的 HTML
     b. 继续渲染其他部分
     c. 当 Suspense 内的数据就绪后,发送补充 HTML(替换 fallback)

  2. 客户端水合:
     a. 不需要等所有 JS 加载完
     b. 哪个组件的 JS 先到,就先水合哪个(Selective Hydration)
     c. 用户点击了还未水合的区域 → React 提升该区域的水合优先级
jsx
// server.js
import { renderToPipeableStream } from 'react-dom/server';

app.get('/', (req, res) => {
  const { pipe, abort } = renderToPipeableStream(
    <App />,
    {
      bootstrapScripts: ['/main.js'],
      onShellReady() {
        // Shell(Suspense 边界外的内容)准备好后开始流式传输
        res.statusCode = 200;
        res.setHeader('Content-Type', 'text/html');
        pipe(res);
      },
      onShellError(error) {
        res.statusCode = 500;
        res.send('<h1>服务器错误</h1>');
      },
      onError(error) {
        console.error(error);
      },
    }
  );

  // 超时中止
  setTimeout(abort, 10000);
});

流式传输过程

html
<!-- 第一次传输:Shell + Suspense fallback -->
<html>
<body>
  <header>导航栏</header>
  <main>
    <template id="B:0"></template>  <!-- Suspense 占位 -->
    <div>加载中...</div>             <!-- fallback -->
  </main>
  <script src="/main.js" async></script>
</body>
</html>

<!-- 第二次传输:Suspense 内容就绪 -->
<div hidden id="S:0">
  <article>实际内容...</article>     <!-- 真正的内容 -->
</div>
<script>
  // 替换 fallback 为真正内容
  $RC('B:0', 'S:0');
</script>

六、Selective Hydration

水合优先级

传统水合:
  加载 JS → 从根节点开始水合 → 按顺序处理所有组件 → 全部完成后可交互

Selective Hydration(React 18):
  加载 JS → 分块水合 → 用户交互的区域优先水合

场景:
  页面有 Header、Sidebar、MainContent、Comments 四个区域
  Comments 数据最慢,用 Suspense 包裹

  1. 服务器先发送 Header + Sidebar + MainContent 的 HTML
  2. 客户端开始水合 Header(JS 最先到达)
  3. Comments 数据到达,服务器发送补充 HTML
  4. 用户点击了 Sidebar 中的按钮
     → React 提升 Sidebar 的水合优先级
     → 优先水合 Sidebar,使其可交互
  5. 继续水合其他区域

代码示例

jsx
// 利用 Suspense 实现 Selective Hydration
function App() {
  return (
    <Layout>
      {/* 这些立即水合 */}
      <Header />
      <SearchBar />

      {/* 这些延迟水合,各自独立 */}
      <Suspense fallback={<FeedSkeleton />}>
        <Feed />
      </Suspense>

      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />
      </Suspense>

      <Suspense fallback={<RecommendSkeleton />}>
        <Recommendations />
      </Suspense>
    </Layout>
  );
}

七、并发特性的组合使用

Transition + Suspense

jsx
function App() {
  const [tab, setTab] = useState('home');
  const [isPending, startTransition] = useTransition();

  function navigate(nextTab) {
    startTransition(() => {
      setTab(nextTab);
    });
  }

  return (
    <div>
      <nav>
        <button onClick={() => navigate('home')}>首页</button>
        <button onClick={() => navigate('posts')}>文章</button>
      </nav>

      {/* Transition 期间:保持显示旧 tab 内容 */}
      {/* 而不是立即显示 Suspense fallback */}
      <div style={{ opacity: isPending ? 0.7 : 1 }}>
        <Suspense fallback={<TabSkeleton />}>
          <TabContent tab={tab} />
        </Suspense>
      </div>
    </div>
  );
}

// 关键行为:
// 没有 Transition:切换 tab → 立即显示 fallback → 数据就绪后显示内容
// 有 Transition:切换 tab → 保持旧内容(isPending=true)→ 新内容就绪后一次性切换

DeferredValue + Suspense

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

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <Suspense fallback={<SearchSkeleton />}>
        <div style={{ opacity: isStale ? 0.5 : 1 }}>
          <SearchResults query={deferredQuery} />
        </div>
      </Suspense>
    </div>
  );
}

// 行为:
// 1. 用户输入 → query 立即更新 → 输入框始终响应
// 2. deferredQuery 延迟更新 → SearchResults 延迟渲染
// 3. 如果 SearchResults suspend → 不会显示 fallback(因为有旧值可用)
// 4. 只有首次加载时才显示 fallback

面试高频题

Q: React 并发模式是多线程吗?

不是。JavaScript 是单线程的。并发模式通过时间切片(每 5ms 让出主线程)和优先级调度(Lane 模型)实现"看起来同时进行"的效果。高优先级更新可以中断低优先级更新的渲染。

Q: useTransition 和 setTimeout 有什么区别?

setTimeout 是真正的延迟执行,回调在下一个事件循环执行,无法被取消或抢占。startTransition 标记更新为低优先级,React 会立即开始渲染但允许被高优先级更新中断。如果没有高优先级更新,Transition 可以立即完成,不会有延迟。

Q: Suspense 是怎么"暂停"组件渲染的?

组件在渲染过程中 throw 一个 Promise(不是 Error)。React 的 Fiber 层面捕获这个 Promise,找到最近的 Suspense 边界,显示 fallback。当 Promise resolve 后,React 重新渲染该子树。组件本身不需要感知 Suspense 的存在。

Q: 流式 SSR 相比传统 SSR 的优势?

  1. 更快的 TTFB:不需要等所有数据就绪,Shell 准备好就开始传输
  2. 渐进式内容:Suspense 内的内容就绪后自动替换 fallback
  3. Selective Hydration:不需要等所有 JS 加载完才开始水合
  4. 交互优先:用户点击的区域优先水合