Chiavi
Cosa sono le chiavi?
Le chiavi rappresentano identità tracciabili. Puoi aggiungerle ai vnode di elementi, componenti e frammenti tramite l'attributo key
, in questo modo:
m('.user', { key: user.id }, [
/* ... */
]);
Sono utili in diversi scenari:
- Quando renderizzi dati del modello o altri dati con stato, hai bisogno delle chiavi per mantenere lo stato locale associato al sottoalbero corretto.
- Quando animi indipendentemente più nodi adiacenti usando CSS e puoi rimuovere uno di essi singolarmente, hai bisogno delle chiavi per assicurarti che le animazioni rimangano associate agli elementi corretti e non vengano applicate inaspettatamente ad altri nodi.
- Quando devi reinizializzare un sottoalbero, aggiungi una chiave, modificala e ridisegna ogni volta che vuoi forzare la reinizializzazione.
Restrizioni delle chiavi
Importante: Per tutti i frammenti, i loro figli devono contenere esclusivamente vnode con attributi chiave (frammento con chiave) o esclusivamente vnode senza attributi chiave (frammento senza chiave). Gli attributi chiave possono esistere solo su vnode che supportano attributi, ovvero vnode di elementi, componenti e frammenti. Altri vnode, come null
, undefined
e stringhe, non possono avere attributi di alcun tipo, quindi non possono avere attributi chiave e, di conseguenza, non possono essere utilizzati in frammenti con chiave.
Questo significa che costrutti come [m(".foo", {key: 1}), null]
e ["foo", m(".bar", {key: 2})]
non funzioneranno, mentre [m(".foo", {key: 1}), m(".bar", {key: 2})]
e [m(".foo"), null]
saranno validi. Se te ne dimentichi, riceverai un errore esplicativo.
Collegamento dei dati del modello in elenchi di viste
Quando renderizzi elenchi, specialmente elenchi editabili, ti troverai spesso a gestire elementi come TODO modificabili e simili. Questi elementi hanno uno stato e un'identità, e devi fornire a Mithril.js le informazioni necessarie per tracciarli correttamente.
Consideriamo un semplice elenco di post sui social media, dove è possibile commentare e nascondere i post, ad esempio per segnalarli.
// `User` e `ComposeWindow` omessi per brevità
function CommentCompose() {
return {
view: function (vnode) {
var post = vnode.attrs.post;
return m(ComposeWindow, {
placeholder: 'Scrivi il tuo commento...',
submit: function (text) {
return Model.addComment(post, text);
},
});
},
};
}
function Comment() {
return {
view: function (vnode) {
var comment = vnode.attrs.comment;
return m(
'.comment',
m(User, { user: comment.user }),
m('.comment-body', comment.text),
m(
'a.comment-hide',
{
onclick: function () {
Model.hideComment(comment).then(m.redraw);
},
},
"Non mi piace"
)
);
},
};
}
function PostCompose() {
return {
view: function (vnode) {
var comment = vnode.attrs.comment;
return m(ComposeWindow, {
placeholder: 'Scrivi il tuo post...',
submit: Model.createPost,
});
},
};
}
function Post(vnode) {
var showComments = false;
var commentsFetched = false;
return {
view: function (vnode) {
var post = vnode.attrs.post;
var comments = showComments ? Model.getComments(post) : null;
return m(
'.post',
m(User, { user: post.user }),
m('.post-body', post.text),
m(
'.post-meta',
m(
'a.post-comment-count',
{
onclick: function () {
if (!showComments && !commentsFetched) {
commentsFetched = true;
Model.fetchComments(post).then(m.redraw);
}
showComments = !showComments;
},
},
post.commentCount,
' comment',
post.commentCount === 1 ? 'o' : 'i'
),
m(
'a.post-hide',
{
onclick: function () {
Model.hidePost(post).then(m.redraw);
},
},
"Non mi piace"
)
),
showComments
? m(
'.post-comments',
comments == null
? m('.comment-list-loading', 'Caricamento...')
: [
m(
'.comment-list',
comments.map(function (comment) {
return m(Comment, { comment: comment });
})
),
m(CommentCompose, { post: post }),
]
)
: null
);
},
};
}
function Feed() {
Model.fetchPosts().then(m.redraw);
return {
view: function () {
var posts = Model.getPosts();
return m(
'.feed',
m('h1', 'Feed'),
posts == null
? m('.post-list-loading', 'Caricamento...')
: m(
'.post-view',
m(PostCompose),
m(
'.post-list',
posts.map(function (post) {
return m(Post, { post: post });
})
)
)
);
},
};
}
Come si può notare, questo codice incapsula molte funzionalità. Vorrei soffermarmi su due aspetti:
// Nel componente `Feed`
m(
'.post-list',
posts.map(function (post) {
return m(Post, { post: post });
})
);
// Nel componente `Post`
m(
'.comment-list',
comments.map(function (comment) {
return m(Comment, { comment: comment });
})
);
Ciascuno di questi si riferisce a un sottoalbero con uno stato associato che Mithril.js non conosce. (Mithril.js conosce solo i vnode). Se non utilizzi le chiavi, il comportamento potrebbe diventare strano e imprevedibile. Prova a cliccare su "N commenti" per visualizzare i commenti, a digitare nella casella di composizione in basso e poi a cliccare su "Non mi piace" in un post precedente. Ecco una demo dal vivo per provarlo, completa di un modello fittizio. (Nota: se sei su Edge o IE, potresti riscontrare problemi a causa della lunghezza dell'hash del link.)
Invece di comportarsi come previsto, si verifica un errore: l'elenco dei commenti aperto viene chiuso e il post successivo a quello con i commenti aperti mostra persistentemente "Caricamento...", anche se i commenti risultano già caricati. Questo accade perché i commenti vengono caricati in modo differito e si presume che venga passato sempre lo stesso commento (il che, in genere, è corretto), ma in questo caso non è così. Questo è dovuto al meccanismo con cui Mithril.js applica le patch ai frammenti senza chiave: li elabora uno per uno, in modo iterativo e semplice. Quindi, in questo caso, il diff potrebbe essere simile a questo:
- Prima:
A, B, C, D, E
- Elaborato:
A, B, C -> D, D -> E, E -> (rimosso)
Poiché il componente rimane lo stesso (è sempre Comment
), vengono modificati solo gli attributi e non viene sostituito.
Per risolvere questo problema, è sufficiente aggiungere una chiave, in modo che Mithril.js possa gestire lo spostamento dello stato, se necessario. Ecco un esempio funzionante con le correzioni apportate.
// Nel componente `Feed`
m(
'.post-list',
posts.map(function (post) {
return m(Post, { key: post.id, post: post });
})
);
// Nel componente `Post`
m(
'.comment-list',
comments.map(function (comment) {
return m(Comment, { key: comment.id, comment: comment });
})
);
Nota che per i commenti, anche se tecnicamente funzionerebbe senza chiavi in questo caso, si romperebbe in modo simile se dovessi aggiungere qualcosa come commenti nidificati o la possibilità di modificarli, e in quel caso dovresti aggiungere le chiavi anche a loro.
Mantenere le collezioni di oggetti animati senza artefatti
In alcuni casi, potresti voler animare elenchi, caselle e oggetti simili. Iniziamo con questo semplice codice:
var colors = ['red', 'yellow', 'blue', 'gray'];
var counter = 0;
function getColor() {
var color = colors[counter];
counter = (counter + 1) % colors.length;
return color;
}
function Boxes() {
var boxes = [];
function add() {
boxes.push({ color: getColor() });
}
function remove(box) {
var index = boxes.indexOf(box);
boxes.splice(index, 1);
}
return {
view: function () {
return [
m('button', { onclick: add }, 'Aggiungi casella, fai clic sulla casella per rimuoverla'),
m(
'.container',
boxes.map(function (box, i) {
return m(
'.box',
{
'data-color': box.color,
onclick: function () {
remove(box);
},
},
m('.stretch')
);
})
),
];
},
};
}
Sembra abbastanza innocuo, ma prova un esempio interattivo. In quell'esempio, crea qualche casella cliccandoci sopra, quindi rimuovi una casella e osserva come cambiano le sue dimensioni. Vogliamo che le dimensioni e la rotazione siano legate alla casella (indicata dal colore) e non alla posizione nella griglia. Noterai invece che la dimensione cambia improvvisamente, ma rimane costante con la posizione. Ciò significa che dobbiamo assegnare delle key.
In questo caso, fornire chiavi univoche è piuttosto semplice: è sufficiente creare un contatore che si incrementa ogni volta che viene utilizzato.
var colors = ['red', 'yellow', 'blue', 'gray'];
var counter = 0;
function getColor() {
var color = colors[counter];
counter = (counter + 1) % colors.length;
return color;
}
function Boxes() {
var boxes = [];
var nextKey = 0;
function add() {
boxes.push({ color: getColor() });
var key = nextKey;
nextKey++;
boxes.push({ key: key, color: getColor() });
}
function remove(box) {
var index = boxes.indexOf(box);
boxes.splice(index, 1);
}
return {
view: function () {
return [
m('button', { onclick: add }, 'Aggiungi casella, fai clic sulla casella per rimuoverla'),
m(
'.container',
boxes.map(function (box, i) {
return m(
'.box',
{
key: box.key,
'data-color': box.color,
onclick: function () {
remove(box);
},
},
m('.stretch')
);
})
),
];
},
};
}
Ecco una demo funzionante che puoi esplorare, per vedere come funziona in modo diverso.
Reinizializzare le viste con frammenti con chiave a figlio singolo
Quando si ha a che fare con entità con stato in modelli e simili, è spesso utile renderizzare le viste del modello con delle chiavi. Supponiamo di avere questo layout:
function Layout() {
// ...
}
function Person() {
// ...
}
m.route(rootElem, '/', {
'/': Home,
'/person/:id': {
render: function () {
return m(Layout, m(Person, { id: m.route.param('id') }));
},
},
// ...
});
Il tuo componente probabilmente assomiglia a questo:
function Person(vnode) {
var personId = vnode.attrs.id;
var state = 'pending';
var person, error;
m.request('/api/person/:id', { params: { id: personId } }).then(
function (p) {
person = p;
state = 'ready';
},
function (e) {
error = e;
state = 'error';
}
);
return {
view: function () {
if (state === 'pending') return m(LoadingIcon);
if (state === 'error') {
return error.code === 404
? m('.person-missing', 'Persona non trovata.')
: m('.person-error', 'Si è verificato un errore. Per favore, riprova più tardi');
}
return m(
'.person',
m(
m.route.Link,
{
class: 'person-edit',
href: '/person/:id/edit',
params: { id: personId },
},
'Modifica'
),
m('.person-name', 'Nome: ', person.name)
// ...
);
},
};
}
Immagina di aver aggiunto un modo per collegarti ad altre persone tramite questo componente, ad esempio aggiungendo un campo "manager" (responsabile).
function Person(vnode) {
// ...
return {
view: function () {
// ...
return m(
'.person',
m(
m.route.Link,
{
class: 'person-edit',
href: '/person/:id/edit',
params: { id: personId },
},
'Modifica'
),
m('.person-name', person.name),
// ...
m(
'.manager',
'Responsabile: ',
m(
m.route.Link,
{
href: '/person/:id',
params: { id: person.manager.id },
},
person.manager.name
)
)
// ...
);
},
};
}
Supponendo che l'ID della persona fosse 1
e l'ID del responsabile fosse 2
, passeresti da /person/1
a /person/2
, rimanendo sulla stessa route. Ma poiché hai utilizzato il metodo render
del risolutore di route, l'albero è stato mantenuto e sei semplicemente passato da m(Layout, m(Person, {id: "1"}))
a m(Layout, m(Person, {id: "2"}))
. In questo caso, Person
non è cambiato e quindi non reinizializza il componente. Ma per il nostro caso, questo non va bene, perché significa che il nuovo utente non viene recuperato. È qui che le chiavi diventano utili. Potremmo modificare il route resolver in questo modo per risolvere il problema:
m.route(rootElem, '/', {
'/': Home,
'/person/:id': {
render: function () {
return m(
Layout,
// Racchiudilo in un array nel caso aggiungessimo altri elementi in seguito.
// Ricorda: i frammenti devono contenere solo figli con chiave
// oppure nessun figlio senza chiave.
[m(Person, { id: m.route.param('id'), key: m.route.param('id') })]
);
},
},
// ...
});
Problemi comuni
Ci sono diversi problemi comuni in cui gli sviluppatori incorrono con le key. Ecco alcuni di questi, per aiutarti a capire perché potrebbero non funzionare.
Avvolgere elementi con key
Questi due snippet di codice non si comportano allo stesso modo:
users.map(function (user) {
return m('.wrapper', [m(User, { user: user, key: user.id })]);
});
users.map(function (user) {
return m('.wrapper', { key: user.id }, [m(User, { user: user })]);
});
Il primo associa la key al componente User
, ma il frammento esterno creato da users.map(...)
non ha una key. Avvolgere un elemento con una key in questo modo non è corretto e il risultato potrebbe essere imprevedibile, da richieste extra ogni volta che l'elenco viene modificato, alla perdita dello stato degli input dei form interni. Il comportamento risultante sarebbe simile all'esempio errato dell'elenco di post, ma senza il problema della corruzione dello stato.
Il secondo associa la key all'elemento .wrapper
, assicurando che il frammento esterno abbia una key. Questo è probabilmente quello che volevi fare fin dall'inizio e la rimozione di un utente non causerà problemi allo stato delle altre istanze utente.
Posizionare le key all'interno del componente
Supponiamo che, nell'esempio della persona, tu abbia fatto questo invece:
// EVITARE
function Person(vnode) {
var personId = vnode.attrs.id;
// ...
return {
view: function () {
return m.fragment(
{ key: personId }
// quello che avevi precedentemente nella view
);
},
};
}
Questo non funziona, perché la key non si applica all'intero componente. Si applica solo alla view e quindi non stai recuperando i dati come speravi.
È preferibile usare la soluzione adottata lì, mettendo la key nel vnode usando il componente piuttosto che all'interno del componente stesso.
// PREFERIRE
return [m(Person, { id: m.route.param('id'), key: m.route.param('id') })];
Usare key inutilmente
È comune fraintendere che le key siano identificatori univoci. Mithril.js richiede che tutti i figli di un frammento abbiano una key, oppure che nessuno di essi ne abbia una e genererà un errore se te ne dimentichi. Supponiamo di avere questo layout:
m('.page', m('.header', { key: 'header' }), m('.body'), m('.footer'));
Questo ovviamente genererà un errore, poiché .header
ha una key e .body
e .footer
ne sono privi. Ma ecco il punto: non hai bisogno di key per questo. Se ti accorgi di usare key in situazioni come questa, la soluzione non è aggiungerne altre, ma rimuoverle. Aggiungile solo se ne hai davvero bisogno. Sì, i nodi DOM di base hanno identità, ma Mithril.js non ha bisogno di tenere traccia di tali identità per aggiornare correttamente il DOM. Praticamente non lo fa mai. Le key sono necessarie solo negli elenchi in cui ogni elemento ha uno stato associato che Mithril.js non gestisce direttamente, ad esempio in un modello, un componente o nel DOM stesso.
Un'ultima cosa: evita le key statiche. Non servono quasi mai. Se non calcoli dinamicamente l'attributo key
, probabilmente stai commettendo un errore.
Nota che se hai davvero bisogno di un singolo elemento con key isolato, usa un frammento con key a figlio singolo. È semplicemente un array con un singolo figlio che è un elemento con key, come [m("div", {key: foo})]
.
Usare tipi diversi per le key
Le key vengono interpretate come nomi di proprietà degli oggetti. Ciò significa che 1
e "1"
vengono trattati in modo identico. Se vuoi evitare problemi, non mescolare i tipi di key se puoi evitarlo. Se lo fai, potresti ritrovarti con key duplicate e un comportamento inaspettato.
// EVITARE
var things = [
{ id: '1', name: 'Book' },
{ id: 1, name: 'Cup' },
];
Se devi assolutamente farlo e non hai alcun controllo su questo, usa un prefisso che denoti il suo tipo in modo che rimangano distinti.
things.map(function (thing) {
return m(
'.thing',
{ key: typeof thing.id + ':' + thing.id }
// ...
);
});
Nascondere elementi con key usando valori nulli/undefined
Valori come null
, undefined
e i valori booleani sono considerati vnode senza key, quindi codice come questo non funzionerà:
// EVITARE
things.map(function (thing) {
return shouldShowThing(thing)
? m(Thing, { key: thing.id, thing: thing })
: null;
});
Invece, filtra l'elenco prima di restituirlo e Mithril.js gestirà correttamente la situazione. Nella maggior parte dei casi, Array.prototype.filter
è esattamente ciò di cui hai bisogno e dovresti assolutamente provarlo.
// PREFERIRE
things
.filter(function (thing) {
return shouldShowThing(thing);
})
.map(function (thing) {
return m(Thing, { key: thing.id, thing: thing });
});
Key duplicate
Le key degli elementi di un frammento devono essere univoche, altrimenti non è chiaro a quale elemento corrisponda ciascuna key. Potresti anche riscontrare problemi con elementi che non si spostano come dovrebbero.
// EVITARE
var things = [
{ id: '1', name: 'Book' },
{ id: '1', name: 'Cup' },
];
Mithril.js utilizza un oggetto vuoto per mappare le key agli indici per sapere come aggiornare correttamente i frammenti con key. Se ci sono key duplicate, Mithril.js non riesce a determinare la posizione corretta degli elementi e potrebbe comportarsi in modo inatteso durante l'aggiornamento, soprattutto se l'elenco è stato modificato. Sono necessarie key distinte affinché Mithril.js connetta correttamente i nodi vecchi a quelli nuovi, quindi devi scegliere qualcosa di univoco a livello specifico per quel contesto da utilizzare come key.
Usare oggetti come key
Le key degli elementi di un frammento vengono interpretate come nomi di proprietà. Situazioni di questo tipo non funzioneranno come ci si aspetta.
// EVITARE
things.map(function (thing) {
return m(Thing, { key: thing, thing: thing });
});
Se l'oggetto ha un metodo toString
, verrebbe chiamato e saresti alla mercé di qualunque cosa restituisca, forse senza renderti conto che quel metodo viene persino chiamato. Altrimenti, tutti gli oggetti verranno convertiti nella stringa "[object Object]"
, causando un problema di key duplicate.