新型用戶端轉送功能:Navigation API

透過全新 API 標準化用戶端導向,徹底改善單頁應用程式建構作業。

瀏覽器支援

  • Chrome:102。
  • Edge:102。
  • Firefox:不支援。
  • Safari:不支援。

資料來源

單頁應用程式 (SPA) 的核心功能是:在使用者與網站互動時,動態重寫內容,而非從伺服器載入全新網頁的預設方法。

雖然 SPA 可以透過 History API 提供這項功能 (或在特定情況下,透過調整網站的 #hash 部分),但這項功能是 SPA 出現之前就已開發的笨拙 API,而網際網路正急需全新的做法。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 通常覺得可以協助處理這些可能性。不過,它實際上只有兩個表面區域:回應使用者在瀏覽器中按下「Back」或「Forward」按鈕,以及推送及取代網址。除非您手動設定點擊事件的監聽器 (如上方所示),否則它與 "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 傳回承諾 (非同步函式會自動傳回承諾),該承諾會告知瀏覽器導覽需要多久時間,以及是否成功。

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,顯示如何將 AbortSignalfetch() 搭配使用:

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}) 則可讓您在一個位置處理。

狀態與網址參數

由於狀態可為結構化物件,因此很容易將其用於所有應用程式狀態。不過,在許多情況下,建議您將該狀態儲存在網址中。

如果您希望使用者與其他使用者分享網址時,狀態能夠保留,請將狀態儲存在網址中。否則,狀態物件是較佳的選擇。

存取所有項目

不過,「目前的項目」並非全部。這個 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 可能會用於表示特定動畫,例如導致下一個網頁顯示的動畫。(您可以改為設定全域變數,或將全域變數納入 #雜湊中。這兩種做法都有些不方便。)值得注意的是,如果使用者稍後透過「返回」和「前進」按鈕等方式導覽,系統就不會重播這個 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 的另一個設計用意,是讓 Navigation API 只在單一畫面內運作,也就是頂層網頁或單一特定 <iframe>。這項功能有許多有趣的影響,規格說明文件會進一步說明這些影響,但在實際應用上,這項功能會減少開發人員的困惑。舊版 History API 有許多令人困惑的邊緣情況,例如支援影格,而重新設計的 Navigation API 一開始就會處理這些邊緣情況。

最後,我們尚未就以程式輔助方式修改或重新排列使用者瀏覽過的項目清單達成共識。這項功能目前正在討論中,但其中一個選項可能會只允許刪除歷史記錄或「所有未來記錄」。後者則可允許暫時狀態。舉例來說,身為開發人員,我可以:

  • 前往新的網址或狀態,向使用者提出問題
  • 讓使用者完成工作 (或返回)
  • 在工作完成後移除記錄項目

這非常適合臨時的對話方塊或插播廣告:使用者可以透過返回手勢離開新網址,但不會不小心按下向前鍵再次開啟 (因為已移除該項目)。但這在目前的 History API 中是不可能的。

試用 Navigation API

在 Chrome 102 中,Navigation API 可在沒有旗標的情況下使用。您也可以試用 Domenic Denicola 的示範。

雖然傳統的 History API 看起來很簡單,但定義不夠明確,且在特殊情況下和不同瀏覽器的實作方式不同,因此有許多問題。希望您能對新的 Navigation API 提供意見。

參考資料

特別銘謝

感謝 Thomas SteinerDomenic Denicola 和 Nate Chapin 審查這篇文章。