在網頁應用程式中加入即時導覽和流暢頁面轉場效果

1. 事前準備

本程式碼研究室將說明如何使用 Google Chrome 原生支援的最新 API,在範例網頁應用程式中加入即時導覽和流暢頁面轉場效果。

網頁應用程式範例會檢查熱門蔬果的營養價值。水果清單和水果詳細資料網頁是以單頁應用程式 (SPA) 的形式建構而成,而蔬菜清單和蔬菜詳細資料網頁則是以傳統的多頁應用程式 (MPA) 建構而成。

行動裝置上的範例應用程式螢幕截圖 行動裝置上的範例應用程式螢幕截圖

具體來說,您將為即時導覽實作預先轉譯往返快取 (bfcache) 和私人預先擷取 Proxy,並實作根/共用元素轉換功能,確保頁面流暢切換。您為 MPA 網頁實作預先算繪和 bfcache,並為 SPA 頁面實作共用元素轉換。

網站速度向來是影響使用者體驗的重要因素,因此 Google 推出了 Core Web Vitals,這是一組用於評估網頁載入速度、互動性和視覺穩定性的指標,用來評估實際使用者體驗。最新的 API 可協助您提高網站在領域的 Core Web Vitals 分數,特別是在載入效能方面。

示範圖片 bfcache 如何縮短載入時間

Mindvalley 的示範

使用者也習慣使用轉場效果,在行動應用程式原生應用程式中,讓導覽和狀態變更變得極為直覺。不過,在網路上複製這類使用者體驗並不容易。雖然您或許可以利用目前的網路平台 API 達到類似的效果,但開發作業可能過於困難或複雜,尤其與 Android 或 iOS 應用程式提供的功能相比更是如此。無與倫比的 API 可填補使用者在應用程式和網站之間的體驗差距。

來自 Pixiv 的 Shared Element Transitions API 示範 Tokopedia 的 Shared Element Transitions API 示範

pixiv Tokopedia 的示範影片

必要條件

具備以下項目的知識:

報告內容:

實作方式:

  • 預先算繪
  • bfcache
  • 私人預先擷取 Proxy
  • 根/共用元素轉換

建構項目

使用 Next.js 建構的範例網頁應用程式,強化了即時且流暢的瀏覽器功能:

  • 透過預先算繪功能近乎即時地瀏覽
  • 使用瀏覽器的「上一頁」和「下一頁」按鈕,立即載入 bfcache
  • 使用 Private Prefetch Proxy 或 Signed Exchange (SXG) 跨來源瀏覽時,良好的第一印象
  • 讓根/共用元素轉換頁面能流暢地切換網頁

軟硬體需求

  • Chrome 101 以上版本

2. 開始操作

啟用 Chrome 旗標

  1. 前往 about://flags,然後啟用 Prerender2documentTransition API 執行階段標記。
  2. 重新啟動瀏覽器。

取得程式碼

  1. 在您偏好的開發環境中開啟這個 GitHub 存放區的程式碼:
git clone -b codelab git@github.com:googlechromelabs/instant-seamless-demo.git
  1. 安裝執行伺服器所需的依附元件:
npm install
  1. 在通訊埠 3000 啟動伺服器:
npm run dev
  1. 透過瀏覽器前往 http://localhost:3000

現在可以編輯並改善應用程式了。每當您進行變更,應用程式都會重新載入並直接顯示變更。

3. 整合預先算繪

就此示範而言,由於伺服器端任意延遲,範例應用程式中的蔬菜詳細資料頁面載入時間相當緩慢。您可以使用預先算繪來縮短等待時間。

如要在蔬菜清單頁面新增預先算繪按鈕,並讓按鈕在使用者點選後觸發預先算繪作業,請按照下列步驟操作:

  1. 建立按鈕元件,以動態方式插入 speculation-rules 指令碼標記:

components/prerender-button.js

import { useContext } from 'react'
import ResourceContext from './resource-context'

// You use resource context to manage global states.
// In the PrerenderButton component, you update the prerenderURL parameter when the button is clicked.
export default function PrerenderButton() {
  const { dispatch } = useContext(ResourceContext)
  const handleClick = (e) => {
    e.preventDefault()
    e.stopPropagation()
    const parent = e.target.closest('a')
    if (!parent) {
      return
    }
    const href = parent.getAttribute('href')
    dispatch({ type: 'update', prerenderURL: href })
  }

  return (
    <button className='ml-auto bg-gray-200 hover:bg-gray-300 px-4 rounded' onClick={handleClick}>
      Prerender
    </button>
  )
}
  1. list-item.js 檔案中匯入 PrerenderButton 元件。

components/list-item.js

// Codelab: Add a PrerenderButton component.
import PrerenderButton from './prerender-button'

...
function ListItemForMPA({ item, href }) {
  return (
    <a href={href} className='block flex items-center'>
      <Icon src={item.image} />
      <div className='text-xl'>{item.name}</div>
      {/* Codelab: Add PrerenderButton component. */}
      <PrerenderButton />
    </a>
  )
}
  1. 建立元件以新增 Speculation Rules API。

應用程式更新 prerenderURL 狀態時,SpeculationRules 元件會以動態方式將指令碼標記插入頁面。

components/speculationrules.js

import Script from 'next/script'
import { useContext, useMemo } from 'react'
import ResourceContext from './resource-context'

export default function SpeculationRules() {
  const { state } = useContext(ResourceContext)
  const { prerenderURL } = state

  return useMemo(() => {
    return (
      <>
        {prerenderURL && (
          <Script id='speculationrules' type='speculationrules'>
            {`
            {
              "prerender":[
                {
                  "source": "list",
                  "urls": ["${prerenderURL}"]
                }
              ]
            }
          `}
          </Script>
        )}
      </>
    )
  }, [prerenderURL])
}
  1. 將元件與應用程式整合。

pages/_app.js

// Codelab: Add the SpeculationRules component.
import SpeculationRules from '../components/speculationrules'

function MyApp({ Component, pageProps }) {
  useAnalyticsForSPA()

  return (
    <ResourceContextProvider>
      <Layout>
        <Component {...pageProps} />
      </Layout>
      {/* Codelab: Add SpeculationRules component */}
      <SpeculationRules />
      <Script id='analytics-for-mpa' strategy='beforeInteractive' src='/analytics.js' />
    </ResourceContextProvider>
  )
}

export default MyApp
  1. 按一下「預先算繪」

現在,您可以看到載入速度大幅提升。在實際使用案例中,使用者接下來可能會造訪的頁面,觸發預先算繪功能。

預先算繪的應用程式示範影片

數據分析

根據預設,當 DOMContentLoaded 事件發生時,範例網頁應用程式中的 analytics.js 檔案會傳送網頁瀏覽事件。很遺憾,由於這個事件會在預先算繪階段觸發,因此您不需要這麼做。

如何導入 document.prerenderingprerenderingchange 事件以修正這個問題:

  • 重新編寫 analytics.js 檔案:

public/analytics.js

  const sendEvent = (type = 'pageview') => {
    // Codelab: Make analytics prerendering compatible.
    // The pageshow event could happen in the prerendered page before activation.
    // The prerendered page should be handled by the prerenderingchange event.
    if (document.prerendering) {
      return
    }
    console.log(`Send ${type} event for MPA navigation.`)
    fetch(`/api/analytics?from=${encodeURIComponent(location.pathname)}&type=${type}`)
  }
  ...

  // Codelab: Make analytics prerendering compatible.
  // The prerenderingchange event is triggered when the page is activated.
  document.addEventListener('prerenderingchange', () => {
    console.log('The prerendered page was activated.')
    sendEvent()
  })
  ...

太棒了!您已成功修改數據分析資料,使其與預先算繪相容。現在,瀏覽器控制台顯示網頁瀏覽記錄的正確時間。

4. 移除 bfcache 攔截器

移除 unload 事件處理常式

出現不必要的 unload 事件是很常見的錯誤,我們日後會不予建議。這不僅會使 bfcache 無法正常運作,也同時不可靠。舉例來說,瀏覽器有時不會在行動裝置Safari 上觸發。

您可以使用 pagehide 事件 (而非 unload 事件),每當觸發 unload 事件,以及網頁進入 bfcache 快取,就會觸發這個事件。

如要移除 unload 事件處理常式,請按照下列步驟操作:

  • analytics.js 檔案中,將 unload 事件處理常式的程式碼替換為 pagehide 事件處理常式的程式碼:

public/analytics.js

// Codelab: Remove the unload event handler for bfcache.
// The unload event handler prevents the content from being stored in bfcache. Use the pagehide event instead.
window.addEventListener('pagehide', () => {
  sendEvent('leave')
})

更新 Cache-control 標頭

使用 Cache-control: no-store HTTP 標頭提供的網頁不受瀏覽器的 bfcache 功能影響,因此最好使用這個標頭。特別是,如果網頁不包含個人化或重要資訊 (例如登入狀態),您可能就不需要使用 Cache-control: no-store HTTP 標頭提供網頁。

如要更新範例應用程式的 Cache-control 標頭,請執行下列操作:

  • 修改 getServerSideProps 程式碼:

pages/vegetables/index.js

export const getServerSideProps = middleware(async (ctx) => {
  const { req, res } = ctx
  // Codelab: Modify the cache-control header.
  res.setHeader('Cache-Control', 'public, s-maxage=10, stale-while-revalidate=59')
  ...

網頁/蔬菜/[名稱].js

export const getServerSideProps = middleware(async (ctx) => {
  const { req, res, query } = ctx
  // Codelab: Modify the cache-control header.
  res.setHeader('Cache-Control', 'public, s-maxage=10, stale-while-revalidate=59')
  ...

判斷是否透過 bfcache 還原頁面

pageshow 事件會在網頁最初載入,以及任何透過 bfcache 還原網頁時,立即觸發 load 事件。pageshow 事件具有 persisted 屬性,如果網頁是從 bfcache 還原,則為 true,如果不是,則為 false。您可以使用 persisted 屬性來區分一般網頁載入和 bfcache 還原。重要的數據分析服務應瞭解 bfcache,但您可以檢查網頁是否透過 bfcache 還原,並手動傳送事件。

如何判斷系統是否透過 bfcache 還原網頁:

  • 將這段程式碼新增至 analytics.js 檔案。

public/analytics.js

  // Codelab: Use the pageshow event handler for bfcache.
  window.addEventListener('pageshow', (e) => {
    // If the persisted flag exists, the page was restored from bfcache.
    if (e.persisted) {
      console.log('The page was restored from bfcache.')
      sendEvent()
    }
  })

對網頁進行偵錯

Chrome 開發人員工具可協助你測試網頁,確保網頁已針對 bfcache 進行最佳化,並找出哪些問題可能導致網頁不符資格。

如何測試特定網頁:

  1. 在 Chrome 中前往該網頁。
  2. 在 Chrome 開發人員工具中,按一下 [應用程式] > 往返快取 >執行測試。

Chrome 開發人員工具會先嘗試離開,接著再返回,判斷網頁能否透過 bfcache 還原。

49bf965af35d5324.png

如果成功,面板會顯示網頁已從往返快取還原:

47015a0de45f0b0f.png

如果失敗,面板會說明網頁無法還原,以及原因。如果原因在於開發人員可以排解這類問題,面板也會提供相關資訊。

dcf0312c3fc378ce.png

5. 啟用跨網站預先擷取功能

預先擷取功能會提早開始擷取,以便在使用者瀏覽時,瀏覽器已有位元組,進而加快瀏覽速度。輕鬆改善 Core Web Vitals,在瀏覽前就可補償部分網路活動。這種做法可直接加快Largest Contentful Paint (LCP) 的速度,並在使用者瀏覽時,為首次輸入延遲時間 (FID) 和累計版面配置位移 (CLS) 提供更大的顯示空間。

私人預先擷取 Proxy 可啟用跨網站預先擷取功能,但不會將使用者的私人資訊透露給目的地伺服器。

私人預先擷取 Proxy 的運作方式

使用私密預先擷取 Proxy 啟用跨網站預先擷取功能

網站擁有者保有預先擷取控制權,這項機制與網路檢索器相似,後者與 /robots.txt 類似,後者可讓 HTTP 伺服器宣告實作代理程式應套用對應的建議。網站擁有者目前可以建議代理程式禁止或限制網路連線。日後可能會提供更多建議。

如要託管流量建議資源:

  1. 新增這個類似 JSON 的檔案:

public/.well-known/traffic-advice

[
  {
    "user_agent": "prefetch-proxy",
    "google_prefetch_proxy_eap": {
      "fraction": 1
    }
  }
]

google_prefetch_proxy_eap 欄位是搶先體驗程式的特殊欄位,fraction 欄位可用來控制 Private 預先擷取 Proxy 傳送的已要求預先擷取內容比例。

流量建議應搭配 application/trafficadvice+json MIME 類型傳回。

  1. next.config.js 檔案中,設定回應標頭:

next.config.js

const nextConfig = {
  // Codelab: Modify content-type for traffic advice file.
  async headers() {
    return [
      {
        source: '/.well-known/traffic-advice',
        headers: [
          {
            key: 'Content-Type',
            value: 'application/trafficadvice+json',
          },
        ],
      },
    ]
  },
}

module.exports = nextConfig

6. 整合 Shared Element Transitions API

使用者從一個網頁前往另一個網頁時,在第一個頁面消失而新頁面出現時,會看到內容突然或意外變更。這種按照順序中斷的使用者體驗變得失調,並會導致認知負荷,因為使用者必須拼湊他們要如何到達目的地。此外,這種體驗方式有助於增加使用者在等待目標到達網頁載入網頁期間,對網頁載入速度有更多的感受。

流暢載入動畫有助於降低認知負荷,因為使用者在瀏覽頁面時可以停留在瀏覽內容,同時縮短他們感受到的載入延遲時間,因為使用者在這段期間看到的內容很有吸引力和樂趣。因此,大多數平台都提供簡單易用的基本功能,可讓開發人員打造順暢的轉換體驗,例如 Android、iOS、MacOS 和 Windows。

無論是跨文件 (MPA) 還是文件內 (SPA) 轉換,Shared Element Transitions API 可為開發人員提供相同的網路功能。

來自 Pixiv 的 Shared Element Transitions API 示範 Tokopedia 的 Shared Element Transitions API 示範

pixiv Tokopedia 的示範影片

如要整合範例應用程式的 SPA 部分的 Shared Element Transitions API:

  1. 建立自訂掛鉤,以管理 use-page-transition.js 檔案中的轉場效果:

utils/use-page-transition.js

import { useEffect, useContext, useRef, useCallback } from 'react'
import ResourceContext from '../components/resource-context'

// Call this hook on this first page before you start the page transition. For Shared Element Transitions, you need to call the transition.start() method before the next page begins to render, and you need to do the Document Object Model (DOM) modification or setting of new shared elements inside the callback so that this hook returns the promise and defers to the callback resolve.
export const usePageTransitionPrep = () => {
  const { dispatch } = useContext(ResourceContext)

  return (elm) => {
    const sharedElements = elm.querySelectorAll('.shared-element')
    // Feature detection
    if (!document.createDocumentTransition) {
      return null
    }

    return new Promise((resolve) => {
      const transition = document.createDocumentTransition()
      Array.from(sharedElements).forEach((elm, idx) => {
        transition.setElement(elm, `target-${idx}`)
      })
      transition.start(async () => {
        resolve()
        await new Promise((resolver) => {
          dispatch({ type: 'update', transition: { transition, resolver } })
        })
      })
    })
  }
}

// Call this hook on the second page. Inside the useEffect hook, you can refer to the actual DOM element and set them as shared elements with the transition.setElement() method. When the resolver function is called, the transition is initiated between the captured images and newly set shared elements.
export const usePageTransition = () => {
  const { state, dispatch } = useContext(ResourceContext)
  const ref = useRef(null)
  const setRef = useCallback((node) => {
    ref.current = node
  }, [])

  useEffect(() => {
    if (!state.transition || !ref.current) {
      return
    }
    const { transition, resolver } = state.transition
    const sharedElements = ref.current.querySelectorAll('.shared-element')
    Array.from(sharedElements).forEach((elm, idx) => {
      transition.setElement(elm, `target-${idx}`)
    })
    resolver()
    return () => {
      dispatch({ type: 'update', transition: null })
    }
  })

  return setRef
}
  1. 在清單頁面中呼叫 usePageTransitionPrep() 自訂掛鉤,然後呼叫非同步函式,觸發 click 事件內的 transition.start() 方法。

在函式中,系統會收集 shared-element 類別元素,並註冊為共用元素。

components/list-item.js

// Codelab: Add the Shared Element Transitions API.
import { usePageTransitionPrep } from '../utils/use-page-transition'
...

function ListItemForSPA({ item, href }) {
  // Codelab: Add Shared Element Transitions.
  const transitionNextState = usePageTransitionPrep()
  const handleClick = async (e) => {
    const elm = e.target.closest('a')
    await transitionNextState(elm)
  }
  return (
    <Link href={href}>
      <a className='block flex items-center' onClick={handleClick}>
        <Icon src={item.image} name={item.name} className='shared-element' />
        <div className='text-xl'>{item.name}</div>
      </a>
    </Link>
  )
}
  1. 在詳細資料頁面中,呼叫 usePageTransition() 掛鉤,完成 transition.start() 回呼函式。

在這個回呼中,詳細資料頁面也會註冊共用元素。

網頁/水果/[名稱].js

// Codelab: Add the Shared Element Transitions API.
import { usePageTransition } from '../../utils/use-page-transition'

const Item = ({ data }) => {
  const { name, image, amountPer, nutrition } = data
  // Codelab: Add the Shared Element Transitions API.
  const ref = usePageTransition()

  return (
    <div className={'flex flex-col items-center justify-center py-4 px-4 sm:flex-row'} ref={ref}>
      <div className='flex flex-col items-center sm:w-2/4'>
        <Image
          className='object-cover border-gray-100 border-2 rounded-full shared-element'
          src={image}
          width='240'
          height='240'
          alt={`picture of ${name}`}
        />
        <h1 className='text-4xl font-bold mt-4'>{name}</h1>
      </div>

      <div className='sm:w-2/4 w-full'>
        <Nutrition amountPer={amountPer} nutrition={nutrition} />
      </div>
    </div>
  )
...
}

現在,您可以看到圖片元素會在清單和詳細資料頁面上共用,並在頁面轉換中完美連結。您還可以使用 CSS 虛擬元素自訂動畫,讓動畫更吸睛。

不含「共用元素轉換」的範例應用程式示範影片 「共用元素轉換」的範例應用程式示範影片

7. 恭喜

恭喜!您建立了一套快速流暢的網頁應用程式,提供順暢無阻、引人入勝又直覺的使用者體驗。

瞭解詳情

預先算繪

bfcache

跨網站預先擷取

Signed Exchange

根/共用元素轉換

這些 API 仍在早期開發階段,您可以前往 crbug.com 分享您的意見,或前往相關 API 的 GitHub 存放區回報問題。