React startTransition 与 SSR 水合Kent Chin / Unsplash

Essay

React startTransition 与 SSR 水合#

By Asuka

构建 SSR 应用时,水合(Hydration)过程中的闪屏是个常见问题。这篇文章以我的博客系统为例,探讨 React 18+ 和 React Router v7 中 startTransition 的作用机制,以及如何正确处理异步组件加载。

问题背景#

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

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

每篇博客文章通过 /p/:id 路由处理,在路由组件中异步加载对应的 MDX 模块并渲染为 React 组件:

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

这种架构带来了代码分割的好处,每篇文章的内容作为独立的 chunk 按需加载。但也引入了一个体验问题:水合时的闪屏

传统 Suspense 方案的局限#

按照传统方案,服务端渲染时会完整输出文章内容的 HTML。但客户端水合时:

  1. 浏览器解析并展示服务端返回的 HTML(文章内容可见)
  2. React 开始水合,遇到 lazy() 组件需要加载对应的 JS chunk
  3. 在 chunk 加载完成前,Suspense 边界会显示 fallback
  4. 用户看到文章内容消失,被 loading 状态替代
  5. Chunk 加载完成后,文章内容重新出现

这个过程导致了明显的闪屏,用户已经看到的内容突然消失又出现,严重影响体验。

什么是 startTransition#

React 18 引入了 startTransition API,用于将状态更新标记为 Transition(过渡),让 React 在后台渲染 UI。

TSX
import { useTransition } from 'react';

const [isPending, startTransition] = useTransition();

// 或独立使用
import { startTransition } from 'react';

Transition 的两个特性#

根据 React 官方文档,被标记为 Transition 的更新有两个特性:

  1. Non-blocking(非阻塞):渲染可以被更高优先级的更新中断。例如用户在 chart 渲染过程中输入文字,React 会先处理输入,再继续 chart 渲染。

  2. Will not display unwanted loading indicators(不显示不必要的 loading):遇到 Suspense 时不会立即显示 fallback,而是保持当前 UI。

startTransition vs useTransition#

startTransition useTransition
使用位置 任意位置(组件外也可) 仅限组件/Hook 内
pending 状态 提供 isPending
适用场景 数据库、第三方库、水合 组件内需要 loading 状态

注意事项#

  • 传给 startTransition 的函数会立即执行
  • 只有同步调用的 setState 会被标记为 Transition
  • setTimeout 中的更新不会被标记
  • await 之后的 setState 需要再次包裹在 startTransition
  • Transition 更新不能用于控制文本输入
  • 多个并发 Transition 会被合并处理

用 startTransition 解决水合闪屏#

理解了 Transition 的特性后,回到水合闪屏问题。当水合过程被包裹在 startTransition 中时,React 会将整个水合作为一个"过渡"来处理:

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

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

关键行为变化:在 startTransition 中,当遇到需要异步加载的 lazy() 组件时,React 不会立即显示 Suspense 的 fallback。它会:

  1. 保持现有的 DOM 内容(服务端渲染的 HTML)不变
  2. 在后台等待异步组件加载完成
  3. 一次性完成水合,无缝过渡

用户始终看到文章内容,不会有任何闪烁。

这正是利用了 Transition 的第二个特性:不显示不必要的 loading。文档中 Preventing unwanted loading indicators 章节解释了这个行为:

Transitions prevent unwanted loading indicators, which lets the user avoid jarring jumps on navigation.

既然 Transition 表示"这个更新可以等",就没必要用 loading 状态打断用户,保持现有 UI 即可。

React Router v7 的默认行为#

React Router v7 默认在水合时使用 startTransition。根据 React Router v7 Changelog

React Router now uses startTransition for hydration by default. This allows lazy route components to suspend during hydration without triggering the Suspense fallback.

这是一个开箱即用的优化。如果你使用 React Router v7 的默认 entry.client.tsx,水合闪屏问题已经被自动处理。

关键陷阱:Promise 的创建时机#

要让 startTransition 正常工作,有一个关键约束必须遵守:

异步加载的 Promise 必须在组件渲染流程之外创建和维护。

错误示例:在 useMemo 中创建 Promise#

TSX
function PostPage({ slug }: { slug: string }) {
  // ❌ 错误:Promise 在 useMemo 中创建
  const PostContent = useMemo(() => {
    return lazy(() => import(`/blog/${slug}.mdx`));
  }, [slug]);

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

这段代码看似合理,但会导致水合永远无法完成。原因如下:

  1. 服务端渲染时,useMemo 计算并返回 lazy 组件
  2. 客户端水合时,useMemo 再次执行(因为水合前 React 状态尚未建立)
  3. 每次渲染都创建新的 Promise
  4. startTransition 等待 Promise resolve
  5. 但水合完成前,下一次渲染又创建了新的 Promise
  6. 形成死循环,页面永远无法水合成功

正确做法:模块级别缓存#

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

// ✅ 正确:Promise 在模块级别创建并缓存
export const getPostContent = memoize((slug: string) => {
  const moduleLoader = postLoaders[getPostModuleId(slug)];
  invariant(moduleLoader, `Post "${slug}" not found`);
  return lazy(moduleLoader);
});

// 组件中直接使用
function PostPage({ slug }: { slug: string }) {
  const PostContent = getPostContent(slug);

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

通过 memoize(或其他缓存机制),确保相同 slug 始终返回同一个 lazy 组件实例。这样:

  1. 首次调用时创建 lazy 组件(及其内部的 Promise)
  2. 后续调用返回缓存的实例
  3. startTransition 等待的是同一个 Promise
  4. Promise resolve 后,水合顺利完成

实现细节#

完整的 post-loader 实现:

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

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`);
  const PostContent = lazy(moduleLoader);
  return PostContent;
});

路由组件中的使用:

TSX
export default function PostPage({ loaderData }: Route.ComponentProps) {
  const { id, frontmatter } = loaderData;
  const PostContent = getPostContent(id);

  return (
    <Container>
      <Prose>
        <PostContent components={MDX_COMPONENTS} />
      </Prose>
    </Container>
  );
}

参考资料#