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.
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:
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:
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.
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 Eventobservable
- dựa trên subject đó, để component có thể đăng ký nhận dữ liệunotifyOfXXXChanged
- 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.
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ó. 😱
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.
English Version
Introduction
In any application, data exchange between components is always essential. And Angular is no exception. Although this task is so common that you might think, "Oh, it's a piece of cake!", in reality, Angular provides various available approaches. Sometimes, these methods are not sufficient, and you need to find more optimal solutions.
So, how do you know if you're writing "bad code"? How do you know you're not "reinventing the wheel"? It's simple. Just learn from others' mistakes and gain experience from them. 😁
Today, I will guide you on how to exchange data between components in Angular. What are the default methods? When are they not enough? And how to handle more complex situations.
If you're ready, let's dive deep into this topic. 🤿
Available Approaches
Parent-child Relationship
Let's start with the most basic scenario. The simplest case is when you have a parent component that wants to pass data to a child component.
In this case, the child component needs to define a property with the Input()
decorator to be able to receive the data:
@Component({
selector: 'child-component',
template: `<p>{{ textProperty }}</p>`
})
export class ChildComponent {
@Input() public textProperty: string;
}
And then, the parent component can use binding to pass the data:
@Component({
selector: 'parent-component',
template: `
<child-component [textProperty]="parentProp"></child-component>
`
})
export class ParentComponent {
public parentProp = 'Hello world';
}
Child-parent Relationship
Usually, you would want to do the reverse, i.e., pass data from a child component to a parent component:
This time, you will need another decorator, Output()
, which is essentially a callback function:
@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');
}
}
So, this time, the parent component can react to the event from the child component and handle the data in its own way:
@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;
}
}
Sibling Relationship
Another common scenario is when you have two sibling components that need to communicate with each other:
Actually, it might seem complex, but you should use a combination of the above two methods.
The idea here is simple: send data from one child component to the parent, and then pass it back to the other child component from the parent.
It may sound like a "code-smell" approach, but for simple situations, it works and there's no reason to make it more complicated.
When the Default Approaches Are Not Enough
Okay, the simple cases have been solved, now let's move on to more challenging situations.
Think about the scenario we mentioned earlier. However, this time you have multiple levels of nested components.
What would you do now? Would you send the data to one parent component, then pass it to another parent component, and so on until you reach the highest component in the hierarchy? And then what? Would you push it back down through all the levels? Yeah, that sounds not easy, right? 😨
Here are some commonly used techniques that can help.
Mediator
While communication between standard components is similar to the Observer Pattern, the Mediator Pattern is a quite popular approach.
In this case, the components don't know about each other and use a mediator to communicate. It's just a simple Service with a pair of properties for each Event:
@Injectable({ providedIn: 'root' })
class EventMediator
{
// customerChanged event
private customerChangedSubject$ = new BehaviorSubject<CustomerData>(null);
public customerChanged = this.customerChangedSubject$.asObservable();
public notifyOnCustomerChanged(customerData: CustomerData): void {
this.customerChangedSubject$.next(customerData);
}
// productChanged event
private productChangedSubject$ = new BehaviorSubject<ProductData>(null);
public productChanged = this.productChangedSubject$.asObservable();
public notifyOnProductChanged(productData: ProductData): void {
this.productChangedSubject$.next(productData);
}
}
Each Event has three necessary components:
subject
- where Events are storedobservable
- based on that subject, so components can subscribe to receive datanotifyOfXXXChanged
- a method to trigger a new event
I've used BehaviorSubject
here, so a component can subscribe later and still receive the last emitted value, but the choice of subject depends on the use case and your needs.
Also note that xxxChangedSubject$
is private
and not exposed directly. Sure, we could just use a public
subject and skip the observable and event-emitting methods. However, in practice, it would create a nightmare of global variables, with uncontrolled access to the data, leading to hours of debugging, trying to figure out which component triggered events and which received them. In short, taking a few minutes to do it right now can save you hours later. (That's why I rarely use any
because it's horrible when you have to debug a feature that you didn't even code, and the person who coded it used any
everywhere... 🤕)
Using the mediator is quite simple. To send data, just use the corresponding notifyOnXXXChanged()
method:
@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);
}
}
To receive the information, simply subscribe to the topic (Subject) you're interested in:
@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;
});
}
}
Note that having multiple mediator services for different purposes is common.
Service Bus
Let's explore another solution for the same problem, which is quite similar, called the Service Bus.
Instead of using a Mediator, we can rely on the Service Bus. With the Service Bus, each component only needs to know about the Service Bus, meaning the components are more loosely coupled. But the downside is, it's not clear who triggered an event without additional information.
// (recommended) Use enum instead of 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);
}
}
The Service Bus is just a service. We don't need many methods for each event anymore, only emit()
and on()
are enough.
The events are stored in a single subject. With emit()
, you simply push a new event into it, while on()
allows you to subscribe to the type of event you're interested in.
Before sending a new event, you have to declare it:
// Event name
enum Events {
CustomerSelected,
//...
}
// Event data
class CustomerSelectedEventData {
constructor(public name: string) { }
}
Then, a component can publish (emit) it:
@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);
}
}
Meanwhile, another component can easily consume it:
@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;
});
}
}
Note, although we have TypeScript here, it is not Type-safe. Changing the name of the event and TypeScript will result in the wrong submission
This Implement way has worked well for a long time, but if you really want to make it as safe as possible then you can refer to the code below 🤓.
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);
}
}
Note that using a mediator or a service bus for communication between components depends on the complexity of your application and your specific requirements. Choose the approach that best suits your needs.
Summary
So, we've gone through a journey exploring how components in Angular communicate with each other. Angular provides you with multiple methods to accomplish this. While the built-in solutions often abuse the Observer pattern, there's nothing stopping you from using other data exchange methods like Mediator or Event Bus.
Although both Mediator and Event Bus aim to solve the same problem, they have their differences that you need to consider before making the final decision.
Mediator exposes observables
directly to components, creating more trust, better control over dependencies, and a better debugging experience. Clear relationships between components not only mean coupling but also a need for code boilerplate. For each event, you need a set of similar methods. For each new feature, you also need a new Service to avoid drowning in code.
On the other hand, the Event Bus can be more extensible and doesn't grow in size depending on the number of events. It's quite flexible and generic, meaning it can be used by any component in the system. The main components are very loosely coupled and don't change when new events appear. It may seem like the ideal approach until one day you wake up and realize that abusing the Event Bus has led you to a complete misunderstanding of your system and not knowing how to debug it. 😱
Anyway, the final decision is up to you. Remember, regardless of the chosen approach, we're dealing with observables
, which means we have to unsubscribe.
These are just a few ways to improve communication between components in Angular, but more importantly, you must always remember that every solution has its pros and cons. Choosing which approach to use depends on the specific requirements of your project and your experience with these techniques.
So, make the most of the knowledge you have, experiment, learn, and don't hesitate to explore new solutions. And remember, no matter which method you choose, don't forget to unsubscribe when you no longer need information from the observable
!
Bonus: The way I usually use to unsubscribe
// Create a Subject
destroy$ = new Subject();
// Use the takeUntil operator at subscription points
this.observableVariable$
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
//... TODO
});
// Finally, in the ngOnDestroy Lifecycle Hook, emit a value to it
ngOnDestroy() {
this.destroy$.next(null);
}
The takeUntil
operator is used to cancel subscriptions when the destroy$
observable emits a value.
When we use takeUntil
on an observable, it listens for values emitted from that observable until another observable (in this case, destroy$
) emits a value. When this happens, takeUntil
automatically cancels the subscriptions on the original observable.
In the above code snippet, when the component is destroyed, we call this.destroy$.next(null)
to emit a null
value from the destroy$
observable. When this value is emitted, takeUntil
receives it and cancels the subscriptions on observableVariable$
. This ensures that no further work is done on observableVariable$
after the component has been destroyed, avoiding memory-related issues and memory leaks.
日本語版
紹介
どんなアプリケーションでも、コンポーネント間でのデータのやり取りは必須です。Angularも例外ではありません。このタスクは非常に一般的なものですが、「簡単なものだろう」と思うかもしれませんが、実際にはAngularではさまざまな方法が提供されています。時にはこれらの方法だけでは十分ではなく、より最適な解決策を見つける必要があります。
では、自分が「悪いコード」を書いていないかどうか、他の人の失敗から学び、経験を積むことで分かるでしょう。😁
今日は、Angularでコンポーネント間でデータをやり取りする方法をご紹介します。デフォルトの方法は何か?それだけでは足りない場合はどうするか?そして、より複雑な状況をどう処理するか。
準備ができたら、このトピックに深く入りましょう。🤿
利用可能な方法
親子関係
まずは最も基本的なシナリオから始めましょう。もっともシンプルなケースは、親コンポーネントから子コンポーネントにデータを渡したい場合です。
この場合、子コンポーネントはInput()
デコレータを使用してデータを受け取るためのプロパティを定義する必要があります。
@Component({
selector: 'child-component',
template: `<p>{{ textProperty }}</p>`
})
export class ChildComponent {
@Input() public textProperty: string;
}
そして、親コンポーネントはバインディングを使用してデータを渡すことができます。
@Component({
selector: 'parent-component',
template: `
<child-component [textProperty]="parentProp"></child-component>
`
})
export class ParentComponent {
public parentProp = 'Hello world';
}
子親関係
通常、逆の操作、つまり子コンポーネントから親コンポーネントにデータを渡す場合があります。
今度は、別のデコレータOutput()
が必要です。これは基本的にはコールバック関数です。
@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');
}
}
したがって、今度は親コンポーネントが子コンポーネントからのイベントに反応し、データを自分の方法で処理することができます。
@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;
}
}
兄弟関係
もう一つよくあるシナリオは、互いに通信する必要がある2つの兄弟コンポーネントがある場合です。
実際には、複数の方法を組み合わせて使用するべきですが、これは複雑に見えるかもしれません。
ここでのアイデアはシンプルです。一つの子コンポーネントからデータを親に送り、親からもう一つの子コンポーネントに渡すことです。
これは「不自然なアプローチ」のように思えるかもしれませんが、単純な状況ではこれで問題ありませんし、複雑にする理由もありません。
デフォルトのアプローチだけでは足りない場合
はい、シンプルなケースは解決しました。では、より困難な状況に移りましょう。
先ほど言及したシナリオを考えてみてください。ただし、今回は複数の入れ子のコンポーネントがあります。
さて、どうしますか?データを1つの親コンポーネントに送り、次に別の親コンポーネントに渡し、階層の最上位のコンポーネントまで続けるでしょうか?それからどうしますか?すべてのレベルを通してデータを下に送り返すのでしょうか?うーん、簡単ではなさそうですね。 😨
以下は、役立つ一般的なテクニックです。
Mediator(仲介者)
標準コンポーネント間の通信はObserverパターンに似ていますが、Mediatorパターンは非常にポピュラーなアプローチです。
この場合、コンポーネント同士は互いを知らず、通信には仲介者(Mediator)を使用します。仲介者は、各イベントに対して一対のプロパティを持つ単純なサービスです。
@Injectable({ providedIn: 'root' })
class EventMediator
{
// customerChangedイベント
private customerChangedSubject$ = new BehaviorSubject<CustomerData>(null);
public customerChanged = this.customerChangedSubject$.asObservable();
public notifyOnCustomerChanged(customerData: CustomerData): void {
this.customerChangedSubject$.next(customerData);
}
// productChangedイベント
private productChangedSubject$ = new BehaviorSubject<ProductData>(null);
public productChanged = this.productChangedSubject$.asObservable();
public notifyOnProductChanged(productData: ProductData): void {
this.productChangedSubject$.next(productData);
}
}
各イベントには次の3つの必要なコンポーネントがあります。
subject
- イベントが格納される場所observable
- そのsubjectに基づいたもので、コンポーネントはデータを受け取るために購読できますnotifyOfXXXChanged
- 新しいイベントをトリガーするためのメソッド
ここではBehaviorSubject
を使用しています。そのため、コンポーネントが後から購読し、最後に発行された値を受け取ることができますが、使用するsubjectの選択肢は使用ケースや必要性によります。
また、xxxChangedSubject$
はprivate
で直接公開されていないことにも注意してください。もちろん、単にpublic
なsubjectを使用してobservableとイベント発生のメソッドを省略することもできます。しかし、実際には、グローバル変数の悪夢を作り出し、データへの制御されていないアクセスが生じ、イベントをトリガーしたコンポーネントと受け取ったコンポーネントを特定するために時間をかけてデバッグを行うことになります。つまり、今から少し時間をかけて正しく行うことで、後で数時間を節約できるのです。(だから私はあまりany
を使わないんですよ。別の人がコーディングした機能のデバッグをしなければならないとき、それもany
を使いまくった人がコーディングした機能をデバッグしなければならないとき、それはひどいんですよ… 🤕)
仲介者を使用することは非常に簡単です。データを送信するには、対応するnotifyOnXXXChanged()
メソッドを使用するだけです。
@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);
}
}
情報を受け取るには、単純に関心のあるSubjectに購読します。
@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;
});
}
}
異なる目的のために複数の仲介者サービスを持つことは一般的です。
サービスバス
同じ問題に対する別の解決策であるサービスバスを探ってみましょう。これは非常に似ていますが、少し異なる方法です。
仲介者(Mediator)の代わりに、サービスバスを使用することができます。サービスバスでは、各コンポーネントはサービスバスについて知る必要があります。つまり、コンポーネント同士の結合度はより緩くなります。ただし、追加情報なしでイベントをトリガーしたコンポーネントが誰なのかは明確ではありません。
//(すすめ)ユニオンの代わりにenumを使用する
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);
}
}
サービスバスはただのサービスです。もはや各イベントごとに多くのメソッドは必要ありません。emit()
とon()
だけで十分です。
イベントは単一のsubjectに格納されます。emit()
では単に新しいイベントをプッシュしますが、on()
では興味のあるイベントのタイプに購読することができます。
新しいイベントを送信する前に、宣言する必要があります。
// イベント名
enum Events {
CustomerSelected,
//...
}
// イベントデータ
class CustomerSelectedEventData {
constructor(public name: string) { }
}
その後、コンポーネントはそれをemitできます。
@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);
}
}
一方、別のコンポーネントは簡単にそれを消費できます。
@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;
});
}
}
ねえ、これを見てみよう!ここではTypeScriptを使っているけど、Typeの安全性が保たれていないんだよ。イベントの名前やTypeScriptを変えると、正しい結果が得られないから気をつけてね。
これまでの実装方法は長い間うまく動いてきたけど、できるだけ安全にするなら、下のコードを参考にしてみて 🤓。
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);
}
}
コンポーネント間の通信には、メディエータやサービスバスを使用するかどうかは、アプリケーションの複雑さと具体的な要件に依存します。最適なアプローチを選択してください。
結論
それでは、Angularのコンポーネント間での通信方法を探求してきました。Angularでは、複数の方法を提供しています。組み込みのソリューションはしばしばObserverパターンを乱用していますが、メディエータやイベントバスなど、他のデータ交換方法を使用することもできます。
メディエータとイベントバスは、同じ問題を解決することを目指していますが、最終的な選択をする前に考慮すべき違いがあります。
メディエータはobservables
をコンポーネントに直接公開することで、より信頼性があり、依存関係の制御が向上し、デバッグの経験も向上します。コンポーネント間の明確な関係性は結合だけでなく、コードの雛形の必要性も意味します。各イベントごとに同様のメソッドのセットが必要です。新機能ごとに新しいサービスが必要になり、コードの泥沼にはまらないようにする必要があります。
一方、イベントバスはより拡張可能であり、イベントの数に応じてサイズが増えません。非常に柔軟で汎用的であり、システム内のどのコンポーネントでも使用できます。主要なコンポーネントは緩く結合されており、新しいイベントが現れても変更はありません。これは理想的なアプローチのように思えるかもしれませんが、ある日目が覚めてイベントバスの乱用によりシステムの完全な誤解とデバッグ方法の不明瞭さに直面することになるかもしれません 😱
とにかく、最終的な決定はあなた次第です。選択したアプローチに関わらず、私たちはobservables
を扱っていることを忘れないでください。つまり、unsubscribeする必要があります。
Angularのコンポーネント間の通信を改善するためのいくつかの方法を紹介しましたが、さらに重要なことは、すべての解決策には長所と短所があるということを常に覚えておくことです。どのアプローチを使用するかは、プロジェクトの具体的な要件とこれらの技術に対する経験に依存します。
したがって、持っている知識を最大限に活用し、実験し、学び、新しい解決策を探求するために躊躇しないでください。そして、どの方法を選んだとしても、observable
から情報を不要になったら、unsubscribeすることを忘れないでください!
ボーナス: 解除するための私の通常の方法
// Subjectを作成する
destroy$ = new Subject();
// 購読ポイントでtakeUntil演算子を使用する
this.observableVariable$
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
//... TODO
});
// 最後に、ngOnDestroyライフサイクルフックで値を発行する
ngOnDestroy() {
this.destroy$.next(null);
}
takeUntil
演算子は、destroy$
observableが値を発行したときに購読をキャンセルするために使用されます。
ObservableにtakeUntil
を使用すると、別のObservable(この場合はdestroy$
)が値を発行するまで、そのObservableから発行される値を受け取ります。このような場合、takeUntil
は元のObservable上の購読を自動的にキャンセルします。
上記のコードスニペットでは、コンポーネントが破棄されるときに、this.destroy$.next(null)
を呼び出してdestroy$
observableからnull
の値を発行しています。この値が発行されると、takeUntil
がそれを受け取り、observableVariable$
上の購読をキャンセルします。これにより、コンポーネントが破棄された後にobservableVariable$
上での作業が行われないようになり、メモリ関連の問題やメモリリークが回避されます。
Mình hy vọng bạn thích bài viết này và học thêm được điều gì đó mới.
Donate mình một ly cafe hoặc 1 cây bút bi để mình có thêm động lực cho ra nhiều bài viết hay và chất lượng hơn trong tương lai nhé. À mà nếu bạn có bất kỳ câu hỏi nào thì đừng ngại comment hoặc liên hệ mình qua: Zalo - 0374226770 hoặc Facebook. Mình xin cảm ơn.
Momo: NGUYỄN ANH TUẤN - 0374226770
TPBank: NGUYỄN ANH TUẤN - 0374226770 (hoặc 01681423001)
Ref
All rights reserved