NgRx 狀態管理基礎

在 Angular 專案中,隨著應用程式複雜度增加,狀態管理變得越來越重要。NgRx 提供了一套完整的狀態管理解決方案,基於 Redux 模式設計,特別適合中大型專案。這篇筆記整理了 NgRx 的核心概念和使用方式。

核心概念

NgRx 建立在幾個關鍵概念上,組成一個完整的單向資料流:

Store

Store 是整個應用程式的中央狀態容器,就像是一個保險箱,存放應用程式中所有的狀態資料。

// 注入 Store
private readonly cartStore: Store<{ cart: CartState }> = inject(Store);

Store 是一個泛型類別,泛型參數定義了它管理的狀態結構。在上面的例子中,它管理一個包含 cart 屬性的狀態,該屬性的類型是 CartState

State

State 就是應用程式的資料結構,通常用介面定義。

// 定義狀態介面
export interface CartState {
  items: CartItem[];
  totalAmount: number;
  discount: number;
  isCheckoutEnabled: boolean;
}

// 定義初始狀態
export const initialState: CartState = {
  items: [],
  totalAmount: 0,
  discount: 0,
  isCheckoutEnabled: false
};

Actions

Actions 是狀態變更的指令,描述「發生了什麼事」。它們是普通的 JavaScript 物件,包含一個 type 屬性和可選的 payload。

// 定義動作類型
export const addItem = createAction(
  '[Cart] Add Item',
  props<{ item: CartItem }>()
);

export const removeItem = createAction(
  '[Cart] Remove Item',
  props<{ productId: string }>()
);

export const clearCart = createAction('[Cart] Clear Cart');

// 使用動作
this.cartStore.dispatch(CartActions.addItem({ item: newItem }));
this.cartStore.dispatch(CartActions.removeItem({ productId: 'product-123' }));

Reducers

Reducers 是純函數,負責根據 Actions 計算新的狀態。它們接收當前狀態和一個動作,然後返回新的狀態。

// 定義 reducer
export const cartReducer = createReducer(
  initialState,

  // 加入商品
  on(CartActions.addItem, (state, { item }) => {
    // 檢查是否已有相同商品
    const existingItemIndex = state.items.findIndex(i => i.productId === item.productId);
    
    let newItems: CartItem[];
    if (existingItemIndex >= 0) {
      // 若已有相同商品,更新數量
      newItems = [...state.items];
      newItems[existingItemIndex] = {
        ...newItems[existingItemIndex],
        quantity: newItems[existingItemIndex].quantity + item.quantity
      };
    } else {
      // 若沒有相同商品,直接加入
      newItems = [...state.items, item];
    }
    
    // 計算新總金額
    const newTotalAmount = calculateTotalAmount(newItems, state.discount);
    
    return {
      ...state,
      items: newItems,
      totalAmount: newTotalAmount,
      isCheckoutEnabled: newItems.length > 0
    };
  }),

  // 移除商品
  on(CartActions.removeItem, (state, { productId }) => {
    const newItems = state.items.filter(item => item.productId !== productId);
    const newTotalAmount = calculateTotalAmount(newItems, state.discount);
    
    return {
      ...state,
      items: newItems,
      totalAmount: newTotalAmount,
      isCheckoutEnabled: newItems.length > 0
    };
  }),

  // 清空購物車
  on(CartActions.clearCart, () => ({
    ...initialState
  }))
);

// 計算總金額的輔助函數
function calculateTotalAmount(items: CartItem[], discount: number): number {
  const subtotal = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
  return subtotal * (1 - discount/100);
}

Selectors

Selectors 是純函數,用於從 Store 中提取特定的狀態片段。它們可以被記憶化,提高性能。

// 特徵選擇器 - 找到整個「購物車狀態」區塊
export const selectCartState = createFeatureSelector<CartState>('cart');

// 取得購物車項目
export const selectCartItems = createSelector(
  selectCartState,
  (state: CartState) => state.items
);

// 取得購物車總金額
export const selectTotalAmount = createSelector(
  selectCartState,
  (state: CartState) => state.totalAmount
);

// 取得購物車項目數量
export const selectItemCount = createSelector(
  selectCartItems,
  (items: CartItem[]) => items.reduce((count, item) => count + item.quantity, 0)
);

// 取得結帳按鈕啟用狀態
export const selectCheckoutEnabled = createSelector(
  selectCartState,
  (state: CartState) => state.isCheckoutEnabled
);

完整資料流程

NgRx 的資料流向是單向的,形成一個清晰的循環:

  1. 元件發出動作:使用 dispatch 方法發送動作

    addToCart(product: Product, quantity: number = 1) {
      const cartItem: CartItem = {
        productId: product.id,
        name: product.name,
        price: product.price,
        quantity: quantity,
        imageUrl: product.imageUrl
      };
      this.cartStore.dispatch(CartActions.addItem({ item: cartItem }));
    }
  2. Reducer 處理動作:根據動作類型更新狀態

    on(CartActions.addItem, (state, { item }) => {
      // 計算新狀態...
      return {
        ...state,
        items: newItems,
        totalAmount: newTotalAmount
      };
    })
  3. 選擇器提取狀態:元件使用選擇器訂閱狀態變化

    // 使用 select 方法和選擇器獲取狀態
    this.cartStore.select(selectCartItems).subscribe(items => {
      this.cartItems = items;
      this.updateCartView();
    });
    
    // 或使用 Angular Signals (Angular 16+)
    cartItems = toSignal(this.cartStore.select(selectCartItems));
    totalAmount = toSignal(this.cartStore.select(selectTotalAmount));
    itemCount = toSignal(this.cartStore.select(selectItemCount));
  4. 元件響應變化:當狀態變化時,所有訂閱的元件自動更新

實際使用範例

購物車功能實作

以下是實作購物車功能的範例,展示了如何使用 NgRx 管理購物車狀態:

@Component({
  selector: 'app-product-detail',
  template: `
    <div class="product-card">
      <img [src]="product.imageUrl" [alt]="product.name">
      <h2>{{ product.name }}</h2>
      <p class="price">NT$ {{ product.price | number:'1.0-0' }}</p>
      <p class="description">{{ product.description }}</p>
      
      <div class="quantity-selector">
        <button (click)="decreaseQuantity()">-</button>
        <span>{{ quantity }}</span>
        <button (click)="increaseQuantity()">+</button>
      </div>
      
      <button 
        class="add-to-cart-btn" 
        [disabled]="product.stock <= 0"
        (click)="addToCart()">
        加入購物車
      </button>
      
      <div *ngIf="(itemCount() > 0)" class="cart-summary">
        購物車: {{ itemCount() }} 件商品,總計: NT$ {{ totalAmount() | number:'1.0-0' }}
        <button (click)="goToCheckout()" [disabled]="!checkoutEnabled()">結帳</button>
      </div>
    </div>
  `
})
export class ProductDetailComponent {
  @Input() product!: Product;
  quantity = 1;
  
  // 使用 Signals 取得購物車狀態
  itemCount = toSignal(this.cartStore.select(selectItemCount));
  totalAmount = toSignal(this.cartStore.select(selectTotalAmount));
  checkoutEnabled = toSignal(this.cartStore.select(selectCheckoutEnabled));
  
  constructor(
    private cartStore: Store<{ cart: CartState }>,
    private router: Router,
    private notificationService: NotificationService
  ) {}
  
  decreaseQuantity(): void {
    if (this.quantity > 1) {
      this.quantity--;
    }
  }
  
  increaseQuantity(): void {
    if (this.quantity < this.product.stock) {
      this.quantity++;
    }
  }
  
  addToCart(): void {
    // 確認商品庫存足夠
    if (this.product.stock < this.quantity) {
      this.notificationService.showError('庫存不足');
      return;
    }
    
    const cartItem: CartItem = {
      productId: this.product.id,
      name: this.product.name,
      price: this.product.price,
      quantity: this.quantity,
      imageUrl: this.product.imageUrl
    };
    
    // 發送加入購物車動作
    this.cartStore.dispatch(CartActions.addItem({ item: cartItem }));
    this.notificationService.showSuccess('已加入購物車');
    
    // 重設數量
    this.quantity = 1;
  }
  
  goToCheckout(): void {
    this.router.navigate(['/checkout']);
  }
}quantity = 1;
  }
  
  goToCheckout(): void {
    this.router.navigate(['/checkout']);
  }
}

這個購物車功能範例完整展示了 NgRx 的使用流程:

  1. 定義購物車狀態(CartState)包含商品清單、總金額等資訊

  2. 創建動作(addItem, removeItem, clearCart)用於描述操作

  3. 實作 Reducer 定義如何根據各種動作更新狀態

  4. 使用選擇器提取所需的狀態片段(商品數量、總金額等)

  5. 元件透過 dispatch 發送動作,並使用選擇器訂閱狀態變化

與傳統的服務+RxJS 實作相比,NgRx 方案提供了更清晰的結構和單向資料流程,讓狀態變更更可預測,也更容易追蹤和除錯。

NgRx 常用 API 參考

這個區塊整理了 NgRx 中最常用的 API,方便快速查閱和使用。

初始化與設定

// 在 app.module.ts 或 app.config.ts 中設定 NgRx Store
import { StoreModule } from '@ngrx/store';

// Angular 16+ 使用 provideStore
providers: [
  provideStore({ cart: cartReducer, user: userReducer }),
  provideState({ name: 'products', reducer: productsReducer }),
]

// 舊版 Angular 使用 StoreModule
imports: [
  StoreModule.forRoot({ cart: cartReducer, user: userReducer }),
  StoreModule.forFeature('products', productsReducer),
]

定義 Action

// 基本 Action(無參數)
export const loadProducts = createAction('[Product] Load Products');

// 帶參數的 Action
export const loadProductsSuccess = createAction(
  '[Product] Load Products Success',
  props<{ products: Product[] }>()
);

// 帶錯誤的 Action
export const loadProductsFailure = createAction(
  '[Product] Load Products Failure',
  props<{ error: any }>()
);

定義 Reducer

// 使用 createReducer
export const productsReducer = createReducer(
  initialState,
  
  // 處理單一 Action
  on(ProductActions.loadProducts, state => ({
    ...state,
    loading: true
  })),
  
  // 處理帶參數的 Action
  on(ProductActions.loadProductsSuccess, (state, { products }) => ({
    ...state,
    products,
    loading: false,
    error: null
  })),
  
  // 處理錯誤 Action
  on(ProductActions.loadProductsFailure, (state, { error }) => ({
    ...state,
    loading: false,
    error
  }))
);

// 轉換為相容舊版 NgRx 的形式
export function reducer(state: ProductsState | undefined, action: Action) {
  return productsReducer(state, action);
}

定義 Selector

// 基本特徵選擇器
export const selectProductState = createFeatureSelector<ProductsState>('products');

// 簡單選擇器
export const selectAllProducts = createSelector(
  selectProductState,
  state => state.products
);

// 帶參數的選擇器
export const selectProductById = (productId: string) => createSelector(
  selectAllProducts,
  products => products.find(product => product.id === productId)
);

// 組合多個選擇器
export const selectProductsWithCategories = createSelector(
  selectAllProducts,
  selectAllCategories,
  (products, categories) => {
    return products.map(product => ({
      ...product,
      categoryName: categories.find(c => c.id === product.categoryId)?.name || 'Unknown'
    }));
  }
);

// 使用 select 方法獲取狀態(Observable 形式)
this.products$ = this.store.select(selectAllProducts);

// 使用 toSignal 轉換為 Signal (Angular 16+)
products = toSignal(this.store.select(selectAllProducts), { initialValue: [] });
isLoading = toSignal(this.store.select(selectProductsLoading), { initialValue: false });

發送 Action

// 不帶參數的 Action
this.store.dispatch(ProductActions.loadProducts());

// 帶參數的 Action
this.store.dispatch(ProductActions.addProduct({ product: newProduct }));

// 串連多個 Action
addProductAndNavigate(product: Product) {
  this.store.dispatch(ProductActions.addProduct({ product }));
  this.store.dispatch(RouterActions.go({ path: ['/products'] }));
}

使用 Effects(副作用)

// 安裝 Effects 模組
providers: [
  provideEffects(ProductEffects)
]

// 定義 Effect
@Injectable()
export class ProductEffects {
  constructor(
    private actions$: Actions,
    private productService: ProductService,
    private store: Store
  ) {}
  
  // 載入產品 Effect
  loadProducts$ = createEffect(() => this.actions$.pipe(
    ofType(ProductActions.loadProducts),
    switchMap(() => this.productService.getProducts().pipe(
      map(products => ProductActions.loadProductsSuccess({ products })),
      catchError(error => of(ProductActions.loadProductsFailure({ error })))
    ))
  ));
  
  // 不分發 Action 的 Effect
  logActions$ = createEffect(() => this.actions$.pipe(
    tap(action => console.log('Action: ', action)),
  ), { dispatch: false });
}

使用 Router Store

// 設定 Router Store
providers: [
  provideRouterStore()
]

// 使用 Router Selectors
selectCurrentRoute = this.store.select(selectRouteParams);
productId = toSignal(this.store.select(selectRouteParam('id')));

// 導航
this.store.dispatch(routerNavigatedAction({
  payload: {
    routerState: { url: '/products/1' },
    event: {}
  }
}));

使用 Entity Adapter

// 創建 Entity Adapter
export const productsAdapter = createEntityAdapter<Product>({
  selectId: product => product.id,
  sortComparer: (a, b) => a.name.localeCompare(b.name)
});

// 初始狀態
export const initialState: ProductsState = productsAdapter.getInitialState({
  loading: false,
  error: null
});

// Reducer 中使用 Adapter
on(ProductActions.loadProductsSuccess, (state, { products }) => {
  return productsAdapter.setAll(products, {
    ...state,
    loading: false,
    error: null
  });
}),

// 使用 Adapter 選擇器
export const {
  selectIds,
  selectEntities,
  selectAll,
  selectTotal
} = productsAdapter.getSelectors(selectProductState);

NgRx 的優勢

  1. 可預測性:狀態變更遵循嚴格的模式,使應用程式行為更可預測

  2. 集中管理:所有狀態在一個地方,避免狀態散落各處

  3. 追蹤性:每個狀態變更都有明確的動作記錄

  4. 可測試性:容易編寫單元測試,測試覆蓋率高

  5. 開發工具:配合 Redux DevTools 提供強大的除錯能力,包括時間旅行功能

  6. 效能優化:通過記憶化選擇器和優化的變更偵測提高效能

常見使用場景

NgRx 特別適合以下情況:

  1. 多元件共享狀態:當多個元件需要訪問和修改相同的資料(如購物車、使用者偏好設定)

  2. 複雜的狀態邏輯:狀態更新邏輯複雜,需要可預測的管理方式(如表單管理、多步驟流程)

  3. 中大型應用:應用程式規模增長到一定程度,需要更結構化的狀態管理

  4. 團隊協作:大型團隊需要一致的狀態管理模式,減少程式碼衝突和溝通成本

結論

NgRx 提供了一套完整的狀態管理解決方案,雖然有一定的學習曲線和樣板程式碼,但它帶來的可預測性、可維護性和可擴展性,使它成為 Angular 應用程式中狀態管理的強大選擇。特別是對於中大型專案來說,NgRx 的投資是值得的。

記住 NgRx 的流程:ComponentActionReducerStateSelectorComponent,這個單向資料流是 NgRx 最核心的設計理念。

Last updated