エッセイ
React startTransition 与 SSR 水合#
构建 SSR 应用时,水合(hydration)阶段的闪屏是一个很常见、也很隐蔽的问题。
典型现象是:服务端已经输出了完整 HTML,用户先看到了页面内容;但客户端开始水合时,某个 React.lazy()、route lazy module 或异步组件还没加载完,Suspense fallback 短暂替换了已经存在的服务端内容。结果用户看到页面内容先出现、再消失成 loading、然后再出现。
这篇文章以一个 Vite + React Router v7 + MDX 博客系统为例,解释 startTransition 在 hydration 里的作用,以及异步组件缓存为什么重要。
问题背景#
我的博客系统曾经使用 Vite 的 import.meta.glob() 动态导入所有 MDX 文件:
const postLoaders = import.meta.glob<MdxModule>("/blog/*.mdx");
每篇博客文章通过 /p/:id 路由处理。路由组件会根据 slug 找到对应 MDX 模块,并把它渲染成 React 组件:
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,流程可能是:
- 服务端返回完整 HTML,文章内容已经可见。
- 浏览器解析 HTML,用户看到文章正文。
- React 开始
hydrateRoot()。 - 水合到
lazy()组件时,需要加载 MDX chunk。 - 在 chunk resolve 前,Suspense 边界显示 fallback。
- 已经显示的文章内容被 loading 替换。
- chunk 加载完成后,文章内容重新出现。
这个体验很糟糕,因为用户已经看到了内容,却又被 loading 打断。
SSR hydration 的理想行为应该是:如果服务端 HTML 已经在那里,就尽量保留它,等客户端代码准备好后再完成绑定,而不是把已经可见的内容替换成 fallback。
startTransition 到底做什么#
React 18 引入了 startTransition 和 useTransition。
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。
典型写法是:
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。
也就是说,变化不是:
HTML → fallback → hydrated content
而是更接近:
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#
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 重新加载。
- 严重时水合无法完成。
正确做法:模块级缓存#
更稳妥的做法是在组件渲染流程之外维护缓存:
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);
});
组件里只读取缓存结果:
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() 默认返回懒加载函数:
const modules = import.meta.glob("./posts/*.mdx");
也可以选择 eager:
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 闪屏或水合卡住,可以按这个顺序检查:
entry.client.tsx是否用 React Router 官方模板,或者是否把hydrateRoot()包在startTransition()中。root.tsx是否正确渲染<Scripts />,否则 route chunks 和 manifest 可能无法正确加载。- lazy component 是否在 render 中反复创建。
import.meta.glob()的 key 是否和 slug 映射完全一致。- 对应 JS chunk 是否 404。
- 是否有 hydration mismatch warning。
- 是否有第三方脚本在 hydration 前修改 DOM。
- Suspense boundary 是否过大,导致 fallback 替换了已经 SSR 出来的主要内容。
- 是否可以把关键 route module 或 MDX chunk 通过框架 manifest 预加载。
- 是否存在 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。
可以把结论压缩成三句话:
startTransition帮助 hydration 期间保留已有 SSR UI,减少不必要 fallback。- React Router v7 默认入口通常已经做了这件事。
- 异步组件必须缓存,不能在 render 过程中反复创建新 Promise。
