主題: Web platform

Navigation API 到 Baseline:別把每個連結都攔成 SPA

Navigation API 能集中處理 SPA 導覽,但只應攔截自己能安全完成的同源 GET。表單、下載、hash、跨站與不支援時的正常導航,都該留給瀏覽器。

動態迷因(展開/收合)
Navigation API 不是「攔截所有連結」的許可證;不能完整處理的導航,讓瀏覽器照原本的方式做。 · 來源:GIPHY

Navigation API 在 2026 年進入 Baseline,對自己寫 router 的人很有吸引力:不用再到處補 link click、popstatepushState(),一個 navigate event 就能看到導航。

容易被漏掉的是,看得到導航,不代表該接管導航。

它不是把 MPA 變成 SPA 的開關,更不是把所有 <a>preventDefault() 的新理由。伺服器完整回傳文件、瀏覽器處理 history、scroll、focus 和 download,本來就是可靠的預設路徑。Navigation API 真正提供的是一個較完整的 router primitive:當我選擇 client-side 導覽時,能在同一處清楚寫下邊界。

先保留「什麼都不做」這條路

一個只有文章、表單與一般連結的網站,沒有因為 API 進入 Baseline 就必須長出 client router。MPA 仍有很好的 first load、SSR、失敗復原與快取行為;沒有量到整頁 reload 是瓶頸,就不需要為了新 API 多養一套 DOM swap、loading state 和錯誤處理。

即使真的要做局部 SPA,第一步也不是定義「我要攔哪些連結」,而是定義「哪些導航我能從頭到尾負責」。下列守門條件刻意保守:只處理可攔截、同源、非 hash、非下載、非表單的導航。

function shouldUseClientRouter(event) {
  const url = new URL(event.destination.url);

  return (
    event.canIntercept &&
    url.origin === location.origin &&
    !event.hashChange &&
    !event.downloadRequest &&
    !event.formData
  );
}

if ("navigation" in window) {
  navigation.addEventListener("navigate", (event) => {
    if (!shouldUseClientRouter(event)) return;

    const url = new URL(event.destination.url);
    event.intercept({
      handler: () => loadRoute(url, event.signal),
    });
  });
}

canIntercept 先替跨站與不能攔的導航做了重要分界;後面的條件仍是產品決策。#comments 讓瀏覽器定位通常更可靠,download 本來就不是頁面 route,帶著 formData 的 POST 則常牽涉驗證、權限、CSRF、redirect 與伺服器錯誤語意。若沒有把這些都實作好,交還給原生導航反而是較少程式、也較少 bug 的方案。

URL 先變了,畫面不能假裝沒事

呼叫 intercept() 後,URL 會在 handler 執行前 commit。若非同步資料還沒回來,使用者可能看到新 URL 配舊內容;相對 URL、分享與返回按鈕也可能因此混亂。

所以 route handler 的第一個可見動作應該很無聊:立刻畫出新路由的 placeholder,接著才載資料。不要等 fetch 完成才第一次回應點擊。

async function loadRoute(url, signal) {
  renderArticlePlaceholder(url.pathname);

  try {
    const response = await fetch(`/api/page?path=${encodeURIComponent(url.pathname)}`, {
      signal,
    });

    if (!response.ok) throw new Error(`Route request failed: ${response.status}`);
    renderArticle(await response.json());
  } catch (error) {
    if (error.name === "AbortError") return;
    throw error;
  }
}

使用者改點另一個連結時,前一個 NavigateEvent.signal 會 abort。把它傳進 fetch,舊請求就不會晚到後覆寫新畫面;AbortError 是預期取消,不該被誤報成「載入失敗」。真正失敗才交給集中式的 navigateerror 顯示重試或回到完整文件導航的選項。

動態迷因(展開/收合)
新導航取代舊導航時,讓過期請求停下來;畫面只跟隨使用者最後一次真正想去的地方。 · 來源:GIPHY

不要搶走瀏覽器已經會做的事

intercept() 預設會在 handler 完成後,讓瀏覽器處理新 route 的捲動與 focus:新 navigation 通常回到頂端或 fragment,back/forward 則嘗試回復原位置;focus 會到 autofocus 元素或 body。這個預設不是缺少控制,而是無障礙與 history 語意的一部分。

只有 router 真的有不同、可驗證的規則時,才設定 scroll: "manual"focusReset: "manual"。例如一個不更換主內容的 inline filter,或由 app 明確管理的 scroll restoration。只是「我想更快」不是理由;手動模式代表自己接下鍵盤焦點、fragment 與返回位置的責任。

完成與失敗也可以集中管理,而不是散落在每個 link callback:

navigation.addEventListener("navigatesuccess", () => {
  hideRouteProgress();
});

navigation.addEventListener("navigateerror", () => {
  hideRouteProgress();
  showRouteError();
});

這正是 API 值得使用的地方:導航成為一段有開始、取消、完成與失敗的流程,而不是各種 click handler 的拼貼。

Fallback 不是相容性附註,而是設計底座

feature detection 只包住 enhancement;每個連結仍指向可以由伺服器完整回應的 URL。舊版瀏覽器或被限制的環境沒有 window.navigation 時,不需要 polyfill 才能「勉強正確」——它什麼都不跑,瀏覽器照常導航即可。

這個分法也讓 rollout 好做:先挑一個同源、讀取型、沒有複雜表單的畫面,觀察取消率、route error、返回位置與 keyboard focus,再決定是否擴大。Navigation API 讓 router 的邊界更清楚,但它沒有消滅這些邊界。

我學到什麼

  • 我把 Navigation API 視為 SPA 導覽的集中式 primitive,而不是替每個 MPA 增加 client router 的理由。
  • 我先列出不能安全攔截的導航;正常 document navigation 是 fallback,也是大多數情況下的正確基線。
  • 一旦攔截,我就要處理 placeholder、取消、scroll、focus 與錯誤;這些不是動畫或速度的附屬品。

外部參考資料