Komponenty renderless w Angularze
Pracuję zarówno z Angularem, jak i Reactem. Jestem też wielkim fanem Vue i Svelte i cały czas śledzę, co się z nimi dzieje. W tym artykule pokażę, jak w Angularze można osiągnąć to, co w Vue nazywa się komponentami renderless, a w React właściwościami renderującymi (render props). Jak sama nazwa wskazuje, renderless odnosi się do komponentów, które niczego nie renderują; ich jedynym zadaniem jest zapewnienie funkcji wielokrotnego użytku.
Weźmy na przykład przełącznik napisany w Vue i przekonwertujmy go na Angulara. Oto wersja Vue:
<toggle>
<div slot-scope="{ on, setOn, setOff }">
<button @click="click(setOn)">Blue pill</button>
<button @click="click(setOff)">Red pill</button>
<div>
<span v-if="on">It's all a dream, go back to sleep.</span>
<span v-else>
I don't know how far the rabbit hole goes,
I'm not a rabbit, neither do I measure holes.
</span>
</div>
</div>
</toggle>
Przełącznik ten w wersji renderless jest odpowiedzialny za udostępnianie API do przełączania widoku i nie ma znaczenia, jaka jest struktura lub styl widoku. Możemy osiągnąć to samo w Angularze, używając dyrektyw strukturalnych.
Dyrektywy strukturalne
Dyrektywa strukturalna zmienia układ DOM, dodając i usuwając elementy DOM (tj. view
). Ma ona również funkcję dostarczania obiektu context
, który jest dostępny dla każdego, kto go używa.
Utwórzmy dyrektywę strukturalną toggle
, której API będzie dostępne za pośrednictwem właściwości context
:
type Toggle = {
on: boolean;
setOn: Function;
setOff: Function;
toggle: Function;
}
@Directive({ selector: '[toggle]' })
export class ToggleDirective implements OnInit {
on = true;
@Input('toggleOn') initialState = true;
constructor(private tpl: TemplateRef<{ $implicit: Toggle }>,
private vcr: ViewContainerRef) {
}
ngOnInit() {
this.on = this.initialState;
this.vcr.createEmbeddedView(this.tpl, {
$implicit: {
on: this.on,
setOn: this.setOn,
setOff: this.setOff,
toggle: this.toggle,
}
});
}
setOn() { this.on = true }
setOff() { this.on = false }
toggle() { this.on = !this.on }
}
Tworzymy view
i przekazujemy publiczne API, które chcemy wystawić za pośrednictwem context
, który jest drugim parametrem createEmbeddedView
.
Fajną rzeczą jest to, że TemplateRef
przyjmuje typ ogólny, który służy jako kontekst. Nowoczesne IDE, takie jak Webstorm, będą w stanie obsłużyć to bez problemu. Użyjmy więc następującego:
<div *toggle="let controller; on: false">
<button (click)="controller.setOn()">Blue pill</button>
<button (click)="controller.setOff()">Red pill</button>
<div>
<span *ngIf="controller.on">...</span>
<span *ngIf="!controller.on">...</span>
</div>
</div>
Dzięki Webstorm! ?
ExportAs
W drugim przypadku skorzystajmy z oficjalnego przykładu Reacta z właściwościami renderującymi. Właściwości renderujące umożliwiają nam współdzielenie stanu lub zachowania, hermetyzującego jeden komponent, z tymi, które również tego stanu potrzebują. Następujący komponent Reacta śledzi pozycję myszy w aplikacji webowej:
class Mouse extends React.Component {
state = { x: 0, y: 0 };
handleMouseMove = (event) {
this.setState({
x: event.clientX,
y: event.clientY
});
}
render() {
return (
<div style={{ height: '100vh' }} onMouseMove={this.handleMouseMove}>
{this.props.children(this.state)}
</div>
);
}
}
Używamy właściwości renderujących children
jako funkcji, która ujawnia stan komponentu każdemu konsumentowi. Technika ta sprawia, że zachowanie, które musimy udostępniać, jest niezwykle przenośne. Aby tego zachowania użyć, wyrenderuj <Mouse>
z właściwością, która mówi mu, co ma renderować dla bieżącego (x, y)
kursora:
class MouseTracker extends React.Component {
render() {
return (
<div>
<h1>Move the mouse around!</h1>
<Mouse>
{mouse => (
<p>The mouse position is {mouse.x}, {mouse.y}</p>
)}
</Mouse>
</div>
);
}
}
Stwórzmy tę samą funkcję w Angularze:
@Component({
selector: 'mouse',
exportAs: 'mouse',
template: `
<div (mousemove)="handleMouseMove($event)">
<ng-content></ng-content>
</div>
`
})
export class MouseComponent {
private _state = { x: 0, y: 0 };
get state() {
return this._state;
};
handleMouseMove(event) {
this._state = {
x: event.clientX,
y: event.clientY
};
}
}
MouseComponent
nie przejmuje się zawartością. Udostępnia swoje API za pomocą właściwości exportAs
, która mówi Angularowi, że możemy użyć tego komponentu w szablonie:
@Component({
selector: 'mouse-tracker',
template: `
<mouse #mouse="mouse">
<p>The mouse position is {{ mouse.state.x }}, {{ mouse.state.y }}</p>
</mouse>
`
})
export class MouseTrackerComponent {}
I to by było na tyle. Możemy też skorzystać z techniki exportAs
, aby utworzyć przełącznik, który napisaliśmy wcześniej, zamiast używać dyrektywy strukturalnej.
Podsumowanie
Jeśli chodzi o używanie dyrektywy strukturalnej lub funkcji exportAs
, to myślę, że nie ma dobrych i złych odpowiedzi. Korzyści z używania dyrektyw strukturalnych są takie, że możemy dzięki nim zdefiniować API, które ujawniamy w widoku oraz łatwo kontrolować, czy widok ma być renderowany, czy nie. Gdy nadal potrzebujemy jakiejś części widoku, jak w przykładzie z MouseComponent, możemy użyć komponentu i funkcji exportAs
.
Oryginał tekstu w języku angieslkim możesz przeczytać tutaj.