iT邦幫忙

2025 iThome 鐵人賽

DAY 15
0
Modern Web

Angular 進階實務 30天系列 第 15

Day 15:用 Angular RouteReuseStrategy 打造可切換分頁的功能

  • 分享至 

  • xImage
  •  

— 以「右側選單點擊 → 左側生成 Tabs」為例

前言

桌面應用程式翻新成網頁的專案中,我們常會遇到這種需求:使用者點擊右側的功能選單,左側也要開啟一個分頁(tab),並且可以自由切換分頁。重點是,切換的時候資料不能消失,否則使用者輸入的表單內容、查詢的結果,都會被重新載入,導致他不能檢索需要的資料。

一開始我們可能會想到用 狀態管理 (NgRx, Signals, service store) 去保存資料,但這通常會增加開發複雜度。事實上,Angular 提供的 RouteReuseStrategy 就能解決「頁面切換資料保留」的問題。只要搭配一個 Tab 管理服務,我們就可以很優雅地完成「多分頁切換不丟資料」的需求。

這篇文章我會從需求出發,介紹 Angular 的 RouteReuseStrategy,並示範如何用它來實作分頁切換,並補充一些常見的踩雷點。


需求情境

假設我們有一個後台系統,畫面分成三個部分:

  • 右側功能選單:列出可以操作的功能,例如「訂單」、「庫存管理」、「歷史訂單」、「 會員管理」等等。
  • 左側 Tabs:使用者點選右側選單時,會在左側新增一個分頁。這些分頁可以關閉、切換。
  • 中間內容區域:顯示當前分頁對應的內容。

期望的效果是:

  1. 同一個功能點擊多次,不會開多個分頁,而是切回既有的分頁。
  2. 不同參數的功能,可以開不同分頁(例如 /day15/order/day15/stock 應該是不同分頁)。
  3. 切換分頁時,保留表單輸入與查詢結果,不要重新載入。
  4. 會員管理在切換的時候不用保存原有狀態。

網頁展示:Day15


Angular RouteReuseStrategy 的角色

Angular Router 預設在切換路由時,會 銷毀目前元件 → 建立新元件。這就是為什麼頁面資料會不見。

RouteReuseStrategy 是一個可以自訂的策略,告訴 Angular 什麼時候要保存元件快照 (DetachedRouteHandle),什麼時候要取回

流程大致如下:

  1. shouldDetach:判斷是否要暫存當前路由。
  2. store:真的把快照存到快取裡。
  3. shouldAttach:判斷是否要從快取取回快照。
  4. retrieve:取回已存的快照。
  5. shouldReuseRoute:決定是否重用同一路由定義。

透過這幾個方法,我們就能精準控制哪些頁面要重用,哪些不要。

⚠️ 注意:在 Angular 17 以 standalone 為主的架構裡,必須在 app.config.ts 透過 providers 註冊 RouteReuseStrategy,否則不會生效。


基礎程式碼實作

以下是一個自訂的 RouteReuseStrategy,專門處理 /day15 底下的頁面,但排除 /day15/member

@Injectable({ providedIn: 'root' })
export class CustomReuseStrategy implements RouteReuseStrategy {
  private storedHandles = new Map<string, DetachedRouteHandle>();

  shouldDetach(route: ActivatedRouteSnapshot): boolean {
    // 只處理可快取的「葉節點」
    if (!this.isCacheableLeaf(route)) return false;

    const url = this.buildFullUrl(route);
    // 只快取 /day15 底下(你也能再加更嚴格的子路由判斷)
    return url.startsWith('/pages/day15') && url !== '/pages/day15/member';
  }

  store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
    if (!this.isCacheableLeaf(route)) return;
    const key = this.buildFullUrl(route);
    // 覆蓋舊的 handle(避免殘留)
    this.storedHandles.set(key, handle); // 存快照到快取
  }

  shouldAttach(route: ActivatedRouteSnapshot): boolean {
    if (!this.isCacheableLeaf(route)) return false;
    const key = this.buildFullUrl(route);
    return this.storedHandles.has(key);
  }

  retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
    // Angular 要求:沒有 routeConfig 時不可取回
    if (!route.routeConfig) return null;
    if (!this.isCacheableLeaf(route)) return null;
    const key = this.buildFullUrl(route);
    return this.storedHandles.get(key) ?? null; // 存快照到快取
  }

  shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
    // 先遵守預設邏輯,避免父節點錯誤共用
    const sameConfig = future.routeConfig === curr.routeConfig;
    if (!sameConfig) return false;

    // 為了避免 /day15/order 與 /day15/stock 共用同一個 component 實例,再用完整 URL 做一道保險
    return this.buildFullUrl(future) === this.buildFullUrl(curr);
  }
  
  // 刪除快照
  clearHandle(key: string) {
    this.storedHandles.delete(key);
  }

  /** 僅允許快取:有元件、主要 outlet、非空 path、且為葉節點 */
  private isCacheableLeaf(route: ActivatedRouteSnapshot): boolean {
    // 有 routeConfig 才可能被快取
    const cfg = route.routeConfig;
    if (!cfg) return false;

    // 不能是 redirect
    if (cfg.redirectTo) return false;

    // 必須有 component(或使用 loadComponent 動態載入)
    const hasComponent = !!(cfg.component || cfg.loadComponent);
    if (!hasComponent) return false;

    // 僅 primary outlet
    if (route.outlet !== 'primary') return false;

    // 不能是空 path 的殼(空字串容易是父節點容器)
    if (!cfg.path) return false;

    // 僅葉節點(避免把父節點整棵樹快取)
    const isLeaf = !route.firstChild;
    return isLeaf;
  }

  private buildFullUrl(route: ActivatedRouteSnapshot): string {
    // 把從 root 到此節點的每段 url segment 串起來
    const segments: string[] = [];
    for (const r of route.pathFromRoot) {
      const seg = r.url.map(s => s.path).filter(Boolean).join('/');
      if (seg) segments.push(seg);
    }
    return '/' + segments.join('/');
  }
}

這樣一來,只要是 /day15 底下的頁面,都會被建立快照並存進快取。切換回來時,就會從快取取回快照,不會重新初始化。


加上白名單控制

白名單指的是:允許不用重整、可以從快取取回快照的路由清單。
有時候我們希望動態決定哪些頁面可以被重用,例如某個頁面在使用者登出後,就應該清掉快取。這時候就需要一個 RouteReuseService 來管理白名單。

@Injectable({ providedIn: 'root' })
export class RouteReuseService {
  private reuseRoutes = new Set<string>();
  readonly routeReuseChanged = new Subject<void>();
  constructor(private strategy: CustomReuseStrategy) { }
  addRouteToReuse(key: string) {
    this.reuseRoutes.add(key);
    this.routeReuseChanged.next();
  }

  removeRouteToReuse(key: string) {
    this.reuseRoutes.delete(key);
    this.strategy.clearHandle(key); // 這裡同時刪除快照

    this.routeReuseChanged.next();
  }

  shouldReuseRoute(key: string): boolean {
    return this.reuseRoutes.has(key);
  }
}


Tab 管理的加入

解決了「切換不丟資料」,接著就是「怎麼把這些頁面顯示成分頁」。

我們可以新增一個 TabService,管理開啟的分頁清單:
這裡的Constructor裡面的內容,是用來處理點擊後加入tab頁籤的部分。

export interface TabItem {
  key: string;
  title: string;
  url: string;
  closable: boolean;
}

@Injectable({ providedIn: 'root' })
export class TabService {

  tabs$ = new BehaviorSubject<TabItem[]>([]);
  readonly tabs = this.tabs$.asObservable();

  constructor(private router: Router, private reuse: RouteReuseService) {
    // 冷啟動:如果當前 URL 應該在 tab 上,就先種一顆,避免重新整理沒有開啟分頁
    this.seedTabOnColdStart();

    this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => {
      const url = this.router.url;
      const key = url;
      if (!this.tabs$.value.find(t => t.key === key) && this.isTabbable(url)) {
        this.openTab({ key, title: this.buildTitleFromUrl(url), url, closable: true });
        this.reuse.addRouteToReuse(key);
      }
    });
  }

  openTab(tab: TabItem) {
    this.tabs$.next([...this.tabs$.value, tab]);
  }

  closeTab(key: string) {
    this.tabs$.next(this.tabs$.value.filter(t => t.key !== key));
    this.reuse.removeRouteToReuse(key);
  }

  // ------------ 冷啟動種子 ------------
  private seedTabOnColdStart() {
    const url = this.router.url;

    // 若目前沒有任何分頁,且當前 URL 應該要在 tab 上,就直接種一顆
    if (this.tabs$.value.length === 0 && this.isTabbable(url)) {
      const key = url;
      this.openTab({ key, title: this.buildTitleFromUrl(url), url, closable: true });
      this.reuse.addRouteToReuse(key);
    }
  }

  // ------------ 規則:哪些路由該進 tab ------------
  // 依你文章的例子:/day15/** 但排除 /day15/member
  private isTabbable(url: string): boolean {
    if (!url) return false;
    if (url.includes('/pages/day15/member')) return false;
    return url.includes('/pages/day15');
  }

  // 這裡你要怎麼產生標題可自由調整
  private buildTitleFromUrl(url: string): string {
    // 簡單取最後一段當標題:/day15/order → order
    const seg = url.split('?')[0].split('#')[0].split('/').filter(Boolean);
    return seg[seg.length - 1] || url;
  }
}


小提醒:關閉當前分頁時,建議自動導向鄰近的分頁或首頁,否則使用者可能會卡在「已不存在的分頁」。


Tabs Component

最後我們需要一個元件,實際把分頁渲染出來:

@Component({
  selector: 'app-day15',
  standalone: true,
  imports: [RouterModule, NgFor, NzTabsModule, AsyncPipe],
  template: '
  <p>day15 重用路由範例</p>
<nz-tabset [nzSelectedIndex]="(selectedIndex$ | async) ?? 0" (nzSelectedIndexChange)="onIndexChange($event)" nzType="editable-card" (nzClose)="close($event.index)" [nzSize]="'small'"
  [nzTabBarGutter]="4">
  <nz-tab *ngFor="let t of tabs; let i = index" [nzTitle]="t.title" [nzClosable]="t.closable && i>0">
    <router-outlet></router-outlet>
  </nz-tab>
</nz-tabset>

  ',
  styleUrl: './day15.component.scss'
})
export class Day15Component {
  private readonly router = inject(Router);
  private readonly ts = inject(TabService);

  tabs: TabItem[] = [];
  selectedIndex$ = combineLatest([
    this.ts.tabs, // Observable<TabItem[]>
    this.router.events.pipe(
      filter(e => e instanceof NavigationEnd),
      startWith(null),           // 冷啟動跑一次
      map(() => this.router.url) // 目前 URL
    )
  ]).pipe(
    map(([tabs, url]) => Math.max(0, tabs.findIndex(t => t.url === url)))
  );


  private sub?: Subscription;

  ngOnInit(): void {
    this.sub = this.ts.tabs.subscribe(list => {
      this.tabs = list;
    });
  }

  onIndexChange(i: number) {
    const target = this.tabs[i];
    if (target) this.router.navigateByUrl(target.url);
  }

  close(index: number) {
    this.ts.closeTab(this.tabs[index].url);
  }

  ngOnDestroy(): void {
    this.sub?.unsubscribe();
  }
}


註冊

// app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    { provide: RouteReuseStrategy, useClass: CustomReuseStrategy },
  ],
};

使用情境展示

  • 點擊右側「訂單」→ 左側新增分頁 /day15/order
  • 再點擊「庫存管理」→ 左側新增分頁 /day15/stock
  • 切換到「訂單」→ 顯示上次輸入的表單,不會重置
  • 關閉「訂單」分頁 → 從快取中刪除快照,再打開會重新載入

這樣一來,整個體驗就和常見的多分頁後台系統一樣流暢。


結語

這套做法將「不丟狀態」與「多分頁操作」一次到位:

  • RouteReuseStrategy 讓頁面切換時不重建、狀態不消失;
  • 白名單 控制可動態清理快取;
  • TabService + ng-zorro Tabs,把路由實體化為可關閉的分頁,貼近使用者心智模型。就能打造出貼近桌面應用程式的操作體驗,同時解決「切換頁面資料保留」與「多分頁操作」的需求。

對於後台管理系統,這種設計可以符合需求。它不僅解決了「切換頁面資料保留」的問題,還能提供更直覺、更貼近桌面應用程式的多分頁體驗。


用詞規則

我寫到後來也有點頭昏,附上整理

快照 (Snapshot)
指 Angular 在 store() 儲存的 單一元件狀態物件(DetachedRouteHandle)。
➡️ 屬於技術名詞,貼近官方 API。

快取 (Cache)
指保存「一組或多個快照」的儲存機制/集合(例如 Map)。
➡️ 屬於開發者用語,描述管理快照的行為或結果。

換句話說:

「快照」= 單一實例的保存

「快取」= 保存快照的池子 / 管理方式


上一篇
Day 14:Angular 路由轉場動畫
下一篇
Day 16:狀態管理 - 資料儲存(Data Storage) → 資料在哪裡?
系列文
Angular 進階實務 30天18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言