エッセイ

React startTransition 与 SSR 水合#

Asuka 著

构建 SSR 应用时,水合(hydration)阶段的闪屏是一个很常见、也很隐蔽的问题。

典型现象是:服务端已经输出了完整 HTML,用户先看到了页面内容;但客户端开始水合时,某个 React.lazy()、route lazy module 或异步组件还没加载完,Suspense fallback 短暂替换了已经存在的服务端内容。结果用户看到页面内容先出现、再消失成 loading、然后再出现。

这篇文章以一个 Vite + React Router v7 + MDX 博客系统为例,解释 startTransition 在 hydration 里的作用,以及异步组件缓存为什么重要。

问题背景#

我的博客系统曾经使用 Vite 的 import.meta.glob() 动态导入所有 MDX 文件:

TSX
const postLoaders = import.meta.glob<MdxModule>("/blog/*.mdx");

每篇博客文章通过 /p/:id 路由处理。路由组件会根据 slug 找到对应 MDX 模块,并把它渲染成 React 组件:

TSX
export const getPostContent = memoize((slug: string) => {
  const moduleLoader = postLoaders[getPostModuleId(slug)];
  invariant(moduleLoader, `Post "${slug}" not found`);
  return lazy(moduleLoader);
});

这种架构的好处是:每篇文章可以作为独立 chunk 按需加载。

但它也带来一个 SSR 场景下的细节问题:服务端已经渲染出文章内容,客户端 hydration 时却还要加载对应的 JS chunk。

闪屏是怎么发生的#

如果没有正确处理 Suspense hydration,流程可能是:

  1. 服务端返回完整 HTML,文章内容已经可见。
  2. 浏览器解析 HTML,用户看到文章正文。
  3. React 开始 hydrateRoot()
  4. 水合到 lazy() 组件时,需要加载 MDX chunk。
  5. 在 chunk resolve 前,Suspense 边界显示 fallback。
  6. 已经显示的文章内容被 loading 替换。
  7. chunk 加载完成后,文章内容重新出现。

这个体验很糟糕,因为用户已经看到了内容,却又被 loading 打断。

SSR hydration 的理想行为应该是:如果服务端 HTML 已经在那里,就尽量保留它,等客户端代码准备好后再完成绑定,而不是把已经可见的内容替换成 fallback。

startTransition 到底做什么#

React 18 引入了 startTransitionuseTransition

TSX
import { startTransition, useTransition } from "react";

startTransition(() => {
  setState(nextState);
});

const [isPending, startTransition] = useTransition();

它们的核心作用是把某些状态更新标记为 Transition。

Transition 有几个重要特征:

  • 它是 non-blocking 的,渲染可以被更高优先级更新打断。
  • 它遇到 Suspense 时,不会总是立刻把已有 UI 替换成 fallback。
  • 它适合导航、切换 tab、搜索结果刷新、路由渲染这类“可以稍等一下”的更新。
  • 它不适合控制输入框这类需要同步反馈的状态。

这里最相关的是第二点:Transition 可以避免不必要的 loading fallback 打断当前 UI。

也要注意,startTransition 不是魔法。它不会让网络更快,也不会自动修复所有 hydration mismatch。它只是告诉 React:这次更新可以作为过渡处理,在 Suspense 没准备好时,尽量保留已有 UI,而不是急着切到 fallback。

startTransition vs useTransition#

对比项 startTransition useTransition
使用位置 可以在组件外调用 只能在组件或 Hook 内调用
是否提供 pending 状态 不提供 提供 isPending
典型场景 router、数据层、框架入口、第三方库 组件内部导航、筛选、tab 切换
是否适合 hydration entry 适合 不适合,因为 entry 通常不在组件内

对于 entry.client.tsx 这种入口文件,通常用独立的 startTransition

React Router v7 的默认行为#

React Router v7 的 Framework Mode 默认入口通常已经在 hydration 时使用 startTransition

典型写法是:

TSX
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";

startTransition(() => {
  hydrateRoot(
    document,
    <StrictMode>
      <HydratedRouter />
    </StrictMode>,
  );
});

这意味着:如果你使用的是 React Router 官方模板或默认生成的 entry.client.tsx,很多 Suspense hydration 闪屏问题已经被框架默认处理了。

你需要特别关注的情况通常是:

  • 自己手写了 entry.client.tsx
  • 从 Remix / React Router 旧模板迁移过来。
  • 修改过 hydration 入口。
  • 在 route 组件里手动创建了新的 lazy component / Promise。
  • 使用了自定义 MDX loader、动态 import、组件注册表。

它为什么能减少 hydration 闪屏#

hydrateRoot() 放进 startTransition() 后,当 hydration 过程中某个 Suspense boundary 暂时还没准备好时,React 更倾向于保留已有的服务端 HTML,而不是立刻把它替换成 fallback。

也就是说,变化不是:

TEXT
HTML → fallback → hydrated content

而是更接近:

TEXT
HTML stays visible → JS chunk ready → hydration completes

这对 SSR 页面特别重要,因为 SSR 的意义就是让用户先看到内容。如果 hydration 阶段又把内容拿掉,体验反而会倒退。

不过要再次强调:startTransition 只是让 React 在 Suspense 期间更好地保留已有 UI。它不能解决:

  • 服务端和客户端渲染结果不一致。
  • 模块路径错误。
  • chunk 404。
  • 组件每次 render 都创建新 Promise。
  • hydration 前就被第三方脚本修改 DOM。
  • CSS 或字体导致的视觉闪烁。

这些问题仍然要分别排查。

关键陷阱:异步组件的创建时机#

要让 hydration 稳定,异步组件和 Promise 的 identity 必须稳定。

换句话说,同一个 slug 对应的 lazy component 不应该在每次 render 时都重新创建。

错误示例:在 render/useMemo 中创建 lazy component#

TSX
function PostPage({ slug }: { slug: string }) {
  const PostContent = useMemo(() => {
    return lazy(() => import(`/blog/${slug}.mdx`));
  }, [slug]);

  return (
    <Suspense fallback={<Loading />}>
      <PostContent />
    </Suspense>
  );
}

这段代码看起来用了 useMemo,但在 hydration 场景里仍然不够稳。

原因是:hydration 前客户端组件状态还没有建立;如果渲染过程被中断、重试,或者组件树因为 Suspense 重走,React 可能会重新执行组件函数。此时如果你创建了新的 lazy component 或新的 import promise,React 看到的就不再是同一个异步资源。

后果可能是:

  • Suspense 一直等不到稳定结果。
  • hydration 反复重试。
  • fallback 不该出现时出现。
  • route component 重新加载。
  • 严重时水合无法完成。

正确做法:模块级缓存#

更稳妥的做法是在组件渲染流程之外维护缓存:

TSX
import { lazy } from "react";
import { memoize } from "es-toolkit";

const postLoaders = import.meta.glob<MdxModule>("/blog/*.mdx");

function getPostModuleId(slug: string): string {
  return `/blog/${slug}.mdx`;
}

export const getPostContent = memoize((slug: string) => {
  const moduleLoader = postLoaders[getPostModuleId(slug)];
  invariant(moduleLoader, `Post "${slug}" not found`);
  return lazy(moduleLoader);
});

组件里只读取缓存结果:

TSX
function PostPage({ slug }: { slug: string }) {
  const PostContent = getPostContent(slug);

  return (
    <Suspense fallback={<Loading />}>
      <PostContent />
    </Suspense>
  );
}

这样同一个 slug 会稳定返回同一个 lazy component。React 等待的是同一个异步资源,hydration 更容易顺利完成。

import.meta.glob 的注意点#

Vite 的 import.meta.glob() 默认返回懒加载函数:

TS
const modules = import.meta.glob("./posts/*.mdx");

也可以选择 eager:

TS
const modules = import.meta.glob("./posts/*.mdx", { eager: true });

对于博客文章这种场景,懒加载能带来按文章拆 chunk 的好处;但 SSR hydration 时必须确保:

  • 服务端和客户端能找到同一个模块路径。
  • slug 到 module id 的映射稳定。
  • lazy component 有缓存。
  • 对应 chunk 能被正确 preload 或加载。
  • Suspense boundary 的 fallback 不会破坏已有 SSR 内容体验。

如果文章数量不多,或者更看重 hydration 稳定性,也可以考虑 eager import、构建期内容索引、或者把 MDX 编译产物纳入 route module,而不是在页面组件内临时 lazy。

React 19 下有什么变化#

React 19 对 Suspense、hydration error 报告、server rendering 相关能力都有改进,但这不改变本文的核心原则:

  • SSR HTML 和客户端组件树要一致。
  • Suspense hydration 期间要避免不必要 fallback。
  • 异步资源 identity 要稳定。
  • hydration 入口如果由框架管理,优先相信框架默认模板。
  • 自定义 hydration 入口时要理解 startTransition 的作用边界。

React 19 让很多错误更容易定位,但不会自动修复错误的 Promise 创建时机或 chunk 加载策略。

排查清单#

如果你遇到 SSR hydration 闪屏或水合卡住,可以按这个顺序检查:

  1. entry.client.tsx 是否用 React Router 官方模板,或者是否把 hydrateRoot() 包在 startTransition() 中。
  2. root.tsx 是否正确渲染 <Scripts />,否则 route chunks 和 manifest 可能无法正确加载。
  3. lazy component 是否在 render 中反复创建。
  4. import.meta.glob() 的 key 是否和 slug 映射完全一致。
  5. 对应 JS chunk 是否 404。
  6. 是否有 hydration mismatch warning。
  7. 是否有第三方脚本在 hydration 前修改 DOM。
  8. Suspense boundary 是否过大,导致 fallback 替换了已经 SSR 出来的主要内容。
  9. 是否可以把关键 route module 或 MDX chunk 通过框架 manifest 预加载。
  10. 是否存在 React StrictMode 下暴露出来的重复执行问题。

小结#

startTransition 对 SSR hydration 的价值,不是让 hydration 本身“更快”,而是让 React 在遇到 Suspense 时更少用 fallback 打断用户已经看到的服务端 HTML。

在 React Router v7 Framework Mode 里,默认 hydration 入口通常已经使用了 startTransition。如果你使用官方模板,大多数情况下不需要额外处理。

真正容易踩坑的是自定义动态 import / MDX lazy component:如果你在组件渲染过程中反复创建新的 lazy component 或 Promise,startTransition 也救不了。正确做法是把异步资源放到模块级缓存里,让同一个输入稳定对应同一个 lazy component。

可以把结论压缩成三句话:

  1. startTransition 帮助 hydration 期间保留已有 SSR UI,减少不必要 fallback。
  2. React Router v7 默认入口通常已经做了这件事。
  3. 异步组件必须缓存,不能在 render 过程中反复创建新 Promise。

参考资料#

This browser prefers English.

You can switch the site to English and save that choice for future visits.