シングルページ アプリケーションの構築を全面的に見直した、まったく新しい API を使用してクライアント側のルーティングを標準化します。
シングルページ アプリケーション(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 を返す場合(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 では、ネイティブの読み込みインジケーターが有効になり、ユーザーは停止ボタンを操作できるようになっています。(現時点では、ユーザーが [戻る/進む] ボタンで移動した場合は発生しませんが、まもなく修正される予定です)。
ナビゲーションのコミット
ナビゲーションをインターセプトすると、handler
コールバックが呼び出される直前に新しい URL が有効になります。すぐに 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
をインライン化して、AbortSignal
を fetch()
とともに使用する方法を示しています。
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"
イベント リスナーによって処理されます。
フォームの送信
次に、POST による HTML <form>
の送信は特殊なタイプのナビゲーションであり、Navigation API によってインターセプトされる可能性があります。追加のペイロードが含まれていますが、ナビゲーションは引き続き "navigate"
リスナーによって一元的に処理されます。
フォームの送信を検出するには、NavigateEvent
の formData
プロパティを探します。次の例では、任意のフォーム送信を、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)を使用しているサイトでは、サーバーが正しい初期状態を返すことができるため、ユーザーにコンテンツを提供する最も速い方法である場合もあります。
しかし、クライアントサイドのコードを利用してページを作成するサイトでは、ページを初期化するための追加関数の作成が必要になる場合があります。
もう 1 つの意図的な設計として、Navigation API は単一フレーム(トップレベル ページ、または単一の特定の <iframe>
)内でのみ動作するようにしています。これには多くの興味深い影響があります。それらは仕様にさらに記載されていますが、実際にはデベロッパーの混乱を軽減できます。以前の History API には、フレームのサポートなど、わかりにくいエッジケースが数多くありましたが、刷新された Navigation API は最初からこれらのエッジケースに対応しています。
最後に、ユーザーが通過したエントリのリストをプログラムで変更または並べ替えることについてのコンセンサスはまだありません。これは現在検討中ですが、1 つの選択肢として、過去のエントリまたは「今後のすべてのエントリ」の削除のみを許可するという選択肢もあります。後者では一時的な状態が許可されます。 たとえば、デベロッパーは次のことを行えます。
- 新しい URL または状態に移動してユーザーに質問する
- ユーザーが作業を完了できるようにする(または戻る)
- タスクの完了時に履歴エントリを削除する
これは一時的なモーダルやインタースティシャルに最適です。新しい URL は、ユーザーが「戻る」ジェスチャーを使用してページを移動したものの、誤って「進む」に移動して同じページを開くことができないためです(エントリが削除されているため)。 これは、現在の History API ではできないことです。
Navigation API を試す
Navigation API は、フラグなしで Chrome 102 で使用できます。Domenic Denicola によるデモを試すこともできます。
従来の History API は単純に見えますが、あまり明確に定義されておらず、ブラウザ間での実装方法の違いや特殊なケースに関して多くの問題があります。新しい Navigation API に関するフィードバックの提供にご協力ください。
リファレンス
謝辞
この投稿のレビューにご協力いただいた Thomas Steiner、Domenic Denicola、Nate Chapin に感謝します。Unsplash のヒーロー画像(作成者: Jeremy Zero)。