Komponenty
Struktura
Komponenty to mechanizm hermetyzacji fragmentów interfejsu użytkownika, ułatwiający organizację i ponowne wykorzystanie kodu.
Komponentem Mithril.js jest każdy obiekt JavaScript posiadający metodę view
. Komponenty używa się za pomocą funkcji m()
:
// Definicja komponentu
var Example = {
view: function (vnode) {
return m('div', 'Hello');
},
};
// Użycie komponentu
m(Example);
// Równoważny kod HTML
// <div>Hello</div>
Metody cyklu życia
Komponenty mogą implementować te same metody cyklu życia, co wirtualne węzły DOM. Warto zauważyć, że vnode
jest przekazywany jako argument do każdej metody cyklu życia oraz do funkcji view
(dodatkowo, poprzedni vnode
jest przekazywany do onbeforeupdate
):
var ComponentWithHooks = {
oninit: function (vnode) {
console.log('initialized');
},
oncreate: function (vnode) {
console.log('DOM created');
},
onbeforeupdate: function (newVnode, oldVnode) {
return true;
},
onupdate: function (vnode) {
console.log('DOM updated');
},
onbeforeremove: function (vnode) {
console.log('exit animation can start');
return new Promise(function (resolve) {
// Wywołaj po zakończeniu animacji
resolve();
});
},
onremove: function (vnode) {
console.log('removing DOM element');
},
view: function (vnode) {
return 'hello';
},
};
Podobnie jak inne typy wirtualnych węzłów DOM, komponenty mogą mieć dodatkowe metody cyklu życia zdefiniowane podczas używania ich jako typów vnode.
function initialize(vnode) {
console.log('initialized as vnode');
}
m(ComponentWithHooks, { oninit: initialize });
Metody cyklu życia zdefiniowane w vnode nie przesłaniają metod komponentu, ani odwrotnie. Metody cyklu życia komponentu są zawsze uruchamiane po odpowiadającej im metodzie vnode.
Należy unikać używania nazw metod cyklu życia dla własnych funkcji zwrotnych w vnode
.
Aby dowiedzieć się więcej o metodach cyklu życia, zobacz stronę poświęconą metodom cyklu życia.
Przekazywanie danych do komponentów
Dane można przekazywać do instancji komponentu, przekazując obiekt attrs
jako drugi argument do funkcji m()
(hyperscript):
m(Example, { name: 'Floyd' });
Dane te są dostępne w widoku komponentu lub metodach cyklu życia poprzez vnode.attrs
:
var Example = {
view: function (vnode) {
return m('div', 'Hello, ' + vnode.attrs.name);
},
};
UWAGA: Metody cyklu życia mogą być również zdefiniowane w obiekcie attrs
, dlatego należy unikać używania ich nazw dla własnych funkcji zwrotnych, ponieważ byłyby one również wywoływane przez Mithril.js. Używaj ich w attrs
tylko wtedy, gdy chcesz ich używać jako metod cyklu życia.
Stan
Podobnie jak wszystkie wirtualne węzły DOM, komponentowe vnode mogą przechowywać stan. Stan komponentu jest przydatny w architekturach obiektowo zorientowanych, hermetyzacji i rozdzielaniu odpowiedzialności.
Zauważ, że w przeciwieństwie do wielu innych frameworków, mutacja stanu komponentu nie wyzwala przerenderowania ani aktualizacji DOM. Przerenderowania są wykonywane, gdy uruchamiają się programy obsługi zdarzeń, gdy żądania HTTP wykonane przez m.request zostaną zakończone lub gdy przeglądarka przechodzi do różnych tras. Mechanizmy stanu komponentu w Mithril.js służą po prostu jako udogodnienie dla aplikacji.
Jeśli nastąpi zmiana stanu, która nie jest wynikiem żadnego z powyższych warunków (np. po setTimeout
), możesz użyć m.redraw()
, aby ręcznie wywołać przerenderowanie.
Stan komponentu z domknięciem
W powyższych przykładach każdy komponent jest zdefiniowany jako POJO (Plain Old JavaScript Object), który jest używany wewnętrznie przez Mithril.js jako prototyp dla instancji tego komponentu. Możliwe jest użycie stanu komponentu z POJO (jak omówimy poniżej), ale nie jest to najczystsze ani najprostsze podejście. Do tego celu użyjemy komponentu domknięcia, który jest po prostu funkcją otaczającą, która zwraca instancję komponentu POJO, która z kolei przenosi własny, zamknięty zakres.
W komponencie domknięcia stan jest przechowywany w zmiennych zadeklarowanych wewnątrz funkcji zewnętrznej:
function ComponentWithState(initialVnode) {
// Zmienna stanu komponentu, unikalna dla każdej instancji
var count = 0;
// Instancja komponentu POJO: dowolny obiekt z
// funkcją view, która zwraca vnode
return {
oninit: function (vnode) {
console.log('init a closure component');
},
view: function (vnode) {
return m(
'div',
m('p', 'Count: ' + count),
m(
'button',
{
onclick: function () {
count += 1;
},
},
'Increment count'
)
);
},
};
}
Wszelkie funkcje zadeklarowane w domknięciu mają również dostęp do jego zmiennych stanu.
function ComponentWithState(initialVnode) {
var count = 0;
function increment() {
count += 1;
}
function decrement() {
count -= 1;
}
return {
view: function (vnode) {
return m(
'div',
m('p', 'Count: ' + count),
m(
'button',
{
onclick: increment,
},
'Increment'
),
m(
'button',
{
onclick: decrement,
},
'Decrement'
)
);
},
};
}
Komponenty domknięcia są używane w taki sam sposób jak POJO, np. m(ComponentWithState, { passedData: ... })
.
Dużą zaletą komponentów domknięcia jest brak konieczności martwienia się o bindowanie this
podczas podłączania funkcji obsługi zdarzeń. W rzeczywistości this
nigdy nie jest używane i nigdy nie musimy myśleć o niejednoznaczności kontekstu this
.
Stan komponentu POJO
Zaleca się ogólnie stosowanie domknięć do zarządzania stanem komponentów. Jeśli jednak istnieje potrzeba zarządzania stanem w POJO, dostęp do stanu komponentu można uzyskać na trzy sposoby: poprzez inicjalizację (jako plan), za pośrednictwem vnode.state
oraz za pomocą słowa kluczowego this
w metodach komponentu.
Przy inicjalizacji
W przypadku komponentów POJO obiekt komponentu jest prototypem każdej instancji, więc każda właściwość zdefiniowana w obiekcie komponentu będzie dostępna jako właściwość vnode.state
. Umożliwia to prostą inicjalizację stanu na podstawie "planu".
W poniższym przykładzie data
staje się właściwością obiektu vnode.state
komponentu ComponentWithInitialState
.
var ComponentWithInitialState = {
data: 'Initial content',
view: function (vnode) {
return m('div', vnode.state.data);
},
};
m(ComponentWithInitialState);
// Równoważny kod HTML
// <div>Initial content</div>
Przez vnode.state
Jak widać, dostęp do stanu można również uzyskać za pośrednictwem właściwości vnode.state
, która jest dostępna dla wszystkich metod cyklu życia, a także metody view
komponentu.
var ComponentWithDynamicState = {
oninit: function (vnode) {
vnode.state.data = vnode.attrs.text;
},
view: function (vnode) {
return m('div', vnode.state.data);
},
};
m(ComponentWithDynamicState, { text: 'Hello' });
// Równoważny kod HTML
// <div>Hello</div>
Przez słowo kluczowe this
Dostęp do stanu można również uzyskać za pośrednictwem słowa kluczowego this
, które jest dostępne dla wszystkich metod cyklu życia, a także metody view
komponentu.
var ComponentUsingThis = {
oninit: function (vnode) {
this.data = vnode.attrs.text;
},
view: function (vnode) {
return m('div', this.data);
},
};
m(ComponentUsingThis, { text: 'Hello' });
// Równoważny kod HTML
// <div>Hello</div>
Należy pamiętać, że podczas używania funkcji ES5 wartość this
w zagnieżdżonych funkcjach anonimowych nie jest instancją komponentu. Istnieją dwa zalecane sposoby obejścia tego ograniczenia JavaScript: użyj funkcji strzałkowych lub, jeśli nie są one obsługiwane, użyj vnode.state
.
Klasy
Jeśli to odpowiada Twoim potrzebom (jak w projektach obiektowo zorientowanych), komponenty można również pisać za pomocą klas:
class ClassComponent {
constructor(vnode) {
this.kind = 'class component';
}
view() {
return m('div', `Hello from a ${this.kind}`);
}
oncreate() {
console.log(`A ${this.kind} was created`);
}
}
Komponenty klasowe muszą definiować metodę view()
, wykrywaną poprzez .prototype.view
, aby umożliwić renderowanie drzewa.
Mogą być używane w taki sam sposób, jak zwykłe komponenty.
// PRZYKŁAD: przez m.render
m.render(document.body, m(ClassComponent));
// PRZYKŁAD: przez m.mount
m.mount(document.body, ClassComponent);
// PRZYKŁAD: przez m.route
m.route(document.body, '/', {
'/': ClassComponent,
});
// PRZYKŁAD: kompozycja komponentów
class AnotherClassComponent {
view() {
return m('main', [m(ClassComponent)]);
}
}
Stan komponentu klasowego
W przypadku klas stanem można zarządzać za pomocą właściwości i metod instancji klasy, a dostęp do nich można uzyskać za pomocą this
:
class ComponentWithState {
constructor(vnode) {
this.count = 0;
}
increment() {
this.count += 1;
}
decrement() {
this.count -= 1;
}
view() {
return m(
'div',
m('p', 'Count: ', this.count),
m(
'button',
{
onclick: () => {
this.increment();
},
},
'Increment'
),
m(
'button',
{
onclick: () => {
this.decrement();
},
},
'Decrement'
)
);
}
}
Zauważ, że musimy użyć funkcji strzałkowych dla funkcji zwrotnych obsługi zdarzeń, aby kontekst this
mógł być poprawnie odwoływany.
Mieszanie rodzajów komponentów
Komponent klasowy może zawierać komponenty domknięcia lub POJO jako elementy potomne, itd.
Atrybuty specjalne
Mithril.js przypisuje specjalne znaczenie kilku kluczom właściwości, dlatego zwykle należy unikać używania ich w normalnych atrybutach komponentu.
- Metody cyklu życia:
oninit
,oncreate
,onbeforeupdate
,onupdate
,onbeforeremove
ionremove
key
(klucz) – używany do śledzenia tożsamości we fragmentach z kluczamitag
(znacznik) – używany do odróżniania vnode od zwykłych obiektów atrybutów i innych elementów, które nie są obiektami vnode.
Unikaj antywzorców
Chociaż Mithril.js jest elastyczny, niektóre wzorce kodowania są niewskazane:
Unikaj rozbudowanych komponentów
Ogólnie rzecz biorąc, "rozbudowany" komponent to komponent, który ma niestandardowe metody instancji. Innymi słowy, należy unikać dołączania funkcji do vnode.state
lub this
. Niezwykle rzadko zdarza się, aby logika logicznie pasowała do metody instancji komponentu i nie mogła być ponownie użyta przez inne komponenty. Stosunkowo często zdarza się, że ta logika może być potrzebna przez inny komponent w przyszłości.
Łatwiej jest refaktoryzować kod, jeśli ta logika jest umieszczona w warstwie danych, niż jeśli jest powiązana ze stanem komponentu.
Rozważ ten rozbudowany komponent:
// views/Login.js
// UNIKAJ
var Login = {
username: '',
password: '',
setUsername: function (value) {
this.username = value;
},
setPassword: function (value) {
this.password = value;
},
canSubmit: function () {
return this.username !== '' && this.password !== '';
},
login: function () {
/*...*/
},
view: function () {
return m('.login', [
m('input[type=text]', {
oninput: function (e) {
this.setUsername(e.target.value);
},
value: this.username,
}),
m('input[type=password]', {
oninput: function (e) {
this.setPassword(e.target.value);
},
value: this.password,
}),
m(
'button',
{ disabled: !this.canSubmit(), onclick: this.login },
'Login'
),
]);
},
};
Zwykle, w kontekście większej aplikacji, komponent logowania, taki jak powyższy, istnieje obok komponentów do rejestracji użytkowników i odzyskiwania haseł. Wyobraź sobie, że chcemy móc wstępnie wypełnić pole e-mail podczas przechodzenia z ekranu logowania do ekranu rejestracji lub odzyskiwania hasła (lub odwrotnie), aby użytkownik nie musiał ponownie wpisywać swojego adresu e-mail, jeśli przypadkowo wypełnił niewłaściwą stronę (lub może chcesz przenieść użytkownika do formularza rejestracyjnego, jeśli nazwa użytkownika nie zostanie znaleziona).
Od razu widzimy, że udostępnianie pól username
i password
z tego komponentu innemu jest trudne. Dzieje się tak, ponieważ rozbudowany komponent hermetyzuje swój stan, co z definicji utrudnia dostęp do tego stanu z zewnątrz.
Bardziej sensowne jest refaktoryzowanie tego komponentu i przeniesienie logiki stanu z komponentu do warstwy danych aplikacji. Może to być tak proste, jak utworzenie nowego modułu:
// models/Auth.js
// PREFERUJ
var Auth = {
username: '',
password: '',
setUsername: function (value) {
Auth.username = value;
},
setPassword: function (value) {
Auth.password = value;
},
canSubmit: function () {
return Auth.username !== '' && Auth.password !== '';
},
login: function () {
/*...*/
},
};
module.exports = Auth;
Następnie możemy wyczyścić komponent:
// views/Login.js
// PREFERUJ
var Auth = require('../models/Auth');
var Login = {
view: function () {
return m('.login', [
m('input[type=text]', {
oninput: function (e) {
Auth.setUsername(e.target.value);
},
value: Auth.username,
}),
m('input[type=password]', {
oninput: function (e) {
Auth.setPassword(e.target.value);
},
value: Auth.password,
}),
m(
'button',
{
disabled: !Auth.canSubmit(),
onclick: Auth.login,
},
'Login'
),
]);
},
};
W ten sposób moduł Auth
staje się źródłem prawdy dla stanu związanego z uwierzytelnianiem, a komponent Register
może łatwo uzyskać dostęp do tych danych i ponownie wykorzystać metody takie jak canSubmit
, jeśli zajdzie taka potrzeba. Ponadto, jeśli wymagany jest kod walidacji (na przykład dla pola e-mail), wystarczy zmodyfikować setEmail
, a ta zmiana spowoduje walidację adresu e-mail dla każdego komponentu, który modyfikuje pole e-mail.
Dodatkowo, zauważ, że nie musimy już używać .bind
, aby zachować odniesienie do stanu dla programów obsługi zdarzeń komponentu.
Nie przekazuj samego vnode.attrs
do innych vnode
Czasami, aby zachować elastyczny interfejs i uprościć implementację, można chcieć przekazać atrybuty do określonego komponentu lub elementu potomnego, na przykład do modala Bootstrapa. Może być kuszące przekazanie atrybutów vnode w ten sposób:
// UNIKAJ
var Modal = {
// ...
view: function (vnode) {
return m('.modal[tabindex=-1][role=dialog]', vnode.attrs, [
// przekazywanie `vnode.attrs` tutaj ^
// ...
]);
},
};
Jeśli zrobisz to jak powyżej, możesz napotkać problemy podczas korzystania z niego:
var MyModal = {
view: function () {
return m(
Modal,
{
// To przełącza go dwa razy, więc się nie pokazuje
onupdate: function (vnode) {
if (toggle) $(vnode.dom).modal('toggle');
},
},
[
// ...
]
);
},
};
Zamiast tego powinieneś przekazywać pojedyncze atrybuty do vnode:
// PREFERUJ
var Modal = {
// ...
view: function (vnode) {
return m('.modal[tabindex=-1][role=dialog]', vnode.attrs.attrs, [
// przekazywanie `attrs:` tutaj ^
// ...
]);
},
};
// Przykład
var MyModal = {
view: function () {
return m(Modal, {
attrs: {
// To przełącza go raz
onupdate: function (vnode) {
if (toggle) $(vnode.dom).modal('toggle');
},
},
// ...
});
},
};
Nie manipuluj children
Jeśli komponent narzuca sposób stosowania atrybutów lub elementów potomnych, należy użyć atrybutów niestandardowych.
Często pożądane jest zdefiniowanie wielu zestawów elementów potomnych, na przykład, jeśli komponent ma konfigurowalny tytuł i treść.
Unikaj dekonstrukcji właściwości children
w tym celu.
// UNIKAJ
var Header = {
view: function (vnode) {
return m('.section', [
m('.header', vnode.children[0]),
m('.tagline', vnode.children[1]),
]);
},
};
m(Header, [m('h1', 'My title'), m('h2', 'Lorem ipsum')]);
// niezręczny przypadek użycia
m(Header, [
[m('h1', 'My title'), m('small', 'A small note')],
m('h2', 'Lorem ipsum'),
]);
Powyższy komponent łamie założenie, że elementy potomne będą wyprowadzane w tym samym ciągłym formacie, w jakim są odbierane. Trudno jest zrozumieć komponent bez przeczytania jego implementacji. Zamiast tego użyj atrybutów jako nazwanych parametrów i zarezerwuj children
dla jednolitej zawartości potomnej:
// PREFERUJ
var BetterHeader = {
view: function (vnode) {
return m('.section', [
m('.header', vnode.attrs.title),
m('.tagline', vnode.attrs.tagline),
]);
},
};
m(BetterHeader, {
title: m('h1', 'My title'),
tagline: m('h2', 'Lorem ipsum'),
});
// jaśniejszy przypadek użycia
m(BetterHeader, {
title: [m('h1', 'My title'), m('small', 'A small note')],
tagline: m('h2', 'Lorem ipsum'),
});
Definiuj komponenty statycznie, a wywołuj dynamicznie
Unikaj tworzenia definicji komponentów wewnątrz widoków
Jeśli tworzysz komponent z poziomu metody view
(bezpośrednio w linii lub przez wywołanie funkcji, która to robi), każde przerenderowanie będzie miało inny klon komponentu. Podczas porównywania komponentów vnode, jeśli komponent, do którego odwołuje się nowy vnode, nie jest ściśle równy temu, do którego odwołuje się stary komponent, zakłada się, że te dwa są różnymi komponentami, nawet jeśli ostatecznie uruchamiają równoważny kod. Oznacza to, że komponenty tworzone dynamicznie za pomocą fabryki będą zawsze tworzone od zera.
Z tego powodu należy unikać ponownego tworzenia komponentów. Zamiast tego, używaj komponentów zgodnie z przyjętymi wzorcami.
// UNIKAJ
var ComponentFactory = function (greeting) {
// tworzy nowy komponent przy każdym wywołaniu
return {
view: function () {
return m('div', greeting);
},
};
};
m.render(document.body, m(ComponentFactory('hello')));
// drugie wywołanie odtwarza div od zera, zamiast nic nie robić
m.render(document.body, m(ComponentFactory('hello')));
// PREFERUJ
var Component = {
view: function (vnode) {
return m('div', vnode.attrs.greeting);
},
};
m.render(document.body, m(Component, { greeting: 'hello' }));
// drugie wywołanie nie modyfikuje DOM
m.render(document.body, m(Component, { greeting: 'hello' }));
Unikaj tworzenia instancji komponentów poza widokami
I odwrotnie, z podobnych powodów, jeśli instancja komponentu jest tworzona poza widokiem, przyszłe przerenderowania wykonają sprawdzenie równości węzła i pominą go. Dlatego instancje komponentów powinny być tworzone wewnątrz widoków:
// UNIKAJ
var Counter = {
count: 0,
view: function (vnode) {
return m(
'div',
m('p', 'Count: ' + vnode.state.count),
m(
'button',
{
onclick: function () {
vnode.state.count++;
},
},
'Increase count'
)
);
},
};
var counter = m(Counter);
m.mount(document.body, {
view: function (vnode) {
return [m('h1', 'My app'), counter];
},
});
W powyższym przykładzie kliknięcie przycisku komponentu licznika zwiększy jego stan licznika, ale jego widok nie zostanie wyzwolony, ponieważ vnode reprezentujący komponent współdzieli to samo odniesienie, a zatem proces renderowania ich nie porównuje. Zawsze powinieneś wywoływać komponenty w widoku, aby upewnić się, że tworzony jest nowy vnode:
// PREFERUJ
var Counter = {
count: 0,
view: function (vnode) {
return m(
'div',
m('p', 'Count: ' + vnode.state.count),
m(
'button',
{
onclick: function () {
vnode.state.count++;
},
},
'Increase count'
)
);
},
};
m.mount(document.body, {
view: function (vnode) {
return [m('h1', 'My app'), m(Counter)];
},
});