新型用戶端轉送功能: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 的說明說明瞭 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 傳回的承諾解析,就會發生這個情況。不過,如果您認為提早捲動畫面是合理的做法,可以呼叫 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}) 則可讓您在單一位置處理。

狀態與網址參數

由於狀態可以是結構化物件,因此建議您在所有應用程式狀態中使用這個物件。不過,在許多情況下,建議您將該狀態儲存在網址中。

如果您預期當使用者與其他使用者分享網址時,系統會保留此狀態,請將其儲存在網址中。 否則狀態物件是較佳的選項。

存取所有項目

不過,「current entry」並非全部。這個 API 也提供一種方法,可透過 navigation.entries() 呼叫存取使用者在使用網站時瀏覽過的完整項目清單,該呼叫會傳回項目的快照陣列。可使用這些功能,例如根據使用者前往特定網頁的方式顯示不同的使用者介面,或單純回顧先前的網址或狀態。 但這在目前的 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 的另一個設計用意是,它只會在單一畫面內運作,也就是頂層頁面或單一特定 <iframe>。這項功能有許多有趣的影響,規格說明文件會進一步說明這些影響,但在實際應用中,這項功能會減少開發人員的困惑。先前的 History API 有許多令人混淆的邊緣情況 (例如頁框支援),而重新構思的 Navigation API 會從一開始就處理這些極端情況。

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

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

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

試用 Navigation API

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

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

參考資料

特別銘謝

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