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 的資料流向是單向的,形成一個清晰的循環:
元件發出動作:使用
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 })); }
Reducer 處理動作:根據動作類型更新狀態
on(CartActions.addItem, (state, { item }) => { // 計算新狀態... return { ...state, items: newItems, totalAmount: newTotalAmount }; })
選擇器提取狀態:元件使用選擇器訂閱狀態變化
// 使用 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));
元件響應變化:當狀態變化時,所有訂閱的元件自動更新
實際使用範例
購物車功能實作
以下是實作購物車功能的範例,展示了如何使用 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 的使用流程:
定義購物車狀態(CartState)包含商品清單、總金額等資訊
創建動作(addItem, removeItem, clearCart)用於描述操作
實作 Reducer 定義如何根據各種動作更新狀態
使用選擇器提取所需的狀態片段(商品數量、總金額等)
元件透過 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 的優勢
可預測性:狀態變更遵循嚴格的模式,使應用程式行為更可預測
集中管理:所有狀態在一個地方,避免狀態散落各處
追蹤性:每個狀態變更都有明確的動作記錄
可測試性:容易編寫單元測試,測試覆蓋率高
開發工具:配合 Redux DevTools 提供強大的除錯能力,包括時間旅行功能
效能優化:通過記憶化選擇器和優化的變更偵測提高效能
常見使用場景
NgRx 特別適合以下情況:
多元件共享狀態:當多個元件需要訪問和修改相同的資料(如購物車、使用者偏好設定)
複雜的狀態邏輯:狀態更新邏輯複雜,需要可預測的管理方式(如表單管理、多步驟流程)
中大型應用:應用程式規模增長到一定程度,需要更結構化的狀態管理
團隊協作:大型團隊需要一致的狀態管理模式,減少程式碼衝突和溝通成本
結論
NgRx 提供了一套完整的狀態管理解決方案,雖然有一定的學習曲線和樣板程式碼,但它帶來的可預測性、可維護性和可擴展性,使它成為 Angular 應用程式中狀態管理的強大選擇。特別是對於中大型專案來說,NgRx 的投資是值得的。
記住 NgRx 的流程:Component
→ Action
→ Reducer
→ State
→ Selector
→ Component
,這個單向資料流是 NgRx 最核心的設計理念。
Last updated