Angular Material Paginator 與 URL 參數同步
當我們需要將 Angular Material 的分頁器 (Paginator) 與 URL 參數同步時,遇到一些挑戰,特別是當我們想要透過 URL 直接導向特定頁碼的時候。
問題情境
我們希望:
使用者可以透過 URL 參數直接訪問特定頁碼 (例如:
/list?page=3
)分頁器的頁碼需要與 URL 參數同步
當使用者點擊分頁器的頁碼按鈕時,URL 也要跟著更新
遇到的難題
單純設置 paginator.pageIndex
屬性並不會使分頁器跳轉到對應的頁面:
// 這樣做不會生效
this.paginator.pageIndex = pageNumber;
即使是使用 page.next()
方法也不一定有效:
// 這樣做可能不會生效
this.paginator.page.next({
pageIndex: pageNumber,
pageSize: this.paginator.pageSize,
length: this.paginator.length
});
解決方案
有兩種有效的解決方案:使用內部方法或使用標準 API。
方法一:使用內部方法
這種方法使用 Angular Material 的內部方法來強制更新分頁器:
goToPage() {
// 使用 setTimeout 避免 ExpressionChangedAfterItHasBeenCheckedError
setTimeout(() => {
// 使用 as any 繞過 TypeScript 的訪問限制
(this.paginator as any)._pageIndex = this.currentPageIndex();
(this.paginator as any)._changePageSize(this.paginator.pageSize);
});
}
關鍵要點
使用
setTimeout
:避免 Angular 的ExpressionChangedAfterItHasBeenCheckedError
錯誤。使用
as any
類型斷言:繞過 TypeScript 的私有屬性存取限制。直接設置
_pageIndex
:修改分頁器的內部頁碼屬性。調用
_changePageSize
:即使不改變頁面大小,也能觸發分頁器的完整更新。
方法二:使用標準 API
這種方法使用公開的標準 API,避免了訪問內部屬性和方法:
goToPage() {
// 使用 setTimeout 避免 ExpressionChangedAfterItHasBeenCheckedError
setTimeout(() => {
// 設置頁碼
this.paginator.pageIndex = this.currentPageIndex();
// 手動觸發頁面事件
this.paginator.page.emit({
length: this.paginator.length,
pageIndex: this.currentPageIndex(),
pageSize: this.paginator.pageSize,
previousPageIndex: 0 // 可以設為 0 或其他合適的值
});
});
}
關鍵要點
直接設置
pageIndex
:使用公開的屬性修改頁碼。手動發射
page
事件:觸發 Angular Material 的分頁更新機制。提供完整的分頁資訊:包括總長度、當前頁碼、每頁大小和前一頁頁碼。
更穩定的方法:由於使用了公開 API,此方法在 Angular Material 更新時更不易受影響。
完整的 Component 範例
使用方法一(內部方法)
import {
Component,
inject,
OnInit,
ViewChild,
DestroyRef,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { MatTableDataSource } from '@angular/material/table';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
// Component 配置
})
export class ListComponent implements OnInit {
private router = inject(Router);
private route = inject(ActivatedRoute);
private destroyRef = inject(DestroyRef);
dataSource = new MatTableDataSource<any>([]);
currentPageIndex = signal(0);
@ViewChild(MatPaginator) paginator!: MatPaginator;
ngOnInit(): void {
this.route.queryParams
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((param) => {
// 從 URL 參數獲取頁碼
this.currentPageIndex.set(param['page'] ? param['page'] - 1 : 0);
// 載入資料
this.loadData();
});
}
ngAfterViewInit() {
this.dataSource.paginator = this.paginator;
// 設置初始頁碼
this.goToPage();
}
onChangePage(event: PageEvent) {
// 更新 URL 參數
this.router.navigate([], {
relativeTo: this.route,
queryParams: { page: event.pageIndex + 1 },
queryParamsHandling: 'merge',
});
}
goToPage() {
// 使用 setTimeout 避免 ExpressionChangedAfterItHasBeenCheckedError
setTimeout(() => {
// 使用 as any 繞過 TypeScript 的訪問限制
(this.paginator as any)._pageIndex = this.currentPageIndex();
(this.paginator as any)._changePageSize(this.paginator.pageSize);
});
}
loadData() {
// 載入資料的邏輯
// ...
// 資料載入後也可以更新分頁器
if (this.paginator) {
this.goToPage();
}
}
}
使用方法二(標準 API)
import {
Component,
inject,
OnInit,
ViewChild,
DestroyRef,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { MatTableDataSource } from '@angular/material/table';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
// Component 配置
})
export class ListComponent implements OnInit {
private router = inject(Router);
private route = inject(ActivatedRoute);
private destroyRef = inject(DestroyRef);
dataSource = new MatTableDataSource<any>([]);
currentPageIndex = signal(0);
previousPageIndex = 0;
@ViewChild(MatPaginator) paginator!: MatPaginator;
ngOnInit(): void {
this.route.queryParams
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((param) => {
// 記錄前一個頁碼
this.previousPageIndex = this.currentPageIndex();
// 從 URL 參數獲取頁碼
this.currentPageIndex.set(param['page'] ? param['page'] - 1 : 0);
// 載入資料
this.loadData();
});
}
ngAfterViewInit() {
this.dataSource.paginator = this.paginator;
// 設置初始頁碼
this.goToPage();
}
onChangePage(event: PageEvent) {
// 記錄前一個頁碼(用於下次設置頁碼時使用)
this.previousPageIndex = event.previousPageIndex ?? 0;
// 更新 URL 參數
this.router.navigate([], {
relativeTo: this.route,
queryParams: { page: event.pageIndex + 1 },
queryParamsHandling: 'merge',
});
}
goToPage() {
// 使用 setTimeout 避免 ExpressionChangedAfterItHasBeenCheckedError
setTimeout(() => {
// 設置頁碼
this.paginator.pageIndex = this.currentPageIndex();
// 手動觸發頁面事件
this.paginator.page.emit({
length: this.paginator.length,
pageIndex: this.currentPageIndex(),
pageSize: this.paginator.pageSize,
previousPageIndex: this.previousPageIndex
});
});
}
loadData() {
// 載入資料的邏輯
// ...
// 資料載入後也可以更新分頁器
if (this.paginator) {
this.goToPage();
}
}
}
解決方案的工作原理說明
為什麼需要 _changePageSize
方法?
_changePageSize
方法?在方法一中,_changePageSize
方法是關鍵。即使我們傳入與當前相同的頁面大小,這個方法也會:
觸發分頁器的完整重新計算
更新 UI 顯示
重新分頁資料源
確保正確的資料項顯示在頁面上
這是一種利用內部方法的副作用來強制更新 UI 的技巧。
為什麼手動發射 page
事件有效?
page
事件有效?在方法二中,手動發射 page
事件是關鍵。當我們發射這個事件時:
Angular Material 的表格資料源會訂閱到這個事件
資料源會根據新的分頁資訊重新計算顯示的數據
分頁器的 UI 會更新以反映當前頁碼
整個分頁機制得到了正確的觸發
這種方法直接使用了 Angular Material 設計的事件系統,更加符合框架的設計理念。
比較兩種方法
方法一(內部方法)
優點:
實現簡單,只需少量程式碼
效果可靠,能有效強制更新分頁
缺點:
使用非公開 API,將來可能因 Angular Material 更新而失效
需要使用 TypeScript 的類型斷言來繞過私有屬性的訪問限制
方法二(標準 API)
優點:
使用公開、標準的 API
更符合 Angular Material 的設計模式
更好的長期穩定性
缺點:
需要追蹤前一頁的頁碼(previousPageIndex)
可能需要更多配置
注意事項
使用內部方法的風險:以
_
開頭的方法和屬性通常意味著它們是內部實現,將來的版本可能會改變。如果 Angular Material 更新後這些方法發生變化,代碼可能需要調整。ExpressionChangedAfterItHasBeenCheckedError:這是 Angular 中的一個常見錯誤,當在變更檢測週期後修改值時會發生。使用
setTimeout
可以避免這個錯誤。檢查頁碼有效性:建議檢查從 URL 獲取的頁碼是否有效(不超出資料範圍),避免顯示空白頁面。
previousPageIndex 處理:在方法二中,需要正確管理 previousPageIndex,以確保分頁事件觸發時有正確的前一頁資訊。
結論
透過這兩種方法,我們可以使 Angular Material Paginator 與 URL 參數保持同步,實現基於 URL 的頁面導航。
如果你需要快速實現且不擔心將來的版本更新,可以選擇方法一(內部方法)。
如果你的專案需要長期維護,或者希望使用更標準的解決方案,建議使用方法二(標準 API)。
這兩種方法各有優缺點,可以根據專案需求選擇合適的實現方式。
Last updated