前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
小程序题库
  • 每日一题每天一道,攒手感
  • 原理篇React / Vue 源码拆解
  • 知识卡片NEW碎片时间过考点
  • 历年面经按年份追踪真实考点
  • 算法题库在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
小程序题库
  • 每日一题每天一道,攒手感
  • 原理篇React / Vue 源码拆解
  • 知识卡片NEW碎片时间过考点
  • 历年面经按年份追踪真实考点
  • 算法题库在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版

别再手写 FLIP 了,React 把页面动画交给了浏览器

首页2026-09-11 10:39:11Front-End
React前端框架动画

文章首发于: 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() 在底下干了什么。它分四步走:

  1. 把当前 DOM 拍一张静态快照,这是 old
  2. 执行你传进去的回调,在回调里把 DOM 改成新状态
  3. 对新状态再拍一张,这是 new
  4. 在一个浮层里播放 old 到 new 的动画,播完拆掉浮层

关键在第四步那个浮层。浏览器会在页面最上层搭出一棵伪元素树:

::view-transition                      浮层根节点
└── ::view-transition-group(name)      每个具名元素一组
    └── ::view-transition-image-pair(name)
        ├── ::view-transition-old(name)   静态快照,淡出
        └── ::view-transition-new(name)   实时 DOM,淡入
@前端进阶之旅: 代码已经复制到剪贴板

浏览器 view transition 的四个步骤与浮层里的伪元素树结构

默认动画就是 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> 但完全没动静」,第一个要查的就是这里。

React 19.3 中 ViewTransition 的动画触发条件与 enter exit update share 四种类型

同一条因果链上还挂着另外两个行为,文档里提了但很容易被跳过。

一个是 flushSync。它要求同步刷新,和 view transition 需要的完整异步序列冲突,React 的处理是直接跳过这次 Transition:

If a flushSync happens 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 一边在被删的子树里、一边在被插的子树里
fe
  • 一、浏览器的 view transition 到底做了什么
  • 二、为什么包了 ViewTransition 还是不动画
  • 三、view-transition-name 重复会怎样
  • 四、enter、exit、update、share 分别什么时候触发
  • 五、share 共享元素配对失败的三个原因
  • 六、浏览器后退按钮为什么没有动画
  • 七、ViewTransition 和 Motion 怎么选
  • 八、prefers-reduced-motion 与浏览器兼容性怎么处理
  • 九、Fragment Refs 怎么用,作用范围是哪些子节点
  • 十、browser() 怎么让组件跳过服务端渲染
  • 十一、上生产前的 checklist
  • 总结
  • 参考

React Native 0.87 解读与升级要点 →