适用于单页应用的同文档视图转换

针对单个文档运行视图转换称为“同文档视图转换”。使用 JavaScript 更新 DOM 的单页应用 (SPA) 中通常就属于这种情况。从 Chrome 111 开始,Chrome 支持同文档视图过渡。

如需触发同文档视图转换,请调用 document.startViewTransition

function handleClick(e) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    updateTheDOMSomehow();
    return;
  }

  // With a View Transition:
  document.startViewTransition(() => updateTheDOMSomehow());
}

被调用时,浏览器会自动截取已声明 view-transition-name CSS 属性的所有元素的快照。

然后,它会执行传入的回调以更新 DOM,之后会拍摄新状态的快照。

然后,这些快照会被排列在伪元素树中,并利用 CSS 动画的强大功能进行动画处理。旧状态和新状态的成对快照从其旧位置和大小顺畅地过渡到新位置,同时其内容淡入淡出。如果需要,您可以使用 CSS 自定义动画。


默认过渡:淡入淡出

默认的视图过渡是淡入淡出,因此为 API 提供了很好的介绍:

function spaNavigate(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    updateTheDOMSomehow(data);
    return;
  }

  // With a transition:
  document.startViewTransition(() => updateTheDOMSomehow(data));
}

其中,updateTheDOMSomehow 用于将 DOM 更改为新状态。您可以根据需要进行此操作。例如,您可以添加或移除元素、更改类名称或更改样式。

这样,页面就会交错淡出:

默认的淡入淡出。极简演示来源

好,淡入淡出没有那么令人印象深刻。幸运的是,转场效果可以自定义,但你需要先了解这种基本的交叉淡入淡出效果如何。


这些过渡的运作方式

我们来更新之前的代码示例。

document.startViewTransition(() => updateTheDOMSomehow(data));

调用 .startViewTransition() 时,该 API 会捕获页面的当前状态。这包括拍摄快照。

完成后,系统会调用传递给 .startViewTransition() 的回调。这就是 DOM 更改的地方。然后,API 会捕获页面的新状态。

捕获新状态后,该 API 会构建一个伪元素树,如下所示:

::view-transition
└─ ::view-transition-group(root)
   └─ ::view-transition-image-pair(root)
      ├─ ::view-transition-old(root)
      └─ ::view-transition-new(root)

::view-transition 位于叠加层中,位于页面上所有其他内容的上方。如果您想设置转场的背景颜色,这会非常有用。

::view-transition-old(root) 是旧视图的屏幕截图,而 ::view-transition-new(root) 是新视图的实时表示形式。两者都以 CSS“替换的内容”(如 <img>)的形式呈现。

旧视图会以动画形式从 opacity: 1opacity: 0,而新视图会从 opacity: 0 以动画形式呈现到 opacity: 1,从而创建淡入淡出。

所有动画均使用 CSS 动画执行,因此可以使用 CSS 对其进行自定义。

自定义转场效果

所有视图过渡伪元素都可以使用 CSS 来定位,而且由于动画是使用 CSS 定义的,因此您可以使用现有的 CSS 动画属性来修改它们。例如:

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 5s;
}

完成这项更改后,淡变效果现在非常慢:

长交叉淡入淡出。极简演示来源

好吧,这还不算令人印象深刻。而以下代码会实现 Material Design 的共享轴转场

@keyframes fade-in {
  from { opacity: 0; }
}

@keyframes fade-out {
  to { opacity: 0; }
}

@keyframes slide-from-right {
  from { transform: translateX(30px); }
}

@keyframes slide-to-left {
  to { transform: translateX(-30px); }
}

::view-transition-old(root) {
  animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}

::view-transition-new(root) {
  animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

结果如下:

共享轴过渡。极简演示来源

过渡多个元素

在之前的演示中,整个页面都参与了共享轴过渡。这适用于页面的大部分内容,但对于标题似乎不太合适,因为它滑出只是为了再次滑回。

为避免出现这种情况,您可以从网页的其余部分提取页眉,以便单独为其添加动画效果。具体方法是为该元素分配一个 view-transition-name

.main-header {
  view-transition-name: main-header;
}

view-transition-name 的值可以是任意值(none 除外,这意味着没有过渡名称)。它用于在过渡期间唯一标识相应元素。

结果是:

具有固定标题的共享轴转场效果。极简演示来源

现在,标题会保留在原位并交错淡出。

该 CSS 声明导致伪元素树发生变化:

::view-transition
├─ ::view-transition-group(root)
│  └─ ::view-transition-image-pair(root)
│     ├─ ::view-transition-old(root)
│     └─ ::view-transition-new(root)
└─ ::view-transition-group(main-header)
   └─ ::view-transition-image-pair(main-header)
      ├─ ::view-transition-old(main-header)
      └─ ::view-transition-new(main-header)

现在有两个转换组。一个用于标头,另一个用于其余部分。可以使用 CSS 单独定位这些元素,并采用不同的转场方式。不过,在本例中,main-header 保留了默认转场效果,即交叉淡入淡出。

好的,默认转场不仅仅是淡入淡出,::view-transition-group 也会进行转场:

  • 定位和转换(使用 transform
  • 宽度
  • 身高

在此之前,这并不重要,因为 DOM 两端的标头大小和位置发生变化。不过,您也可以提取标题中的文本:

.main-header-text {
  view-transition-name: main-header-text;
  width: fit-content;
}

使用了 fit-content,因此元素将根据文本的大小进行调整,而不是拉伸至剩余宽度。如果不执行此操作,返回箭头会减小标题文本元素的大小,而不是在两页中显示相同的大小。

现在,我们可以尝试进行以下三个部分:

::view-transition
├─ ::view-transition-group(root)
│  └─ …
├─ ::view-transition-group(main-header)
│  └─ …
└─ ::view-transition-group(main-header-text)
   └─ …

再次提醒,使用默认值即可:

滑动标题文本。极简演示来源

现在,标题文本会滑过一些有趣的内容,为返回按钮腾出空间。


使用 view-transition-class 以相同的方式为多个伪元素添加动画效果

浏览器支持

  • 125
  • 125
  • x
  • x

假设您有一个视图过渡效果,其中包含许多卡片,但页面上还包含一个标题。若要为除标题以外的所有卡片添加动画效果,您必须编写一个选择器,分别定位每张卡片。

h1 {
    view-transition-name: title;
}
::view-transition-group(title) {
    animation-timing-function: ease-in-out;
}

#card1 { view-transition-name: card1; }
#card2 { view-transition-name: card2; }
#card3 { view-transition-name: card3; }
#card4 { view-transition-name: card4; }
…
#card20 { view-transition-name: card20; }

::view-transition-group(card1),
::view-transition-group(card2),
::view-transition-group(card3),
::view-transition-group(card4),
…
::view-transition-group(card20) {
    animation-timing-function: var(--bounce);
}

有 20 个元素?因此,您需要编写 20 个选择器。要添加新元素吗?然后,您还需要增大应用动画样式的选择器。不完全可扩展。

您可以在视图过渡伪元素中使用 view-transition-class 来应用相同的样式规则。

#card1 { view-transition-name: card1; }
#card2 { view-transition-name: card2; }
#card3 { view-transition-name: card3; }
#card4 { view-transition-name: card4; }
#card5 { view-transition-name: card5; }
…
#card20 { view-transition-name: card20; }

#cards-wrapper > div {
  view-transition-class: card;
}
html::view-transition-group(.card) {
  animation-timing-function: var(--bounce);
}

以下卡片示例就使用了之前的 CSS 代码段。通过一个选择器,所有卡片(包括新添加的卡片)都会应用相同的时间设置:html::view-transition-group(.card)

卡片演示的录制。通过使用 view-transition-class,它会将相同的 animation-timing-function 应用于除添加或移除的卡之外的所有卡片。

调试转换

由于视图过渡是在 CSS 动画之上构建的,因此 Chrome 开发者工具中的 Animations 面板非常适合调试过渡。

使用动画面板,您可以暂停下一个动画,然后在动画之间来回拖动。在此期间,您可以在元素面板中找到过渡伪元素。

使用 Chrome 开发者工具调试视图转换。

转换元素不必是同一 DOM 元素

到目前为止,我们已使用 view-transition-name 为标题中的文本和标题中的文本创建单独的转场元素。从概念上来讲,这些元素在 DOM 更改前后是相同的,但您可以创建转场,而并非如此。

例如,可以为主视频嵌入提供 view-transition-name

.full-embed {
  view-transition-name: full-embed;
}

然后,在用户点击缩略图时,系统仅在转场期间为其提供相同的 view-transition-name

thumbnail.onclick = async () => {
  thumbnail.style.viewTransitionName = 'full-embed';

  document.startViewTransition(() => {
    thumbnail.style.viewTransitionName = '';
    updateTheDOMSomehow();
  });
};

结果:

一个元素过渡到另一个元素。极简演示来源

缩略图现在会过渡到主图片中。尽管它们在概念上(从字面上)是不同的元素,但 Transition API 将它们视为相同的元素,因为它们共享相同的 view-transition-name

此过渡的实际代码比上一个示例稍微复杂一点,因为它还处理返回到缩略图页面的过渡。如需了解完整的实现,请参阅源代码


自定义进入和退出过渡

看看下面这个示例:

进入和退出边栏。极简演示来源

边栏是过渡的一部分:

.sidebar {
  view-transition-name: sidebar;
}

不过,与前面示例中的页眉不同,边栏并未显示在所有页面上。如果这两种状态都有边栏,则过渡伪元素应如下所示:

::view-transition
├─ …other transition groups…
└─ ::view-transition-group(sidebar)
   └─ ::view-transition-image-pair(sidebar)
      ├─ ::view-transition-old(sidebar)
      └─ ::view-transition-new(sidebar)

不过,如果边栏仅在新页面上,那么 ::view-transition-old(sidebar) 伪元素不会出现在新页面上。由于边栏没有“旧”图片,因此图片对将只有 ::view-transition-new(sidebar)。同样,如果边栏仅在旧版页面上,则图片对将只有一个 ::view-transition-old(sidebar)

在上一个演示中,边栏的过渡方式有所不同,具体取决于边栏是在进入、退出还是同时在这两种状态下显示。它通过从右侧滑入并淡入的方式进入,通过向右滑动和淡出退出,在两种状态下都保持在原位。

如要创建特定的进入和退出过渡,当伪元素是图像对中的唯一子元素时,您可以使用 :only-child 伪类来定位旧伪元素:

/* Entry transition */
::view-transition-new(sidebar):only-child {
  animation: 300ms cubic-bezier(0, 0, 0.2, 1) both fade-in,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

/* Exit transition */
::view-transition-old(sidebar):only-child {
  animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right;
}

在此例中,当边栏在两种状态下均显示时,没有特定的过渡,因为默认状态是完美的。

异步 DOM 更新和等待内容

传递给 .startViewTransition() 的回调可以返回一个 promise,允许异步 DOM 更新并等待重要内容准备就绪。

document.startViewTransition(async () => {
  await something;
  await updateTheDOMSomehow();
  await somethingElse;
});

在 promise 执行之前,不会开始转换。在此期间,网页会冻结,因此应尽可能减少此处的延迟。具体而言,网络提取应在调用 .startViewTransition() 之前完成,此时页面仍可完全互动,而不是作为 .startViewTransition() 回调的一部分执行。

如果您决定等待图片或字体准备就绪,请务必使用较宽的超时设置:

const wait = ms => new Promise(r => setTimeout(r, ms));

document.startViewTransition(async () => {
  updateTheDOMSomehow();

  // Pause for up to 100ms for fonts to be ready:
  await Promise.race([document.fonts.ready, wait(100)]);
});

但在某些情况下,最好完全避免延迟,并使用您已有的内容。


充分利用您已有的内容

在缩略图转换为更大的图片时:

转换为较大图片的缩略图。试用演示网站

默认转场效果是淡入淡出,也就是说,缩略图可能会与尚未加载的完整图片交错淡出。

一种处理此问题的方法是等待整个图片加载完毕,然后再开始转场。理想情况下,此操作应在调用 .startViewTransition() 之前完成,以便页面保持互动,并且可能会显示旋转图标以向用户指明内容正在加载。但在这种情况下,有一种更好的方法:

::view-transition-old(full-embed),
::view-transition-new(full-embed) {
  /* Prevent the default animation,
  so both views remain opacity:1 throughout the transition */
  animation: none;
  /* Use normal blending,
  so the new view sits on top and obscures the old view */
  mix-blend-mode: normal;
}

现在,缩略图并没有消失,只是位于整个图片下方。这意味着,如果新视图尚未加载,那么在过渡期间,缩略图始终可见。这意味着转换可以立即开始,并且可以在相应的时间加载完整图片。

如果新视图具有透明度,这样就行不通了,但在本例中,我们知道它不是这样,因此我们可以对其进行优化。

处理宽高比的变化

方便的是,到目前为止的所有转换都是针对具有相同宽高比的元素,但并非始终都如此。如果缩略图的宽高比为 1:1,主图片的宽高比为 16:9,会怎么样?

一个元素过渡到另一个元素,并且宽高比发生变化。极简演示来源

在默认转场效果中,该组会以动画形式呈现从前尺寸到后尺寸。旧视图和新视图均采用该组 100% 宽度并自动设置高度,这意味着无论组大小如何,均保持原有的宽高比。

这是一个很好的默认值,但在本例中并不符合预期。因此:

::view-transition-old(full-embed),
::view-transition-new(full-embed) {
  /* Prevent the default animation,
  so both views remain opacity:1 throughout the transition */
  animation: none;
  /* Use normal blending,
  so the new view sits on top and obscures the old view */
  mix-blend-mode: normal;
  /* Make the height the same as the group,
  meaning the view size might not match its aspect-ratio. */
  height: 100%;
  /* Clip any overflow of the view */
  overflow: clip;
}

/* The old view is the thumbnail */
::view-transition-old(full-embed) {
  /* Maintain the aspect ratio of the view,
  by shrinking it to fit within the bounds of the element */
  object-fit: contain;
}

/* The new view is the full image */
::view-transition-new(full-embed) {
  /* Maintain the aspect ratio of the view,
  by growing it to cover the bounds of the element */
  object-fit: cover;
}

这意味着,当宽度扩大时,缩略图会保留在元素的中心;而在从 1:1 过渡到 16:9 时,整个图片“取消剪裁”。

如需了解详情,请参阅视图过渡:处理宽高比变化


使用媒体查询针对不同设备状态更改转换

您可能需要在移动设备和桌面设备上使用不同的转场效果,例如,本示例在移动设备上从侧面执行完整的幻灯片,但在桌面设备上执行更细微的幻灯片:

一个元素过渡到另一个元素。极简演示来源

这可以通过常规媒体查询来实现:

/* Transitions for mobile */
::view-transition-old(root) {
  animation: 300ms ease-out both full-slide-to-left;
}

::view-transition-new(root) {
  animation: 300ms ease-out both full-slide-from-right;
}

@media (min-width: 500px) {
  /* Overrides for larger displays.
  This is the shared axis transition from earlier in the article. */
  ::view-transition-old(root) {
    animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
      300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
  }

  ::view-transition-new(root) {
    animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
      300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
  }
}

您可能还需要更改为哪些元素分配了 view-transition-name,具体取决于匹配的媒体查询。


响应“减少运动”偏好设置

用户可以表明他们喜欢通过操作系统减少动态效果,并且该偏好设置会在 CSS 中显示

您可以选择阻止这些用户转换:

@media (prefers-reduced-motion) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

不过,首选“减少动作”并不意味着用户不需要任何动作。除了上述代码段,您也可以选择更精细的动画,但该动画仍然可以表达元素与数据流之间的关系。


使用视图转换类型处理多个视图转换样式

有时,从某个特定视图到另一个视图的过渡应有专门定制的过渡。例如,当按分页序列前往下一页或上一页时,您可能想要朝不同方向滑动内容,具体取决于您要前往序列中的更高页面还是更低页面。

分页演示的录制。根据要进入的页面,它会使用不同的转场效果。

为此,您可以使用视图过渡类型,以便向一个活跃的视图过渡分配一种或多种类型。例如,在转到分页序列中的较高页面时,请使用 forwards 类型;当转到较低页面时,请使用 backwards 类型。这些类型仅在捕获或执行过渡时有效,并且每种类型都可以通过 CSS 进行自定义,以使用不同的动画。

如需在同文档视图过渡中使用类型,请将 types 传入 startViewTransition 方法。为此,document.startViewTransition 还接受一个对象:update 是更新 DOM 的回调函数,types 是包含类型的数组。

const direction = determineBackwardsOrForwards();

const t = document.startViewTransition({
  update: updateTheDOMSomehow,
  types: ['slide', direction],
});

如需响应这些类型,请使用 :active-view-transition-type() 选择器。将您要定位的 type 传入选择器。这样,您就可以将多个视图过渡的样式彼此分隔开,而不会干扰另一个的声明。

由于类型仅在捕获或执行过渡时适用,因此您可以使用选择器针对具有该类型的视图过渡,在元素上设置(或取消设置)view-transition-name

/* Determine what gets captured when the type is forwards or backwards */
html:active-view-transition-type(forwards, backwards) {
  :root {
    view-transition-name: none;
  }
  article {
    view-transition-name: content;
  }
  .pagination {
    view-transition-name: pagination;
  }
}

/* Animation styles for forwards type only */
html:active-view-transition-type(forwards) {
  &::view-transition-old(content) {
    animation-name: slide-out-to-left;
  }
  &::view-transition-new(content) {
    animation-name: slide-in-from-right;
  }
}

/* Animation styles for backwards type only */
html:active-view-transition-type(backwards) {
  &::view-transition-old(content) {
    animation-name: slide-out-to-right;
  }
  &::view-transition-new(content) {
    animation-name: slide-in-from-left;
  }
}

/* Animation styles for reload type only (using the default root snapshot) */
html:active-view-transition-type(reload) {
  &::view-transition-old(root) {
    animation-name: fade-out, scale-down;
  }
  &::view-transition-new(root) {
    animation-delay: 0.25s;
    animation-name: fade-in, scale-up;
  }
}

在下面的分页演示中,网页内容会根据您要前往的页码向前或向后滑动。类型是根据点击确定的,点击后会传递到 document.startViewTransition

如需定位到任何 Active View 过渡(无论其类型如何),您都可以改用 :active-view-transition 伪类选择器。

html:active-view-transition {
    …
}

使用视图转换根目录上的类名称处理多个视图转换样式

有时,从一种特定类型的视图转换到另一种视图时应该进行专门定制的转场。或者,“返回”导航与“向前”导航应不同。

“返回”时的不同转换效果。极简演示来源

过渡类型推出之前,处理这些情况的方式是在过渡根上临时设置一个类名称。调用 document.startViewTransition 时,此转换根是 <html> 元素,可在 JavaScript 中使用 document.documentElement 访问:

if (isBackNavigation) {
  document.documentElement.classList.add('back-transition');
}

const transition = document.startViewTransition(() =>
  updateTheDOMSomehow(data)
);

try {
  await transition.finished;
} finally {
  document.documentElement.classList.remove('back-transition');
}

为了在过渡完成后移除类,此示例使用了 transition.finished,该 promise 会在过渡到结束状态后解析。API 参考文档中介绍了此对象的其他属性。

现在,您可以在 CSS 中使用该类名称来更改过渡效果:

/* 'Forward' transitions */
::view-transition-old(root) {
  animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}

::view-transition-new(root) {
  animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in, 300ms
      cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

/* Overrides for 'back' transitions */
.back-transition::view-transition-old(root) {
  animation-name: fade-out, slide-to-right;
}

.back-transition::view-transition-new(root) {
  animation-name: fade-in, slide-from-left;
}

与媒体查询一样,这些类的存在还可用于更改哪些元素会获得 view-transition-name


运行转场而不冻结其他动画

请观看以下视频转换位置演示:

视频过渡。极简演示来源

你看到什么问题了吗?不用担心。此时,它的速度变慢了:

视频转换,较慢。极简演示来源

在过渡期间,视频看起来像是卡住了,然后播放的视频会淡入。这是因为 ::view-transition-old(video) 是旧视图的屏幕截图,而 ::view-transition-new(video) 是新视图的实时图片。

您可以修正此问题,但首先要问问自己它是否值得修正。如果您在以正常速度进行转换时没有看到“问题”,那么我也无需更改它。

如果您确实想要解决此问题,则不要显示 ::view-transition-old(video);请直接切换到 ::view-transition-new(video)。您可以通过替换默认样式和动画来实现此目的:

::view-transition-old(video) {
  /* Don't show the frozen old view */
  display: none;
}

::view-transition-new(video) {
  /* Don't fade the new view in */
  animation: none;
}

这样就大功告成了!

视频转换,较慢。极简演示来源

现在,视频会在整个过渡期间播放。


使用 JavaScript 编写动画

到目前为止,所有转场均使用 CSS 定义,但有时 CSS 还不够:

圆形过渡效果。极简演示来源

单靠 CSS 无法实现这种转变:

  • 动画从点击位置开始。
  • 动画结束时,圆周以半径为距离最远的角落。不过,我们希望在将来通过 CSS 实现这一目标。

幸运的是,您可以使用 Web Animation API 创建转场效果!

let lastClick;
addEventListener('click', event => (lastClick = event));

function spaNavigate(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    updateTheDOMSomehow(data);
    return;
  }

  // Get the click position, or fallback to the middle of the screen
  const x = lastClick?.clientX ?? innerWidth / 2;
  const y = lastClick?.clientY ?? innerHeight / 2;
  // Get the distance to the furthest corner
  const endRadius = Math.hypot(
    Math.max(x, innerWidth - x),
    Math.max(y, innerHeight - y)
  );

  // With a transition:
  const transition = document.startViewTransition(() => {
    updateTheDOMSomehow(data);
  });

  // Wait for the pseudo-elements to be created:
  transition.ready.then(() => {
    // Animate the root's new view
    document.documentElement.animate(
      {
        clipPath: [
          `circle(0 at ${x}px ${y}px)`,
          `circle(${endRadius}px at ${x}px ${y}px)`,
        ],
      },
      {
        duration: 500,
        easing: 'ease-in',
        // Specify which pseudo-element to animate
        pseudoElement: '::view-transition-new(root)',
      }
    );
  });
}

此示例使用 transition.ready,这是一个在成功创建过渡伪元素后进行解析的 promise。API 参考文档中介绍了此对象的其他属性。


将过渡作为增强功能

View Transition API 旨在“封装” DOM 更改并为其创建转场。但是,转换应被视为增强,因为如果 DOM 更改成功但转换失败,您的应用不应进入“错误”状态。理想情况下,转换不会失败,但如果失败,则不应该破坏其余的用户体验。

为了将转换视为增强功能,请注意,在使用转换 promise 时,不要在转换失败时导致应用抛出异常。

错误做法
async function switchView(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    await updateTheDOM(data);
    return;
  }

  const transition = document.startViewTransition(async () => {
    await updateTheDOM(data);
  });

  await transition.ready;

  document.documentElement.animate(
    {
      clipPath: [`inset(50%)`, `inset(0)`],
    },
    {
      duration: 500,
      easing: 'ease-in',
      pseudoElement: '::view-transition-new(root)',
    }
  );
}

此示例的问题是,如果转换无法达到 ready 状态,switchView() 会拒绝,但这并不意味着视图无法切换。DOM 可能已成功更新,但存在重复的 view-transition-name,因此系统跳过了过渡。

请改为执行以下操作:

正确做法
async function switchView(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    await updateTheDOM(data);
    return;
  }

  const transition = document.startViewTransition(async () => {
    await updateTheDOM(data);
  });

  animateFromMiddle(transition);

  await transition.updateCallbackDone;
}

async function animateFromMiddle(transition) {
  try {
    await transition.ready;

    document.documentElement.animate(
      {
        clipPath: [`inset(50%)`, `inset(0)`],
      },
      {
        duration: 500,
        easing: 'ease-in',
        pseudoElement: '::view-transition-new(root)',
      }
    );
  } catch (err) {
    // You might want to log this error, but it shouldn't break the app
  }
}

此示例使用 transition.updateCallbackDone 等待 DOM 更新,并在更新失败时拒绝更新。如果转换失败,switchView 不再拒绝,它会在 DOM 更新完成时解析,并在更新失败时拒绝。

如果您希望 switchView 在新视图“完成处理”时进行解析(例如,任何动画过渡已完成或跳至结束),请将 transition.updateCallbackDone 替换为 transition.finished


不是 polyfill,但是...

对此功能执行 polyfill 并非易事。不过,此辅助函数可以大大简化不支持视图转换的浏览器中的操作:

function transitionHelper({
  skipTransition = false,
  types = [],
  update,
}) {

  const unsupported = (error) => {
    const updateCallbackDone = Promise.resolve(update()).then(() => {});

    return {
      ready: Promise.reject(Error(error)),
      updateCallbackDone,
      finished: updateCallbackDone,
      skipTransition: () => {},
      types,
    };
  }

  if (skipTransition || !document.startViewTransition) {
    return unsupported('View Transitions are not supported in this browser');
  }

  try {
    const transition = document.startViewTransition({
      update,
      types,
    });

    return transition;
  } catch (e) {
    return unsupported('View Transitions with types are not supported in this browser');
  }
}

它可以按如下方式使用:

function spaNavigate(data) {
  const types = isBackNavigation ? ['back-transition'] : [];

  const transition = transitionHelper({
    update() {
      updateTheDOMSomehow(data);
    },
    types,
  });

  // …
}

在不支持视图过渡的浏览器中,系统仍会调用 updateDOM,但不会提供动画过渡效果。

您还可以提供一些要在过渡期间添加到 <html>classNames,以便更轻松地根据导航类型更改过渡效果

如果您不想动画,也可以将 true 传递给 skipTransition,即使在支持视图转换的浏览器中也是如此。如果您的网站有用户偏好,需要停用转换,此设置会非常有用。


使用框架

如果您使用的是将 DOM 更改抽象化的库或框架,那么很难确定 DOM 更改何时完成。以下是一组在各种框架中使用上述帮助程序的示例。

  • React - 这里的键是 flushSync,它会同步应用一组状态更改。是的,该 API 的使用时有很大的警告,但 Dan Abramov 向我保证,在这种情况下使用是合适的。与 React 和异步代码的往常一样,在使用 startViewTransition 返回的各种 promise 时,应注意以正确的状态运行代码。
  • Vue.js - 这里的键是 nextTick,它会在 DOM 更新后执行。
  • Svelte - 与 Vue 非常相似,但等待下一次更改的方法为 tick
  • Lit - 这里的关键是组件中的 this.updateComplete promise,它会在 DOM 更新后执行。
  • Angular - 这里的关键是 applicationRef.tick,它会刷新待处理的 DOM 更改。从 Angular 版本 17 开始,您可以使用 @angular/router 附带的 withViewTransitions

API 参考文档

const viewTransition = document.startViewTransition(update)

启动新的 ViewTransition

update 是一个在捕获文档的当前状态后调用的函数。

然后,当 updateCallback 返回的 promise 执行时,转换将在下一帧中开始。如果 updateCallback 返回的 promise 拒绝,则放弃转换。

const viewTransition = document.startViewTransition({ update, types })

使用指定类型启动新的 ViewTransition

捕获文档的当前状态后,系统会调用 update

types 用于在捕获或执行过渡时为过渡设置活动类型。它最初为空。如需了解详情,请参阅下方的 viewTransition.types

ViewTransition 的实例成员:

viewTransition.updateCallbackDone

一个在 updateCallback 返回的 promise 执行时执行,在拒绝时拒绝的 promise。

View Transition API 可封装 DOM 更改并创建转场。但是,有时您并不关心过渡动画的成功或失败,您只想知道 DOM 更改是否发生以及何时发生。updateCallbackDone 适用于该用例。

viewTransition.ready

一旦创建了转场的伪元素且动画即将开始播放,系统就会执行该 promise。

如果无法开始转换,则会拒绝。这可能是因为配置错误(例如 view-transition-name 重复),或者 updateCallback 返回被拒绝的 promise。

这对于使用 JavaScript 为过渡伪元素添加动画效果非常有用。

viewTransition.finished

一旦结束状态对用户完全可见且可互动,即执行的 promise。

仅当 updateCallback 返回被拒绝的 promise 时,它才会拒绝,因为这表明未创建结束状态。

否则,如果转换无法开始或在转换过程中被跳过,系统仍会达到结束状态,因此系统会执行 finished

viewTransition.types

类似于 Set 的对象,用于保存活动视图过渡的类型。如需操控这些条目,请使用其实例方法 clear()add()delete()

如需响应 CSS 中的特定类型,请在过渡根上使用 :active-view-transition-type(type) 伪类选择器。

当视图转换完成时,系统会自动清理类型。

viewTransition.skipTransition()

跳过过渡的动画部分。

这不会跳过调用 updateCallback,因为 DOM 更改与过渡是分开的。


默认样式和过渡参考

::view-transition
根伪元素,用于填充视口并包含每个 ::view-transition-group
::view-transition-group

定位准确。

在“之前”和“之后”状态之间转换 widthheight

在视口空间四边形“之前”和“之后”之间转换 transform

::view-transition-image-pair

绝对能填满整个群组。

具有 isolation: isolate,用于限制 mix-blend-mode 对旧视图和新视图的影响。

::view-transition-new”和“::view-transition-old

绝对位于封装容器的左上角。

会填满整个群组宽度,但高度会自动调整,因此它会保持宽高比不变,而不会填充整个群组。

包含 mix-blend-mode: plus-lighter,以允许真正的淡入淡出。

旧视图从 opacity: 1 转换为 opacity: 0。新视图从 opacity: 0 过渡到 opacity: 1


反馈

我们衷心期待开发者提供反馈。为此,请在 GitHub 上向 CSS 工作组提交问题,并附上相关建议和问题。为您的问题添加前缀 [css-view-transitions]

如果您遇到错误,请改为提交 Chromium 错误