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

透過全新的 API,將用戶端轉送標準化,大幅大幅改進單頁應用程式的建構方式。

山姆索羅德
Sam Thorogood
阿奇巴德 (Jake Archibald)
Jake Archibald

瀏覽器支援

  • 102
  • 102
  • x
  • x

資料來源

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

雖然 SPA 一直都能透過 History API 提供這項功能 (或在少數情況下,可調整網站的 #hash 部分來達到這個目的),但是這是一個複雜的 API,在 SPA 成為主流趨勢後就開始運作,而網路世界正面臨各種全新的方法。 Navigation API 是一項提議的 API,能徹底徹底改良這個空間,而不只是單純修補 History API 的邊緣。(例如,捲動還原功能修補了 History API,而不是試圖重新定義)。

本文會概略說明 Navigation API。如要閱讀技術提案,請前往 WICG 存放區查看「草稿報表」

使用範例

如要使用 Navigation API,請先在全域 navigation 物件上新增 "navigate" 事件監聽器。這個事件基本上是「集中化」,亦即針對所有瀏覽類型、使用者執行某項動作 (例如按下連結、提交表單或返回/下一頁),或以程式化方式觸發導覽 (也就是透過網站的程式碼) 觸發。 在大多數情況下,這可讓程式碼覆寫瀏覽器對該動作的預設行為。如果是垃圾郵件,可能是指讓使用者留在同一個網頁上,並載入或變更網站內容。

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
如果發生此情況,導覽便是透過包含 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 示範如何搭配 fetch() 使用 AbortSignal

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 就會使用 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}`);
});

接收 ErrorEvent"navigateerror" 事件監聽器特別實用,因為可保證會收到設定新頁面的程式碼所發生的錯誤。您只需 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 可用來表示能帶出下一頁的特定動畫。(替代方法是設定全域變數,或是將變數加為 #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 協助評論這篇文章。 主頁橫幅由 Jeremy Zero 提供,來源:Unsplash