Kent Chin / Unsplash文章
React startTransition 与 SSR 水合#
构建 SSR 应用时,水合(Hydration)过程中的闪屏是个常见问题。这篇文章以我的博客系统为例,探讨 React 18+ 和 React Router v7 中 startTransition 的作用机制,以及如何正确处理异步组件加载。
问题背景#
我的博客系统使用 Vite 的 import.meta.glob() 动态导入所有 MDX 文件:
const postLoaders = import.meta.glob<MdxModule>('/blog/*.mdx');
每篇博客文章通过 /p/:id 路由处理,在路由组件中异步加载对应的 MDX 模块并渲染为 React 组件:
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。但客户端水合时:
- 浏览器解析并展示服务端返回的 HTML(文章内容可见)
- React 开始水合,遇到
lazy()组件需要加载对应的 JS chunk - 在 chunk 加载完成前,
Suspense边界会显示 fallback - 用户看到文章内容消失,被 loading 状态替代
- Chunk 加载完成后,文章内容重新出现
这个过程导致了明显的闪屏,用户已经看到的内容突然消失又出现,严重影响体验。
什么是 startTransition#
React 18 引入了 startTransition API,用于将状态更新标记为 Transition(过渡),让 React 在后台渲染 UI。
import { useTransition } from 'react';
const [isPending, startTransition] = useTransition();
// 或独立使用
import { startTransition } from 'react';
Transition 的两个特性#
根据 React 官方文档,被标记为 Transition 的更新有两个特性:
-
Non-blocking(非阻塞):渲染可以被更高优先级的更新中断。例如用户在 chart 渲染过程中输入文字,React 会先处理输入,再继续 chart 渲染。
-
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 会将整个水合作为一个"过渡"来处理:
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。它会:
- 保持现有的 DOM 内容(服务端渲染的 HTML)不变
- 在后台等待异步组件加载完成
- 一次性完成水合,无缝过渡
用户始终看到文章内容,不会有任何闪烁。
这正是利用了 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
startTransitionfor 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#
function PostPage({ slug }: { slug: string }) {
// ❌ 错误:Promise 在 useMemo 中创建
const PostContent = useMemo(() => {
return lazy(() => import(`/blog/${slug}.mdx`));
}, [slug]);
return (
<Suspense fallback={<Loading />}>
<PostContent />
</Suspense>
);
}
这段代码看似合理,但会导致水合永远无法完成。原因如下:
- 服务端渲染时,
useMemo计算并返回 lazy 组件 - 客户端水合时,
useMemo再次执行(因为水合前 React 状态尚未建立) - 每次渲染都创建新的 Promise
startTransition等待 Promise resolve- 但水合完成前,下一次渲染又创建了新的 Promise
- 形成死循环,页面永远无法水合成功
正确做法:模块级别缓存#
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 组件实例。这样:
- 首次调用时创建 lazy 组件(及其内部的 Promise)
- 后续调用返回缓存的实例
startTransition等待的是同一个 Promise- Promise resolve 后,水合顺利完成
实现细节#
完整的 post-loader 实现:
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;
});
路由组件中的使用:
export default function PostPage({ loaderData }: Route.ComponentProps) {
const { id, frontmatter } = loaderData;
const PostContent = getPostContent(id);
return (
<Container>
<Prose>
<PostContent components={MDX_COMPONENTS} />
</Prose>
</Container>
);
}