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

新しい API を通じてクライアント側のルーティングを標準化し、シングルページ アプリケーションの構築を全面的に見直しました。

対応ブラウザ

  • 102
  • 102
  • x
  • x

ソース

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

SPA では、History API を通じて(または一部のケースではサイトの #hash 部分を調整することで)この機能を提供してきましたが、SPA が主流になる以前から開発された扱いにくい API であり、ウェブはまったく新しいアプローチを求めています。 Navigation API は、History API の大まかな部分にパッチを適用するのではなく、この分野を全面的に見直すため提案された API です。(たとえば、スクロール復元では、History API を最初から作り直すのではなく、履歴 API にパッチを適用しています)。

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

使用例

Navigation API を使用するには、まず、グローバル navigation オブジェクトに "navigate" リスナーを追加します。このイベントは基本的に集中されるもので、ユーザーがアクション(リンクのクリック、フォームの送信、前後に移動するなど)を行ったか、ナビゲーションがプログラムによって(サイトのコードによって)トリガーされた場合でも、あらゆる種類のナビゲーションに対して呼び出されます。ほとんどの場合、そのアクションに対するブラウザのデフォルトの動作をコードでオーバーライドできます。SPA の場合は、ユーザーが同じページに留まることや、サイトのコンテンツの読み込みや変更が必要になる可能性があります。

NavigateEvent"navigate" リスナーに渡されます。このリスナーにはナビゲーションに関する情報(リンク先 URL など)が含まれており、一元的な場所でナビゲーションに対応できます。基本的な "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" イベント リスナーは、URL の変更処理を SPA 内に一元化します。古い 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 は多くの場合、こうした可能性の解決に役立つように感じます。ただし、実際には 2 つのサーフェス領域しかありません。ユーザーがブラウザで [戻る] または [進む] を押した場合に応答することと、URL をプッシュして置き換えることはありません。ただし、上記のようにクリック イベントのリスナーを手動でセットアップしている場合を除き、"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 を返すと(async functionsで自動的に行われます)、その 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() ハンドラが呼び出されると、次の 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 は変更されます。

デベロッパーにとって 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 を URL に保存します。それ以外の場合は、状態オブジェクトを使用することをおすすめします。

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

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

個々の NavigationHistoryEntry"dispose" イベントをリッスンすることもできます。このイベントは、エントリが閲覧履歴の一部でなくなったときに発生します。これは一般的なクリーンアップの一環として発生することがありますが、ナビゲーション時にも発生します。たとえば、10 箇所遡って前方に移動すると、その 10 件の履歴エントリが破棄されます。

前述のとおり、"navigate" イベントはすべてのナビゲーションで発生します。(実際には、考えられるすべてのタイプの仕様に長い付録があります)。

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

プログラムによるナビゲーション

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

コード内のどこからでも navigation.navigate('/another_page') を呼び出し、ナビゲーションを発生させることができます。これは、"navigate" リスナーに登録された集中型イベント リスナーによって処理され、集中型リスナーは同期的に呼び出されます。

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

navigation.navigate() メソッドは、{ committed, finished } に 2 つの Promise インスタンスを含むオブジェクトを返します。これにより、起動元は、遷移が「commit」(表示 URL が変更されて新しい NavigationHistoryEntry が利用可能になる)または「finished」(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" イベント リスナーによって処理されます。

フォームの送信

次に、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 のもう一つの設計上の選択は、単一フレーム内(トップレベル ページ内、または単一の特定の <iframe> 内)でのみ動作することです。この仕様にはさまざまな興味深い影響があります(この仕様の詳しい説明をご覧ください)。ただし、実際はデベロッパーの混乱を軽減します。 以前の History API には、フレームのサポートなど、わかりにくいエッジケースが多数ありますが、再構築された Navigation API は最初からそのようなエッジケースを処理します。

最後に、ユーザーが閲覧したエントリのリストをプログラムで変更したり並べ替えたりする方法について、まだコンセンサスが得られていません。これは現在検討中ですが、削除のみを許可する選択肢として、履歴エントリまたは「将来のすべてのエントリ」があります。 後者では一時的な状態を使用できます。 たとえば、デベロッパーは

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

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

Navigation API を試す

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

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

参照

謝辞

この投稿をレビューしてくれた Thomas SteinerDomenic Denicola、Nate Chapin に感謝します。 Unsplash のヒーロー画像(Jeremy Zero