Giải Mã NgRx: Từ 42 Lời Kêu Gọi API Thừa Thãi Đến Hiệu Năng Vượt Trội!
Lê Lân
0
Tối Ưu Kiến Trúc NgRx: Tránh Anti-Pattern Clone Deep Trong Ứng Dụng Angular
Mở Đầu
Trong quá trình phát triển ứng dụng Angular sử dụng NgRx để quản lý trạng thái, một số anti-pattern phổ biến có thể làm giảm hiệu suất và gây ra những rủi ro về bảo trì mã nguồn. Bài viết này sẽ giới thiệu một vấn đề cụ thể liên quan đến việc sử dụng cloneDeep và cách khắc phục hiệu quả trong dự án thực tế.
Quản lý trạng thái với NgRx đã trở thành lựa chọn ưu việt cho nhiều dự án Angular hiện nay bởi khả năng mở rộng và hỗ trợ hiệu quả lọc dữ liệu. Tuy nhiên, nếu không thiết kế đúng, việc xử lý trạng thái có thể dẫn đến việc gọi API thừa thãi, hiệu năng giảm, và thậm chí vi phạm nguyên tắc của NgRx như serialization action và immutable state.
Bài viết dựa trên kinh nghiệm cải tiến ứng dụng tạo thế giới AI - Summon Worlds sẽ phân tích chi tiết các bản chất của anti-pattern và đưa ra giải pháp kiến trúc giúp giảm thiểu các vấn đề này, phù hợp áp dụng trong mọi dự án có sử dụng NgRx.
0. The Backstory 🏗️
Dự án AI World Building App (Summon Worlds) được xây dựng lại trên nền Ionic và Angular với sự hỗ trợ quản lý trạng thái từ NgRx. Ban đầu, nhóm phát triển không chú trọng nhiều vào thiết kế pattern NgRx, dẫn đến nhiều vấn đề kiến trúc phát sinh trong quá trình sử dụng.
Cụ thể, tác giả phát hiện ra các mức độ gọi API trùng lặp khi người dùng sử dụng giao diện feed, gây tốn kém về tài nguyên và thời gian xử lý. Điều này khiến họ phải nghiên cứu lại mô hình quản lý dữ liệu và sửa chữa những anti-pattern ảnh hưởng đến hiệu năng và trải nghiệm người dùng.
1. Phát Hiện Anti-Pattern
1.1 Triệu Chứng Vấn Đề
Gọi lặp lại hàm loadEntity nhiều lần đến 42 lần chỉ trong một lần tải màn hình.
Người dùng trên mạng chậm phải chờ lâu, phản hồi interface kém mượt.
Giao thức xử lý state có nhiều class instance được tạo lại liên tục gây ra lỗi vi phạm serialization của NgRx.
1.2 Nguyên Nhân Gốc Rễ
Helper service quá "hăng hái" dispatch lệnh load dữ liệu mỗi khi component subscribe.
Mỗi lần gọi getEntity$() lại tạo instance mới của domain model.
Trạng thái bị giữ dưới dạng class thay vì dạng plain object, khiến Redux DevTools và cross-tab sync gặp lỗi.
Anti-pattern nổi bật là dispatch thẳng các class instances thay vì chỉ truyền các payload dạng plain object trong actions, đồng thời việc gọi API quá nhiều lần gây lãng phí tài nguyên.
2. Nguyên Tắc Cốt Lõi Cho Giải Pháp
2.1 Phân Tách Rõ Ràng Trạng Thái và Mô Hình
Store chỉ lưu trạng thái dạng plain JSON.
Các class domain model được khởi tạo chính xác một lần trong helper, không tạo thừa trong component.
2.2 Centralize Loading Logic
Dispatch gọi load dữ liệu phải được xử lý tập trung và chỉ thực hiện một lần cho mỗi ID.
Sử dụng các operator RxJS như combineLatest, filter và shareReplay để chia sẻ dữ liệu hiệu quả giữa nhiều subscriber.
2.3 Hạn Chế Dispatch Payload Phức Tạp
Chỉ truyền dữ liệu dạng plain object trong các action, tránh gửi class instance.
Cần thêm hàm .toPlainObject() trong class để chuyển đổi dữ liệu sang dạng thuần.
3. Công Thức Refactor Đa Dụng
3.1 Tối Ưu Helper Service
@Injectable({ providedIn: 'root' })
exportclassGenericHelperService {
constructor(privatestore: Store<AppState>) {}
// Raw state slice với cache chia sẻ
getEntityState$(): Observable<EntityState> {
returnthis.store.select(selectEntityState).pipe(
shareReplay(1)
);
}
// Dispatch load dữ liệu một lần và trả về domain model đã mapping
getEntity$(id: string): Observable<EntityModel> {
this.store.dispatch(loadEntity({ id }));
returncombineLatest([
this.store.select(selectEntityById(id)),
this.store.select(selectEntityLoading(id))
]).pipe(
filter(([data, loading]) => !!data && !loading),
map(([data]) =>newEntityModel(data)),
shareReplay({ bufferSize: 1, refCount: true })
);
}
// Cập nhật dữ liệu với payload plain object
saveChanges(model: EntityModel): void {
const payload = model.toPlainObject();
this.store.dispatch(updateEntity({ payload }));
}
}
Giải thích:
shareReplay(1): Chia sẻ 1 subscription công khai, tránh gọi lại API thừa.
combineLatest + filter: Chờ đến khi dữ liệu tải xong mới phát ra giá trị.
toPlainObject(): Loại bỏ các phương thức và chỉ giữ dữ liệu dạng JSON.
3.2 Component Sử Dụng Async Pipe
Trước đây:
ngOnInit() {
this.sub = this.helper.getEntity$(id)
.subscribe(model =>this.value = model.compute());
}
ngOnDestroy() {
this.sub.unsubscribe();
}
Sau tối ưu:
value$ = this.helper.getEntity$(id).pipe(
map(model => model.compute())
);
<div>{{ value$ | async }}</div>
Không dùng subscription thủ công, tránh rò rỉ bộ nhớ và code phức tạp.
3.3 Loại Bỏ Workaround cloneDeep
Thay vì sao chép sâu dữ liệu một cách thủ công:
const safeData = cloneDeep(rawData);
Có thể xử lý bằng kiểu strict TypeScript và mapping model đúng cách:
computed$ = this.helper.getEntity$(id).pipe(
map(model => model.optionalProp ?? defaultValue)
);
4. Centralize Error Handling
Tránh xử lý lỗi rải rác trong component bằng cách chuyển hết sang các effect:
Component chỉ cần tập trung vào hiển thị trạng thái thành công, giảm phức tạp và bảo trì dễ dàng.
5. Migration Checklist
Bước
Mô tả
Helper Overhaul
Thêm
shareReplay(1)
cho raw selectors.
Implement getEntity$()
Dispatch load, dùng
combineLatest()
,
filter()
,
shareReplay()
.
Component Cleanup
Thay subscription manual bằng async pipe.
Payload Serialization
Dùng
.toPlainObject()
, dispatch JSON thuần.
Remove cloneDeep
Xóa hoàn toàn code clone deep.
Centralize Error Flow
Xử lý lỗi trong effect.
Runtime Checks
Bật runtime checks trong
StoreModule.forRoot
.
6. Đo Lường Hiệu Quả
Chỉ số
Trước
Sau
Hiệu quả
Lượng gọi backend mỗi view
42
4
-90%
Bộ nhớ khi render lần đầu
2818 MB
1818 MB
-36%
Thời gian paint đầu tiên
4.2s
2.7s
-1.5s
7. Câu Hỏi Thường Gặp (FAQs)
Q: Tại sao không dispatch class instances?
A: Việc này vi phạm quy tắc serialization của NgRx, làm cho DevTools, replay effects hoặc đồng bộ dữ liệu giữa tab trình duyệt bị lỗi. Dữ liệu plain object là chuẩn an toàn nhất.
Q: Selector memoized mà sao vẫn gọi loadEntity nhiều lần?
A: Selector chỉ cache dữ liệu khi đã có trong state. Việc dispatch loadEntity trước khi dữ liệu về làm bypass cache và gây gọi thừa. shareReplay giúp chia sẻ một Observable duy nhất và tránh gọi lại API.
8. Mẫu Code Cần Copy
Mô Hình Domain
exportclassEntityModel {
constructor(privatedata: EntityData) {}
getid(): string {
returnthis.data.id;
}
compute(): number {
// logic tính toán trên domain
return0;
}
toPlainObject(): EntityData {
return { ...this.data };
}
}
Helper Service
@Injectable({ providedIn: 'root' })
exportclassGenericHelperService {
constructor(privatestore: Store<AppState>) {}
getEntity$(id: string): Observable<EntityModel> {
this.store.dispatch(loadEntity({ id }));
returncombineLatest([
this.store.select(selectEntityById(id)),
this.store.select(selectEntityLoading(id))
]).pipe(
filter(([data, loading]) => !!data && !loading),
map(([data]) =>newEntityModel(data)),
shareReplay({ bufferSize: 1, refCount: true })
);
}
}
9. Kết Luận
Việc tránh các anti-pattern như dispatch class instances hay gọi API lặp lại không cần thiết giúp tăng đáng kể hiệu năng và trải nghiệm người dùng trong ứng dụng Angular sử dụng NgRx. Áp dụng mô hình domain model tập trung, shareReplay cùng async pipe là chìa khóa quan trọng để xây dựng kiến trúc ứng dụng scalable và dễ bảo trì.
Bạn nên tiến hành đo lường trước-sau khi refactor từng entity để đảm bảo hiệu quả rõ rệt. Chia sẻ kiến thức này với cộng đồng sẽ giúp giảm thiểu sai lầm phổ biến trong phát triển dự án NgRx.
Chúc bạn một quá trình refactor suôn sẻ và hiệu quả!