首页 / 文章

利用ViewTransition呈现悬念动画:备用方案、图片与字体

了解如何将 Suspense 包装在 ViewTransition 中来实现骨架屏与内容切换的动画效果,哪些属性可以阻止多余的动画,以及 React 是如何等待图片和字体加载完成的。

发布者:REACTAPP.TOP阅读约 14 分钟3151 词

来源: React 19.3 View Transitions with Suspense: Animate the Fallback, Wait for Images and Fonts

Suspense 边界本身就已经描述了一种过渡过程:它先显示备用内容,随后再切换为实际内容。过去这种切换是突然的硬切,往往过一会儿会有图片出现,之后网页字体才会加载。React 19.3 允许将这种边界包裹在 <ViewTransition> 中,从而使切换过程变为动画;同时,React 会延迟该动画的播放,直到新内容中的图片和网页字体全部加载完成。本指南将介绍如何设置这一功能、如何在不需要时阻止动画播放、应在何处放置边界,以及内置的等待机制具体起到了什么作用。

由于此功能是近期才推出的,在撰写本文时,若要在生产环境中依赖精确的时间节点,请先参考最新的 React 文档确认相关细节。这些示例假设您已经了解 <ViewTransition> 会在特定的触发条件(enter、exit、update)下执行动画,并且必须包裹需要动画处理的 DOM 元素。

将内容展示过程转化为动画

最简实现方式就是在 Suspense 的边界处添加一个 <ViewTransition>:

<ViewTransition>
  <Suspense fallback={<Skeleton />}>
    <Forecast city={city} />
  </Suspense>
</ViewTransition>

进行内容替换时无需使用 startTransition,因为 React 已将显示 Suspense 边界视为一种过渡操作。一旦 Forecast 完成挂起状态,替换骨架屏的操作就会在视图过渡过程中执行。由于骨架屏和预测内容都位于同一个 <ViewTransition> 内,它们会共享同一个生成的快照名称,因此该变化会被视为 update 操作,浏览器会实现两者之间的平滑过渡。

可运行的预测示例

演示首先导入所需的依赖:

import { ViewTransition, Suspense, use, useState, startTransition } from 'react';

组件代码在 Map 中为每个城市缓存一个 Promise,因为 use() 需要在多次渲染中使用同一个 Promise 实例;如果每次渲染都创建新的 Promise,程序将会永远处于挂起状态。Forecast 使用 use() 读取已解析的数据,Skeleton 渲染占位卡,而 App 则在 startTransition 方法中切换整个界面的显示状态:

// use() needs the same Promise instance across renders, so cache it
const cache = new Map();
function fetchForecast(city) {
  if (!cache.has(city)) {
    cache.set(city, new Promise(resolve =>
      setTimeout(() => resolve({ city, high: 31, low: 24, sky: '⛅' }), 1100)
    ));
  }
  return cache.get(city);
}function Forecast({ city }) {
  const f = use(fetchForecast(city));
  return <div className="card">{f.sky} {f.city}: {f.high}° / {f.low}°</div>;
}function Skeleton() {
  return <div className="card skeleton">Loading forecast…</div>;
}export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => startTransition(() => setShow(s => !s))}>
        {show ? 'Hide' : 'Load'} forecast
      </button>
      {show && (
        <ViewTransition>
          <Suspense fallback={<Skeleton />}>
            <Forecast city="Pune" />
          </Suspense>
        </ViewTransition>
      )}
    </>
  );
}

几条 CSS 规则用于设置卡片的样式,并为占位内容提供中性背景,同时使用 light-dark() 函数实现两种颜色方案:

.card { margin-top: 12px; padding: 14px 16px; border-radius: 10px; background: light-dark(#e0f2fe, #0c3350); }
.skeleton { color: transparent; background: light-dark(#e2e8f0, #2a2f3a); }

最后一条规则会减缓默认的渐变过渡速度,以便在实验时更清晰地观察到变化:

/* slow the default cross-fade so the swap is visible */
::view-transition-old(*), ::view-transition-new(*) { animation-duration: 450ms; }

点击按钮后会显示骨架结构,等待大约一秒以模拟数据获取过程,随后在骨架上渐显预测内容。不过,如果隐藏后再重新加载,就会发现一个问题:数据已被缓存,因此不会显示备用内容,但卡片依然会像隐藏时那样渐显。这就引出了下一节的内容。

停止那些并非用户要求的动画

没有属性的<ViewTransition>会为每个触发点添加动画:组件挂载时执行enter动画,卸载时执行exit动画,而内容发生变化时则执行update动画。当这种结构被包裹在Suspense中时,原本只需一个动画,实际上却会生成多达三个动画。React的文档明确了加载UI时的预期行为:

  • 备用内容应立即显示且无需动画,因为用户刚刚采取了操作,期望得到即时反馈。
  • 从备用内容切换到实际内容时应有动画效果。
  • 已被缓存、因此不会暂停加载的内容应立即无动画地显示出来。

有两个属性可以准确实现这一点。default="none"可关闭所有触发功能,而update="auto"则可重新开启你需要的那个功能:

<ViewTransition update="auto" default="none">
  <Suspense fallback={<Skeleton />}>
    <Forecast city={city} />
  </Suspense>
</ViewTransition>

两种配置的对比

第二个演示使用了相同的导入项:

import { ViewTransition, Suspense, use, useState, startTransition } from 'react';

它允许你加载三个城市中的任意一个,并在纯 <ViewTransition> 格式与包含两个属性的版本之间切换。注意 <ViewTransition> 上的 key={city}:切换城市时会创建新的边界,而非更新现有的边界。

const cache = new Map();
function fetchForecast(city) {
  if (!cache.has(city)) {
    const data = { Pune: '⛅ 31° / 24°', Goa: '🌧 29° / 26°', Shimla: '☀️ 18° / 9°' }[city];
    cache.set(city, new Promise(r => setTimeout(() => r(data), 1100)));
  }
  return cache.get(city);
}function Forecast({ city }) {
  const text = use(fetchForecast(city));
  return <div className="card">{city}: {text}</div>;
}export default function App() {
  const [city, setCity] = useState(null);
  const [onlyUpdates, setOnlyUpdates] = useState(false);
  const props = onlyUpdates ? { update: 'auto', default: 'none' } : {};  return (
    <>
      <div className="bar">
        {['Pune', 'Goa', 'Shimla'].map(c => (
          <button key={c} onClick={() => startTransition(() => setCity(city === c ? null : c))}>
            {city === c ? 'Hide' : 'Load'} {c}
          </button>
        ))}
        <label><input type="checkbox" checked={onlyUpdates} onChange={e => setOnlyUpdates(e.target.checked)} /> only animate updates</label>
      </div>
      {city && (
        <ViewTransition key={city} {...props}>
          <Suspense fallback={<div className="card skeleton">Loading {city}…</div>}>
            <Forecast city={city} />
          </Suspense>
        </ViewTransition>
      )}
    </>
  );
}

该样式表添加了灵活的工具栏,以及相同的卡片布局、骨架结构以及缓动的交叉淡入淡出规则:

.bar { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; }
.card { margin-top: 12px; padding: 14px 16px; border-radius: 10px; background: light-dark(#e0f2fe, #0c3350); }
.skeleton { color: transparent; background: light-dark(#e2e8f0, #2a2f3a); }
::view-transition-old(*), ::view-transition-new(*) { animation-duration: 450ms; }

当启用“仅动画更新”功能时,尚未加载的城市会立即显示其骨架结构,随后再通过动画呈现内容;而已加载过的城市则直接出现,没有动画效果。文档建议仅偶尔为 Suspense 的内容添加动画,对于本可立即渲染的缓存内容则不应添加动画,因为那样只会增加延迟。实际上,几乎所有包裹 Suspense 边界的 <ViewTransition> 都应设置 update="auto" default="none"。

放置位置决定触发机制

<ViewTransition> 相对于 Suspense 的放置位置决定了哪些触发器会被激活,进而决定哪些部分可以单独进行动画处理。

在边界之外:仅一次更新

从外部包裹悬念效果会形成一个单一的边界,该边界会将这种变化视为更新:

<ViewTransition update="cross">
  <Suspense fallback={<A />}>
    <B />
  </Suspense>
</ViewTransition>

备用内容与实际内容共享同一个快照名称,因此只能通过交叉渐变或变形的方式在二者之间切换,此处使用了自定义的cross类。

两个分支内部:退出与进入

将备用内容与实际内容分别包裹会形成两个独立的边界:

<Suspense
  fallback={<ViewTransition exit="out"><A /></ViewTransition>}
>
  <ViewTransition enter="in"><B /></ViewTransition>
</Suspense>

备用内容以退出动画离开,而实际内容则以进入动画出现,这样就能让占位符向下消失,同时真实内容上升至合适位置。

使用内部放置方式的资料卡

第三个演示同样从导入部分开始:

import { ViewTransition, Suspense, use, useState, startTransition } from 'react';

loadProfile 每次点击都会创建一个新的 Promise,因此边界会每次都暂停状态。Profile 和 Skeleton 都将自己的标记内容包裹在各自的 <ViewTransition> 中,分别使用 enter="rise" 和 exit="sink",同时设置 default="none"。在 Suspense 中设置 key={attempt} 非常重要:因为每次加载都会创建一个新的边界,而已经在页面中存在的边界如果在 Transition 内再次暂停,就会继续显示之前的内容,而不会展示备用内容。

function loadProfile() {
  // a fresh Promise per click, so it suspends every time
  return new Promise(r => setTimeout(() => r({ name: 'Rahul', role: 'Frontend' }), 1100));
}function Profile({ promise }) {
  const p = use(promise);
  return (
    <ViewTransition enter="rise" default="none">
      <div className="card">👤 {p.name} — {p.role}</div>
    </ViewTransition>
  );
}function Skeleton() {
  return (
    <ViewTransition exit="sink" default="none">
      <div className="card skeleton">loading…</div>
    </ViewTransition>
  );
}export default function App() {
  const [attempt, setAttempt] = useState(0);
  const [promise, setPromise] = useState(null);
  return (
    <>
      <button onClick={() => startTransition(() => { setAttempt(a => a + 1); setPromise(loadProfile()); })}>
        {attempt ? 'Load again' : 'Load profile'}
      </button>
      {promise && (
        // key: a NEW boundary each time. A mounted boundary that re-suspends
        // inside a Transition keeps showing the old content instead of the fallback.
        <Suspense key={attempt} fallback={<Skeleton />}>
          <Profile promise={promise} />
        </Suspense>
      )}
    </>
  );
}

基础样式决定了卡片和骨架的视觉外观:

.card { margin-top: 12px; padding: 14px 16px; border-radius: 10px; background: light-dark(#e0f2fe, #0c3350); }
.skeleton { color: light-dark(#64748b, #94a3b8); background: light-dark(#e2e8f0, #2a2f3a); }

动画规则针对的是那些被命名的类。::view-transition-old(.sink)用于处理即将消失的界面元素,而::view-transition-new(.rise)则用于处理即将出现的新的内容,两者都有各自的关键帧:

::view-transition-old(.sink) { animation: 240ms ease-in  both sink; }
::view-transition-new(.rise) { animation: 320ms ease-out both rise; }
@keyframes sink { to   { transform: translateY(16px);  opacity: 0; } }
@keyframes rise { from { transform: translateY(-16px); opacity: 0; } }

等待图片、字体和样式表加载

Suspense机制长期以来一直限制着数据和延迟加载代码的加载。在React 19.3中,当通过<ViewTransition>实现动画效果时,该机制限制的内容范围进一步扩大了:

  • 使用use()读取的数据以及延迟加载的组件:两种情况都需要等待。
  • 通过<link rel="stylesheet" precedence="…">声明的样式表:两种情况都需要等待(普通Suspense会有超时处理)。
  • 新内容引入的字体:普通 Suspense 会忽略这些字体;在视图过渡更新期间,React 会设置时间限制等待其加载完成。
  • 可见的 <img> 元素:普通 Suspense 会忽略这些元素;在视图过渡更新期间,React 会等待最多 800 毫秒以完成图像解码,但使用 loading="lazy" 或具有 onLoad 处理器的图像除外。
  • 这样设计的原因是出于视觉效果考虑。如果过渡到一张图片仅部分加载完成或文字仍使用系统字体的卡片,其效果会比直接切换差很多。因此 React 会推迟显示该视图过渡内容,直到该区域内的图片和新字体都准备就绪。如果没有视图过渡,一切都不会改变,图像仍会在加载完成后立即显示。

    React 愿意等待的时间长度

    等待时间是有上限的。这些具体数值来自对 react-dom 实现代码的阅读,而非文档说明,因此应将其视为可能发生变化的实现细节:

    • 对于图片,React 会调用 img.decode(),并将数据提交的延迟控制在最多800毫秒以内。
    • 它还会根据图片的 width 和 height 属性估算图片大小,再通过资源加载时间信息判断网络速度。如果估算结果显示下载时间会超过大约500毫秒,React就会将等待时间设定为仅50毫秒,随后仍会执行下载操作。
    • 如果没有 width 和 height 属性,系统会默认假设图片尺寸为100×100像素,因此实际的尺寸值才能让估算结果具有参考意义。
    • 对于字体文件,等待时间的上限约为500毫秒。

    图片加载延迟的演示

    该图片演示示例还引入了几个额外的钩子函数:

    import { ViewTransition, Suspense, use, useState, useRef, useLayoutEffect, startTransition } from 'react';
    

    每次点击都会获取一个带有随机生成图片URL的列表项,因此图像永远不会被缓存。两个区域并排显示相同的卡片:左侧让React等待图片加载,右侧则设置一个空的onLoad处理函数,从而跳过图片加载步骤,使卡片立即显示,图片随后再加载。useLayoutEffect会在每个卡片渲染完成时记录img.complete的状态,这样在网络速度较快、难以察觉差异的情况下也能确认两者的区别。

    function fetchListing() {
      return new Promise(r => setTimeout(() => r({
        title: 'Sea-facing 2BHK, Bandra',
        // a new seed every time → a fresh download every time
        photo: `https://picsum.photos/seed/${Math.random().toString(36).slice(2)}/600/300`,
      }), 300));
    }function Listing({ promise, optOut, label }) {
      const l = use(promise);
      const img = useRef(null);
      // runs at the moment the content is committed — was the photo already there?
      useLayoutEffect(() => {
        console.log(`${label}: photo decoded before reveal → ${img.current.complete}`);
      }, [label]);
      return (
        <div className="card">
          <img
            ref={img}
            src={l.photo}
            width={600}
            height={300}
            alt=""
            onLoad={optOut ? () => {} : undefined}
          />
          <strong>{l.title}</strong>
        </div>
      );
    }function Slot({ label, optOut }) {
      const [promise, setPromise] = useState(null);
      return (
        <div>
          <button onClick={() => startTransition(() => setPromise(fetchListing()))}>{label}</button>
          {promise && (
            <ViewTransition update="auto" default="none">
              <Suspense fallback={<div className="card skeleton"><div className="ph" /><strong>loading…</strong></div>}>
                <Listing promise={promise} optOut={optOut} label={label} />
              </Suspense>
            </ViewTransition>
          )}
        </div>
      );
    }export default function App() {
      return (
        <div className="two">
          <Slot label="Wait for photo" optOut={false} />
          <Slot label="onLoad opt-out" optOut={true} />
        </div>
      );
    }
    

    样式用于布局这两列,并在卡片和占位符中保持图像的宽高比一致:

    .two { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
    .card { margin-top: 10px; padding: 10px; border-radius: 10px; background: light-dark(#f1f5f9, #262a33); }
    .card img, .ph { display: block; width: 100%; height: auto; aspect-ratio: 2 / 1; border-radius: 6px; object-fit: cover; margin-bottom: 8px; }
    .ph { background: light-dark(#e2e8f0, #363b47); }
    .skeleton strong { color: light-dark(#64748b, #94a3b8); }
    ::view-transition-old(*), ::view-transition-new(*) { animation-duration: 450ms; }
    

    可作为可追踪资源的字体

    字体也遵循相同的模式。关键在于在<style>元素中声明带有href和precedence属性的@font-face规则,这样就能让React将其提升并作为资源进行跟踪,等待相关操作完成。

    <ViewTransition update="auto" default="none">
      <Suspense fallback={<QuoteSkeleton />}>
        <style href={fontUrl} precedence="default">
          {`@font-face {
            font-family: 'Quote';
            src: url(${fontUrl}) format('woff2');
            font-display: swap;
          }`}
        </style>
        <blockquote style={{ fontFamily: 'Quote' }}>{quote}</blockquote>
      </Suspense>
    </ViewTransition>
    

    React的文档中有一个更完整的示例,它将获取的数据、带有precedence属性的样式表、自定义字体以及肖像照片整合在同一个框架下展示。这种组合方式与大多数真实的卡片设计一致:包括个人资料、产品信息块以及带有头像的聊天消息。

    以往的做法

    在此之前,要等待图片加载完成后再显示内容,需要手动进行预加载:先创建一个Image对象,等待onload事件触发,然后再更新状态。典型的钩子实现方式如下:

    function useImageReady(src) {
      const [ready, setReady] = useState(false);
      useEffect(() => {
        const img = new Image();
        img.onload = () => setReady(true);
        img.src = src;
      }, [src]);
      return ready;
    }
    

    每个组件都拥有自己的副本且彼此之间没有协调,因此一张同时包含照片和自定义字体的卡片仍然需要分两步才能显示完整。字体问题的情况更糟,要么需要使用document.fonts.load()加上相关标志,要么使用font-display: block并伴随短暂的无文字显示。要实现切换动画,就必须在数据到达时手动调用startViewTransition,而由于该操作要经过flushSync处理,它本身无法等待其他任何操作。

    为何这一改变很重要

    UI加载过程从四个决策步骤简化为单一决策。你现在所设置的Suspense边界同时也决定了内容何时被视为已准备就绪,涵盖了数据、代码、样式、字体和图片等元素,动画也会在那一刻一次性执行。针对不同资源的特殊效果消失了,那些试图同步这些效果的辅助代码也同样不再需要。

    这还让 React 能够决定浏览器何时首次渲染已加载的图片。此前,<img>会在解码后立即被渲染。通过将其与视图过渡关联,无需手动实现预加载即可获得类似原生应用的“立即显示完成”的效果。

    需注意的要点

    • 字体和图片仅在视图过渡更新时才会被等待加载。如果没有使用<ViewTransition>,或者设置了update="none",图片仍会像以前一样立即显示。
    • 添加onLoad处理程序后,无论是否有视图过渡,该图片都不会被纳入等待列表。这对于页面下方的图片很有用,但如果添加处理程序是为了分析数据,则会让人感到意外。
  • 字体的限制约为半秒,图片为800毫秒;当React判断图片尺寸超过当前网络承载能力时,则限制为50毫秒。性能不佳的CDN可能会略微延迟内容显示,但绝不会使其完全停止。
  • 出于此目的,视口外的图片会被忽略。
  • 缓存的内容默认会再次播放动画,因此需将Suspense与update="auto" default="none"一起使用。
  • 当内容置于视口外部时,不同高度的骨架结构与实际内容会迫使浏览器调整快照尺寸,从而产生视觉上的拉伸效果。应将占位符的尺寸设置与最终内容一致,或者改为将内容置于视口内部,这样两者就能各自独立地播放动画。
  • 每次提交后,过渡类型都会被重置,因此那些在显示回退内容时添加的如 next 或 back 这样的类型,在实际内容被提交后就不再存在了。
  • 针对早期 React 版本的回退方案

    如果您暂时无法升级,避免图片突然显示的可靠方法是将图片视为正在加载的内容的一部分:在只有当 JSON 数据到达且图片解码完成之后才会解决的 Promise 上挂起加载操作:

    function loadListing() {
      return fetch('/api/listing').then(r => r.json()).then(async data => {
        const img = new Image();
        img.src = data.photo;
        await img.decode().catch(() => {});   // don't let a broken image block forever
        return data;
      });
    }
    

    decode() 中的 .catch(() => {}) 能确保损坏的图片不会永远阻碍内容显示。虽然仍会直接切换而非通过动画呈现,但该图片会在渲染时存入缓存,并与卡片中的其他内容一同显示在同一帧中。字体也可以通过链式调用 document.fonts.load('1em Quote') 返回的 Promise 以类似方式处理。

    关键要点

    • 将 Suspense 包装在 <ViewTransition> 中,无需显式调用 startTransition 即可实现内容切换的动画效果。
    • 使用 update="auto" default="none",这样备用内容和缓存内容会立即显示,只有实际的内容展示部分才会进行动画处理。
  • 如需单次渐变过渡,可将<ViewTransition>置于Suspense之外;如需独立的进入和退出动画,则应将其放在两个分支内部。
  • 在视图过渡期间,React会在严格的时间限制内等待图片和新字体的加载完成,从而省去了大部分手动预加载的操作。
  • 请为图片设置真实的width和height值,同时注意onLoad选项会取消这一设置。
  • 主要参考资料:React 19.3版本说明、Suspense文档、ViewTransition文档以及样式组件文档。