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

透過全新的 API 標準化用戶端路徑,徹底改造單頁應用程式的建構方式。

Jake Archibald
Jake Archibald

Browser Support

  • Chrome: 102.
  • Edge: 102.
  • Firefox: 147.
  • Safari: 26.2.

Source

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

雖然 SPA 能夠透過 History API 為您提供這項功能 (或在有限的情況下,透過調整網站的 #hash 部分),但這是笨拙的 API,早在 SPA 成為常態之前就已開發完成,而網路正呼喚著全新的方法。 Navigation API 是一項提案 API,旨在徹底改造這個空間,而非只是修補 History API 的粗糙邊緣。 (舉例來說,「捲動復原」修補了 History API,而不是嘗試重新發明這項 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 感覺上可以協助實現這些可能性。 不過,它實際上只有兩個表面區域:在使用者於瀏覽器中按下「返回」或「前進」時做出回應,以及推送和取代網址。除了手動設定點擊事件的監聽器 (如上所示) 之外,它與 "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 傳回 Promise (使用 async 函式時會自動發生),該 Promise 會告知瀏覽器導覽所需的時間,以及導覽是否成功。

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 傳回的 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 會觸發 "navigatesuccess" 並傳回 Event
  • 如果傳回的 Promise 遭到拒絕,API 會觸發 "navigateerror" 並傳回 ErrorEvent

這些事件可讓您的程式碼集中處理成功或失敗情況。舉例來說,您可能會在成功時隱藏先前顯示的進度指標,如下所示:

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,這是每個項目的專屬字串屬性,代表目前項目及其 slot。 即使目前項目的網址或狀態有所變更,這個鍵仍會維持不變。 仍位於同一個插槽。 反之,如果使用者按下「返回」鍵,然後重新開啟同一網頁,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 }) 傳回的所有 Promise 都已完成或遭拒,因為發生錯誤或遭其他導覽作業搶先)。

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 SteinerDomenic Denicola 和 Nate Chapin 審查這篇文章。