Navigation API 是 Chrome 提出的一套導航 API,提供了操作和攔截導航的能力,以及對應用程式的歷史導航記錄進行訪問。這為 window.history 和 window.location 提供了一個更有用的替代品,特別是 SPA 這種模式。目前該 API 只有 Chromium 核心的瀏覽器才支援。
Why
SPA:在使用者與網站互動時動態重寫其內容,而不是預設的從伺服器載入全新頁面的方法。
雖然基於 History API 已經可以實現 SPA 了,但是 History API 過於簡陋,不是專門為 SPA 量身定製的(早在 SPA 成為標準之前就開發出來了),在一些邊界情況存在大量的問題,參見 W3C HTML History Issues。
如果開發人員在沒有了解 History API 的情況下,想要基於 History API 實現類似 vue-router 路由守衛這種的功能時,會發現 window.onpopstate
只能監聽到導航前進和後退的事件,無法監聽到 push 或 replace 事件。此外,在使用超連結標籤 a
或表單標籤 form
時,觸發的導航都是不支援 SPA 的,像前端常用的路由庫 vue-router 或 react-router 都會提供自己的 Link 元件,用於實現 SPA 路由跳轉。
在開源社群有已經有一些針對 history 的封裝了,例如:history、history.js,前者正是 react-router 的路由底層實現。而現在 Navigation API 提供一個全新的標準化客戶端路由,專門為 SPA 定製,提供了完整的操作和攔截導航的能力,以及對應用程式的歷史導航記錄進行訪問。
快速上手
要使用 Navigation API,首先在 window.navigation
上新增一個 “navigate” 事件監聽。這個事件代表了頁面上的所有同域導航事件,無論是使用者點選連結,提交表單,或回退和前進。大多數情況下,在這個事件處理函式裡可以重寫瀏覽器對這些操作的預設行為。對於 SPA,這意味著可以讓使用者保持在同一個頁面上,並動態載入或更改站點的內容。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<main>
<ul>
<li>
<a href="subpage.html">subpage.html</a>
</li>
<li>
<a href="#console">#console</a>
</li>
<li>
<button onclick="history.pushState(null, '', '/subpage.html')">
Go to subpage by history.pushState
</button>
</li>
<li>
<button onclick="history.back()">history.back()</button>
</li>
<li>
<button onclick="location.reload()">location.reload()</button>
</li>
<li>
<button onclick="location.href = 'subpage.html'">
Go to subpage by location.href
</button>
</li>
<li>
<a href="https://www.baidu.com">baidu</a>
</li>
</ul>
<div id="console"></div>
</main>
<script type="module">
navigation.addEventListener("navigate", (e) => {
console.log(e);
console.log('navigationType', e.navigationType); // 導航型別: "reload", "push", "replace", or "traverse"
console.log('destination', e.destination); // 導航目標:{ url: '', index: '', getState() {} }
console.log('hashChange', e.hashChange); // 是否是錨點
console.log('canTransition', e.canTransition); // 是否可以攔截,即是否可以使用 transitionWhile
if (e.hashChange || !e.canTransition) {
// 忽略錨點跳轉
return;
}
e.transitionWhile(
(async () => {
e.signal.addEventListener("abort", () => {
// 監聽取消事件
const newMain = document.createElement("main");
newMain.textContent =
"Navigation was aborted, potentially by the browser stop button!";
document.querySelector("main").replaceWith(newMain);
});
await delay(2000); // 故意延遲 2 秒,測試用的
// 動態載入目標頁面內容
const body = await (
await fetch(e.destination.url, { signal: e.signal })
).text();
const parser = new DOMParser();
const doc = parser.parseFromString(body, "text/html");
const title = doc.title;
const main = doc.querySelector("main");
document.title = title;
document.querySelector("main").replaceWith(main);
})()
);
});
navigation.addEventListener(
"navigatesuccess",
() => console.log("navigatesuccess") // 導航成功事件(transitionWhile 正常響應)
);
navigation.addEventListener(
"navigateerror",
(ev) => console.log("navigateerror", ev.error) // 導航失敗事件(transitionWhile 異常響應)
);
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
</script>
</body>
</html>
如上所示的示例,演示了超連結、history 和 location 操作導航的場景,它們都會觸發 navigation 的 navigate 事件。通過事件的 navigationType 屬性可以區分導航型別,hashChange 表示是否是錨點跳轉,destination 包含了跳轉模板頁面的資訊,可以根據該資訊動態的載入目標頁面,從而實現 SPA。
導航事件
上文所示的 navigate 事件就是瀏覽器所有涉及到地址變化時都會觸發的導航事件,不僅包括 History API 對導航的操作,超連結標籤 a、表單標籤 form 的提交和 Location API 等對導航的操作都會觸發 navigate 事件。在該事件的處理函式裡可以對導航進行攔截、重定向和取消。
window.navigation.addEventListener("navigate", function (event: NavigateEvent) {
console.log('navigationType', event.navigationType); // 導航型別: "reload", "push", "replace", or "traverse"
console.log('destination', event.destination); // 導航目標:{ url: '', index: '', getState() {} }
console.log('hashChange', event.hashChange); // 是否是錨點
console.log('canTransition', event.canTransition); // 是否可以攔截,即是否可以使用 transitionWhile
});
導航型別資訊
reload
:重新整理push
:開啟新頁面replace
:替換當前頁面traverse
:導航前進或後退
導航目標資訊主要包含了導航目標地址和狀態資訊
event.destination.url; // 目標地址
event.destination.getState(); // 類似 History API 的 state
除了以上 destination 和 navigationType 兩個主要資訊外,event 還提供了一些標識資訊
- hashChange:是否是錨點導航
- canTransition:表示是否可以重寫本次導航,實現 SPA 的自定義響應,除了跨域的導航無法重寫處理外,該標識一般都是為 true。
如果 canTransition 為 true,那麼可以呼叫 event.transitionWhile
來重寫導航行為,event.transitionWhile
會接受一個返回 Promise 的導航重寫函式,這個 api 在下文的“導航處理”部分化詳細介紹。根據導航重寫函式的處理結果,還會觸發成功和失敗事件。
navigatesuccess
:導航重寫函式返回的 Promise 響應成功(resolve)時觸發;navigateerror
:`導航重寫函式返回的 Promise 響應失敗(reject)時觸發。
導航處理
在 navigate 事件處理函式中,我們可以根據需要對導航行為進行攔截以阻止預設的導航行為,也可以自定義導航行為來覆蓋預設的導航方式,從而實現 SPA。
阻止導航
通過呼叫 event.preventDefault()
可以取消本次導航事件的預設行為,例如:點選超連結時預設會開啟新頁面。除了不能阻止瀏覽器的前進和後退行為外,其他導航變化事件都可以阻止。
自定義導航
當在 navigate 事件處理函式中呼叫 transitionWhile()
時,它通知瀏覽器現在正在為新的導航目標準備頁面,瀏覽器不需要處理了(相當於阻止了預設行為,從而實現自定義的 SPA)。並且導航可能需要一些時間,傳遞給 transitionWhile() 的 Promise 會告訴瀏覽器導航需要多長時間。在這個處理過程中,我們可以讓瀏覽器顯示導航的開始、結束或潛在的失敗。例如,Chrome 瀏覽器會顯示載入指示器,並允許使用者與停止按鈕互動。
navigation.addEventLisnter('navigate', () => {
event.transitionWhile(async () => {
// 顯示導航目標載入動畫
const reponse = await (await fetch('...')).text();
// 載入失敗會觸發 navigateerror 事件,否則觸發 navigatesuccess 事件
// 更新 DOM
});
})
navigation.addEventLisnter('navigatesuccess', () => {
// 隱藏導航目標載入動畫
})
navigation.addEventLisnter('navigateerror', () => {
// 隱藏導航目標載入動畫
// 顯示錯誤頁面
})
需要注意的是,跨域的導航目標是不允許重寫導航行為的。此外現有的地址更新模式還存在問題,瀏覽器預設處理導航時,會在目標地址的伺服器響應後才會同步更新地址。但是新的 navigation API 在現階段修改了這種行為,在重寫了導航後,只要 navigate
事件處理函式執行結束,就會立刻同步更新瀏覽器的地址,即使動態載入的內容還沒響應回來。這會導致地址和頁面顯示內容不同步,因為非同步載入目標頁面時,當前頁面還是顯示上一個地址的內容,當前內容的一些資源的相對路徑引用會出錯。
下圖所示是預設行為,點選連結後需要等待服務端響應後才會更新地址
下圖是使用 transitionWhile 處理好的效果,在點選跳轉後地址立刻就發生了變化,但內容還是顯示的舊地址頁面。
目前最新的規範已經調整了相關的實現,具體參考下面的一些討論情況。截止到本文編寫時間,瀏覽器的實現還是舊的方案,所以本文還是按現有的實現去講解。
- When should the URL change when using transitionWhile? #232
- Will the current transitionWhile() design of updating the URL/history entry immediately meet web developer needs? #66
- Make all same-document navigations sync #46
- Worries about making all navigations async #19
導航取消
由於自定義處理的導航是一個非同步任務,在處理過程中使用者可能點選頁面上的其他攔截,或者點選了瀏覽器的導航取消、前進和後退按鈕。為了處理這些情況,navigate 事件物件包含一個 AbortController 的訊號屬性 signal
,通過這個訊號屬性可以監聽導航取消事件,你也可以將這個訊號傳給非同步網路請求 fetch,以取消網路請求任務,節省頻寬。
navigation.addEventLisnter('navigate', (event) => {
event.signal.addEventListener("abort", () => {
// ...
});
event.transitionWhile(async () => {
await (await fetch('...', { signal: navigateEvent.signal })).text();
});
})
導航操作
除了我們移植的超連結標籤 <a>
, Location 和 History API 外,新的 Navigation API 也封裝了導航操作方法。
navigation.navigate(url: string, options: { state: any, history: 'auto' | 'push' | 'replace' })
開啟目標地址頁面,相等於
history.pushState
和history.replaceState
,但是支援跨域地址。navigation.reload({ state: any })
重新整理當前頁面,相當於呼叫了
location.reload()
navigation.back()
在導航會話歷史中向後移動一頁,相當於
history.back()
navigation.forward()
在導航會話歷史中向前移動一頁,相當於
history.forward()
navigation.traverseTo(key: string)
在導航會話歷史記錄中載入特定頁面,相當於
history.go()
,但區別在於傳參不同,navigation 給每個導航會話設定了一個唯一標識,traverseTo 接受的引數正是該唯一標識,下文會介紹該唯一標識。
導航歷史棧
在過去,History API 只提供了一個 history.length 來標識當前歷史棧的大小,但是無法訪問導航會話歷史棧資訊。而 Navigation API 提供了 currentEntry 和 entries 這兩個 api 來訪問當前導航會話和會話歷史棧。
每個導航會話物件的結構:
interface NavigationHistoryEntry extemds EventTarget {
readonly id: string;
readonly url: string;
readonly key: string;
readonly index: number;
getState(): any;
ondispose: EventHandler;
}
- id:導航會話的唯一標識
- url:導航會話的 URL 地址
key:在導航會話歷史棧中的唯一標識
id 與 key 的區別在於,key 標識是在棧中的唯一標識,id 是 NavigationHistoryEntry 例項的唯一標識。例如:呼叫 replace 或 reload 時並沒有產生新的導航會話,但會生成新的
NavigationHistoryEntry
,前後兩個 NavigationHistoryEntry 例項的 key 相同,但 id 不同。上文提到的
traverseTo
方法傳參就是該 key 值。- index:指示該導航會話在歷史棧的位置,預設從 0 開始
- getState: 返回導航會話儲存的狀態,類似 history.state,詳見下本介紹。
- ondispose:監聽 dispose 事件,在該導航會話從歷史棧中刪除時觸發。
如下所示是一個簡單示例演示歷史棧的工作情況。
navigation.currentEntry // 當前屬於首頁 { index: 0, url: '/' }
navigation.navigate('/a', { state: { v: 1 }}) // 開啟 a 頁面
navigation.navigate('/b', { state: { v: 2 }}) // 開啟 b 頁面
navigation.navigate('/c', { state: { v: 3 }}) // 開啟 c 頁面
navigation.back() // 返回上一頁
navigation.currentEntry // 當前位於 b 頁面 { index: 2, url: '/b' }
navigation.currentEntry.getState() // { v: 2 }
navigation.entries() // 當前導航會話不一定位於棧頂
/*
[
{ index: 0, url: '/' },
{ index: 1, url: '/a' },
{ index: 2, url: '/b' },
{ index: 3, url: '/c' },
]
*/
導航狀態
類似 history.state
,navigation 在每個導航會話提供了 getState
方法來獲取當前導航會話的快取狀態,即使重新整理了瀏覽器該狀態仍能恢復。
navigation.currentEntry.getState() // 當前導航會話的快取狀態
navigation.entries().map(entry => entry.getState()) // 所有導航會話歷史的快取狀態
導航會話的狀態是在呼叫 navigation.navigate(url: string, options: { state: any })
時設定的。如果要更新當前導航會話的狀態,可以呼叫 navigation.updateCurrentEntry(options: { state: any })。
,這在過去使用 History API 時,並沒有那麼方便的類似 API 可用。
在 SPA 裡,導航狀態還是有很大的應用場景,在過去由於沒有方便使用的 API,很多時候需要使用其他方案來代替。例如:有些開發者需要記住頁面狀態時,會使用全域性狀態管理來儲存。早期 redux 流行時,其官方的示例演示瞭如何在全域性快取頁面狀態。但這麼做,在一些路由元件在導航歷史同時出現時,會出現狀態衝突。
假設一個 SPA 存在一個列表頁面 /list
和一個詳細頁面 /detail
,列表頁面的每一項點選後開啟詳情頁面,詳情頁面存在更多連結又可以開啟新的列表頁面。這樣的話,在導航歷史棧中就存在兩個列表頁面,但這兩個列表頁面的頁面狀態應該是不一樣的。如果按照全域性狀態管理的方式處理,那麼會兩個列表頁面的頁面狀態就會存在衝突,要麼是新的列表覆蓋了就的列表狀態,要麼就是新的列表錯誤的使用了舊列表的頁面狀態。在這種情況下,我們應該使用”導航狀態“來快取頁面狀態。
總結
Navigation API 綜合封裝了瀏覽器的導航能力,提供了中心化的監聽事件和方便的自定義導航實現方式,而且補充提供了導航會話歷史的訪問和狀態管理,這些大大簡化了 SPA 的實現。