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

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

Browser Support

  • Chrome: 102.
  • Edge: 102.
  • Firefox: 147.
  • Safari: 26.2.

Source

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

SPA は History API を介して(または、限定的なケースではサイトの #hash 部分を調整することで)この機能を実現してきましたが、これは SPA が一般的になるずっと前に開発された使いにくい API であり、ウェブはまったく新しいアプローチを求めています。Navigation API は、History API の粗削りな部分を単純に修正するのではなく、この領域を完全に刷新することを目的とした提案中の API です。(たとえば、スクロール復元は、History API を再発明しようとするのではなく、パッチを適用しました)。

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

使用例

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

NavigateEvent"navigate" リスナーに渡されます。これには、ナビゲーションに関する情報(宛先 URL など)が含まれており、ナビゲーションに 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" リスナーに渡されるイベントには signal プロパティ(AbortSignal)が含まれています。詳しくは、中断可能なフェッチをご覧ください。

簡単に言うと、作業を停止するタイミングでイベントを発生させるオブジェクトを提供します。特に、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 インスタンスを含むオブジェクトを返します。これにより、呼び出し元は、トランジションが「コミット」される(表示される URL が変更され、新しい NavigationHistoryEntry が利用可能になる)か、「完了」する(intercept({ handler }) によって返されたすべての Promise が完了する、または失敗や別のナビゲーションによるプリエンプションにより拒否される)まで待機できます。

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

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

特に、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 では、これらのエッジケースが最初から処理されます。

最後に、ユーザーが移動したエントリのリストをプログラムで変更または並べ替えることについては、まだ合意が得られていません。これは現在検討中ですが、1 つの選択肢として、過去のエントリまたは「今後のすべてのエントリ」のいずれかの削除のみを許可することが考えられます。後者の場合、一時的な状態が許可されます。たとえば、デベロッパーは次のことができます。

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

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

Navigation API を試す

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

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

参照

謝辞

この記事を推敲してくれた Thomas SteinerDomenic Denicola、Nate Chapin に感謝します。