現代前端原生路由:Navigation API

zhbhun發表於2022-07-04

Navigation API 是 Chrome 提出的一套導航 API,提供了操作和攔截導航的能力,以及對應用程式的歷史導航記錄進行訪問。這為 window.history 和 window.location 提供了一個更有用的替代品,特別是 SPA 這種模式。目前該 API 只有 Chromium 核心的瀏覽器才支援。

compatibility

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 的封裝了,例如:historyhistory.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 事件處理函式執行結束,就會立刻同步更新瀏覽器的地址,即使動態載入的內容還沒響應回來。這會導致地址和頁面顯示內容不同步,因為非同步載入目標頁面時,當前頁面還是顯示上一個地址的內容,當前內容的一些資源的相對路徑引用會出錯。

下圖所示是預設行為,點選連結後需要等待服務端響應後才會更新地址

1

下圖是使用 transitionWhile 處理好的效果,在點選跳轉後地址立刻就發生了變化,但內容還是顯示的舊地址頁面。

2

目前最新的規範已經調整了相關的實現,具體參考下面的一些討論情況。截止到本文編寫時間,瀏覽器的實現還是舊的方案,所以本文還是按現有的實現去講解。

導航取消

由於自定義處理的導航是一個非同步任務,在處理過程中使用者可能點選頁面上的其他攔截,或者點選了瀏覽器的導航取消、前進和後退按鈕。為了處理這些情況,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.pushStatehistory.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 的實現。

參考文獻

相關文章