最新のクライアントサイド ルーティング: 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"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" リスナーに渡されるイベントには、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() ハンドラが呼び出されると、次の 2 つのいずれかが発生します。

  • 返された 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 が変更されます。

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

// 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 }) によって返されたすべての Promise が完了するか、失敗または別のナビゲーションによってプリエンプトされたために拒否される)まで待機できます。

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 は、ユーザーが「戻る」ジェスチャーを使用してページを移動したものの、誤って「進む」操作で再度開くことができない(エントリが削除されているため)、新しい URL です。 これは、現在の History API ではできないことです。

Navigation API を試す

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

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

参照

謝辞

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