正在載入索引…

Day 24:SPA Router 為什麼不再只靠 History API?

6.3k 字,約 6 分鐘

2026 iThome 鐵人賽:Angular 22 Signal 進化論。 原文發表於 iThome,也可以在下方留言。

文章目錄
  1. History API 如何讓 SPA 不重新載入
  2. History API 管的是瀏覽紀錄,不是完整的 Navigation
  3. Navigation API
  4. SPA 可以接手 Browser Navigation
  5. Angular Router 開始使用 Browser Navigation API
  6. Platform Navigation 與 Router Scroll 的關係
  7. 本日結語
    1. 資料來源

平常使用 Angular Router 時,可以透過 routerLink 或 router.navigate() 切換不同頁面。

模板連結:

html
<a routerLink="/users">使用者</a>

程式導向:

ts
router.navigate(['/users']);

網址會改變,畫面也會跟著切換,但整個 Angular Application 並不會重新載入。

不過瀏覽器原本的頁面導覽並不是這樣運作的。如果直接修改 window.location:

ts
window.location.href = '/users';

瀏覽器會真正前往新的 URL,離開目前的 Document,再向 Server 請求新的頁面。HTML、JavaScript、CSS 等資源也可能重新載入,Application 也會重新建立。

這種方式很適合傳統的 Multi-Page Application,但 SPA 希望在 URL 改變時保留目前的 Document,只更新需要切換的畫面,因此需要另一種管理 URL 與瀏覽紀錄的方式。

History API 如何讓 SPA 不重新載入

History API 提供幾個常見方法:

ts
// 新增一筆瀏覽紀錄,網址切換成 /users
history.pushState({}, '', '/users');

// 取代目前這筆瀏覽紀錄,不會新增新的 History Entry
history.replaceState({}, '', '/profile');

// 回到上一筆瀏覽紀錄
history.back();

// 前往下一筆瀏覽紀錄
history.forward();

例如:

ts
history.pushState({}, '', '/users');

網址會變成 /users,但目前的 Document 不會重新載入,這也是 SPA Router 能運作的重要基礎。

不過 pushState() 只負責建立新的 History Entry,它不知道 Angular 接下來要顯示哪個 Component。Angular Router 還需要根據目前的 URL 比對 Route,執行對應的導覽並更新畫面。

使用者也可能透過瀏覽器的上一頁或下一頁切換位置。這時 Browser 會改變目前的 History Entry,並觸發 popstate:

ts
window.addEventListener('popstate', () => {
  console.log(location.pathname);
});

Angular Router 監聽到這類變化後,再根據目前的 URL 執行對應的 Navigation。

傳統 SPA Router 便是透過 History API、popstate 與 Router 本身的導覽機制,同時處理程式導向與瀏覽器的上一頁、下一頁。

History API 管的是瀏覽紀錄,不是完整的 Navigation

History API 可以新增、取代或切換 History Entry,但不會描述 Angular Router 內部的導覽狀態。

例如:

ts
router.navigate(['/users']);

Router 開始處理這次 Navigation 後,中間仍可能被取消,或在載入過程中發生錯誤。

Angular Router 本身就有對應的 Navigation Event:

ts
// Navigation 開始
NavigationStart

// Navigation 成功完成
NavigationEnd

// Navigation 被取消,例如 Guard 阻擋
NavigationCancel

// Navigation 過程發生錯誤
NavigationError

History API 知道瀏覽紀錄發生了變化,卻不知道 Router Navigation 最後是成功、取消還是失敗,這些狀態仍然要由 Router 自己管理。

Navigation API 則把 Navigation 本身帶進 Browser 的原生模型中。

Navigation API 的入口是:

ts
window.navigation

可以直接執行導覽:

ts
navigation.navigate('/users');

也可以監聽 Navigation:

ts
navigation.addEventListener('navigate', event => {
  console.log(event.destination.url);
});

使用者點擊連結、按下 Back / Forward,或程式主動執行 Navigation 時,都可以透過 navigate Event 取得這次導覽的資訊。

History API 著重在 History Entry 的操作;Navigation API 則讓 Browser 能直接掌握一次 Navigation 的發生與狀態。

SPA 可以接手 Browser Navigation

Navigation API 提供 intercept(),讓 Application 可以接手這次導覽。

假設頁面中有一般的連結:

html
<a href="/users">使用者</a>

正常情況下,Browser 會前往 /users 並載入新的 Document。

透過 Navigation API,可以在 navigate 發生時攔截:

ts
navigation.addEventListener('navigate', event => {
  event.intercept({
    async handler() {
      await renderPage();
    }
  });
});

Browser 仍然知道使用者正在前往 /users,但 Application 可以接手後續處理,在目前的 Document 中更新畫面,不需要重新載入整個頁面。

過去 SPA 通常需要監聽 Link Click、阻止預設行為,再透過 History API 修改 URL;Navigation API 則提供統一的 Navigation Event,讓不同的導覽來源可以從同一套機制處理。

Navigation API 也可以取得目前的 History Entry:

ts
navigation.currentEntry;

或取得目前這個分頁中可存取的同源 History Entries:

ts
navigation.entries();

每一筆都是 NavigationHistoryEntry,包含 URL、索引、識別資訊與 State。

Navigation 的成功與失敗也有對應事件:

ts
navigation.addEventListener('navigatesuccess', () => {
  console.log('Navigation completed');
});

navigation.addEventListener('navigateerror', () => {
  console.log('Navigation failed');
});

使用 navigate() 時,還可以取得這次導覽的兩個階段:

ts
const result = navigation.navigate('/users');

await result.committed;
await result.finished;
  • committed:瀏覽器已經切換到新的 History Entry。
  • finished:這次 Navigation 相關的處理已經完成。

Navigation API 也包含換頁後的 Scroll 與 Focus 行為。如果 Application 想自行控制,可以在 intercept() 中設定:

ts
navigation.addEventListener('navigate', event => {
  event.intercept({
    scroll: 'manual',
    focusReset: 'manual',

    async handler() {
      await renderPage();

      window.scrollTo({
        top: 0
      });

      document.querySelector('h1')?.focus();
    }
  });
});
  • scroll: 'manual':捲動位置改由 Application 自己處理。
  • focusReset: 'manual':不讓 Browser 自動重設焦點。

這些屬於 Browser Navigation 的處理範圍;Angular Application 要顯示哪個畫面,以及 Route 是否能成功啟用,仍然是 Angular Router 的工作。

Angular Router 開始使用 Browser Navigation API

Navigation API 並不會取代 Angular Router。

Browser 可以處理 URL、History Entry 與 Navigation Lifecycle,但它不知道 Angular 的 Route 設定代表什麼:

ts
{
  path: 'users/:id',
  component: UserDetailComponent
}

Route Matching、Component Activation,以及 Angular Application 內部的導覽規則,仍然由 Router 處理。

Angular 提供:

ts
import {
  provideRouter,
  withExperimentalPlatformNavigation
} from '@angular/router';

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(
      routes,
      withExperimentalPlatformNavigation()
    )
  ]
});

withExperimentalPlatformNavigation() 讓 Angular Router 可以嘗試使用 Browser Navigation API。

這個功能從 Angular 21.1 開始提供,目前在 Angular 22 仍屬於實驗功能,比較適合用來了解 Router 未來可能採用的底層 Navigation 模型,而不是直接導入正式環境。

在連結寫法上也會有所不同:

html
<!-- 傳統 Angular Router -->
<a routerLink="/users">使用者</a>

<!-- Platform Navigation -->
<a href="/users">使用者</a>

差別在於 Navigation 從哪裡進入 Router。routerLink 直接由 Angular 發起導覽;Platform Navigation 則先由 Browser 辨識這次 Navigation,再交給 Angular Router 處理 Application Routing。

Platform Navigation 與 Router Scroll 的關係

Angular Router 本來就有自己的 Scroll Restoration 機制,withInMemoryScrolling() 並不是 Navigation API 出現後才新增的功能。

例如:

ts
import {
  provideRouter,
  withExperimentalPlatformNavigation,
  withInMemoryScrolling
} from '@angular/router';

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(
      routes,
      withExperimentalPlatformNavigation(),
      withInMemoryScrolling({
        scrollPositionRestoration: 'enabled',
        anchorScrolling: 'enabled'
      })
    )
  ]
});

兩個 Feature 處理的是不同層次:

  • withExperimentalPlatformNavigation():改變 Angular Router 與 Browser Navigation 的整合方式。
  • withInMemoryScrolling():負責 Angular Router 本身的 Scroll Restoration。

scrollPositionRestoration: 'enabled' 會在一般新的 Navigation 後回到頁面頂端,Back / Forward 則恢復先前記錄的捲動位置。

anchorScrolling: 'enabled' 則處理 URL Fragment,例如:

text
/users#profile

頁面中有:

html
<section id="profile">
  ...
</section>

Router 可以在導覽後捲到對應的位置。

這裡處理的是整個頁面的 Viewport Scroll。如果畫面裡還有自己的 Scroll Container,例如設定 overflow: auto 的 Table 區塊,裡面的 scrollTop 不會由 withInMemoryScrolling() 自動保存。

在使用 Platform Navigation 時,單次 Navigation 還可以透過 NavigationBehaviorOptions 控制 Scroll:

ts
router.navigate(['/users'], {
  scroll: 'manual'
});

scroll 主要有兩種設定:

  • after-transition:Navigation 完成後,按照 Router 的 Scroll Restoration 設定處理。
  • manual:這次 Navigation 不讓 Router 自動處理 Scroll。

因此 withInMemoryScrolling() 定義 Router 平常怎麼處理 Scroll,而 NavigationBehaviorOptions.scroll 則控制這一次 Navigation 是否交給 Router 自動處理。

例如:

ts
router.navigate(['/users'], {
  scroll: 'manual'
});

這次導覽就不會套用 Router 的 Scroll Restoration,後續的捲動位置改由 Application 自己處理。

Angular Router Scroll Restoration - StackBlitz

本日結語

window.location 會讓瀏覽器真正前往新的頁面,而 History API 讓 SPA 可以在不重新載入 Document 的情況下修改 URL,再由 Angular Router 更新對應畫面。

History API 主要處理瀏覽紀錄,完整的 Navigation 狀態仍需要 Router 自己管理。Navigation API 則把 Navigation 帶進 Browser 的原生模型中,讓導覽可以被監聽、攔截,也能取得完成與失敗狀態。

Angular Router 仍然負責 Application 內部的 Routing,而 Browser Navigation、History Entry、Scroll 與 Focus 等行為,則可以和 Web Platform 有更直接的整合。

withExperimentalPlatformNavigation() 目前仍屬於實驗功能,不適合直接當成正式環境的預設方案,但它提供了一個方向:Angular Router 不需要自己包辦所有 Browser Navigation 細節,而是能逐步建立在瀏覽器原生的 Navigation 能力上。

資料來源

留言

留言使用 GitHub 帳號登入(Giscus),留言會存在 GitHub Discussions。