透過全新的 API,將用戶端轉送標準化,大幅大幅改進單頁應用程式的建構方式。
單頁應用程式 (亦稱為 SPA) 是由核心功能所定義:當使用者與網站互動時,系統會動態重新寫入其內容,而不是從伺服器載入全新的網頁預設方法。
雖然 SPA 一直都能透過 History API 提供這項功能 (或在少數情況下,可調整網站的 #hash 部分來達到這個目的),但是這是一個複雜的 API,在 SPA 成為主流趨勢後就開始運作,而網路世界正面臨各種全新的方法。 Navigation API 是一項提議的 API,能徹底徹底改良這個空間,而不只是單純修補 History API 的邊緣。(例如,捲動還原功能修補了 History API,而不是試圖重新定義)。
本文會概略說明 Navigation API。如要閱讀技術提案,請前往 WICG 存放區查看「草稿報表」。
使用範例
如要使用 Navigation API,請先在全域 navigation
物件上新增 "navigate"
事件監聽器。這個事件基本上是「集中化」,亦即針對所有瀏覽類型、使用者執行某項動作 (例如按下連結、提交表單或返回/下一頁),或以程式化方式觸發導覽 (也就是透過網站的程式碼) 觸發。
在大多數情況下,這可讓程式碼覆寫瀏覽器對該動作的預設行為。如果是垃圾郵件,可能是指讓使用者留在同一個網頁上,並載入或變更網站內容。
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
- 如果發生此情況,導覽便是透過包含
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
傳回承諾 (使用async functions自動執行),承諾會告知瀏覽器導覽需要多少時間,以及是否成功。
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 會啟用原生的載入指標,並讓使用者與停止按鈕互動。目前當使用者透過上一頁/下一頁按鈕瀏覽內容時,並不會發生這種情況,但很快就會修復。
Navigation 修訂版本
攔截導覽時,新網址會在呼叫 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
示範如何搭配 fetch()
使用 AbortSignal
:
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 就會使用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"
事件監聽器特別實用,因為可保證會收到設定新頁面的程式碼所發生的錯誤。您只需 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})
則可讓您在單一位置處理。
狀態與網址參數
由於狀態可以是結構化物件,所以建議您將其用於所有應用程式狀態。不過,在多數情況下,建議您將狀態儲存在網址中。
如果您預期當使用者將網址分享給其他使用者時,系統會保留狀態,請將狀態儲存在網址中。 否則,狀態物件是更好的選項。
存取所有項目
但「目前的項目」並非全部。此外,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 })
傳回的所有承諾皆已完成,或是因失敗或已由其他導覽程序先佔) 而遭拒。
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 協助評論這篇文章。 主頁橫幅由 Jeremy Zero 提供,來源:Unsplash。