Ключи
Что такое ключи?
Ключи — это идентификаторы, используемые Mithril.js для отслеживания vnode. Вы можете добавить их к элементам, компонентам и фрагментам vnode через специальный атрибут key
. Вот пример использования:
m('.user', { key: user.id }, [
/* ... */
]);
Ключи полезны в следующих случаях:
- При отображении данных модели или других данных, имеющих состояние, ключи необходимы для того, чтобы локальное состояние оставалось привязанным к правильному поддереву.
- При независимой анимации нескольких смежных узлов с помощью CSS и возможности удаления любого из них по отдельности, ключи гарантируют, что анимация останется с элементами и не будет неожиданно перескакивать на другие узлы.
- Когда необходимо принудительно переинициализировать поддерево, добавьте ключ, затем измените его и перерисуйте при каждой необходимости переинициализации.
Ограничения ключей
Важно: Во всех фрагментах дочерние элементы должны содержать либо исключительно vnode с атрибутами key
(фрагменты с ключами), либо исключительно vnode без атрибутов key
(фрагменты без ключей). Атрибуты key
могут быть только у тех vnode, которые поддерживают атрибуты: элементов, компонентов и фрагментов vnode. Другие vnode, такие как null
, undefined
и строки, не могут иметь атрибуты какого-либо рода, поэтому они не могут иметь атрибуты key
и, следовательно, не могут использоваться во фрагментах с ключами.
Это означает, что конструкции вроде [m(".foo", {key: 1}), null]
и ["foo", m(".bar", {key: 2})]
не будут работать, но [m(".foo", {key: 1}), m(".bar", {key: 2})]
и [m(".foo"), null]
будут. Если вы забудете об этом, вы получите информативное сообщение об ошибке.
Связывание данных модели в списках представлений
При отображении списков, особенно редактируемых списков, часто приходится работать с редактируемыми TODO и подобными элементами. Они имеют состояние и идентификаторы, и вам необходимо предоставить Mithril.js информацию, необходимую для их отслеживания.
Предположим, у нас есть простой список сообщений в социальной сети. В нем можно комментировать и скрывать сообщения, например, по причине жалобы на них.
// `User` и `ComposeWindow` опущены для краткости
function CommentCompose() {
return {
view: function (vnode) {
var post = vnode.attrs.post;
return m(ComposeWindow, {
placeholder: 'Напишите свой комментарий...',
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);
},
},
"Мне это не нравится"
)
);
},
};
}
function PostCompose() {
return {
view: function (vnode) {
var comment = vnode.attrs.comment;
return m(ComposeWindow, {
placeholder: 'Напишите свой пост...',
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 ? '' : 's'
),
m(
'a.post-hide',
{
onclick: function () {
Model.hidePost(post).then(m.redraw);
},
},
"Мне это не нравится"
)
),
showComments
? m(
'.post-comments',
comments == null
? m('.comment-list-loading', 'Загрузка...')
: [
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', 'Лента'),
posts == null
? m('.post-list-loading', 'Загрузка...')
: m(
'.post-view',
m(PostCompose),
m(
'.post-list',
posts.map(function (post) {
return m(Post, { post: post });
})
)
)
);
},
};
}
Здесь много функциональности, но я хотел бы подробнее рассмотреть два момента:
// В компоненте `Feed`
m(
'.post-list',
posts.map(function (post) {
return m(Post, { post: post });
})
);
// В компоненте `Post`
m(
'.comment-list',
comments.map(function (comment) {
return m(Comment, { comment: comment });
})
);
Каждый из этих фрагментов относится к поддереву со связанным состоянием, о котором Mithril.js ничего не знает (Mithril.js знает только о vnode). Если вы оставите их без ключей, поведение может стать странным и непредсказуемым. В этом случае попробуйте нажать на "N комментариев", чтобы раскрыть список комментариев, введите текст в поле ввода внизу, а затем нажмите "Мне это не нравится" на одном из сообщений выше. Вот рабочий пример, чтобы вы могли попробовать, с макетом модели. (Примечание: в Edge или IE могут возникнуть проблемы из-за большой длины URL.)
Вместо ожидаемого результата происходит сбой: закрывается открытый список комментариев, а у сообщения, следующего за тем, где были открыты комментарии, постоянно отображается "Загрузка...", даже если кажется, что комментарии уже загружены. Это происходит из-за ленивой загрузки комментариев. Компонент предполагает, что каждый раз передается один и тот же комментарий (что в целом логично), но в данном случае это не так. Это связано с тем, как Mithril.js обрабатывает фрагменты без ключей: он выполняет их патчинг один за другим итеративно, очень простым способом. В этом случае разница может выглядеть так:
- До:
A, B, C, D, E
- Исправлено:
A, B, C -> D, D -> E, E -> (удалено)
И поскольку компонент остается тем же (это всегда Comment
), изменяются только атрибуты, и он не заменяется.
Чтобы исправить эту ошибку, добавьте ключ. Тогда Mithril.js сможет правильно перенести состояние, если это потребуется. Вот живой, рабочий пример с исправлением.
// В компоненте `Feed`
m(
'.post-list',
posts.map(function (post) {
return m(Post, { key: post.id, post: post });
})
);
// В компоненте `Post`
m(
'.comment-list',
comments.map(function (comment) {
return m(Comment, { key: comment.id, comment: comment });
})
);
Обратите внимание, что для комментариев, хотя технически это работало бы и без ключей в этом конкретном случае, это также сломалось бы, если бы вы добавили что-нибудь вроде вложенных комментариев или возможности их редактировать, и вам пришлось бы добавить к ним ключи.
Обеспечение бесперебойной работы коллекций анимированных объектов
В некоторых случаях может потребоваться анимировать списки, контейнеры и тому подобное. Начнем с простого примера:
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 }, 'Add box, click box to remove'),
m(
'.container',
boxes.map(function (box, i) {
return m(
'.box',
{
'data-color': box.color,
onclick: function () {
remove(box);
},
},
m('.stretch')
);
})
),
];
},
};
}
На первый взгляд все выглядит безобидно, но попробуйте этот пример вживую. В этом примере создайте несколько коробок, щелкнув по кнопке, затем выберите коробку и следите за ее размером. Вы заметите, что размер изменяется скачкообразно, но остается постоянным для определенной позиции. Это происходит потому, что нам нужно присвоить элементам уникальные ключи.
В данном случае это довольно просто: достаточно создать счетчик, который увеличивается при каждом добавлении элемента.
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 }, 'Add box, click box to remove'),
m(
'.container',
boxes.map(function (box, i) {
return m(
'.box',
{
key: box.key,
'data-color': box.color,
onclick: function () {
remove(box);
},
},
m('.stretch')
);
})
),
];
},
};
}
Вот исправленный пример, с которым вы можете поэкспериментировать, чтобы увидеть разницу.
Повторная инициализация представлений с помощью фрагментов с одним дочерним элементом и ключами
При работе с сущностями, отслеживающими состояние в моделях и т.п., часто бывает полезно отображать представления моделей с ключами. Предположим, у вас есть следующая структура:
function Layout() {
// ...
}
function Person() {
// ...
}
m.route(rootElem, '/', {
'/': Home,
'/person/:id': {
render: function () {
return m(Layout, m(Person, { id: m.route.param('id') }));
},
},
// ...
});
Скорее всего, ваш компонент Person
выглядит примерно так:
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', 'Person not found.')
: m('.person-error', 'An error occurred. Please try again later');
}
return m(
'.person',
m(
m.route.Link,
{
class: 'person-edit',
href: '/person/:id/edit',
params: { id: personId },
},
'Edit'
),
m('.person-name', 'Name: ', person.name)
// ...
);
},
};
}
Предположим, вы добавили ссылки на других пользователей из этого компонента, например, добавив поле "manager" (менеджер).
function Person(vnode) {
// ...
return {
view: function () {
// ...
return m(
'.person',
m(
m.route.Link,
{
class: 'person-edit',
href: '/person/:id/edit',
params: { id: personId },
},
'Edit'
),
m('.person-name', person.name),
// ...
m(
'.manager',
'Manager: ',
m(
m.route.Link,
{
href: '/person/:id',
params: { id: person.manager.id },
},
person.manager.name
)
)
// ...
);
},
};
}
Предполагая, что ID человека был 1
, а ID менеджера был 2
, вы переключитесь с /person/1
на /person/2
, оставаясь на том же маршруте. Но поскольку вы использовали метод разрешения маршрута render
, дерево компонентов осталось прежним, и вы просто изменили m(Layout, m(Person, {id: "1"}))
на m(Layout, m(Person, {id: "2"}))
. В этом случае компонент Person
не изменился и, следовательно, не инициализируется повторно. Но в нашем случае это нежелательно, так как данные нового пользователя не загружаются. Здесь-то и пригодятся ключи. Чтобы это исправить, можно изменить конфигурацию маршрутизатора следующим образом:
m.route(rootElem, '/', {
'/': Home,
'/person/:id': {
render: function () {
return m(
Layout,
// Рекомендуется обернуть его в массив, чтобы упростить добавление других элементов в будущем.
// Помните: фрагменты должны содержать либо только дочерние элементы с ключами,
// либо вообще не содержать дочерних элементов с ключами.
[m(Person, { id: m.route.param('id'), key: m.route.param('id') })]
);
},
},
// ...
});
Типичные ошибки
При работе с ключами часто возникают определенные ошибки. Вот некоторые из них, которые помогут вам понять, почему ваш код может работать не так, как ожидается.
Оборачивание элементов с ключами
Следующие два фрагмента кода ведут себя по-разному:
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 })]);
});
В первом случае ключ привязывается к компоненту User
, но внешний фрагмент, созданный users.map(...)
, остается без ключа. Оборачивание элемента с ключом таким образом не работает и может привести к непредсказуемым результатам: от дополнительных запросов при каждом изменении списка до потери состояния внутренних элементов форм. Поведение будет похоже на неправильный пример списка сообщений, но без повреждения состояния.
Во втором случае ключ привязывается к элементу .wrapper
, гарантируя, что внешний фрагмент имеет ключ. Это именно то, что вы, вероятно, хотели сделать, и удаление пользователя не вызовет проблем с состоянием других экземпляров пользователя.
Размещение ключей внутри компонента
Предположим, в примере с человеком вы сделали следующее:
// ИЗБЕГАЙТЕ
function Person(vnode) {
var personId = vnode.attrs.id;
// ...
return {
view: function () {
return m.fragment(
{ key: personId }
// что у вас было ранее в представлении
);
},
};
}
Это не сработает, потому что ключ не применяется к компоненту в целом. Он применяется только к представлению, и поэтому данные не будут перезапрашиваться, как ожидалось.
Предпочтительное решение: поместите ключ в vnode, используя компонент, а не внутри самого компонента.
// ПРЕДПОЧТИТЕЛЬНО
return [m(Person, { id: m.route.param('id'), key: m.route.param('id') })];
Избыточное использование ключей
Распространено заблуждение, что ключи сами по себе являются идентификаторами. Mithril.js требует, чтобы все дочерние элементы фрагмента либо имели ключи, либо не имели их вовсе. В противном случае будет выдана ошибка. Предположим, у вас есть такая структура:
m('.page', m('.header', { key: 'header' }), m('.body'), m('.footer'));
Это вызовет ошибку, так как .header
имеет ключ, а .body
и .footer
- нет. Но суть в том, что ключи здесь не нужны. Если вы используете ключи для подобных вещей, решение не в том, чтобы добавить ключи, а в том, чтобы их удалить. Добавляйте ключи только тогда, когда они действительно необходимы. Да, у базовых DOM-узлов есть идентификаторы, но Mithril.js не нужно отслеживать их для корректного обновления (patch). Практически никогда. Ключи нужны только для списков, где каждая запись имеет какое-то связанное состояние, которое Mithril.js сам не отслеживает, будь то в модели, в компоненте или в самом DOM.
И наконец: избегайте статических ключей. Они всегда излишни. Если вы не вычисляете атрибут key
, вероятно, вы делаете что-то не так.
Обратите внимание, что если вам действительно нужен один элемент с ключом, используйте фрагмент с одним ключевым элементом. Это просто массив с одним дочерним элементом, который является элементом с ключом, например [m("div", {key: foo})]
.
Смешение типов ключей
Ключи интерпретируются как имена свойств объекта. Это значит, что 1
и "1"
считаются одинаковыми. Во избежание проблем старайтесь не смешивать типы ключей. В противном случае могут возникнуть дублирующиеся ключи и неожиданное поведение.
// ИЗБЕГАЙТЕ
var things = [
{ id: '1', name: 'Book' },
{ id: 1, name: 'Cup' },
];
Если это абсолютно необходимо и вы не контролируете это, используйте префикс, обозначающий тип, чтобы ключи оставались разными.
things.map(function (thing) {
return m(
'.thing',
{ key: typeof thing.id + ':' + thing.id }
// ...
);
});
Сокрытие элементов с ключами через пустые значения
"Дыры" (holes), такие как null
, undefined
и булевы значения, считаются vnode без ключа, поэтому код, подобный этому, не будет работать:
// ИЗБЕГАЙТЕ
things.map(function (thing) {
return shouldShowThing(thing)
? m(Thing, { key: thing.id, thing: thing })
: null;
});
Вместо этого отфильтруйте список перед возвращением, и Mithril.js корректно обработает его. В большинстве случаев Array.prototype.filter
- это именно то, что вам нужно, и стоит попробовать.
// ПРЕДПОЧТИТЕЛЬНО
things
.filter(function (thing) {
return shouldShowThing(thing);
})
.map(function (thing) {
return m(Thing, { key: thing.id, thing: thing });
});
Дублирующиеся ключи
Ключи элементов фрагмента должны быть уникальными, иначе будет неясно, какому элементу соответствует тот или иной ключ. Это может привести к проблемам с перемещением элементов не так, как ожидается.
// ИЗБЕГАЙТЕ
var things = [
{ id: '1', name: 'Book' },
{ id: '1', name: 'Cup' },
];
Mithril.js использует пустой объект для сопоставления ключей с индексами, чтобы правильно обновлять (patch) фрагменты с ключами. Когда есть дублирующийся ключ, становится непонятно, куда переместился этот элемент, и Mithril.js будет работать некорректно и делать неожиданные вещи при обновлении, особенно если список изменился. Для корректного сопоставления старых и новых узлов Mithril.js требуются различные ключи, поэтому необходимо выбрать что-то уникальное в пределах контекста для использования в качестве ключа.
Использование объектов для ключей
Ключи элементов фрагмента рассматриваются как ключи свойств. Использование объектов в качестве ключей может привести к непредсказуемым результатам.
// ИЗБЕГАЙТЕ
things.map(function (thing) {
return m(Thing, { key: thing, thing: thing });
});
Если у объекта есть метод toString
, он будет вызван, и результат его работы будет использован в качестве ключа, возможно, без вашего ведома. Если метода toString
нет, все объекты будут преобразованы в строку "[object Object]"
, что приведет к проблеме с дублирующимися ключами.