Angular Material Paginator 與 URL 參數同步

當我們需要將 Angular Material 的分頁器 (Paginator) 與 URL 參數同步時,遇到一些挑戰,特別是當我們想要透過 URL 直接導向特定頁碼的時候。

問題情境

我們希望:

  1. 使用者可以透過 URL 參數直接訪問特定頁碼 (例如: /list?page=3)

  2. 分頁器的頁碼需要與 URL 參數同步

  3. 當使用者點擊分頁器的頁碼按鈕時,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);
  });
}

關鍵要點

  1. 使用 setTimeout:避免 Angular 的 ExpressionChangedAfterItHasBeenCheckedError 錯誤。

  2. 使用 as any 類型斷言:繞過 TypeScript 的私有屬性存取限制。

  3. 直接設置 _pageIndex:修改分頁器的內部頁碼屬性。

  4. 調用 _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 或其他合適的值
    });
  });
}

關鍵要點

  1. 直接設置 pageIndex:使用公開的屬性修改頁碼。

  2. 手動發射 page 事件:觸發 Angular Material 的分頁更新機制。

  3. 提供完整的分頁資訊:包括總長度、當前頁碼、每頁大小和前一頁頁碼。

  4. 更穩定的方法:由於使用了公開 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 方法是關鍵。即使我們傳入與當前相同的頁面大小,這個方法也會:

  1. 觸發分頁器的完整重新計算

  2. 更新 UI 顯示

  3. 重新分頁資料源

  4. 確保正確的資料項顯示在頁面上

這是一種利用內部方法的副作用來強制更新 UI 的技巧。

為什麼手動發射 page 事件有效?

在方法二中,手動發射 page 事件是關鍵。當我們發射這個事件時:

  1. Angular Material 的表格資料源會訂閱到這個事件

  2. 資料源會根據新的分頁資訊重新計算顯示的數據

  3. 分頁器的 UI 會更新以反映當前頁碼

  4. 整個分頁機制得到了正確的觸發

這種方法直接使用了 Angular Material 設計的事件系統,更加符合框架的設計理念。

比較兩種方法

方法一(內部方法)

優點:

  • 實現簡單,只需少量程式碼

  • 效果可靠,能有效強制更新分頁

缺點:

  • 使用非公開 API,將來可能因 Angular Material 更新而失效

  • 需要使用 TypeScript 的類型斷言來繞過私有屬性的訪問限制

方法二(標準 API)

優點:

  • 使用公開、標準的 API

  • 更符合 Angular Material 的設計模式

  • 更好的長期穩定性

缺點:

  • 需要追蹤前一頁的頁碼(previousPageIndex)

  • 可能需要更多配置

注意事項

  1. 使用內部方法的風險:以 _ 開頭的方法和屬性通常意味著它們是內部實現,將來的版本可能會改變。如果 Angular Material 更新後這些方法發生變化,代碼可能需要調整。

  2. ExpressionChangedAfterItHasBeenCheckedError:這是 Angular 中的一個常見錯誤,當在變更檢測週期後修改值時會發生。使用 setTimeout 可以避免這個錯誤。

  3. 檢查頁碼有效性:建議檢查從 URL 獲取的頁碼是否有效(不超出資料範圍),避免顯示空白頁面。

  4. previousPageIndex 處理:在方法二中,需要正確管理 previousPageIndex,以確保分頁事件觸發時有正確的前一頁資訊。

結論

透過這兩種方法,我們可以使 Angular Material Paginator 與 URL 參數保持同步,實現基於 URL 的頁面導航。

  • 如果你需要快速實現且不擔心將來的版本更新,可以選擇方法一(內部方法)。

  • 如果你的專案需要長期維護,或者希望使用更標準的解決方案,建議使用方法二(標準 API)。

這兩種方法各有優缺點,可以根據專案需求選擇合適的實現方式。

Last updated