使用 React 19.3 的 View Transition 功能,通过 JavaScript 实现进入和离开动画
通过 onEnter、onExit、onUpdate 和 onShare 以及 Web Animations API,在 JavaScript 中实现 React 19 的视图过渡效果,这是 CSS 类无法实现的。
来源: React 19.3 View Transition Events: Drive Enter, Exit and Share Animations from JavaScript
四种事件及其对应内容
视图过渡类负责处理大多数动画效果:每个触发条件对应一个类,再加上CSS关键帧动画。当动画效果依赖于只有JavaScript才能识别的值时——如列表索引、用户偏好设置或弹簧常数——这些方法就无能为力了。对于这类情况,<ViewTransition>提供了四个事件属性,可让你获取浏览器的快照伪元素,从而通过Web动画API来控制它们。本内容是在React 19.3视图过渡系列中早先介绍的触发条件与过渡类型基础之上的延伸。
<ViewTransition
onEnter={(instance, types) => { /* … */ return () => { /* cleanup */ }; }}
onExit={(instance, types) => { /* … */ }}
onUpdate={(instance, types) => { /* … */ }}
onShare={(instance, types) => { /* … */ }}
>
React在每次过渡的每个边界点都会触发一个事件,该事件的类型与所选触发条件相对应;此时onShare会优先于onEnter和onExit被触发。每个回调函数都会接收到:
instance.old——即::view-transition-old对应的“之前”状态快照,包含.animate()方法
instance.new — ::view-transition-new 的“之后”快照instance.group — 用于移动和调整大小的 ::view-transition-group 盒子instance.imagePair — 包含两个快照的 ::view-transition-image-pairinstance.name — React 分配的 view-transition-name(或您自定义的值)types — 通过 addTransitionType 设置的过渡类型,或空数组回调函数在过渡的 ready 承诺解析后执行——此时快照已生成,默认的浏览器动画及所有 CSS 类也已应用。需返回一个清理函数。当另一个过渡开始或文档被隐藏时,浏览器会中止当前操作;清理函数可用于取消未完成 的 WAAPI 动画。
通过 WAAPI 驱动快照
在 instance.new 上调用 .animate(keyframes, options) 的行为类似于元素方法,会返回一个 Animation 对象。操作流程为:启动时间轴,保留该对象,然后在清理阶段取消它。
onEnter={(instance) => {
const anim = instance.new.animate(
[{ transform: 'translateY(24px) scale(.96)', opacity: 0 }, { transform: 'none', opacity: 1 }],
{ duration: 380, easing: 'cubic-bezier(.2,.9,.25,1.15)', fill: 'both' }
);
return () => anim.cancel();
}}
CSS 和 JavaScript 可以协同工作。交叉淡入淡出效果由 CSS 处理(enter="auto", exit="auto"),而移动动画——包括通过滑块调整的超出量——则由 JavaScript 控制。
import { ViewTransition, useState, startTransition } from 'react';
export default function App() {
const [show, setShow] = useState(false);
const [bounce, setBounce] = useState(1.15); return (
<>
<div className="bar">
<button onClick={() => startTransition(() => setShow(s => !s))}>
{show ? 'Dismiss' : 'Show'} notification
</button>
<label>overshoot <input type="range" min="1" max="1.6" step="0.05" value={bounce}
onChange={e => setBounce(Number(e.target.value))} /> {bounce}</label>
</div> {show && (
<ViewTransition
enter="auto"
exit="auto"
default="none"
onEnter={(instance) => {
const anim = instance.new.animate(
[{ transform: 'translateY(28px) scale(.94)' }, { transform: 'none' }],
{ duration: 420, easing: `cubic-bezier(.2,.9,.25,${bounce})`, fill: 'both' }
);
return () => anim.cancel();
}}
onExit={(instance) => {
const anim = instance.old.animate(
[{ transform: 'none' }, { transform: 'translateY(28px) scale(.94)' }],
{ duration: 220, easing: 'ease-in', fill: 'both' }
);
return () => anim.cancel();
}}
>
<div className="toast">🔔 Your build finished in 42 s</div>
</ViewTransition>
)}
</>
);
}
.bar { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
.toast { margin-top: 12px; padding: 12px 16px; border-radius: 10px; background: light-dark(#fef3c7, #4a3410); }
/* the fade is CSS; slow the UA default so both layers are visible */
::view-transition-old(*), ::view-transition-new(*) { animation-duration: 420ms; }
滑块数值存储在组件的闭包中,因此事件可以在动画播放时直接读取该值,而无需使用 CSS 变量或计算得到的类名。
CSS 无法实现的功能:基于闭包的错开动画
每个元素都需要按顺序设置索引。在 CSS 中,这通常意味着使用 --i 自定义属性以及 calc() 来实现延迟,而这些值永远不会传递给 view-transition 伪元素。在事件处理过程中,索引早已处于作用域内:
import { ViewTransition, useState, useDeferredValue } from 'react';
const RESULTS = ['Frontend Master — Mock interviews', 'React 19.3, explained', 'View Transitions, part 5', 'Fragment Refs', 'use(browser())'];function Result({ text, index }) {
return (
<ViewTransition
default="none"
enter="auto"
onEnter={(instance) => {
const anim = instance.new.animate(
[{ transform: 'translateX(-16px)', opacity: 0 }, { transform: 'none', opacity: 1 }],
{ duration: 300, delay: index * 70, easing: 'ease-out', fill: 'both' }
);
return () => anim.cancel();
}}
>
<li>{text}</li>
</ViewTransition>
);
}export default function App() {
const [q, setQ] = useState('');
// the input stays urgent; the list follows a deferred value, which is a Transition
const deferredQ = useDeferredValue(q);
const hits = deferredQ ? RESULTS.filter(r => r.toLowerCase().includes(deferredQ.toLowerCase())) : [];
return (
<>
<input placeholder="type 'react' or 'r'" value={q} onChange={e => setQ(e.target.value)} />
<ul className="hits">
{hits.map((h, i) => <Result key={h} text={h} index={i} />)}
</ul>
</>
);
}
input { width: 100%; box-sizing: border-box; }
.hits { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 6px; }
.hits li { padding: 8px 12px; border-radius: 8px; background: light-dark(#f1f5f9, #262a33); }
/* keep the UA fade out of the way; the JS animation owns opacity too */
::view-transition-new(*) { animation: none; }
有两个细节很重要。设置 ::view-transition-new(*) { animation: none } 可以消除浏览器的淡入淡出效果,从而使透明度完全由 JavaScript 控制;否则淡入淡出效果会叠加。另外,对于延迟动画而言 fill: 'both' 非常重要:如果不设置该属性,快照在延迟期间会保持完全不透明状态,直到动画开始时才会发生变化。
在回调函数中使用过渡类型
第二个参数是由 addTransitionType 生成的列表。当需要计算持续时间或方向时,可将其视为 CSS 类映射的一种可编程替代方案。
onUpdate={(instance, types) => {
const duration = types.includes('fast') ? 120 : 400;
const dir = types.includes('back') ? -1 : 1;
const anim = instance.new.animate(
[{ transform: `translateX(${dir * 40}px)`, opacity: 0 }, { transform: 'none', opacity: 1 }],
{ duration, easing: 'ease-out', fill: 'both' }
);
return () => anim.cancel();
}}
当映射仅为简单查找时,建议使用类-对象形式,这样动画效果仍保留在 CSS 中且便于查看。只有在需要根据某种类型进行计算时才使用事件处理。
路由器:导航 API 与返回按钮
有两条路由规则会让那些将视图切换功能集成到客户端路由器中的团队感到意外。
在开始动画之前,React 会等待正在进行的导航操作完成。那些在渲染之后才继续处理 Navigation API 的 navigate 事件的路由器,必须在 useLayoutEffect 中释放该承诺。如果在 useEffect 中释放,则会形成循环:React 等待导航完成,而导航又等待仅在过渡之后才会触发的效果处理,此时开发构建时会提示导航已停滞。
通过经典的 popstate 事件实现回退功能时同样不支持动画过渡。这类导航操作必须同步完成,这样才能保证滚动位置和表单状态的正确性,而同步提交无法实现视图切换——因此 React 会省略动画效果。建议使用 Navigation API 的 navigate 监听器,它能够异步处理前进/后退操作:
useEffect(() => {
if (!window.navigation) return; // Chrome 102+, Safari 26.2, Firefox 147
const onNavigate = (event) => {
if (!event.canIntercept) return;
event.intercept({
handler() {
return new Promise((resolve) => {
startTransition(() => {
addTransitionType(event.navigationType === 'traverse' ? 'navigation-back' : 'navigation-forward');
setPath(new URL(event.destination.url).pathname);
});
resolveWhenPainted.current = resolve; // resolved from a useLayoutEffect after commit
});
},
});
};
window.navigation.addEventListener('navigate', onNavigate);
return () => window.navigation.removeEventListener('navigate', onNavigate);
}, []);
生产环境中的路由器还会过滤同一文档内的哈希变化、下载操作以及跨源请求;期望相关库能提供此类集成功能,而非让每个应用都重新实现。
早期的手动处理方式
在这些事件出现之前,团队们仍使用 WAAPI 的 pseudoElement 选项来操控伪元素:
const vt = document.startViewTransition(() => flushSync(update));
await vt.ready;
document.documentElement.animate(
[{ transform: 'translateY(24px)' }, { transform: 'none' }],
{ duration: 380, pseudoElement: '::view-transition-new(toast)' }
);
那种方式迫使开发者手动为元素生成唯一名称,不得不通过 React 的沉默状态来推断是哪个触发器被激活了,同时还需精心控制与 ready 的时序关系。而事件属性则直接提供自动生成的名称、触发该事件的类型以及适用的条件,还有一个在快照准备就绪时才会触发的钩子函数。
建议将简单的淡入淡出和布局变形效果处理在 CSS 中,而把那些真正需要闭包功能的值——如索引、指针位置、弹簧参数或特定功能标志对应的持续时间——的处理交由事件回调函数来完成。这样的分工能让常规场景下的样式表更便于在开发者工具中调试,同时当实时 DOM 上的 CSS 变量无法传递到快照层时,仍能启用 WAAPI 功能。
当演示内容与带动画的列表共享滑块或搜索框时,应将状态置于渲染 <ViewTransition> 的同一组件中。这样,事件处理函数就能直接读取最新值,而无需依赖全局动画注册表。如果多个边界需要错开处理,则应将索引作为属性传递给一个拥有自身 onEnter 方法的子组件,而非在无法查看每个快照实例的父组件中计算延迟。
为何重要
声明式动画系统最终仍需要一种命令式的解决方案。这种解决方案的设计决定了团队是否会在遇到第一个边界情况时就放弃该库。在此例中,相关结构较为简单——仅有四个回调函数和一个实例对象——它通过与 CSS 结合而非替代 CSS 来实现动画效果,同时其清理机制可防止中断的动画留下冻结的快照。
动画逻辑也存在于拥有它的元素旁边,其作用域与组件的状态相同。不存在全局名称注册表,组件挂载后也不需要查找引用。
注意事项
- 务必返回清理函数。如果忘记写
return () => anim.cancel(),则在该动画被中断后仍会继续播放直到其时间线结束。 - 事件会在默认动画之后执行。浏览器的过渡效果以及 View Transition Class 的关键帧已经应用,除非 CSS 为该伪元素设置了
animation: none,否则在 JS 中对opacity进行动画处理时会与这些效果叠加。 - 每个过渡边界只能有一个事件。共享元素只会触发
onShare,而不会同时触发进入和离开事件。
instance.old 却存在。在 onEnter 中调用 instance.old.animate() 会指向浏览器从未创建的伪元素,因此会静默无操作。应在进入时为 new 添加动画效果,在离开时为 old 添加。flushSync 会完全跳过过渡动画,因此不会触发任何事件。隐藏文档也会产生相同效果。matchMedia('(prefers-reduced-motion: reduce)');CSS 媒体查询无法控制 WAAPI 时间线。popstate 实现的返回/前进操作永远不会带有动画效果。在承诺提供动画历史记录之前,应先检测 window.navigation 的功能支持情况。如果暂时无法使用该功能
这种手动实现的 WAAPI 方式适用于任何支持 document.startViewTransition 的浏览器中的 React 应用。只需将其包裹一层即可:
import { flushSync } from 'react-dom';
export async function transitionWith(update, animateFn) {
if (!document.startViewTransition) { update(); return; }
const vt = document.startViewTransition(() => flushSync(update));
await vt.ready;
const anim = animateFn((pseudo) => ({
animate: (kf, opts) => document.documentElement.animate(kf, { ...opts, pseudoElement: pseudo }),
}));
vt.finished.finally(() => anim?.cancel());
}// usage — you own the names
transitionWith(
() => setShow(true),
(get) => get('::view-transition-new(toast)').animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300 })
);
这样会丢失触发机制及相关类型信息,且需要自行设置 view-transition-name。在升级后,animateFn 的内容通常会几乎不变地移至 onEnter 中。
参考资料
- React
<ViewTransition>文档中关于事件、JavaScript 动画以及路由集成的内容 - 通过事件自定义动画的
addTransitionType使用指南 - MDN 中的
Element.animate()及pseudoElement选项相关说明 - MDN 导航 API 参考文档
采用 React 19.3 实现视图过渡的团队应将事件 API 视为应急方案而非默认选择。应先从类和类型开始,只有在不可避免需要计算或闭包时才使用 onEnter / onExit / onUpdate / onShare。这样的做法既能确保大多数动画效果可在样式面板中查看,同时也能处理 CSS 无法表达的场景。