使用檢視轉換 API

本文解釋了 檢視轉換 API 的工作原理,如何建立檢視轉換以及自定義轉換動畫,以及如何操作活動檢視轉換。這涵蓋了單頁應用程式 (SPA) 中 DOM 狀態更新的檢視轉換以及多頁應用程式 (MPA) 中的文件導航。

檢視轉換過程

讓我們逐步瞭解檢視轉換的工作過程

  1. 觸發檢視轉換。如何觸發取決於檢視轉換的型別
    • 對於相同文件轉換 (SPA),透過將觸發檢視更改 DOM 更新的函式作為回撥傳遞給 document.startViewTransition() 方法來觸發檢視轉換。
    • 對於跨文件轉換 (MPA),透過啟動導航到新文件來觸發檢視轉換。導航的當前文件和目標文件都需要位於同一來源,並且透過在它們的 CSS 中包含 @view-transition 規則並使用 navigation 描述符 auto 來選擇加入檢視轉換。

      注意: 活動檢視轉換與一個關聯的 ViewTransition 例項相關聯(例如,對於相同文件 (SPA) 轉換,由 startViewTransition() 返回)。ViewTransition 物件包含多個 promise,允許您在檢視轉換過程到達不同部分時執行程式碼。有關更多資訊,請參閱 使用 JavaScript 控制檢視轉換

  2. 在當前(舊頁面)檢視上,API 會捕獲聲明瞭 view-transition-name 的元素的快照。
  3. 發生檢視更改
    • 對於相同文件轉換 (SPA),傳遞給 startViewTransition() 的回撥被呼叫,這會導致 DOM 發生更改。當回撥成功執行後,ViewTransition.updateCallbackDone promise 完成,允許您對 DOM 更新做出響應。
    • 對於跨文件轉換 (MPA),導航發生在當前文件和目標文件之間。
  4. API 會捕獲新檢視的快照作為即時表示。此時,檢視轉換即將執行,並且 ViewTransition.ready promise 完成,允許您透過執行自定義 JavaScript 動畫來做出響應,而不是預設動畫,例如。
  5. 舊頁面快照“退出”動畫,而新檢視快照“進入”動畫。預設情況下,舊檢視快照從 opacity 1 動畫到 0,新檢視快照從 opacity 0 動畫到 1,這會建立交叉淡入淡出效果。
  6. 當轉換動畫到達其結束狀態時,ViewTransition.finished promise 完成,允許您做出響應。

注意: 如果文件的 頁面可見性狀態document.startViewTransition() 呼叫期間為 hidden(例如,如果文件被視窗遮擋、瀏覽器最小化或其他瀏覽器選項卡處於活動狀態),則檢視轉換將完全跳過。

檢視轉換偽元素樹

為了處理創建出站和入站轉換動畫,API 會構建一個具有以下結構的偽元素樹

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

注意: 對於每個捕獲的 view-transition-name,都會建立一個 ::view-transition-group 子樹。

對於相同文件轉換 (SPA),偽元素樹在文件中可用。對於跨文件轉換 (MPA),偽元素樹僅在目標文件中可用。

樹結構中最有趣的部分如下所示

  • ::view-transition 是檢視轉換疊加層的根節點,它包含所有檢視轉換快照組,並位於所有其他頁面內容之上。
  • ::view-transition-group 充當每個檢視轉換快照組的容器。root 引數指定預設快照組 - 檢視轉換動畫將應用於 view-transition-nameroot 的快照。預設情況下,這是 :root 元素,因為預設瀏覽器樣式定義了這一點
    css
    :root {
      view-transition-name: root;
    }
    
    但是請注意,頁面作者可以透過取消設定以上內容併為其他元素設定 view-transition-name: root 來更改這一點。
  • ::view-transition-old 針對舊頁面元素的靜態快照,::view-transition-new 針對新頁面元素的即時快照。這兩者都呈現為替換內容,與 <img><video> 的方式相同,這意味著它們可以使用方便的屬性(如 object-fitobject-position)進行樣式化。

注意: 透過為每個元素設定不同的 view-transition-name,可以針對不同的 DOM 元素使用不同的自定義檢視轉換動畫。在這種情況下,將為每個元素建立一個 ::view-transition-group。有關示例,請參閱 不同元素的動畫不同

注意: 正如您將在後面看到的,要自定義出站和入站動畫,您需要分別使用 ::view-transition-old::view-transition-new 偽元素來定位您的動畫。

建立基本檢視轉換

本節說明了如何在 SPA 和 MPA 情況下建立基本檢視轉換。

基本 SPA 檢視轉換

例如,SPA 可能包含功能,用於在某種事件(例如單擊導航連結或從伺服器推送更新)發生時獲取新內容並更新 DOM。在我們的 檢視轉換 SPA 演示 中,我們將此簡化為 displayNewImage() 函式,該函式基於單擊的縮圖顯示新的全尺寸影像。我們將此封裝在 updateView() 函式中,該函式僅在瀏覽器支援時呼叫檢視轉換 API

js
function updateView(event) {
  // Handle the difference in whether the event is fired on the <a> or the <img>
  const targetIdentifier = event.target.firstChild || event.target;

  const displayNewImage = () => {
    const mainSrc = `${targetIdentifier.src.split("_th.jpg")[0]}.jpg`;
    galleryImg.src = mainSrc;
    galleryCaption.textContent = targetIdentifier.alt;
  };

  // Fallback for browsers that don't support View Transitions:
  if (!document.startViewTransition) {
    displayNewImage();
    return;
  }

  // With View Transitions:
  const transition = document.startViewTransition(() => displayNewImage());
}

此程式碼足以處理顯示影像之間的轉換。支援的瀏覽器將顯示從舊影像到新影像以及標題的平滑交叉淡入淡出效果(預設檢視轉換)。它仍然可以在不支援的瀏覽器中工作,但沒有漂亮的動畫效果。

基本 MPA 檢視轉換

在建立跨文件 (MPA) 檢視轉換時,過程比 SPA 甚至更簡單。不需要 JavaScript,因為檢視更新是由跨文件、同一來源導航觸發的,而不是由 JavaScript 觸發的 DOM 更改。要啟用基本 MPA 檢視轉換,您需要在當前文件和目標文件的 CSS 中指定 @view-transition 規則,使其選擇加入,如下所示

css
@view-transition {
  navigation: auto;
}

我們的 檢視轉換 MPA 演示 顯示了此規則的實際應用,並額外演示瞭如何 自定義檢視轉換的出站和入站動畫

注意: 目前 MPA 檢視轉換隻能在同一來源文件之間建立,但此限制可能會在將來的實現中放鬆。

自定義您的動畫

檢視轉換偽元素具有預設的 CSS 動畫 應用(在它們的 參考頁面 中有詳細說明)。

大多數外觀轉換都具有預設的平滑交叉淡入淡出動畫,如上所述。有一些例外

  • heightwidth 轉換具有平滑縮放動畫效果。
  • positiontransform 轉換具有平滑移動動畫效果。

您可以使用常規 CSS 以任何您想要的方式修改預設動畫 - 使用 ::view-transition-old 來定位“從”動畫,使用 ::view-transition-new 來定位“到”動畫。

例如,要更改兩者速度

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

建議您在要將樣式應用於 ::view-transition-old()::view-transition-new() 的情況下,使用 ::view-transition-group() 來定位此類樣式。由於偽元素層次結構和預設的使用者代理樣式,這些樣式將被兩者繼承。例如

css
::view-transition-group(root) {
  animation-duration: 0.5s;
}

注意: 這也是保護程式碼的好方法 - ::view-transition-group() 也會動畫,您最終可能會為 group/image-pair 偽元素獲得與 oldnew 偽元素不同的持續時間。

對於跨文件 (MPA) 轉換,偽元素需要僅包含在目標文件中才能使檢視轉換正常工作。如果您希望在兩個方向上使用檢視轉換,當然需要將它包含在兩個方向上。

我們的 檢視過渡 MPA 演示 包含了上述 CSS,但進一步自定義了它,定義了自定義動畫並將其應用於 ::view-transition-old(root)::view-transition-new(root) 偽元素。結果是,在導航時,預設的交叉淡入淡出過渡被替換為“向上滑動”過渡。

css
/* Create a custom animation */

@keyframes move-out {
  from {
    transform: translateY(0%);
  }

  to {
    transform: translateY(-100%);
  }
}

@keyframes move-in {
  from {
    transform: translateY(100%);
  }

  to {
    transform: translateY(0%);
  }
}

/* Apply the custom animation to the old and new page states */

::view-transition-old(root) {
  animation: 0.4s ease-in both move-out;
}

::view-transition-new(root) {
  animation: 0.4s ease-in both move-in;
}

不同元素的動畫不同

預設情況下,檢視更新期間更改的所有不同元素都使用相同的動畫進行過渡。如果你希望某些元素的動畫效果與預設的 root 動畫不同,可以使用 view-transition-name 屬性將它們分離。例如,在我們的 檢視過渡 SPA 演示 中,<figcaption> 元素被賦予了 view-transition-namefigure-caption 的值,以便在檢視過渡方面將它們與頁面上的其他元素區分開來。

css
figcaption {
  view-transition-name: figure-caption;
}

應用了此 CSS 後,生成的偽元素樹將如下所示:

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

第二組偽元素的存在允許將單獨的檢視過渡樣式僅應用於 <figcaption>。不同的舊檢視和新檢視捕獲將獨立處理。

注意:view-transition-name 的值可以是任何你想要的值,除了 none —— none 值專門表示該元素將不參與檢視過渡。

view-transition-name 值也必須是唯一的。如果兩個渲染的元素在同一時間具有相同的 view-transition-name,則 ViewTransition.ready 將被拒絕,並且過渡將被跳過。

以下程式碼將自定義動畫應用於 <figcaption>

css
@keyframes grow-x {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}

@keyframes shrink-x {
  from {
    transform: scaleX(1);
  }
  to {
    transform: scaleX(0);
  }
}

::view-transition-group(figure-caption) {
  height: auto;
  right: 0;
  left: auto;
  transform-origin: right center;
}

::view-transition-old(figure-caption) {
  animation: 0.25s linear both shrink-x;
}

::view-transition-new(figure-caption) {
  animation: 0.25s 0.25s linear both grow-x;
}

在這裡,我們建立了一個自定義 CSS 動畫,並將其應用於 ::view-transition-old(figure-caption)::view-transition-new(figure-caption) 偽元素。我們還在兩者中添加了一些其他樣式,以使它們保持在相同位置,並阻止預設樣式干擾我們的自定義動畫。

注意:你可以在偽元素中使用 * 作為識別符號來定位所有快照偽元素,無論它們具有什麼名稱。例如:

css
::view-transition-group(*) {
  animation-duration: 2s;
}

利用預設動畫樣式:

請注意,我們還發現另一個過渡選項,它比上面介紹的方法更簡單,並且產生了更好的結果。我們最終的 <figcaption> 檢視過渡看起來像這樣:

css
figcaption {
  view-transition-name: figure-caption;
}

::view-transition-group(figure-caption) {
  height: 100%;
}

這之所以有效,是因為預設情況下,::view-transition-group 使用平滑縮放在舊檢視和新檢視之間過渡 widthheight。我們只需要在兩種狀態下都設定一個固定的 height 即可使它正常工作。

注意:使用檢視過渡 API 實現平滑且簡單的過渡 包含其他一些自定義示例。

使用 JavaScript 控制檢視轉換

檢視過渡具有一個關聯的 ViewTransition 物件例項,其中包含多個 Promise 成員,允許你在達到過渡的不同狀態時執行 JavaScript。例如,ViewTransition.ready 一旦建立了偽元素樹並且動畫即將開始,就會被實現,而 ViewTransition.finished 一旦動畫完成並且新頁面檢視對使用者可見且可互動,就會被實現。

ViewTransition 可以這樣訪問:

  1. 在同文檔 (SPA) 過渡的情況下,document.startViewTransition() 方法返回與過渡關聯的 ViewTransition
  2. 在跨文件 (MPA) 過渡的情況下:

讓我們看一些示例程式碼,以展示如何使用這些功能。

JavaScript 驅動的自定義同文檔 (SPA) 過渡:

以下 JavaScript 可以用來建立一個圓形顯示檢視過渡,該過渡從使用者點選時的游標位置開始,動畫由 Web Animations API 提供。

js
// Store the last click event
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),
  );

  // Create 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)",
      },
    );
  });
}

此動畫還需要以下 CSS,以關閉預設 CSS 動畫,並阻止舊檢視狀態和新檢視狀態以任何方式混合(新狀態“擦除”舊狀態,而不是過渡到舊狀態):

css
::view-transition-image-pair(root) {
  isolation: auto;
}

::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
  display: block;
}

JavaScript 驅動的自定義跨文件 (MPA) 過渡:

Chrome DevRel 團隊成員列表 演示提供了一組基本的團隊簡介頁面,並演示瞭如何使用 pageswappagereveal 事件根據“來自”和“到”URL 自定義跨文件檢視過渡的出站動畫和入站動畫。

pageswap 事件監聽器如下所示。這將在出站頁面上的連結到簡介頁面的元素上設定檢視過渡名稱。在從主頁導航到簡介頁面時,僅為每種情況下單擊的連結元素提供自定義動畫。

js
window.addEventListener("pageswap", async (e) => {
  // Only run this if an active view transition exists
  if (e.viewTransition) {
    const currentUrl = e.activation.from?.url
      ? new URL(e.activation.from.url)
      : null;
    const targetUrl = new URL(e.activation.entry.url);

    // Going from profile page to homepage
    // ~> The big img and title are the ones!
    if (isProfilePage(currentUrl) && isHomePage(targetUrl)) {
      // Set view-transition-name values on the elements to animate
      document.querySelector(`#detail main h1`).style.viewTransitionName =
        "name";
      document.querySelector(`#detail main img`).style.viewTransitionName =
        "avatar";

      // Remove view-transition-names after snapshots have been taken
      // Stops naming conflicts resulting from the page state persisting in BFCache
      await e.viewTransition.finished;
      document.querySelector(`#detail main h1`).style.viewTransitionName =
        "none";
      document.querySelector(`#detail main img`).style.viewTransitionName =
        "none";
    }

    // Going to profile page
    // ~> The clicked items are the ones!
    if (isProfilePage(targetUrl)) {
      const profile = extractProfileNameFromUrl(targetUrl);

      // Set view-transition-name values on the elements to animate
      document.querySelector(`#${profile} span`).style.viewTransitionName =
        "name";
      document.querySelector(`#${profile} img`).style.viewTransitionName =
        "avatar";

      // Remove view-transition-names after snapshots have been taken
      // Stops naming conflicts resulting from the page state persisting in BFCache
      await e.viewTransition.finished;
      document.querySelector(`#${profile} span`).style.viewTransitionName =
        "none";
      document.querySelector(`#${profile} img`).style.viewTransitionName =
        "none";
    }
  }
});

注意:在每種情況下都拍攝快照後,我們將刪除 view-transition-name 值。如果我們保留它們,它們將保留在導航時儲存在 bfcache 中的頁面狀態中。如果隨後按下後退按鈕,則正在導航回的頁面的 pagereveal 事件處理程式將嘗試在不同的元素上設定相同的 view-transition-name 值。如果多個元素設定了相同的 view-transition-name,則檢視過渡將被跳過。

pagereveal 事件監聽器如下所示。它的工作原理與 pageswap 事件監聽器類似,但請注意,這裡我們正在自定義“到”動畫,用於新頁面上的頁面元素。

js
window.addEventListener("pagereveal", async (e) => {
  // If the "from" history entry does not exist, return
  if (!navigation.activation.from) return;

  // Only run this if an active view transition exists
  if (e.viewTransition) {
    const fromUrl = new URL(navigation.activation.from.url);
    const currentUrl = new URL(navigation.activation.entry.url);

    // Went from profile page to homepage
    // ~> Set VT names on the relevant list item
    if (isProfilePage(fromUrl) && isHomePage(currentUrl)) {
      const profile = extractProfileNameFromUrl(fromUrl);

      // Set view-transition-name values on the elements to animate
      document.querySelector(`#${profile} span`).style.viewTransitionName =
        "name";
      document.querySelector(`#${profile} img`).style.viewTransitionName =
        "avatar";

      // Remove names after snapshots have been taken
      // so that we're ready for the next navigation
      await e.viewTransition.ready;
      document.querySelector(`#${profile} span`).style.viewTransitionName =
        "none";
      document.querySelector(`#${profile} img`).style.viewTransitionName =
        "none";
    }

    // Went to profile page
    // ~> Set VT names on the main title and image
    if (isProfilePage(currentUrl)) {
      // Set view-transition-name values on the elements to animate
      document.querySelector(`#detail main h1`).style.viewTransitionName =
        "name";
      document.querySelector(`#detail main img`).style.viewTransitionName =
        "avatar";

      // Remove names after snapshots have been taken
      // so that we're ready for the next navigation
      await e.viewTransition.ready;
      document.querySelector(`#detail main h1`).style.viewTransitionName =
        "none";
      document.querySelector(`#detail main img`).style.viewTransitionName =
        "none";
    }
  }
});

穩定頁面狀態以使跨文件轉換保持一致

在執行跨文件過渡之前,你理想情況下需要等待頁面狀態穩定,依靠 渲染阻塞 來確保:

  1. 關鍵樣式已載入並應用。
  2. 關鍵指令碼已載入並執行。
  3. 使用者首次檢視頁面時可見的 HTML 已被解析,因此它能一致地渲染。

樣式預設情況下被渲染阻塞,指令碼可以使用 blocking="render" 屬性進行渲染阻塞。

要確保你的初始 HTML 已被解析,並且在過渡動畫執行之前始終能一致地渲染,可以使用 <link rel="expect">。在這個元素中,你包含以下屬性:

  • rel="expect" 表示你希望使用此 <link> 元素來渲染阻塞頁面上的某些 HTML。
  • href="#element-id" 表示要渲染阻塞的元素的 ID。
  • blocking="render" 表示要渲染阻塞指定的 HTML。

讓我們透過一個簡單的示例 HTML 文件來探索一下它的樣子:

html
<!doctype html>
<html lang="en">
  <head>
    <!-- This will be render-blocking by default -->
    <link rel="stylesheet" href="style.css" />

    <!-- Marking critical scripts as render blocking will
         ensure they're run before the view transition is activated -->
    <script async href="layout.js" blocking="render"></script>

    <!-- Use rel="expect" and blocking="render" to ensure the
         #lead-content element is visible and fully parsed before
         activating the transition -->
    <link rel="expect" href="#lead-content" blocking="render" />
  </head>
  <body>
    <h1>Page title</h1>
    <nav>...</nav>
    <div id="lead-content">
      <section id="first-section">The first section</section>
      <section>The second section</section>
    </div>
  </body>
</html>

結果是文件渲染將被阻塞,直到主內容 <div> 被解析,從而確保檢視過渡一致。

你也可以在 <link rel="expect"> 元素上指定 media 屬性。例如,你可能希望在窄屏裝置上載入頁面時阻塞渲染較少的內容,而在寬屏裝置上阻塞渲染更多內容。這是有道理的——在移動裝置上,頁面首次載入時顯示的內容比桌面裝置要少。

這可以透過以下 HTML 來實現:

html
<link
  rel="expect"
  href="#lead-content"
  blocking="render"
  media="screen and (min-width: 641px)" />
<link
  rel="expect"
  href="#first-section"
  blocking="render"
  media="screen and (max-width: 640px)" />