ウェブアプリに迅速なナビゲーションとシームレスなページ遷移を追加する

1. 始める前に

この Codelab では、Google Chrome がネイティブにサポートしている最新の API を使って、サンプル ウェブアプリにインスタント ナビゲーションとシームレスなページ遷移を追加する方法について説明します。

サンプル ウェブアプリは、人気の果物や野菜の栄養価をチェックします。果物の一覧ページと果物の詳細ページはシングルページ アプリ(SPA)、野菜リストと野菜の詳細ページは従来の複数ページアプリ(MPA)として作成されている。

モバイルのサンプルアプリのスクリーンショット モバイルのサンプルアプリのスクリーンショット

具体的には、迅速なナビゲーションのために事前レンダリングバックフォワード キャッシュ(bfcache)、Private Prefetch Proxy を実装し、シームレスなページ移行のために ルート / 共有要素遷移を実装します。MPA ページには事前レンダリングと bfcache を実装し、SPA ページでは共有要素遷移を実装します。

サイトの速度は常にユーザー エクスペリエンスの重要な要素です。そこで Google は Core Web Vitals を導入しました。これは、実際のユーザー エクスペリエンスを評価するために、ウェブページの読み込みパフォーマンス、インタラクティビティ、視覚的安定性を測定する一連の指標です。最新の API を使用すると、特に読み込みパフォーマンスについて、実際のウェブサイトの Core Web Vitals スコアを改善できます。

bfcache によって読み込み時間が短縮される仕組みのデモ画像

Mindvalley のデモ

また、ユーザーは遷移を使用して、モバイル ネイティブ アプリで非常に直感的なナビゲーションや状態変更を行うことにも慣れています。残念ながら、そのようなユーザー エクスペリエンスをウェブ上で再現するのは簡単ではありません。現行のウェブ プラットフォーム API でも同様の効果が得られることもありますが、特に Android アプリや iOS アプリの同等の機能と比較すると、開発は難しすぎたり複雑すぎたりすることがあります。シームレスな API は、アプリとウェブの間のユーザーとデベロッパーのエクスペリエンスのギャップを埋めることができるように設計されています。

pixiv の Shared Element Transitions API のデモ Tokopedia の共有 Element Transitions API のデモ

pixiv Tokopedia のデモ

前提条件

以下に関する知識:

演習内容

実装方法:

  • 事前レンダリング
  • bfcache
  • プライベート プリフェッチ プロキシ
  • ルート/共有要素の遷移

作成するアプリの概要

最新のインスタントでシームレスなブラウザ機能で強化された、Next.js で構築したサンプル ウェブアプリ:

  • 事前レンダリングによるほぼリアルタイムのナビゲーション
  • ブラウザの [戻る] ボタンと [進む] ボタンで即座に読み込むための bfcache
  • Private Prefetch Proxy または Signed Exchange(SXG)を使用したクロスオリジン ナビゲーションで第一印象が良くなります
  • ルート/共有要素遷移によるページ間のシームレスな移行

必要なもの

  • Chrome バージョン 101 以降

2. 始める

Chrome のフラグを有効にする

  1. about://flags に移動し、Prerender2 ランタイム フラグと documentTransition 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 を追加するコンポーネントを作成します。

SpeculationRules コンポーネントは、アプリが prerenderURL 状態を更新するときに、スクリプトタグをページに動的に挿入します。

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. コンポーネントをアプリに統合します。

page/_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. [Prerender] をクリックします。

読み込みが大幅に改善されていることがわかります。実際のユースケースでは、ユーザーが次にアクセスする可能性が高いページについて、ヒューリスティックにより事前レンダリングがトリガーされます。

サンプルアプリの事前レンダリングのデモ動画

アナリティクス

デフォルトでは、サンプル ウェブアプリの analytics.js ファイルは、DOMContentLoaded イベントが発生したときにページビュー イベントを送信します。このイベントは事前レンダリング フェーズ中に発生するため、これは賢明ではありません。

この問題を解決するには、document.prerendering イベントと prerenderingchange イベントを導入します。

  • 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 では常に呼び出されるとは限りません。

unload イベントの代わりに pagehide イベントを使用します。このイベントは、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 コードを変更します。

page/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')
  ...

page/vegetables/[名前].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 イベントは、ページが最初に読み込まれたときの load イベントの直後と、ページが bfcache から復元されるたびに発生します。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)を直接高速化し、ナビゲーション時に First Input Delay(FID)と Cumulative Layout Shift(CLS)に対応する余地が生まれます。

Private Prefetch Proxy はクロスサイト プリフェッチを有効にしますが、ユーザーの個人情報は宛先サーバーに公開しません。

Private Prefetch Proxy の仕組み

Private Prefetch Proxy を使用してクロスサイト プリフェッチを有効にする

ウェブサイトの所有者は、よく知られた traffic-advice リソースを介してプリフェッチの制御を維持します。これはウェブ クローラーの /robots.txt に類似しています。これにより、実装するエージェントが対応するアドバイスを適用することを HTTP サーバーが宣言できます。現在、ウェブサイトの所有者は、ネットワーク接続を禁止または制限するようエージェントにアドバイスできます。今後、他のアドバイスが追加される場合もあります。

traffic-advice リソースをホストするには:

  1. 次の JSON に似たファイルを追加します。

public/.well-known/traffic-advice

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

google_prefetch_proxy_eap フィールドは早期アクセス プログラム用の特別なフィールドで、fraction フィールドは、Private Prefetch 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 など、ほとんどのプラットフォームには、デベロッパーがシームレスな遷移を構築できる使いやすいプリミティブが用意されています。

Shared Element Transitions API を使用すると、デベロッパーは、ドキュメント間(MPA)とドキュメント内(SPA)のどちらでも、ウェブ上で同じ機能を使用できます。

pixiv の Shared Element Transitions API のデモ Tokopedia の共有 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() コールバック関数を終了します。

このコールバックでは、詳細ページ内の共有要素も登録されます。

page/fruits/[名前].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 リポジトリで Issue としてフィードバックをお寄せください。