首页 / 文章

使用 addTransitionType 实现 React 中的面向方向的视图过渡

了解如何为 React Transition 添加 transitionType 标签并指定其触发原因,从而使 ViewTransition、CSS 和事件处理程序无需额外状态即可确定滑动方向。

发布者:REACTAPP.TOP阅读约 13 分钟2900 词

来源: addTransitionType in React 19.3: Direction-Aware Animations Without Direction State

想象一个具有“下一步”和“上一步”按钮的多步骤结账流程。这两个按钮都会更新相同的step值,但动画效果应当有所不同:向前操作时新面板应从右侧出现,向后操作时则应从左侧出现。由于无论哪种方式状态变化看起来都相同,该组件本身并无法判断应播放哪种动画,而传统的解决办法是设置一个独立的direction值,希望在动画最终播放时该值的准确性依然成立。

React 19.3 引入了 addTransitionType 函数,可简洁地解决这一问题。你可以在 startTransition 中调用它来记录更新的原因,<ViewTransition> 会依据该原因选择相应的动画效果。读完本文后,你将了解过渡类型可在何处被使用、各种类型如何组合、需要注意哪些陷阱,以及如何在旧版本的 React 中实现相同效果。该功能基于 <ViewTransition> 组件本身;如果你还不熟悉该组件,建议先查阅 React 的相关参考资料。

仅需两次导入即可使用全部 API

该函数位于主 react 包中,与 startTransition 放在同一个位置。

import { startTransition, addTransitionType } from 'react';

每个导航处理函数都会创建一个 Transition 对象,用字符串为其添加标签,然后执行常规的状态更新操作。该标签可以是任意字符串;此处两个处理函数分别使用 next 和 back 作为标签。

function goNext() {
  startTransition(() => {
    addTransitionType('next');   // any string
    setStep(s => s + 1);
  });
}function goBack() {
  startTransition(() => {
    addTransitionType('back');
    setStep(s => s - 1);
  });
}

这个接口非常简单。addTransitionType(type) 方法仅接受一个字符串参数且没有返回值,它必须在传递给 startTransition 的回调函数内部执行。如果在其他地方调用它,React 的开发版本会输出警告“addTransitionType 只能在 startTransition() 回调函数内部调用”,并忽略该类型信息。该方法允许多次调用,React 会将所有类型信息汇总到同一个 Transition 对象中。

有趣的部分并非这个函数本身,而是调用它的地方——总共有三个调用点。

第一个使用方:ViewTransition 属性中的对象值

<ViewTransition>的每个触发器属性(enter、exit、update、share和default)可以接受对象而非单纯的类名。该对象会将过渡类型映射到类名、"auto"或"none"。当列出的类型均不存在时,default键将作为默认值。

<ViewTransition
  enter={{ next: 'from-right', back: 'from-left', default: 'auto' }}
  exit={{ next: 'to-left', back: 'to-right', default: 'auto' }}
>
  <Step />
</ViewTransition>

基于类型的完整向导

以下向导展示了这一机制的实践。注意边界处的key={step}:每一步变化都会卸载旧面板并加载新面板,因此React会将其视为一次退出操作加上一次进入操作,而当前激活的类型则决定两个部分的切换方向。

import { ViewTransition, useState, startTransition, addTransitionType } from 'react';

一个go辅助函数负责接收步骤变化量与类型名,这样两个按钮就可以共享同一段代码路径。

const STEPS = ['Cart', 'Address', 'Payment', 'Review'];export default function App() {
  const [step, setStep] = useState(0);  function go(delta, type) {
    startTransition(() => {
      addTransitionType(type);
      setStep(s => s + delta);
    });
  }  return (
    <>
      <div className="bar">
        <button disabled={step === 0} onClick={() => go(-1, 'back')}>← Back</button>
        <span>Step {step + 1} of {STEPS.length}</span>
        <button disabled={step === STEPS.length - 1} onClick={() => go(1, 'next')}>Next →</button>
      </div>      <div className="stage">
        <ViewTransition
          key={step}
          enter={{ next: 'from-right', back: 'from-left' }}
          exit={{ next: 'to-left', back: 'to-right' }}
        >
          <section className="panel">{STEPS[step]}</section>
        </ViewTransition>
      </div>
    </>
  );
}

布局样式将面板以绝对定位方式置于受限的舞台区域内,这样就能避免即将显示和正在显示的快照相互干扰。

.bar { display: flex; align-items: center; gap: 12px; }
.stage { position: relative; height: 90px; margin-top: 12px; overflow: hidden; border-radius: 12px; }
.panel {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  font: 600 22px system-ui;
  background: light-dark(#e0e7ff, #1e1b4b);
}

动画规则通过类来定位视图过渡的伪元素。旧的快照会使用to-left或to-right作为退出关键帧,新的快照则使用from-right或from-left,这四个关键帧会在淡出效果的同时使元素整体平移一个完整宽度。

::view-transition-old(.to-left)    { animation: 260ms ease-in  both to-left; }
::view-transition-old(.to-right)   { animation: 260ms ease-in  both to-right; }
::view-transition-new(.from-right) { animation: 300ms ease-out both from-right; }
::view-transition-new(.from-left)  { animation: 300ms ease-out both from-left; }@keyframes to-left    { to   { transform: translateX(-100%); opacity: 0; } }
@keyframes to-right   { to   { transform: translateX(100%);  opacity: 0; } }
@keyframes from-right { from { transform: translateX(100%);  opacity: 0; } }
@keyframes from-left  { from { transform: translateX(-100%); opacity: 0; } }

看看该组件缺少什么:根本没有存储方向信息。方向属于更新操作,在安排更新的瞬间就会被附加,并随更新一起在 React 调度器中传递。当用户在第一张幻灯片仍在播放时快速按两次“上一步”键时,React 会将待处理的任务批量处理,第二段动画则会读取为该次过渡收集的类型信息。不存在可能导致不同步的 directionRef。

React 如何确定匹配的类型

React 按以下顺序应用这些规则:

  • 当有多个类型匹配时,会将对应的类名拼接起来并全部应用。
  • 如果没有类型匹配,React 会回退到 default 选项;如果没有该选项,则不会触发动画效果。
  • 当任何匹配项为 "none" 时,将优先采用该设置,此时边界动画将完全跳过。
  • 消费者 2:CSS 中的原生视图过渡类型

    React 还会将每种过渡类型作为原生视图过渡类型传递给浏览器。因此,你可以使用 :active-view-transition-type() 伪类在样式表中限定动画范围,而无需修改组件属性。

    第一步是为元素设置 view-transition-class。快照会继承该类,React 不会覆盖它。

    /* give the element a class the snapshot inherits — React leaves it alone */
    .panel { view-transition-class: panel; }
    

    接着编写仅在特定类型的过渡进行时才生效的规则。嵌套语法可将同一方向的两个伪元素组合在一起。

    /* applies only while a transition tagged 'next' is running */
    :root:active-view-transition-type(next) {
      &::view-transition-old(.panel) { animation: 220ms ease-in both to-left; }
      &::view-transition-new(.panel) { animation: 280ms ease-out both from-right; }
    }
    :root:active-view-transition-type(back) {
      &::view-transition-old(.panel) { animation: 220ms ease-in both to-right; }
      &::view-transition-new(.panel) { animation: 280ms ease-out both from-left; }
    }
    

    同样的向导界面,仅通过 CSS 进行样式设计

    此版本的向导完全不传递enter或exit属性。边界为空,样式表则根据当前类型来决定所有样式。

    import { ViewTransition, useState, startTransition, addTransitionType } from 'react';
    

    该组件的代码与其他第一个演示版本完全相同。

    const STEPS = ['Cart', 'Address', 'Payment', 'Review'];export default function App() {
      const [step, setStep] = useState(0);  function go(delta, type) {
        startTransition(() => {
          addTransitionType(type);
          setStep(s => s + delta);
        });
      }  return (
        <>
          <div className="bar">
            <button disabled={step === 0} onClick={() => go(-1, 'back')}>← Back</button>
            <span>Step {step + 1} of {STEPS.length}</span>
            <button disabled={step === STEPS.length - 1} onClick={() => go(1, 'next')}>Next →</button>
          </div>
          <div className="stage">
            <ViewTransition key={step}>
              <section className="panel">{STEPS[step]}</section>
            </ViewTransition>
          </div>
        </>
      );
    }
    

    面板样式现在包含了view-transition-class: panel,下方的选择器正是基于此来应用的。

    .bar { display: flex; align-items: center; gap: 12px; }
    .stage { position: relative; height: 90px; margin-top: 12px; overflow: hidden; border-radius: 12px; }
    .panel {
      position: absolute; inset: 0;
      display: grid; place-items: center;
      font: 600 22px system-ui;
      background: light-dark(#fce7f3, #4a1d3a);
      view-transition-class: panel;
    }
    

    类型作用域内的规则位于:root上,关键帧的设置与之前一致。

    /* The type React forwarded to the browser scopes the whole rule */
    :root:active-view-transition-type(next) {
      &::view-transition-old(.panel) { animation: 260ms ease-in  both to-left; }
      &::view-transition-new(.panel) { animation: 300ms ease-out both from-right; }
    }
    :root:active-view-transition-type(back) {
      &::view-transition-old(.panel) { animation: 260ms ease-in  both to-right; }
      &::view-transition-new(.panel) { animation: 300ms ease-out both from-left; }
    }@keyframes to-left    { to   { transform: translateX(-100%); opacity: 0; } }
    @keyframes to-right   { to   { transform: translateX(100%);  opacity: 0; } }
    @keyframes from-right { from { transform: translateX(100%);  opacity: 0; } }
    @keyframes from-left  { from { transform: translateX(-100%); opacity: 0; } }
    

    当整个页面需要对某一触发因素作出响应时,可选择此样式,例如将导航标记为navigation-forward或navigation-back的路由器。如果某个特定区域需要独立的映射处理,则建议使用对象属性。请保留view-transition-class这一行:若将目标设置为(*)而非(.panel),该规则也会匹配到根元素快照,此时整个页面会与面板一同滑动。

    消费者3:事件回调

    onEnter、onExit、onUpdate 和 onShare 回调函数的第二个参数即为收集到的类型信息。这使得命令式动画代码也能根据触发原因进行分支处理。在这段示例中,当触发 back 操作时,进入的元素会从屏幕左侧出现,否则则从右侧出现;该元素通过 Web Animations API 进行动画处理,并会返回一个清理函数,以便在 React 中断动画时取消它。

    <ViewTransition
      onEnter={(instance, types) => {
        const from = types.includes('back') ? '-100%' : '100%';
        const anim = instance.new.animate(
          [{ transform: `translateX(${from})` }, { transform: 'none' }],
          { duration: 300, easing: 'ease-out' }
        );
        return () => anim.cancel();
      }}
    >
    

    返回清理函数非常重要:如果没有它,快速被中断的过渡动画可能会让仍在运行的动画绑定在已不存在的快照上。

    在一个过渡动画中叠加多种类型

    由于 React 会将所有类型都保存在 Transition 内部,因此你可以在方向属性之上叠加其他独立的控制逻辑。在这个轮播组件中,“快速”复选框会添加一个名为 quick 的类型。该类仅用于缩短动画时长,最终这两个类会一同出现在快照中。

    import { ViewTransition, useState, startTransition, addTransitionType } from 'react';
    

    go 辅助函数始终会添加方向属性,并根据条件决定是否添加 quick 类。每个属性对象都会将 quick 对应到同名的类,这样匹配的方向类与 quick 类就会合并在一起。

    const SLIDES = ['🍎', '🍋', '🥝', '🍇'];export default function App() {
      const [i, setI] = useState(0);
      const [quick, setQuick] = useState(false);  function go(delta, type) {
        startTransition(() => {
          addTransitionType(type);
          if (quick) addTransitionType('quick');
          setI(n => (n + delta + SLIDES.length) % SLIDES.length);
        });
      }  return (
        <>
          <div className="bar">
            <button onClick={() => go(-1, 'back')}>←</button>
            <button onClick={() => go(1, 'next')}>→</button>
            <label><input type="checkbox" checked={quick} onChange={e => setQuick(e.target.checked)} /> quick</label>
          </div>
          <div className="stage">
            <ViewTransition
              key={i}
              enter={{ next: 'from-right', back: 'from-left', quick: 'quick' }}
              exit={{ next: 'to-left', back: 'to-right', quick: 'quick' }}
            >
              <div className="slide">{SLIDES[i]}</div>
            </ViewTransition>
          </div>
        </>
      );
    }
    

    布局思路与之前相同,只是为较大的表情符号设置了更高的显示区域。

    .bar { display: flex; align-items: center; gap: 12px; }
    .stage { position: relative; height: 110px; margin-top: 12px; overflow: hidden; border-radius: 12px; }
    .slide { position: absolute; inset: 0; display: grid; place-items: center; font-size: 56px; background: light-dark(#fef9c3, #3f3a12); }
    

    方向规则设置了刻意较慢的 700 毫秒动画时长,而 quick 规则仅会覆盖 animation-duration 属性,不会改变关键帧和缓动效果。

    ::view-transition-old(.to-left)    { animation: 700ms ease-in  both to-left; }
    ::view-transition-old(.to-right)   { animation: 700ms ease-in  both to-right; }
    ::view-transition-new(.from-right) { animation: 700ms ease-out both from-right; }
    ::view-transition-new(.from-left)  { animation: 700ms ease-out both from-left; }/* second class on the same snapshot: only the duration changes */
    ::view-transition-old(.quick), ::view-transition-new(.quick) { animation-duration: 120ms; }@keyframes to-left    { to   { transform: translateX(-100%); opacity: 0; } }
    @keyframes to-right   { to   { transform: translateX(100%);  opacity: 0; } }
    @keyframes from-right { from { transform: translateX(100%);  opacity: 0; } }
    @keyframes from-left  { from { transform: translateX(-100%); opacity: 0; } }
    

    这种设计正是类型系统带来的真正优势:方向、速度或强调程度等相互独立的特性可以以单独的标签形式表达,而无需使用日益复杂的组合类名矩阵。

    它所替代的基于状态的方法

    在出现这个 API 之前,方向信息通常存储在组件状态中,但这并不是存放它的合适位置。典型的实现方式会新增另一项状态:

    const [step, setStep] = useState(0);
    const [direction, setDirection] = useState('next');
    

    每个处理函数会先记录方向信息,再记录步进值,而渲染输出则将方向信息整合到类名中:

    function goNext() {
      setDirection('next');
      setStep(s => s + 1);
    }// then, in render:
    <div className={`panel enter-${direction}`} key={step}>…</div>
    

    这种模式存在三个缺陷:

    • 额外的状态会导致那些并非真正属于 UI 的信息也被重新渲染。
    • 如果快速点击两个相反方向,direction 所描述的更新内容可能会与实际正在动画化的内容不一致。
  • 当由于深度链接或重启按钮等其他原因导致step发生变化时,剩余的方向值会被应用到与之无关的更新上。
  • 动画库通常会通过属性来隐藏同样的问题。Framer Motion传递给变体的custom={direction}仍然是一个存储值,只是名称更合理了一些。

    为何将原因置于更新操作中很重要

    通过 addTransitionType,触发原因属于更新的一部分而非状态的一部分。虽然变化很小,但影响却很显著:该触发原因不会过时,不会影响到无关的更新,也不会增加渲染开销。此外它还为各种库提供了统一的通信渠道。路由器只需为向前和向后的导航各标记一次,页面上的每个 <ViewTransition> 都可以响应这些标记。React 的文档指出,它期望路由器和其他库能采用统一的类型名称。

    React 团队还表示,类型并不局限于动画场景,未来还计划为过渡的触发原因找到更多用途。将元数据附加到 Transition 上并在提交时读取该元数据的底层机制足够通用,能够支持这些新用途。

    发布前需了解的隐患

    • 该类型仅在 startTransition 内有效。在其外部,React 会丢弃该类型并给出仅在开发环境中显示的警告,而且由于更新十分紧急,根本不会进行任何动画处理。
    • 每次提交后类型都会被清除。如果过渡状态首先显示 <Suspense> 作为备用内容,那么该备用的提交会保留你的类型信息,而之后实际内容的显示则不会。对于需要在加载边界处实现方向感知的动画,应在内容准备好时再次添加类型信息,或者设计一种无需考虑方向的备用内容切换方式。
    • "none" 总是具有优先权。如果任何匹配项的值为 "none",那么即使有其他匹配类型提供了类名,该触发条件也会被禁用。
  • 没有default键的对象仅会对其中列出的类型进行动画处理,因此经过该边界的未标记过渡效果将不会产生动画。这通常是刻意为之,但很容易被忽略。
  • 两种实现方式的浏览器支持情况有所不同。由于React会自行在快照上设置类名,因此使用对象属性的方式可在任何支持View Transition Classes的浏览器中运行:Chrome 125、Safari 18.2和Firefox 144。而通过:active-view-transition-type()进行样式设置的要求则稍高,需要Chrome 125、Safari 18.2或Firefox 147。可查看MDN的兼容性数据以了解最新情况。
  • 类型名称是直接传递给浏览器的全局字符串。如果页面上的两个功能都可能使用next,建议为其添加前缀,例如wizard-next。
  • React 19.2及更早版本的替代方案

    较旧版本的 React 无法使用 addTransitionType,但浏览器 API 仍然可以携带过渡类型信息。这样做的代价是放弃了 React 对过渡过程的协调功能,而非方向信息。你需要自行调用 document.startViewTransition,传入 types 参数,然后直接复用第二个使用示例中的 CSS,无需做任何修改。此外还需要 flushSync,以确保状态更新能在浏览器的内置更新回调中同步执行:

    import { flushSync } from 'react-dom';
    

    该辅助函数还能优雅地降级:当浏览器不支持 startViewTransition 时,它只会直接更新状态而不会触发动画。

    function go(delta, type) {
      if (!document.startViewTransition) {
        setStep(s => s + delta);
        return;
      }
      document.startViewTransition({
        update: () => flushSync(() => setStep(s => s + delta)),
        types: [type],
      });
    }
    

    升级到 19.3 版本后,go 函数的实现又变回了第一部分中那种简短的 startTransition 版本,而样式表则完全没有变化。

    关键要点

    • 应将动画方向视为与更新相关的元数据,而非组件状态;addTransitionType正是用于此目的的工具。
    • 可通过对象属性实现针对特定边界的类型映射,通过:active-view-transition-type()设定全页面规则,或通过事件回调实现命令式动画。
    • 应堆叠独立的类型,如方向与速度,而非创造组合类名。
    • 需牢记以下要点:Suspense状态下的元素会丢失其类型,"none"会覆盖所有设置,且类型名称共享全局命名空间。
    • 如需完整的API详情,请参阅addTransitionType参考文档以及ViewTransition文档中的基于类型的自定义部分。