Angular Best Practices: Viết selector hiệu quả trong NgRx

Published on
6 min read
Angular Best Practices: Viết selector hiệu quả trong NgRx

Mấy hôm nay mình ngồi review code cho team và phát hiện ra một vấn đề rất thú vị. Một số bạn trong team viết selector theo cách tưởng là đúng nhưng lại vô tình khiến performance của app tụt dốc không phanh. Sau khi feedback và cùng ngồi sửa, mình thấy vấn đề này khá phổ biến nên quyết định viết bài chia sẻ cho anh em dev Angular luôn.

Hôm nay, chúng ta sẽ cùng mổ xẻ một chủ đề tưởng nhỏ mà không nhỏ: Viết selector sao cho đúng và hiệu quả khi sử dụng NgRx Store.

Code hiện tại

Requirement của chức năng mình đang xem xét thì cũng khá đơn giản đó là hiển thị danh sách các người dùng đang active trong hệ thống, nhìn hình bên dưới là anh em hiểu luôn rồi user table

Sử dụng NgRx để quản lý state, giả sử ta có state như sau

interface User {
  name: string
  isActive: boolean
}

interface UserManagementState {
  users: Array<User>
  query: string
  status: 'idle' | 'loading' | 'error'
}

Đoạn code mình review cơ bản nó sẽ như sau

// Users Selector
const selectActiveUsers = createSelector(selectUserManagementState, (state) => {
  return state.users.filter((user) => user.isActive)
})
// Users Component
@Component({
  template: ` <div *ngFor="let user of activeUsers$ | async">{{ user.name }}</div> `,
})
export class UsersComponent {
  activeUsers$ = this.store.select(selectActiveUsers)

  constructor(store: Store<AppState>) {}
}

Nhìn qua có vẻ ngon lành cành đào đúng không anh em, code chạy được ngon lành, UI hiển thị đúng dữ liệu.

Lúc này anh em thử log trong selector xem thế nào nhé

Update lại code selectActiveUsers như sau:

// Users Selector
const selectActiveUsers = createSelector(selectUserManagementState, (state) => {
  console.log('select active users')
  return state.users.filter((user) => user.isActive)
})

Bật Devtool thần thánh lên và nhìn vào console, anh em sẽ thấy dòng chữ select active users được ghi ra rất nhiều lần mặc dù không update gì đến mảng users

Không tin thì vào xem demo luôn nha - nhớ bật console lên

Thực tế, mỗi lần state thay đổi, ví dụ update query, dù không liên quan đến users nhưng selector selectActiveUsers vẫn bị réo tên, vẫn phải làm việc ... Vấn đề sẽ trầm trọng hơn khi danh sách user nhiều, logic filter phức tạp cần thực hiện nhiều vòng lặp ....

Anh em có thể nói là tôi lo xa, tôi khó này nọ, máy tính bây giờ mạnh, xử lý vài trăm, vài nghìn vòng lặp thì có vấn đề gì. Nhưng anh em có nghe qua hiệu ứng cánh bướm chưa?

? Một con bướm vỗ cánh ở Brazil có thể tạo ra cơn lốc xoáy ở Texas — nghe có vẻ xa vời, nhưng trong code thì chẳng xa chút nào.

Một selector tưởng chừng như vô hại, như selectActiveUsers, nếu không tối ưu, mỗi lần state thay đổi là nó chạy lại. Mỗi lần chạy lại là một lần component re-render không cần thiết. Ban đầu nhìn tưởng nhẹ nhàng, nhưng khi dự án ngày càng lớn, logic phức tạp lên, dữ liệu nhiều lên, một lần re-render thì có thể kéo theo nhiều action khác nữa - call API chẳn hạn, có thể khiến cả app giật lag.

Và anh em có chắc chỉ mỗi selector này sai không? Lỡ như chỗ khác cũng lặp lại lỗi này thì sao? Một lỗi nhỏ nhân lên nhiều lần, hiệu ứng dây chuyền kéo theo cả ứng dụng sụp đổ — đúng kiểu "cánh bướm đập cánh, app sập không hay"!

Vậy nên... tối ưu ngay từ đầu chưa bao giờ là thừa cả.

Refactor lại như thế nào?

Thay vì sử dụng trực tiếp selectAppState làm input cho function createSelector của selectActiveUsers, chúng ta khai báo 1 selector trung gian với tên selectUsers

const selectUsers = createSelector(selectAppState, (state) => state.users)

Sau đó sửa dụng selectUsers làm input cho function createSelector của selectActiveUsers

// Users Selector
const selectActiveUsers = createSelector(selectUsers, (users) => {
  console.log('select active users')
  if (!users) {
    return []
  }
  return users.filter((user) => user.isActive)
})

Lần này anh em sẽ thấy select active users chỉ được log ra khi nào anh em thực sự thay đổi mảng users.

Nguyên nhân ở đây là gì - đó chính là nhờ vào memoization mà function createSelector đã cung cấp sẵn rồi, chúng ta chỉ việc dùng cho đúng thôi

When using the createSelector and createFeatureSelector functions @ngrx/store keeps track of the latest arguments in which your selector function was invoked. Because selectors are pure functions, the last result can be returned when the arguments match without reinvoking your selector function. This can provide performance benefits, particularly with selectors that perform expensive computation. This practice is known as memoization.

Memoization là gì?

Memoization là kỹ thuật tối ưu hiệu năng bằng cách lưu trữ kết quả của hàm đã tính trước đó. Khi hàm được gọi lại với cùng một input, thay vì tính toán lại từ đầu, chương trình sẽ lấy kết quả từ bộ nhớ cache để trả về nhanh hơn. Điều này đặc biệt hữu ích cho hàm tốn nhiều tài nguyên, như tính toán đệ quy hoặc xử lý dữ liệu lớn. Memoization giúp giảm thời gian xử lý đáng kể, đổi lại sẽ tốn thêm bộ nhớ để lưu trữ kết quả trung gian. Đây là một dạng tối ưu hóa "space-time tradeoff" — hy sinh bộ nhớ để tiết kiệm thời gian xử lý.

Xem thêm về Memoization

Đến đây tôi biết anh em đang nghĩ gì.

Anh em đừng lo sẽ bị ngốn RAM đâu nha, team NgRx đã tính toán trước rồi và xử lý thông minh hơn mình tưởng nhiều.

Lời kết

Mình hy vọng bài viết này giúp anh em đỡ mất thời gian debug và performance app lúc nào cũng mượt như bơ đậu phộng trét bánh mì. 🍞

Anh em cũng có thể tham khảo thêm các bài viết liên quan như

NgRx Selectors - everything you need to know about Angulars' NgRx! from angular.love

Nếu anh em thấy hay, đừng quên chia sẻ cho đồng đội, và tất nhiên đừng quên ghé blog the4am.dev của mình để xem thêm nhiều mẹo hay nữa nha! 🚀