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

透過全新的 API 將用戶端轉送作業標準化,此 API 已全面翻新建構單頁應用程式。

瀏覽器支援

  • 102
  • 102
  • x
  • x

來源

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

雖然 SPA 已透過 History API 提供這項功能 (或在極少數情況下,透過調整網站的 #hash 部分) 來提供這項功能,但這個 API 是很複雜的 API,比 SPA 早已成為常態,因此網路更趨完善。 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 就能協助處理這些可能問題。不過,它實際上只有兩個途徑區域:當使用者按下瀏覽器的「返回」或「向前」時,就會回應,以及推送和取代網址。這不會與 "navigate" 類似,但如果您已經手動設定點擊事件的事件監聽器,如上所示。

決定處理導覽的方式

navigateEvent 包含有關導覽的大量資訊,可用來決定如何處理特定導覽。

主要屬性包括:

canIntercept
如果設為 false,則無法攔截導覽。無法攔截跨來源瀏覽和跨文件週遊。
destination.url
可能是處理導航時應考量的重要資訊。
hashChange
如果導覽內容為同一份文件,且雜湊是網址中與目前網址不同的部分,則為「是」。 在現代 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 傳回承諾 (使用async functions自動執行),則承諾會告知瀏覽器導覽所需時間,以及導覽是否成功。

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}) 可讓您在同一處處理。

狀態與網址參數

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

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

存取所有項目

但「目前項目」不是全部。 使用者透過 navigation.entries() 呼叫使用您的網站時,API 也會傳回項目的快照陣列,這樣 API 也能讓您存取他們瀏覽過的所有項目清單。可使用這些功能,例如根據使用者前往特定網頁的方式顯示不同的使用者介面,或單純回顧先前的網址或狀態。 這項規定無法與目前的 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 可能相當實用,例如表示會觸發下一頁的特定動畫。(另一種做法是設定全域變數,或是將該變數納入 #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

Navigation API 適用於 Chrome 102 版,不含旗標。此外,你也可以試用 Domenic Denicola 的示範影片。

雖然傳統版 History API 看起來相當簡單,但這並不明確,而是有大量問題。歡迎您對這個新的 Navigation API 提供意見。

參考資料

特別銘謝

感謝 Thomas SteinerDomenic Denicola 和 Nate Chapin 的貼文。主頁橫幅由 Jeremy Zero 提供。