Quản lý state trong Tini App - Cái nhìn tổng quan và cách tiếp cận (P2)

Quản lý state trong Tini App - Cái nhìn tổng quan và cách tiếp cận (P2)


Xin chào các bạn, trong phần 1 của Quản lý state trong Tini App - Cái nhìn tổng quan và cách tiếp cận chúng ta đã cùng nhau tìm hiểu về các vấn đề rất cơ bản nhưng rất quan trọng của một ứng dụng front-end nói chung và của một ứng dụng Tini App nói riêng. Tuy nhiên còn một vài vấn đề đang bị bỏ ngỏ.

Để gỡ những nút thắt đó, hôm nay chúng sẽ cùng nhau đi qua các nội dung:

  • I. Nhắc lại vấn đề
  • II. Event emitter pattern - Giải pháp hoàn hảo
  • III. Disposable pattern - Bạn chỉ việc sử dụng, dọn dẹp cứ để tôi
  • IV. Kết luận

I. Nhắc lại vấn đề


Ở phần trước, chúng ta đã có một “global store” chính là app - nơi mà có thể được truy cập ở bất kì đâu và bất kì khi nào thông qua phương thức getApp().

Tuy nhiên vẫn còn 2 vấn đề khiến app thay đổi nhưng UI vẫn không được cập nhật:

  • Thứ nhất: Khi các properties ở app thay đổi, chúng không trigger event nào để báo cho các page/component sử dụng getApp() nhận biết.
  • Thứ hai: Cho dù ở app có trigger event thông báo sự thay đổi đi chăng nữa thì UI cũng sẽ không được cập nhật vì hiện tại ở các page/component ấy không có phương thức hay life cycle nào lắng nghe sự thay đổi để cập nhật lại state.

Thật may mắn cho chúng ta, có 1 pattern sinh ra để giải quyết 2 vấn đề này: Event emitter.


II. Event emitter pattern - Giải pháp hoàn hảo


Nếu các bạn đã làm quen với Nodejs thì có thể các bạn sẽ quen với pattern này. Còn nếu bạn là một front-end developer thì sao, đừng lo hãy đọc những dòng code sau:

function handleOnClick() {
  // Do something
}

const button = document.getElementById("#button");
button.addEventListener("click", handleOnClick)
  • Chúng ta đăng ký sự kiện click của một button với callback handleOnClick.
  • Khi bấm vào button, sự kiện click sẽ được gọi và nó sẽ trigger callback handleOnClick.
  • Và khi không sử dụng nữa thì ta hủy lắng nghe:
button.removeEventListener("click", handleOnClick)

Có phải nó rất quen thuộc không ?
Đây chính là một ví dụ về event emitter, để hiểu sâu hơn, hãy cùng mình cài đặt một simple event emitter

utils/event-emitter.js

class EventEmitter {
  constructor() {
    this.events = {};
  }
}

Class EventEmitter gồm 1 property events là một object lưu giữ tất cả các sự kiện, nó sẽ có dạng như sau:

this.events = {
  'eventName': [function callback() {}],
};

Với key eventName là tên của sự kiện và value là 1 mảng các callback sẽ được thực thi khi sự kiện được gọi.

Vậy còn các method thì sao ? Như ví dụ trên chúng ta cần cài đặt thêm các methods sau:

  • on (eventName, callback): Đăng ký lắng nghe eventName và sẽ thực thi callback khi eventName được emit.
  • removeListener (eventName, callback): Ngược lại với on, method này sẽ loại bỏ callback tương ứng với eventName
  • emit(eventName, data): Thực thi tất cả callback ứng với eventName với input là data

Chúng ta sẽ có class EventEmitter hoàn chỉnh như thế này:

utils/event-emitter.js

class EventEmitter {
  constructor() {
    this.events = {};
  }

  on(eventName, callback) {
    if (!Array.isArray(this.events[eventName])) {
      this.events[eventName] = [];
    }
    this.events[eventName].push(callback);
  }

  emit(eventName, data) {
    if (Array.isArray(this.events[eventName])) {
      this.events[eventName].forEach((callback) => callback(data));
    }
  }

  removeListener(eventName, callback) {
    this.events[eventName] = this.events[eventName].filter(
      (listener) => listener !== callback
    );
  }
}

export default EventEmitter;

Còn chờ gì nữa mà không ráp vào ứng dụng của chúng ta nào:

app.js

import EventEmitter from './utils/event-emitter';

App({
  cart: [],
  // Thêm cartEvent
  cartEvent: new EventEmitter(),

  onAddToCart(product) {
    this.cart.push(product);

    // Emit event `cart::update` sau khi đã cập nhật giỏ hàng
    this.cartEvent.emit('cart::update', this.cart);
  },
});

pages/product-list/index.js

Page({
  data: {
    cart: [],
    products: [
    // ...
    ],
  },

  onReady() {
    this.setData({
      cart: getApp().cart,
    });
  },

  onLoad() {
    this.cartUpdate = (cart) => {
      this.setData({
        cart,
      });
    };

    // Đăng ký event `cart:update` với callback `cartUpdate`
    getApp().cartEvent.on('cart::update', this.cartUpdate);
  },

  // Và trước khi page unmount thì ta hủy lắng nghe
  onUnload() {
    getApp().cartEvent.removeListener('cart::update', this.cartUpdate);
  },
});

Bạn có thể tham khảo code ở đây.


Và ta thu được kết quả:

Event up demo


Xin chúc mừng vì chúng ta đã giải quyết được 2 vấn đề còn tồn đọng cũng như đã tiếp cận được cách quản lý state cơ bản bằng app và event emitter.

Bạn có thể dừng tại đây, tuy nhiên nếu bạn muốn code của mình chuyên nghiệp hơn cũng như là để dễ bảo trì sau này thì hãy đi cùng mình đến phần tiếp theo: Disposable pattern.


III. Disposable pattern - Bạn chỉ việc sử dụng, dọn dẹp cứ để tôi


Hiện tại mọi thứ vẫn ổn, nhưng hãy tưởng tượng bạn phải lắng nghe hàng chục events thì ở life-cycle method onLoad() bạn phải xóa bỏ từng event một.
Và nếu ứng dụng của bạn có đăng ký các timer như setInterval thì onLoad() của bạn sẽ trông như thế này:

  onUnload() {
    getApp().event.removeListener('event_1', callback_1);
    getApp().event.removeListener('event_2', callback_2);
    // ...
    getApp().event.removeListener('event_n', callback_n);

    clearInterval(timer_1);
    clearInterval(timer_2);
    // ...
    clearInterval(timer_n);
  },

Rất cồng kềnh, khó bảo trì và nếu như bạn quên clear một vài event hoặc timer, nó sẽ rò rỉ bộ nhớ và ảnh hưởng tới hiệu suất ứng dụng của bạn.


Nhưng không sao, mình ở đây để giới thiệu với các bạn cách giải quyết vấn đề này: Disposable pattern.

Hiện tại chúng ta đang cài đặt các event theo các bước sau:
Đăng ký event–> Sử dụng event → Xóa bỏ event.

Nhưng bây giờ bạn hãy tưởng tượng bạn có thêm một chiếc máy (tạm gọi là cleaner) chuyên dọn dẹp các event và timer, việc của bạn là hướng dẫn nó cách dọn dẹp và nó sẽ tự động xóa bỏ chúng khi hết vòng đời của page/component.

Khi đó các bước cài đặt một event của chúng ta sẽ chuyển thành:
Đăng ký event + Hướng dẫn cho cleaner cách dọn dẹp event đó → Sử dụng event → Khi cần dọn dẹp thì cleaner sẽ làm chuyện đó giúp bạn.


Nói suông có vẻ khó hiểu, hãy bắt tay vào cài đặt nào:

pages/product-list/index.js

Page({
  // Khai báo chiếc máy dọn dẹp
  cleaner: [],

  // ...

  onLoad() {
    this.cartUpdate = (cart) => {
      this.setData({
        cart,
      });
    };

    getApp().cartEvent.on('cart::update', this.cartUpdate);

    // Ngay khi đăng ký event, hãy hướng dẫn cleaner cách dọn dẹp nó
    this.cleaner.push(
      getApp().cartEvent.removeListener('cart::update', this.cartUpdate)
    );
  },

  onUnload() {
    // Và hãy để cleaner dọn dẹp thay bạn
    this.cleaner.forEach((dispose) => dispose());
  },
});

Tuy nhiên, chúng ta có thể cài đặt gọn hơn nữa bằng cách return về removeListener() ngay trong method on của EventEmitter

utils/event-emitter.js

class EventEmitter {

  // ...

  on(eventName, callback) {
    if (!Array.isArray(this.events[eventName])) {
      this.events[eventName] = [];
    }
    this.events[eventName].push(callback);

    // Ở method on chúng ta sẽ return về luôn cách dọn dẹp event
    return () => this.removeListener(eventName, callback);
  }

  // ...

}

Khi đó, product-list của chúng ta sẽ gọn hơn:

pages/product-list/index.js

Page({
  cleaner: [],

  // ...

  onLoad() {
    this.cleaner.push(
      getApp().cartEvent.on('cart::update', (cart) => {
        this.setData({
          cart,
        });
      })
    );
  },

  onUnload() {
    this.cleaner.forEach((dispose) => dispose());
  },
});

Còn nếu sử dụng thêm các timer thì sao ?

pages/product-list/index.js

Page({
  cleaner: [],

  // ...

  onLoad() {
    const timer = setInterval(() => {
      // Do something
    });

    this.cleaner.push(
      getApp().cartEvent.on('cart::update', (cart) => {
        this.setData({
          cart,
        });
      }),

      // Thêm clearInterval vào cleaner
      () => clearInterval(timer)
    );
  },

  onUnload() {
    this.cleaner.forEach((dispose) => dispose());
  },
});

Rất đơn giản phải không nào.

Bạn có thể tham khảo source code tại đây.


IV. Kết luận


Ở phần này chúng ta đã tìm được giải pháp Event emitter kết hợp với Disposable để giải quyết vấn đề re-render khi sử dụng appgetApp() làm global store.
Trên đây chỉ là source code đơn giản, bạn có thể tìm đọc thêm các bài viết về Event emitterDisposable để bổ sung vào code của mình.

Chúng ta đã cùng nhau đi hết 2 phần của Quản lý state trong Tini App - Cái nhìn tổng quan và cách tiếp cận.
Có thể còn rất nhiều cách để quản lý store đơn cử là dùng các thư viện bên thứ ba như Redux, tuy nhiên mình vọng đây là bài viết đơn giản giúp các bạn làm quen và mang lại cảm hứng cho bạn cho hành trình chinh phục Tini App sắp tới.

Hẹn gặp các bạn ở các bài viết sau.

5 Likes