主題: Web platform
Navigation API 到 Baseline:別把每個連結都攔成 SPA
Navigation API 能集中處理 SPA 導覽,但只應攔截自己能安全完成的同源 GET。表單、下載、hash、跨站與不支援時的正常導航,都該留給瀏覽器。
動態迷因(展開/收合)
Navigation API 在 2026 年進入 Baseline,對自己寫 router 的人很有吸引力:不用再到處補 link click、popstate、pushState(),一個 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 顯示重試或回到完整文件導航的選項。
動態迷因(展開/收合)
不要搶走瀏覽器已經會做的事
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 與錯誤;這些不是動畫或速度的附屬品。