文章首发于: https://feinterview.poetries.top/blog/react-19-3-release-overview
本文围绕六个问题展开:
- 浏览器做一次 view transition,到底在屏幕上摆了些什么东西?
startViewTransition是命令式的、要求同步改 DOM,而 React 的并发渲染是可中断的,这两者怎么凑到一起?- 为什么包了
<ViewTransition>却不动画,有哪几种成因? share配对失败最常见的三个原因是什么?- 浏览器后退按钮为什么不走动画,能不能修?
- 手写
FLIP、Motion、next-view-transitions、原生<ViewTransition>,四条路线该怎么选?
列表页点一张缩略图,它飞过去变成详情页的大图。这个效果前端做了十来年,方案一直就那两种,要么自己读 getBoundingClientRect 算首末位置手写 FLIP,要么上 Motion 这类动画库。前者代码量大,跨路由还得把旧位置存下来一路传过去;后者要背包体,动画全程跑在主线程的 JS 里。
React 19.3 在 2026 年 9 月 9 日发布,把去年还挂着实验标签的 View Transitions 和 Fragment Refs 转成了稳定 API。页面动画这件事,现在可以整个交给浏览器做,位置计算、快照、插值都不用你管。
听起来很美好,但网上已经有一堆「五分钟带你了解 React 19.3」,把公告翻译一遍就发了。这篇不打算那么写。我自己读完公告的第一反应是「包个标签就能动画?那坑肯定在别处」,于是把 React 的参考文档、MDN 的 View Transition API、Next.js 的官方指南全啃了一遍,发现坑确实不少,而且几乎全部来自一个根源,就是浏览器这套 API 的工作方式和 React 的渲染模型天然不合拍。
所以这篇从浏览器那层讲起。先交代一句,版本发布到现在才两天,下面的代码我只在本地 demo 里跑通过,没在生产项目上完整验证一轮,涉及线上表现的地方我会标出来。
# 一、浏览器的 view transition 到底做了什么
想搞懂 React 的那些限制,得先知道 document.startViewTransition() 在底下干了什么。它分四步走:
- 把当前 DOM 拍一张静态快照,这是 old
- 执行你传进去的回调,在回调里把 DOM 改成新状态
- 对新状态再拍一张,这是 new
- 在一个浮层里播放 old 到 new 的动画,播完拆掉浮层
关键在第四步那个浮层。浏览器会在页面最上层搭出一棵伪元素树:
::view-transition 浮层根节点
└── ::view-transition-group(name) 每个具名元素一组
└── ::view-transition-image-pair(name)
├── ::view-transition-old(name) 静态快照,淡出
└── ::view-transition-new(name) 实时 DOM,淡入

默认动画就是 old 淡出、new 淡入,位置变了就顺带滑过去。你想改,就去写这些伪元素的 animation:
::view-transition-old(root) {
animation: 150ms ease-in both fade reverse;
}
::view-transition-new(root) {
animation: 210ms ease-out 150ms both fade;
}
现在看那行标粗的事实。
::view-transition-old 里装的是一张静态图片,不是活着的 DOM。
这一条解释了后面一大半的行为。既然 old 是图,那整块内容在动画期间就是被当成一张图去缩放、移动、交叉淡化的,里面每个子元素不会各自算自己的位置。React 官方文档把这个差别说得很直白,它和你在 React Native 或者 Motion 里见过的 Layout Animation 不是一回事:
<ViewTransition>creates an image that can be moved around, scaled and cross-faded. Unlike Layout Animations you may have seen in React Native or Motion, this means that not every individual Element inside of it animates its position.
好处是性能好、观感连续,一整块一起动;代价是内部该自己动的东西不会动,想要就得手动多加几层 <ViewTransition> 边界。这个取舍你在动手之前就该知道,不然做到一半会觉得「怎么和我想的不一样」。
顺着这条再往下推一层。既然要拍快照,那浏览器就必须知道「拍哪些块」,靠的是 CSS 的 view-transition-name。同一时刻,一个名字只能对应一个元素。这条约束是后面所有配对问题的总根源,第三节细说。
# 二、为什么包了 ViewTransition 还是不动画
回到 React 这边。startViewTransition 的签名决定了它要求一个同步的 DOM 更新点,回调跑完,新状态就得落定。
而 React 的并发渲染恰恰相反,一次渲染可以被打断、可以丢弃重来、可以让位给更紧急的更新。这两个模型摆在一起就是矛盾的。
React 的解法是划一条线,只有那些「本来就允许被推迟、允许被打断」的更新才接进 view transition。落到 API 上就是三个入口:
// 1. 显式 startTransition
startTransition(() => setPage('detail'));
// 2. useDeferredValue 产生的延迟更新
const deferred = useDeferredValue(query);
// 3. Suspense 边界揭示内容
<Suspense fallback={<Skeleton />}>
<Content />
</Suspense>
普通 setState 不在其中。它是紧急更新,语义上要求立刻反映到屏幕上,硬塞进动画等于让用户多等 300 毫秒。
我一开始也是想岔了,以为包上标签就万事大吉,demo 里点了半天没反应,回头才发现触发更新的那一行是个裸 setState。「包了 <ViewTransition> 但完全没动静」,第一个要查的就是这里。

同一条因果链上还挂着另外两个行为,文档里提了但很容易被跳过。
一个是 flushSync。它要求同步刷新,和 view transition 需要的完整异步序列冲突,React 的处理是直接跳过这次 Transition:
If a
flushSynchappens to get in the middle of this sequence, then React will skip the Transition since it relies on being able to complete synchronously.
另一个是动画排队。浏览器一次只能跑一个 view transition,所以 React 会排队,而且中间的更新会被合并。假设 A 到 B 的动画正在跑,期间来了 C 到 D 的更新,等 A 到 B 播完,下一段动画是 B 直接到 D,不会补一段 B 到 C。这个行为在快速连点的场景下要留意,用户狂点三下,他不会看到三段动画。
React 在揭示内容之前还会等资源,这点对首屏体验挺关键:等数据、等带 precedence 的 CSS、等 <ViewTransition> 里包着的图片,字体最多等 500 毫秒。所以下面这种写法能让图片加载完再一起揭示,不会出现文字先到、图片后面咣当一下把版面顶开:
<ViewTransition>
<Suspense fallback={<Fallback />}>
<img src={imageSrc} />
<style href={fontSrc} precedence="default">
{`@font-face { font-family: 'Fancy'; src: url(${fontSrc}) format('truetype'); font-display: swap; }`}
</style>
</Suspense>
</ViewTransition>
# 三、view-transition-name 重复会怎样
React 的 name 属性最终会落成 CSS 的 view-transition-name。第一节说过,同一时刻一个名字只能对应一个元素。
违反了会怎样?两层后果叠着来。React 这层先报错:
There are two
<ViewTransition name=%s>components with the same name mounted at the same time. This is not supported and will cause View Transitions to error.
浏览器那层更狠,一个快照里出现重名,它会整个跳过这次 transition。不是那一组不动,是全都不动。所以线上突然「动画全没了」,先去查是不是哪个列表渲染出了重名。
最容易写错的就是列表:
// 会炸:每一项都叫 item
function Item() {
return <ViewTransition name="item">...</ViewTransition>;
}
function ItemList({ items }) {
return items.map((item) => <Item key={item.id} />);
}
改法是把 id 拼进名字:
function Item({ id }) {
return <ViewTransition name={`item-${id}`}>...</ViewTransition>;
}
这里插一句我自己的判断。名字是全局唯一的,它就是个全局命名空间,页面一复杂很容易和别的模块撞上。React 文档建议把固定名字提到单独模块里用常量管理:
// shared-name.js
export const PHOTO_HERO = 'photo-hero';
// 两个页面都从这里导入,避免各写各的字符串
import { PHOTO_HERO } from './shared-name';
<ViewTransition name={PHOTO_HERO}>
动态名字则建议统一前缀加业务 id,比如 photo-${id},别用数组下标,重排之后就对不上了。
# 四、enter、exit、update、share 分别什么时候触发
你不用告诉 React 跑哪种动画,它按树的变化方式自己判:
| 类型 | 判定条件 |
|---|---|
enter |
在一次 Transition 中,它是第一个被插入的 ViewTransition |
exit |
在一次 Transition 中,它是第一个被删除的 ViewTransition |
update |
它内部有 DOM 变更,或者受紧邻兄弟影响导致自身尺寸、位置变化 |
share |
同名的 ViewTransition 一边在被删的子树里、一边在被插的子树里 |