透過全新的 API 將用戶端轉送作業標準化,此 API 已全面翻新建構單頁應用程式。
單頁應用程式 (又稱 SPA) 是由核心功能定義:使用者與網站互動時,系統會動態重新撰寫內容,而不是從伺服器載入全新網頁的預設方法。
雖然 SPA 已透過 History API 提供這項功能 (或在極少數情況下,透過調整網站的 #hash 部分) 來提供這項功能,但這個 API 是很複雜的 API,比 SPA 早已成為常態,更吸引網路採取全新做法。 Navigation API 是一套建議的 API,可以完全徹底改善這個空間,而不是僅嘗試修補 History API 的粗糙邊緣。 (例如,Scroll Restoration 已修補了 History API,而不是嘗試重新開發)。
本文將概略介紹 Navigation API。 如要閱讀技術提案,請查看 WICG 存放區中的草稿報告。
使用範例
如要使用 Navigation API,請先在全域 navigation
物件中新增 "navigate"
事件監聽器。
這類事件基本上就是「集中式」:無論使用者執行何種動作 (例如點選連結、提交表單,或是來回/向前快轉),或是以程式輔助方式觸發導覽 (透過網站程式碼觸發),都會觸發這個事件。
在大多數情況下,您可以讓程式碼覆寫瀏覽器針對該動作的預設行為。
若是 SPA 服務,表示使用者可能維持相同網頁,載入或變更網站內容。
NavigateEvent
會傳遞到 "navigate"
事件監聽器,該事件監聽器包含到達網頁網址等導覽資訊,讓您能夠集中回應導覽。
基本的 "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});
}
});
您可以透過下列其中一種方式處理導覽:
- 呼叫
intercept({ handler })
(如上所述) 以處理導覽。 - 呼叫
preventDefault()
可完全取消導航。
本範例會在事件中呼叫 intercept()
。
瀏覽器會呼叫 handler
回呼,設定網站的下一個狀態。
這項操作會建立轉換物件 navigation.transition
,其他程式碼可用來追蹤導覽進度。
intercept()
和 preventDefault()
通常是允許,但出現無法呼叫的情況。
如果導覽為跨來源導覽,您就無法透過 intercept()
處理導覽。
此外,如果使用者按下瀏覽器的「返回」或「向前」按鈕,則無法透過 preventDefault()
取消導覽。您就不可能強迫使用者在您的網站上購物
(這是 GitHub 上的討論主題)。
即使無法停止或攔截導覽本身,"navigate"
事件仍會觸發。
而且只提供資訊,因此程式碼就可以記錄 Analytics 事件來表示使用者即將離開你的網站。
為何要在平台中加入另一個事件?
"navigate"
事件監聽器會集中處理 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 經常會「融入」這些可能性,
不過,它實際上只有兩個區域:當使用者按下瀏覽器中的 [上一頁] 或 [下一頁] 時,才會回應,以及推送和取代網址。
這樣做不會與 "navigate"
相似,但您可以手動設定點擊事件的事件監聽器 (如上所示)。
決定處理導覽的方式
navigateEvent
包含有關導覽的大量資訊,可用來決定如何處理特定導覽。
主要屬性包括:
canIntercept
- 如果為 false,則無法攔截導覽。 無法攔截跨來源瀏覽和跨文件週遊。
destination.url
- 可能是處理導航時應考量的重要資訊。
hashChange
- 如果導覽內容為同一份文件,且雜湊是網址中與目前網址不同的部分,則為「是」。
在現代 SPA 中,雜湊應連結至目前文件中不同部分。因此,如果
hashChange
為 true,可能就不需要攔截此導覽。 downloadRequest
- 如果為 true,則導覽是由內含
download
屬性的連結啟動。 在多數情況下,您不需要攔截這項資訊。 formData
- 如果這不是 null,則此導覽是 POST 表單提交內容的一部分。
處理導覽時,請務必將這一點納入考量。
如果只想處理 GET 導覽,請避免攔截
formData
不是空值的導覽。 如要查看後續處理表單提交步驟的範例,請參閱這篇文章。 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
傳回承諾 (使用非同步函式自動執行),則承諾會告知瀏覽器導覽所需時間,以及導覽是否成功。
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 瀏覽作業隨著時間推移而將文件從先前的網址和狀態變更為新的網址。 這麼做有許多潛在優點,包括使用便利性:瀏覽器可能顯示瀏覽開始、結束,或可能發生瀏覽故障等問題。 例如,Chrome 會啟動原生載入指標,並允許使用者與停止按鈕互動。(目前當使用者透過「上一頁」/「下一頁」按鈕瀏覽時,就不會發生這個問題,但這個做法很快就會修正)。
導航修訂
攔截導覽時,新網址會在呼叫 handler
回呼之前生效。
如未立即更新 DOM,系統會建立一段期間,讓舊內容隨著新網址一起顯示。
這會影響擷取資料或載入新子資源時的相對網址解析度。
雖然我們在 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);
},
});
}
});
這樣不僅能避免網址解析問題,也能讓您立即回應使用者,感覺相當快速。
中止信號
由於您可以在 intercept()
處理常式中執行非同步作業,因此導覽作業可能會重複。
這類情況包括:
- 使用者點按其他連結,或某些程式碼執行其他導覽。 在這種情況下,系統會捨棄舊的導覽面板,並改用新版導覽。
- 使用者按一下「停止」按鈕。
為處理上述任一可能,傳遞至 "navigate"
事件監聽器的事件會包含 signal
屬性,即 AbortSignal
。
詳情請參閱「取消擷取」。
簡而言之,它基本上提供了一個物件,會在您應該停止工作時觸發事件。
值得注意的是,您可以將 AbortSignal
傳遞至您對 fetch()
的所有呼叫,這會在系統先佔導航時取消傳輸中的網路要求。
這樣可以節省使用者的頻寬,並拒絕 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"
時),這表示嘗試捲動至網址片段指定的部分 (#
後方的位元),或將捲動重設為頁面頂端。
如果是重新載入和周遊,這表示將捲動位置還原至這個記錄項目上次顯示的位置。
根據預設,在 handler
傳回的承諾解析後,就會發生此情況,但如果提早捲動內容,則可呼叫 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
傳回的承諾值解析後,瀏覽器會聚焦在已設定 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}`);
});
"navigateerror"
事件監聽器會接收 ErrorEvent
,這個事件監聽器特別實用,因為其設定新網頁的程式碼一定會發生任何錯誤。
您只需 await fetch()
即可知道當網路無法使用時,錯誤最終會轉送至 "navigateerror"
。
導覽項目
navigation.currentEntry
提供目前項目的存取權。
這個物件會說明使用者目前的位置。
這個項目包含目前網址、隨時間變化的中繼資料,以及開發人員提供的狀態。
中繼資料包含 key
,這是每個項目的專屬字串屬性,代表目前項目及其版位。
即使目前項目的網址或狀態變更,這個鍵也會維持不變。
仍位於同一個時段中。
相反地,如果使用者按下「返回」按鈕,再開啟相同頁面,由於這個新項目會建立新的版位,因此 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})
可讓您在同一處處理。
狀態與網址參數
由於狀態可以是結構化物件,因此建議您在所有應用程式狀態中使用這個物件。 不過在多數情況下,最好將狀態儲存在網址中。
如果您預期當使用者與其他使用者分享網址時,系統會保留此狀態,請將其儲存在網址中。 否則狀態物件是較佳的選項。
存取所有項目
「目前項目」並非全部
使用者透過 navigation.entries()
呼叫使用您的網站時,API 也會傳回項目的快照陣列,這樣 API 也能讓您存取他們瀏覽過的所有項目清單。
本功能可用於多種用途,例如根據使用者前往特定網頁的方式顯示不同的使用者介面,或單純回顧先前的網址或狀態。
這項規定無法與目前的 History API 搭配使用。
您也可以監聽個別 NavigationHistoryEntry
上的 "dispose"
事件,如果該項目不再包含在瀏覽器歷史記錄中,就會觸發這個事件。這項差異會納入一般清理作業,但也會在導航時發生。舉例來說,如果你反向移動過 10 個地點,再向前瀏覽,則系統會丟棄這 10 個記錄項目。
範例
如上所述,所有類型的導覽都會觸發 "navigate"
事件。
(實際上,所有可能類型都有長附錄)。
雖然許多網站最常見的情況是使用者點選 <a href="...">
時,但仍需要介紹兩種較複雜的導覽類型。
程式輔助導覽
第一種是程式輔助導覽,其中導覽是由用戶端程式碼中的方法呼叫所導致。
您可以在程式碼中的任何位置呼叫 navigation.navigate('/another_page')
以啟動導覽。
這項作業會由在 "navigate"
事件監聽器上註冊的集中式事件監聽器處理,且系統會同步呼叫集中式事件監聽器。
這是為了改善 location.assign()
和好友等舊方法的匯總,以及 History API 的 pushState()
和 replaceState()
方法。
navigation.navigate()
方法會傳回一個物件,其中包含 { committed, finished }
中的兩個 Promise
例項。
這樣一來,叫用端就能等到轉換作業「修訂」(可見網址已變更,新的 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()
一樣。
表單提交
第二,透過 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) 的網站,這可能沒有問題;您的伺服器可能會傳回正確的初始狀態,這是向使用者提供內容最快的方法。
不過,使用用戶端程式碼建立網頁的網站可能需要建立額外函式來初始化網頁。
Navigation API 的另一個刻意設計選擇,就是只在單一頁框中運作,也就是頂層頁面或單一特定的 <iframe>
。
這個做法不僅會詳細記錄在規格中,也可能造成一些有趣的影響,但實際上可以減少開發人員的混淆。
先前的 History API 有許多令人混淆的極端情況 (例如頁框支援),而重新構思的 Navigation API 會從一開始就處理這些極端情況。
最後,使用者尚未透過程式修改或重新排列使用者已瀏覽的項目清單。 目前正在進行討論,但有一個選項是只允許刪除記錄:歷史項目或「未來所有項目」。 後者會允許暫時狀態。 例如開發人員可以:
- 請前往新的網址或狀態,向使用者提出問題
- 允許使用者完成作業 (或返回)
- 完成工作時移除記錄項目
這個做法最適合用於暫時的強制回應或插頁式廣告:新網址可供使用者使用「返回」手勢離開,但卻無法不小心「前進」功能再次開啟 (因為項目已移除)。 目前的 History API 根本無法做到這一點。
試用 Navigation API
Navigation API 適用於 Chrome 102 (不含旗標)。 此外,你也可以試用 Domenic Denicola 的示範影片。
雖然傳統版 History API 看起來相當簡單,但這並不明確,而是有大量問題。 歡迎您對這個新的 Navigation API 提供意見。
參考資料
特別銘謝
感謝 Thomas Steiner、Domenic Denicola 和 Nate Chapin 的貼文。 主頁橫幅由 Jeremy Zero 提供。