— 以「右側選單點擊 → 左側生成 Tabs」為例
在桌面應用程式翻新成網頁的專案中,我們常會遇到這種需求:使用者點擊右側的功能選單,左側也要開啟一個分頁(tab),並且可以自由切換分頁。重點是,切換的時候資料不能消失,否則使用者輸入的表單內容、查詢的結果,都會被重新載入,導致他不能檢索需要的資料。
一開始我們可能會想到用 狀態管理 (NgRx, Signals, service store) 去保存資料,但這通常會增加開發複雜度。事實上,Angular 提供的 RouteReuseStrategy 就能解決「頁面切換資料保留」的問題。只要搭配一個 Tab 管理服務,我們就可以很優雅地完成「多分頁切換不丟資料」的需求。
這篇文章我會從需求出發,介紹 Angular 的 RouteReuseStrategy,並示範如何用它來實作分頁切換,並補充一些常見的踩雷點。
假設我們有一個後台系統,畫面分成三個部分:
期望的效果是:
/day15/order
與 /day15/stock
應該是不同分頁)。網頁展示:Day15
Angular Router 預設在切換路由時,會 銷毀目前元件 → 建立新元件。這就是為什麼頁面資料會不見。
RouteReuseStrategy 是一個可以自訂的策略,告訴 Angular 什麼時候要保存元件快照 (DetachedRouteHandle),什麼時候要取回。
流程大致如下:
shouldDetach
:判斷是否要暫存當前路由。store
:真的把快照存到快取裡。shouldAttach
:判斷是否要從快取取回快照。retrieve
:取回已存的快照。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);
}
}
解決了「切換不丟資料」,接著就是「怎麼把這些頁面顯示成分頁」。
我們可以新增一個 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;
}
}
小提醒:關閉當前分頁時,建議自動導向鄰近的分頁或首頁,否則使用者可能會卡在「已不存在的分頁」。
最後我們需要一個元件,實際把分頁渲染出來:
@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
這樣一來,整個體驗就和常見的多分頁後台系統一樣流暢。
這套做法將「不丟狀態」與「多分頁操作」一次到位:
對於後台管理系統,這種設計可以符合需求。它不僅解決了「切換頁面資料保留」的問題,還能提供更直覺、更貼近桌面應用程式的多分頁體驗。
我寫到後來也有點頭昏,附上整理
快照 (Snapshot)
指 Angular 在 store() 儲存的 單一元件狀態物件(DetachedRouteHandle)。
➡️ 屬於技術名詞,貼近官方 API。
快取 (Cache)
指保存「一組或多個快照」的儲存機制/集合(例如 Map)。
➡️ 屬於開發者用語,描述管理快照的行為或結果。
換句話說:
「快照」= 單一實例的保存
「快取」= 保存快照的池子 / 管理方式