Get in touch
or send us a question?
CONTACT

Giao tiếp giữa các components trong Angular

Giới thiệu

Trong bất kỳ ứng dụng nào, việc trao đổi dữ liệu giữa các components luôn là công việc thiết yếu. Và Angular cũng không phải là ngoại lệ. Mặc dù công việc này phổ biến đến mức bạn có thể nghĩ “à, cái này dễ ợt!”, nhưng thực tế Angular cung cấp nhiều cách tiếp cận sẵn có. Đôi khi, các phương pháp này vẫn không đủ, và bạn sẽ phải tìm kiếm các giải pháp tối ưu hơn.

Vậy làm sao bạn biết mình không đang viết “code tởm”? Làm sao bạn biết bạn không đang “phát minh lại cái bánh xe”? Rất đơn giản. Chỉ cần học từ những lỗi của người khác và lấy kinh nghiệm từ họ thôi 😁

Hôm nay, mình sẽ hướng dẫn các bạn cách trao đổi dữ liệu giữa các components trong Angular. Những phương pháp mặc định là gì. Khi nào chúng không đủ. Và làm thế nào để đối phó với những tình huống phức tạp hơn.

Nếu các bạn đã sẵn sàng, thì cùng đào sâu vào vấn đề này nhé 🤿

Các cách tiếp cận sẵn có

Mối quan hệ cha-con (Parent-child)

Chúng ta bắt đầu từ điều cơ bản nhất. Trường hợp đơn giản nhất là khi bạn có một component cha muốn truyền dữ liệu cho component con.

image.png

Trong trường hợp này, component con cần định nghĩa một thuộc tính với decorator Input() để có thể nhận dữ liệu:

@Component({  
  selector: 'child-component',  
  template: \`<p>{{ textProperty }}</p>\`  
})  
export class ChildComponent {

  @Input() public textProperty: string;  
}

Và sau đó, component cha có thể sử dụng binding để truyền dữ liệu:

@Component({  
  selector: 'parent-component',  
  template: \`  
    <child-component [textProperty]="parentProp"></child-component>  
  \`  
})  
export class ParentComponent {  
  public parentProp = 'Hello world';  
}

Mối quan hệ con-cha (Child-parent)

Thông thường, bạn sẽ muốn làm ngược lại, tức là truyền dữ liệu từ component con đến component cha:

image.png

Lần này bạn sẽ cần một decorator khác là Output() và nói cách khác là một hàm callback:

@Component({  
  selector: 'child-component',  
  template: \`<button (click)="sendDataToParent()">Click Me</button>\`  
})  
export class ChildComponent {  
  @Output() public onChildHasData = new EventEmitter<string>();  
  
  public sendDataToParent(): void {  
      this.onChildHasData.emit('Hello world');  
  }  
}

Vì vậy, lần này, component cha có thể phản ứng với sự kiện của component con, và xử lý dữ liệu theo cách mà nó muốn:

@Component({  
  selector: 'parent-component',  
  template: \`  
    <child-component (onChildHasData)="showChildData($event)">  
    </child-component>  
  
    <p>{{ parentProp }} </p>  
  \`  
})  
export class ParentComponent {  
  public parentProp = '';  
  
  public showChildData(dataFromChild: string): void {  
      this.parentProp = dataFromChild;  
  }  
}

Mối quan hệ giữa các component anh em (Sibling)

Vấn đề phổ biến khác là khi bạn có hai component con muốn giao tiếp với nhau:

image.png

Thật sự, nó có thể trông phức tạp, nhưng bạn nên sử dụng sự kết hợp của cả hai phương pháp trên.

Ý tưởng ở đây rất đơn giản: gửi dữ liệu từ một component con lên cha, và sau đó từ cha đó truyền lại cho component con khác.

Nó có thể nghe có vẻ như một cách tiếp cận “code-smell”, nhưng đối với các tình huống đơn giản, nó hoạt động và không có lý do gì để làm nó phức tạp hơn.

Khi những phương pháp tích hợp sẵn không đủ

Okay, các trường hợp đơn giản đã được giải quyết, giờ đây chúng ta cần tiến đến những trường hợp khó hơn.

Hãy nghĩ về tình huống mà chúng ta đã nêu phía trên. Tuy nhiên, lần này bạn có một vài cấp độ của các component lồng nhau.

image.png

Bạn sẽ làm gì bây giờ? Liệu bạn có gửi dữ liệu đến một component cha, sau đó tiếp tục gửi đến một component cha khác, và cứ như vậy cho đến khi bạn đạt được component cao nhất trong chuỗi? Và sau đó? Bạn có push nó trở lại qua tất cả các cấp độ không? Yeah, điều đó nghe không hề dễ dàng, đúng không? 😨

Dưới đây là một số kỹ thuật thông dụng có thể giúp ích.

Mediator

Trong khi việc giao tiếp giữa các component tiêu chuẩn tương tự như Observer Pattern, thì Mediator Pattern là một phương pháp khá phổ biến.

Trong trường hợp này, các component không biết về nhau và sử dụng một người trung gian để giao tiếp. Đó chỉ là một Serivce đơn giản với một cặp thuộc tính cho mỗi Event:

@Injectable({ providedIn: 'root' })  
class EventMediator  
{  
    // sự kiện thay đổi customer
    private customerChangedSubject$ = new BehaviorSubject<CustomerData\>(null);  
    public customerChanged = this.customerChangedSubject$.asObservable();  
  
    public notifyOnCustomerChanged(customerData: CustomerData): void {  
        this.customerChangedSubject$.next(customerData);  
    }  
  
    // sự kiện thay đổi product
    private productChangedSubject$ = new BehaviorSubject<ProductData\>(null);  
    public productChanged = this.productChangedSubject$.asObservable();  
  
    public notifyOnProductChanged(productData: ProductData): void {  
        this.productChangedSubject$.next(productData);  
    }  
}

Mỗi Event đều có ba thành phần cần thiết:

  • subject – nơi lưu trữ các Event
  • observable – dựa trên subject đó, để component có thể đăng ký nhận dữ liệu
  • notifyOfXXXChanged – một phương thức để kích hoạt một sự kiện mới

Mình đã sử dụng BehaviourSubject ở đây, vì vậy một component có thể đăng ký sau vẫn nhận được giá trị phát đi cuối cùng, nhưng lựa chọn subject phải phụ thuộc vào trường hợp sử dụng và nhu cầu của bạn.

Cũng lưu ý rằng xxxChangedSubject$ là private và không được tiếp xúc trực tiếp. Chắc chắn, chúng ta có thể chỉ sử dụng một subject public và tránh observable và phương thức phát sự kiện. Tuy nhiên, trên thực tế, nó sẽ tạo ra một nỗi kinh hoàng về biến toàn cục, khi mọi người có quyền truy cập không kiểm soát vào dữ liệu, dẫn đến hàng giờ gỡ rối, cố gắng tìm hiểu component nào phát sự kiện, và component nào nhận chúng. Nói ngắn gọn, dành vài phút để làm đúng cách ngay từ bây giờ, có thể giúp bạn tiết kiệm hàng giờ sau này. (Đó là lý do tại sao mình rất ít khi sử dụng any nó thật khủng khiếp khi bạn phải Debug một feature ko phải do chính bạn code ra và người code ra nó đã sử dụng any cho toàn bộ… 🤕)

Việc sử dụng mediator khá đơn giản. Để gửi dữ liệu, chỉ cần sử dụng phương thức notifyOnXXXChanged() tương ứng:

@Component({  
  selector: 'component-a',  
  template: \`<button (click)="sendData()">Click Me</button>\`  
})  
export class AComponent {  

  constructor(private eventMediator: EventMediator) { }  
  
  public sendData(): void {  
    const dataToSend = new CustomerData('John', 'Doe');  
  
    this.eventMediator.notifyOnCustomerChanged(dataToSend);  
  }  
}

Để nhận thông tin, chỉ cần đăng ký chủ đề (Subject) bạn quan tâm:

@Component({  
  selector: 'component-b',  
  template: \`<p>{{ customerName }}</p>\`  
})  
export class BComponent implements OnInit {  

  public customerName: string;  
  
  constructor(private eventMediator: EventMediator) { }  
  
  public ngOnInit(): void {  
    this.eventMediator  
      .customerChanged  
      .subscribe((customerData) => {  
        this.customerName = customerData.Name;  
      });  
  }  
}

Đáng chú ý, việc có nhiều mediator service cho các mục đích khác nhau là phổ biến.

Service Bus

Chúng ta sẽ cùng đi tìm hiểu thêm một giải pháp khác cho cùng một vấn đề mà cũng khá tương tự đó là Service Bus.

Thay vì sử dụng Mediator, chúng ta có thể dựa vào Service Bus. Với Service Bus, mỗi component chỉ cần biết về Service Bus mà thôi, nghĩa là các component được “gắn kết lỏng lẻo – loosely coupled” với nhau hơn. Nhưng mặt trái là, không rõ ràng ai đã kích hoạt một sự kiện nếu không có thêm thông tin.

// (khuyến nghị) nên dùng enum thay vì union
enum Events {  
    //...
}  
  
class EmitEvent {  
    constructor(public name: Events, public value?: any) { }  
}  
  
@Injectable({ providedIn: 'root' })  
class EventBus  
{  
    private subject = new Subject<any>();  
  
    public emit(event: EmitEvent): void {  
        this.subject.next(event);  
    }  
  
    public on(event: Events, action: any): Subscription {  
        return this.subject  
            .pipe(  
                filter((e: EmitEvent) => e.name === event),  
                map((e: EmitEvent) => e.value),  
            )  
            .subscribe(action);  
    }  
}

Service Bus chỉ đơn giản là một service thôi. Chúng ta không cần nhiều phương thức cho mỗi sự kiện nữa, chỉ cần emit() và on() là đủ rồi.

Các sự kiện được lưu trữ trong một subject duy nhất. Với emit(), bạn chỉ đẩy một sự kiện mới vào nó, trong khi on() cho phép bạn đăng ký loại sự kiện mà bạn quan tâm.

Trước khi gửi một sự kiện mới, bạn phải khai báo nó:

// tên sự kiện
enum Events {  
    CustomerSelected,  
    //...
}  
  
// dữ liệu sự kiện
class CustomerSelectedEventData {  
    constructor(public name: string) { }  
}

Rồi sau đó, một component có thể xuất bản (publish or emit) nó:

@Component({  
  selector: 'component-a',  
  template: \`<button (click)="sendData()">Click Me</button>\`  
})  
export class AComponent {  

  constructor(private eventBus: EventBus) { }  
  
  public sendData(): void {  
    const dataToSend = new CustomerSelectedEventData('John');  
    const eventToEmit = new EmitEvent(Events.CustomerSelected, dataToSend);  
  
    this.eventBus.emit(eventToEmit);  
  }  
}

Trong khi đó, một component khác có thể tiêu thụ (consume) nó một cách dễ dàng:

@Component({  
  selector: 'component-b',  
  template: \`<p>{{ customerName }}</p>\`  
})  
export class BComponent implements OnInit {  

  public customerName: string;  
  
  constructor(private eventBus: EventBus) { }  
  
  public ngOnInit(): void {  
    this.eventBus  
      .on(Events.CustomerSelected, (e: CustomerSelectedEventData) => {  
        this.customerName = e.name;  
      });  
  }  
}

Lưu ý, mặc dù chúng ta có TypeScript ở đây nhưng nó không đảm bảo an toàn cho Type. Thay đổi tên của sự kiện và TypeScript sẽ dẫn đến gửi sai 😔

Cách Implement này đã hoạt động tốt trong một thời gian dài, nhưng nếu bạn thực sự muốn làm cho nó an toàn nhất có thể thì bạn có thể tham khảo code bên dưới 🤓.

enum Events {
    CustomerSelected,
    CustomerChanged,
    CustomerDeleted,
}

class CustomerSelectedEventData {
    constructor(public name: string) { }
}

class CustomerChangedEventData {
    constructor(public age: number) { }
}

type EventPayloadMap = {
    [Events.CustomerSelected]: CustomerSelectedEventData;
    [Events.CustomerChanged]: CustomerChangedEventData;
    [Events.CustomerDeleted]: undefined;
};

class EmitEvent<T extends Events> {
    constructor(public name: T, public value: EventPayloadMap[T]) { }
}

class EventBus {
    private subject = new Subject<any>();

    public emit<T extends Events>(event: EmitEvent<T>): void {
        this.subject.next(event);
    }

    public on<T extends Events>(event: T, action: (payload: EventPayloadMap[T]) => void): void {
        return this.subject
            .pipe(
                filter((e: EmitEvent<T>) => e.name === event),
                map((e: EmitEvent<T>) => e.value),
            )
            .subscribe(action);
    }
}

Tổng kết

Vậy là ta đã đi qua một hành trình khám phá cách các component trong Angular giao tiếp với nhau. Angular cung cấp cho bạn nhiều phương thức để thực hiện điều này. Trong khi những giải pháp tích hợp sẵn thường lạm dụng mẫu Observer, không có gì ngăn bạn sử dụng các phương thức trao đổi dữ liệu khác như Mediator hoặc Event Bus.

Dù cả Mediator và Event Bus đều nhằm giải quyết cùng một vấn đề, nhưng giữa chúng lại có những khác biệt mà trước khi quyết định cuối cùng, bạn cần cân nhắc.

Mediator tiết lộ observables trực tiếp đến các component, tạo ra sự tin cậy, kiểm soát tốt hơn các dependency và trải nghiệm debugger tốt hơn. Các mối quan hệ rõ ràng giữa các component không chỉ nghĩa là coupling, mà còn cần viết code mẫu. Với mỗi sự kiện, bạn cần một tập hợp các phương thức tương tự. Với mỗi chức năng mới, bạn cũng cần một Service mới để không bị chìm trong biển code.

image.png

Mặt khác, Event Bus có thể mở rộng hơn và không tăng kích thước tùy thuộc vào số lượng sự kiện. Nó khá linh hoạt và chung chung, nghĩa là nó có thể được sử dụng bởi bất kỳ component nào trong hệ thống. Các component chính rất lỏng lẻo và không thay đổi khi xuất hiện sự kiện mới. Có thể dường như đây là phương pháp lý tưởng, cho đến một ngày bạn thức dậy và nhận ra rằng việc lạm dụng Event Bus đã dẫn bạn đến sự hiểu lầm hoàn toàn về hệ thống của mình và không biết làm thế nào để debug nó. 😱

image.png

Dù sao, quyết định cuối cùng vẫn nằm ở bạn. Hãy nhớ, dù chọn cách tiếp cận nào, chúng ta đều đang làm việc với observable, nghĩa là chúng ta phải hủy đăng ký.

Đây chỉ là một vài cách để cải thiện khả năng giao tiếp giữa các components trong Angular, nhưng quan trọng hơn cả, là bạn phải luôn nhớ rằng bất cứ giải pháp nào cũng đều có ưu và nhược điểm. Việc lựa chọn sử dụng giải pháp nào phụ thuộc vào yêu cầu cụ thể của dự án và kinh nghiệm của bạn với những kỹ thuật này.

Vậy nên, hãy tận dụng tối đa những kiến thức mà bạn có, hãy thử nghiệm, học hỏi và không ngần ngại khám phá những giải pháp mới. Đừng quên, bất kể bạn chọn phương pháp nào, đừng quên unsubscribe khi bạn không còn cần đến các thông tin từ observable nữa, nhé!

Bonus cách mà mình thường hay dùng để unsubscribe

// Tạo một Subject
destroy$ = new Subject();

// Tại các vị trí subscribe hãy dùng operator takeUntil
this.observableVariable$
    .pipe(takeUntil(this.destroy$))
    .subscribe(() => {
        //... TODO
    });

// Cuối cùng tại LifecycleHook ngOnDestroy hãy next cho nó một giá trị bất kỳ
ngOnDestroy() {
    this.destroy$.next(null);
}

Operator takeUntil được sử dụng để hủy bỏ các subscription khi observable destroy$ phát ra giá trị.

Khi chúng ta sử dụng takeUntil trên một observable, nó lắng nghe giá trị phát ra từ observable đó cho đến khi observable khác (ở đây chính là destroy$) phát ra giá trị. Khi điều này xảy ra, takeUntil tự động hủy bỏ các subscription trên observable gốc.

Trong đoạn code trên, khi component được hủy bỏ, chúng ta gọi this.destroy$.next(null) để phát ra giá trị null từ observable destroy$. Khi giá trị này được phát ra, takeUntil nhận được nó và hủy bỏ các subscription trên observableVariable$. Điều này đảm bảo rằng không có thêm công việc nào được thực hiện trên observableVariable$ sau khi component đã bị hủy bỏ, giúp tránh các vấn đề liên quan đến bộ nhớ và memory leak.

Nguồn: Viblo