Navigation API 怎麼取代 pushState?SPA 先處理 navigate 與 fallback
SPA 以前常用 click、history.pushState()、popstate 和一堆例外條件拼出路由。這套做法可以運作,但返回、前進、表單、下載、跨來源連結與程式導覽常常各走一條路,最後變成「某個按鈕有攔截、瀏覽器返回卻沒有」的狀態。
Navigation API 是這個問題的新入口。MDN 將它描述為 History API 與 window.location 在 SPA 場景的後繼方案,並標示為 Baseline 2026;但它不是把 server routing 或初次頁面載入消失。實務上的結論是:用 navigate 集中處理同源、可攔截的應用程式路由,透過 intercept() 接管渲染,再保留初次載入與不支援瀏覽器的 fallback。
Navigation API 解決的是哪一層
先把幾個責任分開:
| 需求 | 舊做法常見入口 | Navigation API 的入口 |
|---|---|---|
| 使用者點同源連結 | 每個 link 自己加 click handler | navigate event |
| 瀏覽器返回/前進 | popstate | 同一個 navigation event 流程 |
| 程式導覽 | history.pushState() | navigation.navigate() |
| 導覽完成時機 | 自己管理 render promise | committed 與 finished |
| 初次載入 | server 或 hydration | 仍由 server/既有 bootstrap 負責 |
這個 API 管理的是瀏覽器的 navigation 與 history 生命週期,不是替你決定 URL 對應哪個元件。路由表、資料取得、快取和畫面更新仍然要由應用程式或框架處理。
先用 feature detection 接上 navigate
下面是以原生 JavaScript 寫的最小骨架。renderDocument() 代表你的畫面更新函式;實際專案應另外處理 title、focus、scroll restoration、錯誤畫面和快取。
const appNavigation = globalThis.navigation;
if (appNavigation) { appNavigation.addEventListener('navigate', (event) => { if ( !event.canIntercept || event.hashChange || event.downloadRequest || event.formData ) { return; }
const url = new URL(event.destination.url); if (url.origin !== location.origin) { return; }
event.intercept({ async handler() { const response = await fetch(url, { headers: { Accept: 'text/html' }, signal: event.signal, });
if (!response.ok) { throw new Error('Navigation failed: ' + response.status); }
renderDocument(await response.text(), url); }, }); });}event.canIntercept 先排除瀏覽器不允許頁面接管的導覽;hashChange 保留原生的同頁錨點行為;downloadRequest 和 formData 則避免把下載或非 GET 表單錯當成純頁面切換。event.signal 讓 fetch 可以在下一次導覽取代目前請求時中止。
不要把所有 navigate 都攔下來。跨來源連結、下載、檔案上傳和需要完整 document navigation 的情況,讓瀏覽器照原本的方式處理通常更可靠。
intercept() 後要管理兩個完成時機
event.intercept({ handler }) 接管後,handler 的成功或失敗會影響 navigation 的結果。畫面開始切換與資料真的完成不是同一件事,因此程式導覽可以拆開等待:
const result = globalThis.navigation.navigate('/settings');
result.committed .then(() => showRouteLoadingState()) .catch((error) => showRouteError(error));
result.finished .then(() => recordRouteReady()) .catch((error) => recordRouteFailure(error));committed 適合用來表示 history entry 已經提交、畫面可以進入切換狀態;finished 則適合收尾 metrics、focus 或 loading indicator。兩者都要處理 rejection,否則使用者看到的可能是已經改變 URL,畫面卻停在半完成狀態。
保留 pushState fallback
Navigation API 雖然已進入 Baseline 2026,專案仍可能需要支援沒有 globalThis.navigation 的執行環境。把程式導覽包在一個小函式中,就能讓新舊路徑共用呼叫端:
function navigateTo(path) { const target = new URL(path, location.href);
if (globalThis.navigation) { return globalThis.navigation.navigate(target.href).finished; }
history.pushState(null, '', target.href); window.dispatchEvent(new PopStateEvent('popstate')); return Promise.resolve();}fallback 不是把兩套 router 永久並行,而是讓 migration 有一個清楚的邊界:支援 Navigation API 的瀏覽器走 navigate,其他環境保留既有 popstate renderer。當框架本身已經擁有 router 時,應先確認它是否已接管 navigation event,避免在 framework router 外再攔一次。
初次載入和同源限制不能忽略
Navigation API 不會因為頁面第一次載入就送出 navigate event。使用者直接開啟深層 URL、重新整理或從外部網站進入時,仍需要 server 回傳正確 document,或由既有 hydration/bootstrap 讀取目前 URL。只在 client listener 裡宣告路由,不能取代 SSR、靜態 fallback 或 server rewrite。
另外,這個 API 的可攔截範圍以目前 browsing context 的同源 navigation 為中心,並且是 frame-local。它不會讓頁面任意改寫瀏覽器 history,也不會把跨來源頁面變成同一個 SPA route。實作時至少要保留:
- server 對每個可分享 URL 的直接回應。
- 跨來源、下載、檔案上傳和非頁面導覽的原生行為。
- 同一個 tab、iframe 或 nested browsing context 的路由邊界。
- route handler 失敗時的
navigateerror、重試與可理解錯誤畫面。
從舊 router 遷移的順序
若現有程式已經大量使用 pushState,可以分階段移動:
- 先把 route table 和 render 函式從 click handler 抽出來。
- 將同源的 anchor navigation 接到
navigate,保留原本的popstate作為 fallback。 - 用
event.canIntercept、hashChange、downloadRequest和formData建立拒絕清單。 - 將資料請求綁定
event.signal,測試快速連點、返回/前進與請求取消。 - 最後才把程式導覽改成
navigation.navigate(),並記錄committed/finished的錯誤。
這個順序讓 navigation semantics 先穩定,再替換 API 呼叫。不要一次把 history、資料載入、View Transition 和整個 component lifecycle 同時重寫,否則遇到返回按鈕問題時很難知道是哪一層回歸。
常見問題
Navigation API 是所有 SPA 都應該立刻使用嗎?
不一定。它在最新瀏覽器已有 Baseline 2026 標示,但專案仍要評估目標瀏覽器、框架 router 和 SSR。先用 feature detection 導入,再保留 fallback,比直接刪掉既有路由層更容易回復。
navigate event 會處理第一次頁面載入嗎?
不會。第一次載入需要 server 或既有 bootstrap 處理;navigate 比較適合接管頁面已經載入後的導覽。
finished 和 committed 為什麼要分開?
committed 表示 navigation entry 已提交,finished 表示 handler 及後續導覽流程完成。前者適合切換 loading 狀態,後者適合做完成紀錄與收尾;兩者都要處理失敗。
參考資料:
回報錯字、失效連結,或告訴我你想看的延伸主題。