最新のクライアントサイド ルーティング: Navigation API

単一ページ アプリケーションの構築を完全に見直した、まったく新しい API によるクライアントサイド ルーティングの標準化。

対応ブラウザ

  • Chrome: 102。
  • Edge: 102.
  • Firefox: サポートされていません。
  • Safari: サポートされていません。

ソース

シングルページ アプリケーション(SPA)は、ユーザーがサイトを操作するたびにコンテンツを動的に書き換えるというコア機能によって定義されます。これは、サーバーから完全に新しいページを読み込むというデフォルトの方法とは異なります。

SPA では、History API を介して(または限定的なケースでは、サイトの #hash 部分を調整することで)この機能を実現できましたが、これは SPA が標準になるずっと前に開発された扱いにくい APIであり、ウェブはまったく新しいアプローチを必要としています。Navigation API は、History API の粗い部分を単にパッチするのではなく、この領域を完全にオーバーホールする API として提案されています。(たとえば、スクロールの復元では、History API を再作成するのではなく、パッチを適用しました)。

この記事では、Navigation API の概要について説明します。技術的な提案については、WICG リポジトリのドラフト レポートをご覧ください。

使用例

Navigation API を使用するには、まずグローバル navigation オブジェクトに "navigate" リスナーを追加します。このイベントは基本的に一元化されています。ユーザーがアクションを実行した場合(リンクのクリック、フォームの送信、前後に移動するなど)や、ナビゲーションがプログラムによってトリガーされた場合(サイトのコード経由でトリガーされた場合など)に、すべてのタイプのナビゲーションに対して発生します。ほとんどの場合、コードでそのアクションに対するブラウザのデフォルトの動作をオーバーライドできます。SPA の場合、ユーザーを同じページに留め、サイトのコンテンツを読み込むか変更することを意味します。

NavigateEvent は、宛先 URL などのナビゲーションに関する情報が含まれる "navigate" リスナーに渡され、1 つの場所でナビゲーションに応答できます。基本的な "navigate" リスナーは次のようになります。

navigation.addEventListener('navigate', navigateEvent => {
  // Exit early if this navigation shouldn't be intercepted.
  // The properties to look at are discussed later in the article.
  if (shouldNotIntercept(navigateEvent)) return;

  const url = new URL(navigateEvent.destination.url);

  if (url.pathname === '/') {
    navigateEvent.intercept({handler: loadIndexPage});
  } else if (url.pathname === '/cats/') {
    navigateEvent.intercept({handler: loadCatsPage});
  }
});

ナビゲーションには、次の 2 つの方法で対応できます。

  • intercept({ handler }) を呼び出して(上記を参照)、ナビゲーションを処理します。
  • preventDefault() を呼び出すと、ナビゲーションを完全にキャンセルできます。

この例では、イベントで intercept() を呼び出します。ブラウザは handler コールバックを呼び出し、サイトの次の状態を構成します。これにより、遷移オブジェクト navigation.transition が作成されます。このオブジェクトは、他のコードがナビゲーションの進行状況を追跡するために使用できます。

通常、intercept()preventDefault() の両方が許可されますが、呼び出せないケースもあります。ナビゲーションがクロスオリジン ナビゲーションの場合、intercept() を介してナビゲーションを処理することはできません。また、ユーザーがブラウザの [戻る] ボタンまたは [進む] ボタンを押している場合、preventDefault() を使用してナビゲーションをキャンセルすることはできません。ユーザーをサイトに閉じ込めないようにしてください。(この問題は GitHub で議論中です)。

ナビゲーション自体を停止またはインターセプトできない場合でも、"navigate" イベントはトリガーされます。情報提供のため、コードでアナリティクス イベントをロギングして、ユーザーがサイトから離脱したことを示すことができます。

プラットフォームに別のイベントを追加する理由

"navigate" イベント リスナーは、SPA 内の URL 変更の処理を集中化します。これは、古い API を使用すると難しいプロポーザルです。History API を使用して独自の SPA のルーティングを記述したことがある場合は、次のようなコードを追加したことがあるかもしれません。

function updatePage(event) {
  event.preventDefault(); // we're handling this link
  window.history.pushState(null, '', event.target.href);
  // TODO: set up page based on new URL
}
const links = [...document.querySelectorAll('a[href]')];
links.forEach(link => link.addEventListener('click', updatePage));

これは問題ありませんが、網羅的ではありません。ページ上のリンクは、表示したり非表示にしたりできます。また、ユーザーがページを移動する方法はリンクだけではありません。たとえば、フォームを送信したり、画像マップを使用したりできます。ページでこれらの問題に対処している場合でも、簡単に解決できる可能性はたくさんあります。新しい Navigation API は、そうした問題を解決します。

また、上記のコードでは「戻る」と「進む」のナビゲーションが処理されません。そのための別のイベント "popstate" があります。

個人的には、History API がこうした可能性を実現する一助になるのではないかと感じます。ただし、実際には、ユーザーがブラウザで [戻る] または [進む] を押した場合の応答と、URL のプッシュと置換の 2 つのサーフェス領域しかありません。上記のように、クリック イベントのリスナーを手動で設定する場合を除き、"navigate" に類似するものはありません。

ナビゲーションを処理する方法の決定

navigateEvent には、特定のナビゲーションの処理方法を決定するために使用できる、ナビゲーションに関する多くの情報が含まれています。

主なプロパティは次のとおりです。

canIntercept
false の場合、ナビゲーションをインターセプトできません。クロスオリジン ナビゲーションとクロスドキュメント トラバーサルはインターセプトできません。
destination.url
ナビゲーションを処理する際に考慮すべき最も重要な情報です。
hashChange
ナビゲーションが同じドキュメント内であり、ハッシュが URL の現在の URL と異なる唯一の部分である場合、True です。最新の SPA では、ハッシュは現在のドキュメントのさまざまな部分へのリンクに使用する必要があります。したがって、hashChange が true の場合、このナビゲーションをインターセプトする必要はほとんどありません。
downloadRequest
この値が true の場合、download 属性を持つリンクによってナビゲーションが開始されました。ほとんどの場合、これをインターセプトする必要はありません。
formData
null でない場合、このナビゲーションは POST フォーム送信の一部です。ナビゲーションを処理する際は、この点に注意してください。GET ナビゲーションのみを処理する場合は、formData が null でないナビゲーションをインターセプトしないでください。フォームの送信の処理の例については、この記事の後半をご覧ください。
navigationType
"reload""push""replace""traverse" のいずれかです。"traverse" の場合、このナビゲーションを preventDefault() でキャンセルすることはできません。

たとえば、最初の例で使用した shouldNotIntercept 関数は次のようになります。

function shouldNotIntercept(navigationEvent) {
  return (
    !navigationEvent.canIntercept ||
    // If this is just a hashChange,
    // just let the browser handle scrolling to the content.
    navigationEvent.hashChange ||
    // If this is a download,
    // let the browser perform the download.
    navigationEvent.downloadRequest ||
    // If this is a form submission,
    // let that go to the server.
    navigationEvent.formData
  );
}

インターセプト

コードが "navigate" リスナー内から intercept({ handler }) を呼び出すと、更新された新しい状態のページを準備していること、ナビゲーションが少し時間がかかる可能性があることをブラウザに通知します。

ブラウザはまず、現在の状態のスクロール位置をキャプチャします。これにより、後で必要に応じて復元できます。次に、handler コールバックを呼び出します。handler が Promise を返す場合(非同期関数では自動的に返されます)、その Promise はブラウザにナビゲーションにかかる時間と、成功したかどうかを伝えます。

navigation.addEventListener('navigate', navigateEvent => {
  if (shouldNotIntercept(navigateEvent)) return;
  const url = new URL(navigateEvent.destination.url);

  if (url.pathname.startsWith('/articles/')) {
    navigateEvent.intercept({
      async handler() {
        const articleContent = await getArticleContent(url.pathname);
        renderArticlePage(articleContent);
      },
    });
  }
});

そのため、この API では、ブラウザが理解できるセマンティック コンセプトを導入しています。SPA ナビゲーションが現在進行中で、時間の経過とともにドキュメントが以前の URL と状態から新しい URL と状態に変更されることを意味します。これには、ユーザー補助など、いくつかのメリットがあります。ブラウザは、ナビゲーションの開始、終了、または潜在的な障害を表示できます。たとえば、Chrome ではネイティブの読み込みインジケーターが有効になり、ユーザーは停止ボタンを操作できます。(現在のところ、ユーザーが [戻る] ボタンまたは [進む] ボタンを使用して移動した場合は発生しませんが、近日中に修正される予定です)。

ナビゲーションをインターセプトすると、新しい URL は handler コールバックが呼び出される直前に有効になります。DOM をすぐに更新しないと、新しい URL とともに古いコンテンツが表示される期間が発生します。これは、データの取得時や新しいサブリソースの読み込み時の相対 URL 解決などに影響します。

URL の変更を遅らせる方法については GitHub で議論されていますが、通常は、新しいコンテンツのプレースホルダを表示してページをすぐに更新することをおすすめします。

navigation.addEventListener('navigate', navigateEvent => {
  if (shouldNotIntercept(navigateEvent)) return;
  const url = new URL(navigateEvent.destination.url);

  if (url.pathname.startsWith('/articles/')) {
    navigateEvent.intercept({
      async handler() {
        // The URL has already changed, so quickly show a placeholder.
        renderArticlePagePlaceholder();
        // Then fetch the real data.
        const articleContent = await getArticleContent(url.pathname);
        renderArticlePage(articleContent);
      },
    });
  }
});

これにより、URL 解決の問題を回避できるだけでなく、ユーザーに即座に返信できるため、迅速に対応しているように感じられます。

中止シグナル

intercept() ハンドラで非同期処理を行うことができるため、ナビゲーションが冗長になる可能性があります。これは、次のような場合に発生します。

  • ユーザーが別のリンクをクリックするか、コードが別のナビゲーションを実行します。この場合、古いナビゲーションは廃止され、新しいナビゲーションが使用されます。
  • ユーザーがブラウザの「停止」ボタンをクリックします。

このような可能性に対応するため、"navigate" リスナーに渡されるイベントには、AbortSignal である signal プロパティが含まれています。詳細については、中断可能な取得をご覧ください。

簡単に説明すると、基本的には、処理を停止する必要があるときにイベントを発生させるオブジェクトを提供します。特に、fetch() への呼び出しに AbortSignal を渡すことができます。これにより、ナビゲーションがプリエンプトされた場合に、進行中のネットワーク リクエストがキャンセルされます。これにより、ユーザーの帯域幅を節約し、fetch() によって返された Promise を拒否して、DOM を更新して無効なページ ナビゲーションを表示するなどの、後続のコードによるアクションを防ぐことができます。

getArticleContent をインライン化した前述の例を次に示します。AbortSignalfetch() で使用できる方法を示しています。

navigation.addEventListener('navigate', navigateEvent => {
  if (shouldNotIntercept(navigateEvent)) return;
  const url = new URL(navigateEvent.destination.url);

  if (url.pathname.startsWith('/articles/')) {
    navigateEvent.intercept({
      async handler() {
        // The URL has already changed, so quickly show a placeholder.
        renderArticlePagePlaceholder();
        // Then fetch the real data.
        const articleContentURL = new URL(
          '/get-article-content',
          location.href
        );
        articleContentURL.searchParams.set('path', url.pathname);
        const response = await fetch(articleContentURL, {
          signal: navigateEvent.signal,
        });
        const articleContent = await response.json();
        renderArticlePage(articleContent);
      },
    });
  }
});

スクロール処理

ナビゲーションを intercept() すると、ブラウザはスクロールを自動的に処理しようとします。

新しい履歴エントリへの移動の場合(navigationEvent.navigationType"push" または "replace" の場合)、これは URL フラグメント(# の後の部分)で指定された部分までスクロールしようとするか、スクロールをページの上部にリセットすることを意味します。

再読み込みと移動の場合、これは、この履歴エントリが最後に表示されたときと同じ位置にスクロール位置を復元することを意味します。

デフォルトでは、handler によって返された Promise が解決するとスクロールが開始されますが、スクロールを早めに開始する場合は navigateEvent.scroll() を呼び出します。

navigation.addEventListener('navigate', navigateEvent => {
  if (shouldNotIntercept(navigateEvent)) return;
  const url = new URL(navigateEvent.destination.url);

  if (url.pathname.startsWith('/articles/')) {
    navigateEvent.intercept({
      async handler() {
        const articleContent = await getArticleContent(url.pathname);
        renderArticlePage(articleContent);
        navigateEvent.scroll();

        const secondaryContent = await getSecondaryContent(url.pathname);
        addSecondaryContent(secondaryContent);
      },
    });
  }
});

または、intercept()scroll オプションを "manual" に設定して、自動スクロール処理を完全に無効にすることもできます。

navigateEvent.intercept({
  scroll: 'manual',
  async handler() {
    // …
  },
});

フォーカスの処理

handler によって返された Promise が解決すると、autofocus 属性が設定された最初の要素がフォーカスされます。その属性が設定されていない要素がない場合、<body> 要素がフォーカスされます。

この動作をオプトアウトするには、intercept()focusReset オプションを "manual" に設定します。

navigateEvent.intercept({
  focusReset: 'manual',
  async handler() {
    // …
  },
});

成功イベントと失敗イベント

intercept() ハンドラが呼び出されると、次のいずれかが行われます。

  • 返された Promise が満たされた場合(または intercept() を呼び出していない場合)、Navigation API は Event とともに "navigatesuccess" をトリガーします。
  • 返された Promise が拒否されると、API は ErrorEvent"navigateerror" をトリガーします。

これらのイベントにより、コードは成功または失敗を集中的に処理できます。たとえば、次のように、以前に表示されていた進行状況インジケーターを非表示にすることで、成功を処理できます。

navigation.addEventListener('navigatesuccess', event => {
  loadingIndicator.hidden = true;
});

失敗した場合は、エラー メッセージを表示することもできます。

navigation.addEventListener('navigateerror', event => {
  loadingIndicator.hidden = true; // also hide indicator
  showMessage(`Failed to load page: ${event.message}`);
});

ErrorEvent を受信する "navigateerror" イベント リスナーは、新しいページを設定するコードからエラーを必ず受信するため、特に便利です。ネットワークが利用できない場合、エラーは最終的に "navigateerror" に転送されることを前提に、単に await fetch() を実行できます。

navigation.currentEntry は、現在のエントリへのアクセスを提供します。これは、ユーザーが現在地を説明するオブジェクトです。このエントリには、現在の URL、このエントリを経時的に識別するために使用できるメタデータ、デベロッパーが指定した状態が含まれます。

メタデータには、現在のエントリとそのスロットを表す各エントリの一意の文字列プロパティ key が含まれています。このキーは、現在のエントリの URL や状態が変更されても変わりません。同じスロットのままです。逆に、ユーザーが「戻る」ボタンを押して同じページを再度開くと、この新しいエントリによって新しいスロットが作成されるため、key が変更されます。

デベロッパーにとって key は便利です。Navigation API を使用すると、キーが一致するエントリにユーザーを直接移動できます。他のエントリの状態でも保持して、ページ間を簡単に移動できます。

// On JS startup, get the key of the first loaded page
// so the user can always go back there.
const {key} = navigation.currentEntry;
backToHomeButton.onclick = () => navigation.traverseTo(key);

// Navigate away, but the button will always work.
await navigation.navigate('/another_url').finished;

Navigation API は「状態」という概念を表示します。これは、現在の履歴エントリに永続的に保存されるデベロッパー提供の情報ですが、ユーザーには直接表示されません。これは History API の history.state と非常によく似ていますが、改善されています。

Navigation API では、現在のエントリ(または任意のエントリ)の .getState() メソッドを呼び出して、その状態のコピーを返すことができます。

console.log(navigation.currentEntry.getState());

デフォルトでは undefined です。

設定の状態

状態オブジェクトは変更できますが、その変更は履歴エントリとともに保存されないため、次のように動作します。

const state = navigation.currentEntry.getState();
console.log(state.count); // 1
state.count++;
console.log(state.count); // 2
// But:
console.info(navigation.currentEntry.getState().count); // will still be 1

状態を設定するには、スクリプトのナビゲーション中に行うのが正しい方法です。

navigation.navigate(url, {state: newState});
// Or:
navigation.reload({state: newState});

ここで、newState は任意のクローンを作成可能なオブジェクトにできます。

現在のエントリの状態を更新する場合は、現在のエントリを置き換えるナビゲーションを実行することをおすすめします。

navigation.navigate(location.href, {state: newState, history: 'replace'});

その後、"navigate" イベント リスナーは navigateEvent.destination を介してこの変更を検出できます。

navigation.addEventListener('navigate', navigateEvent => {
  console.log(navigateEvent.destination.getState());
});

状態の同期更新

通常は、navigation.reload({state: newState}) を介して状態を非同期で更新し、"navigate" リスナーがその状態を適用することをおすすめします。ただし、ユーザーが <details> 要素を切り替えたり、フォーム入力の状態を変更したりしたときなど、コードが状態の変化を認識するまでに、状態の変化がすでに完全に適用されていることがあります。このような場合は、状態を更新して、これらの変更が再読み込みと走査で保持されるようにすることをおすすめします。これは updateCurrentEntry() を使用して行えます。

navigation.updateCurrentEntry({state: newState});

この変更について説明するイベントも開催されます。

navigation.addEventListener('currententrychange', () => {
  console.log(navigation.currentEntry.getState());
});

ただし、"currententrychange" で状態変化に反応する場合、"navigate" イベントと "currententrychange" イベントの間で状態処理コードが分割されるか、重複する可能性があります。一方、navigation.reload({state: newState}) では 1 か所で処理できます。

状態と URL パラメータ

状態は構造化オブジェクトにできるため、すべてのアプリ状態に使用したくなります。ただし、多くの場合、その状態を URL に保存することをおすすめします。

ユーザーが URL を別のユーザーと共有するときに状態が保持されることが想定される場合は、URL に保存します。それ以外の場合は、状態オブジェクトを使用することをおすすめします。

すべてのエントリにアクセスする

ただし、「現在のエントリ」がすべてではありません。また、この API では、navigation.entries() 呼び出しを使用して、サイトの使用中にユーザーが移動したエントリのリスト全体にアクセスすることもできます。この呼び出しは、エントリのスナップショット配列を返します。たとえば、ユーザーが特定のページに移動した方法に基づいて異なる UI を表示したり、以前の URL やその状態を確認したりできます。これは現在の History API では不可能です。

個々の NavigationHistoryEntry"dispose" イベントをリッスンすることもできます。このイベントは、エントリがブラウザの履歴に含まれなくなったときに発生します。これは一般的なクリーンアップの一環として発生する可能性がありますが、ナビゲーション中に発生することもあります。たとえば、10 個の場所を遡って移動してから前方に移動すると、その 10 個の履歴エントリは破棄されます。

"navigate" イベントは、上記のようにすべてのタイプのナビゲーションに対して発生します。(実際には、すべての可能なタイプの長い付録が仕様に記載されています)。

多くのサイトでは、ユーザーが <a href="..."> をクリックするのが最も一般的なケースですが、注目すべき複雑なナビゲーション タイプが 2 つあります。

プログラマティック ナビゲーション

1 つ目はプログラマティック ナビゲーションです。これは、クライアントサイド コード内のメソッド呼び出しによってナビゲーションが発生します。

コード内の任意の場所から navigation.navigate('/another_page') を呼び出して、ナビゲーションを開始できます。これは、"navigate" リスナーに登録された一元化イベント リスナーによって処理され、一元化リスナーが同期的に呼び出されます。

これは、location.assign() などの古いメソッドと、History API のメソッド pushState()replaceState() の改善された集約を目的としています。

navigation.navigate() メソッドは、{ committed, finished } に 2 つの Promise インスタンスを含むオブジェクトを返します。これにより、呼び出し元は、遷移が「commit」される(表示 URL が変更され、新しい NavigationHistoryEntry が使用可能になる)か「完了」する(intercept({ handler }) から返されたすべてのプロミスが完了するか、失敗または別のナビゲーションによってプリエンプトされたために拒否される)まで待機できます。

navigate メソッドにはオプション オブジェクトもあり、次のことを設定できます。

  • state: 新しい履歴エントリの状態。NavigationHistoryEntry.getState() メソッドで取得できます。
  • history: 現在の履歴エントリを置き換えるために "replace" に設定できます。
  • info: navigateEvent.info を介してナビゲート イベントに渡すオブジェクト。

特に、info は、次のページを表示する特定のアニメーションを示す場合に便利です。(別の方法としては、グローバル変数を設定するか、#hash の一部として含める方法があります。どちらのオプションも少し不便です)。なお、この info は、ユーザーが後で [戻る] ボタンや [進む] ボタンなどを使用してナビゲーションを実行した場合に再生されません。実際、そのような場合は常に undefined になります。

左または右から開くデモ

navigation には、他にも多くのナビゲーション メソッドがあります。これらはすべて、{ committed, finished } を含むオブジェクトを返します。traverseTo()(ユーザーの履歴内の特定のエントリを表す key を受け入れます)と navigate() についてはすでに説明しました。また、back()forward()reload() も含まれます。これらのメソッドはすべて、navigate() と同様に、一元化された "navigate" イベント リスナーによって処理されます。

フォームの送信

2 つ目は、POST を介した HTML <form> の送信は特別なタイプのナビゲーションであり、Navigation API でインターセプトできることです。追加のペイロードが含まれていますが、ナビゲーションは引き続き "navigate" リスナーによって一元的に処理されます。

フォームの送信は、NavigateEventformData プロパティを探すことで検出できます。フォームの送信を fetch() を使用して現在のページに留めるだけの例を次に示します。

navigation.addEventListener('navigate', navigateEvent => {
  if (navigateEvent.formData && navigateEvent.canIntercept) {
    // User submitted a POST form to a same-domain URL
    // (If canIntercept is false, the event is just informative:
    // you can't intercept this request, although you could
    // likely still call .preventDefault() to stop it completely).

    navigateEvent.intercept({
      // Since we don't update the DOM in this navigation,
      // don't allow focus or scrolling to reset:
      focusReset: 'manual',
      scroll: 'manual',
      handler() {
        await fetch(navigateEvent.destination.url, {
          method: 'POST',
          body: navigateEvent.formData,
        });
        // You could navigate again with {history: 'replace'} to change the URL here,
        // which might indicate "done"
      },
    });
  }
});

不足している情報

"navigate" イベント リスナーは集中型であるにもかかわらず、現在の Navigation API 仕様では、ページの初回読み込み時に "navigate" がトリガーされません。すべての状態にサーバーサイド レンダリング(SSR)を使用するサイトでは、これは問題ない場合があります。サーバーが正しい初期状態を返すことができれば、ユーザーにコンテンツを最も速く提供できます。ただし、クライアントサイド コードを使用してページを作成するサイトでは、ページを初期化するための追加の関数を作成しなければならない場合があります。

Navigation API の設計上の意図的な選択のもう 1 つは、単一のフレーム(最上位ページまたは単一の特定の <iframe>)内でのみ動作することです。これにはいくつかの興味深い意味合いがあり、仕様で詳しく説明されていますが、実際にはデベロッパーの混乱を軽減します。以前の History API には、フレームのサポートなど、混乱を招くエッジケースがいくつかありましたが、再設計された Navigation API では、こうしたエッジケースを最初から処理します。

最後に、ユーザーが移動したエントリのリストをプログラムで変更または並べ替えることについては、まだコンセンサスがありません。現在、この点について議論中ですが、過去のエントリまたは「今後のすべてのエントリ」の削除のみを許可する方法が考えられます。後者では一時的な状態が許可されます。たとえば、デベロッパーは次のことができます。

  • 新しい URL または状態に移動してユーザーに質問する
  • ユーザーが作業を完了できるようにする(または [戻る] に移動できるようにする)
  • タスクの完了時に履歴エントリを削除する

これは、一時的なモーダルやインタースティシャルに最適です。新しい URL は、ユーザーが「戻る」ジェスチャーを使用して離れることができますが、誤って「進む」ジェスチャーを使用して再度開くことはできません(エントリが削除されているため)。これは現在の History API では不可能です。

Navigation API を試す

Navigation API は、Chrome 102 でフラグなしで使用できます。Domenic Denicola によるデモをお試しいただくこともできます。

従来の History API は単純に見えますが、明確に定義されておらず、コーナーケースやブラウザ間での実装方法に関する多くの問題があります。新しい Navigation API についてフィードバックをお寄せください。

参照

謝辞

この投稿のレビューに協力してくれた Thomas Steiner 氏、Domenic Denicola 氏、Nate Chapin 氏に感謝します。