透過全新的 API 標準化用戶端路徑,徹底改造單頁應用程式的建構方式。
單頁應用程式 (SPA) 的核心功能是:在使用者與網站互動時動態重寫內容,而不是採用預設方法,從伺服器載入全新的網頁。
雖然 SPA 能夠透過 History API 為您提供這項功能 (或在有限的情況下,透過調整網站的 #hash 部分),但這是笨拙的 API,早在 SPA 成為常態之前就已開發完成,而網路正呼喚著全新的方法。 Navigation API 是一項提案 API,旨在徹底改造這個空間,而非只是修補 History API 的粗糙邊緣。 (舉例來說,「捲動復原」修補了 History API,而不是嘗試重新發明這項 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- 如果導覽是相同的文件,且只有網址的雜湊部分與目前網址不同,則為 True。
在現代 SPA 中,雜湊應是用於連結至目前文件的不同部分。因此,如果
hashChange為 true,您可能不需要攔截這項導覽。 downloadRequest- 如果這是 true,表示導覽是由具有
download屬性的連結啟動。 在大多數情況下,您不需要攔截這項要求。 formData- 如果這不是空值,則此導覽屬於 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 傳回 Promise (使用 async 函式時會自動發生),該 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 導覽,隨著時間推移,文件會從先前的網址和狀態變更為新的網址和狀態。這項功能有許多潛在優點,包括無障礙設計:瀏覽器可以顯示導覽的開始、結束或潛在失敗。 舉例來說,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 傳回的 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 會觸發"navigatesuccess"並傳回Event。 - 如果傳回的
Promise遭到拒絕,API 會觸發"navigateerror"並傳回ErrorEvent。
這些事件可讓您的程式碼集中處理成功或失敗情況。舉例來說,您可能會在成功時隱藏先前顯示的進度指標,如下所示:
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,這是每個項目的專屬字串屬性,代表目前項目及其 slot。
即使目前項目的網址或狀態有所變更,這個鍵仍會維持不變。
仍位於同一個插槽。
反之,如果使用者按下「返回」鍵,然後重新開啟同一網頁,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}) 則可讓您在一個位置處理。
狀態與網址參數
由於狀態可以是結構化物件,因此您可能會想將其用於所有應用程式狀態。不過,在許多情況下,最好將該狀態儲存在網址中。
如果您希望使用者與他人共用網址時,狀態能保留下來,請將狀態儲存在網址中。 否則,狀態物件會是較好的選擇。
存取所有項目
不過,「目前項目」並非全部。
API 也提供方法,可透過 navigation.entries() 呼叫存取使用者在瀏覽網站時導覽的所有項目清單,並傳回項目快照陣列。
舉例來說,您可以根據使用者前往特定網頁的方式顯示不同的 UI,或是回顧先前的網址或狀態。目前的 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 }) 傳回的所有 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),這或許沒問題,因為伺服器可以傳回正確的初始狀態,這是將內容提供給使用者最快的方式。
不過,如果網站使用用戶端程式碼建立網頁,可能需要建立額外函式來初始化網頁。
Navigation API 的另一個刻意設計選擇是,它只會在單一影格內運作,也就是頂層網頁或單一特定 <iframe>。這項做法會產生許多有趣的影響,詳情請參閱規格文件,但實際上可減少開發人員的困惑。
舊版 History API 有許多令人困惑的極端情況,例如支援框架,而重新設計的 Navigation API 從一開始就處理這些極端情況。
最後,對於以程式輔助方式修改或重新排列使用者瀏覽過的項目清單,目前尚未達成共識。 這項功能目前仍在討論中,但其中一個選項可能是只允許刪除記錄,包括過往記錄或「所有未來的記錄」。後者可允許暫時狀態。 舉例來說,開發人員可以:
- 前往新網址或狀態,向使用者提出問題
- 讓使用者完成工作 (或返回)
- 在工作完成後移除記錄項目
這項功能非常適合暫時性的模式或插頁式廣告:使用者可以透過返回手勢離開新網址,但無法意外地透過「前進」手勢再次開啟該網址 (因為項目已移除)。目前的 History API 無法做到這一點。
試用 Navigation API
Chrome 102 版已推出 Navigation API,不必使用旗標。 您也可以試用 Domenic Denicola 提供的範例。
雖然傳統的 History API 看似簡單,但定義不夠明確,且在邊角案例和不同瀏覽器的實作方式方面,有大量問題。歡迎對新的 Navigation API 提供意見回饋。
參考資料
特別銘謝
感謝 Thomas Steiner、Domenic Denicola 和 Nate Chapin 審查這篇文章。