Tránh Prop Drilling với Provide/Inject trong Angular
Prop Drilling
xảy ra khi một input được truyền qua nhiều component trung gian từ component gốc đến component con cấp dưới. Đây được coi là một anti-pattern vì nó dẫn đến việc code bị ràng buộc chặt chẽ, khó bảo trì và ảnh hưởng đến hiệu suất do kích hoạt các chu kỳ kiểm tra thay đổi (change detection) không cần thiết.
Ví dụ ban đầu hiển thị một cây component nơi component App có hai component con. Mỗi component con lại có một component cháu. Component App
có một nút để thay đổi màu nền của component cháu. Giá trị này được truyền từ App xuống component cháu thông qua component con. Cuối cùng, component cháu sử dụng giá trị này để thay đổi màu nền giữa màu vàng và trong suốt.
Giải pháp sửa đổi sử dụng mẫu provide/inject để khắc phục vấn đề prop drilling. Component App
khai báo một InjectionToken để cung cấp giá trị toggle. Component cháu sau đó sẽ inject token này để lấy giá trị. Cuối cùng, component cháu sử dụng giá trị đó để xác định màu nền của nó.
Prop Drilling trong các Component Angular
Demo 1: Giải pháp Prop Drilling
@Component({
selector: 'app-root',
imports: [OnPushChildComponent],
template: `
<p>Time: {{ showCurrentTime() }}</p>
<button (click)="toggle()">Toggle Grandchild's background</button>
<div class="child" >
<app-on-push-child [toggleGrandchild]="toggleGrandchild()" />
<app-on-push-child [toggleGrandchild]="toggleGrandchild()" />
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
toggleGrandchild = signal(false);
toggle() {
this.toggleGrandchild.update((prev) => !prev);
}
showCurrentTime() {
return getCurrentTime();
}
}
Component App
có một nút để chuyển đổi giá trị của biến toggleGrandchild
. Biến này được truyền xuống OnPushChildComponent
. Ngoài ra, phương thức showCurrentTime
hiển thị thời gian hiện tại để kiểm tra khi nào chu kỳ change detection xảy ra. Khi nhấn nút, App
cập nhật thời gian trong quá trình kiểm tra thay đổi.
@Component({
selector: 'app-on-push-child',
imports: [OnPushGrandChildComponent],
template: `
<div class="container">
<h3>Child Component</h3>
<p>Time: {{ showCurrentTime() }}</p>
<app-on-push-grandchild [toggleGrandchild]="toggleGrandchild()" />
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPushChildComponent {
toggleGrandchild = input(false);
showCurrentTime() {
return getCurrentTime();
}
}
Component OnPushChildComponent
không sử dụng giá trị toggleGrandchild
, mà chỉ đơn giản truyền nó xuống OnPushGrandchildComponent
. Tuy nhiên, khi nhận giá trị mới, nó vẫn phải cập nhật lại template để hiển thị thời gian hiện tại mới.
@Component({
selector: 'app-on-push-grandchild',
template: `
<div class="container" [style.background]="background()">
<h3>Grandchild Component</h3>
<p>{{ showCurrentTime() }}</p>
<p>toggle: {{ toggleGrandchild() }}</p>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPushGrandchildComponent {
toggleGrandchild = input(false);
background = computed(() => this.toggleGrandchild() ? 'yellow' : 'transparent');
showCurrentTime() {
return getCurrentTime();
}
}
Component OnPushGrandchildComponent
nhận giá trị toggleGrandchild
và sử dụng nó để tính toán màu nền (background). Trong template, thuộc tính [style.background]
thay đổi màu nền dựa trên giá trị của biến tính toán này.
Vấn đề của cách tiếp cận này:
- Code khó bảo trì: Nếu component
App
cần truyền nhiều input hơn đếnOnPushGrandchildComponent
, thìOnPushChildComponent
cũng phải cập nhật theo. - Chu kỳ change detection dư thừa:
OnPushChildComponent
bị cập nhật ngay cả khi nó không sử dụng giá trị mới này. - Tight coupling (ràng buộc chặt chẽ):
OnPushChildComponent
trở nên kém linh hoạt, không thể tái sử dụng nếu các component khác không thể cung cấp giá trịtoggleGrandchild
cho nó.
Tiếp theo, tôi sẽ chỉ cách tránh prop drilling bằng cách sử dụng InjectionToken và mẫu provide/inject.
Mô hình Provide/Inject
Demo 2: Sử dụng Provide/Inject trong Providers Array
Tạo InjectionTokens để chuyển đổi:
import { InjectionToken, Signal, WritableSignal } from "@angular/core";
export const TOGGLE_TOKEN = new InjectionToken<WritableSignal<boolean>>('TOGGLE_TOKEN');
export const BACKGROUND_TOKEN = new InjectionToken<Signal<string>>('BACKGROUND_TOKEN');
Khai báo mã thông báo TOGGLE_TOKEN và BACKGROUND_TOKEN để đưa giá trị chuyển đổi và màu nền.
import { computed, signal } from '@angular/core';
import { BACKGROUND_TOKEN, CURRENT_TIME_TOKEN, TOGGLE_TOKEN } from './toggle.constant';
export const toggleValue = signal(false);
export const background = computed(() => toggleValue()? 'yellow' : 'transparent');
export const toggleProviders = [
{
provide: TOGGLE_TOKEN,
useValue: toggleValue,
},
{
provide: BACKGROUND_TOKEN,
useValue: background,
},
]
Mảng toggleProviders cung cấp các giá trị của InjectionToken.
- TOGGLE_TOKEN: Cung cấp một biến boolean để lưu trạng thái toggle.
- BACKGROUND_TOKEN: Cung cấp một biến tính toán (computed signal) để xác định màu nền.
Khai báo Providers trong App Component
<p>{{ `Time: ${showCurrentTime()}` }}</p>
<button (click)="toggle()">Toggle background</button>
<div class="child" >
<app-on-push-child />
<app-on-push-child />
</div>
@Component({
selector: 'app-root',
imports: [OnPushChildComponent],
templateUrl: './app.component.html',
providers: toggleProviders,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
toggleValue = inject(TOGGLE_TOKEN);
showCurrentTime = inject(CURRENT_TIME_TOKEN);
toggle() {
this.toggleValue.update((prev) => !prev);
}
}
App
injects TOGGLE_TOKEN
để lấy giá trị toggle. Khi nhấn nút trong template, giá trị của toggleValue
được thay đổi.
Component Con
@Component({
selector: 'app-on-push-child',
imports: [OnPushGrandChildComponent],
template: `
<h3>Child Component</h3>
<p>Time: {{ showCurrentTime() }}</p>
<app-on-push-grand-child />
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPushChildComponent {}
OnPushChildComponent
không cần thay đổi gì vì nó không còn phải truyền toggleGrandchild
xuống nữa.
@Component({
selector: 'app-on-push-grandchild',
template: `
<div class="container" [style.background]="background()">
<h3>Grandchild Component</h3>
<p>Time: {{ showCurrentTime() }}</p>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPushGrandchildComponent {
background = inject(BACKGROUND_TOKEN)
}
OnPushGrandchildComponent
Inject BACKGROUND_TOKEN
để lấy màu nền tính toán. Giá trị này được gán vào [style.background]
để thay đổi màu nền.
Kết quả:
- Khi nhấn nút trong
App
, màu nền củaOnPushGrandchildComponent
thay đổi giữa vàng và trong suốt. - Template cũng cập nhật thời gian hiện tại, nhưng
OnPushChildComponent
không bị ảnh hưởng và không bị cập nhật.
Lợi ích của Provide/Inject
- Code dễ bảo trì: Component trung gian không cần sửa đổi để hỗ trợ thêm input.
- Tái sử dụng cao: Các component trung gian không bị phụ thuộc vào toggleValue, có thể tái sử dụng linh hoạt hơn.
- Cải thiện hiệu suất: Các component trung gian không bị kích hoạt change detection dư thừa.
Tóm lại
Sử dụng Provide/Inject giúp Angular xử lý trạng thái tốt hơn mà không gây ra prop drilling
. Điều này giúp cải thiện tính tái sử dụng, hiệu suất, và bảo trì code trong các ứng dụng Angular có nhiều component lồng nhau.
All rights reserved