键(Key)
什么是键(Key)?
键(Key)用于追踪元素的身份标识。你可以通过 key
属性将它们添加到 元素、组件和片段 vnode 中。使用方法如下:
m('.user', { key: user.id }, [
/* ... */
]);
键在以下场景中特别有用:
- 当渲染模型数据或其他有状态数据时,使用键可以确保局部状态与正确的子树关联。
- 当使用 CSS 为多个相邻节点独立设置动画,并且可以单独删除其中任何一个节点时,使用键可以确保动画与元素保持一致,避免意外跳转到其他节点。
- 当需要强制重新初始化一个子树时,添加一个键,然后在需要重新初始化时更改它并重新绘制。
键(Key)的限制
重要提示: 对于片段,其子节点必须全部为带有 key
属性的 vnode(带键的片段)或者全部为不带 key
属性的 vnode(不带键的片段)。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,
' 条评论',
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)。如果这些子树没有 key
,可能会出现难以预测的错误行为。例如,尝试点击“N 条评论”以显示评论,在底部的评论撰写框中输入内容,然后点击上面帖子的“我不喜欢这个”。这是一个供你尝试的实时演示,其中包含一个模拟模型。(注意:如果你使用的是 Edge 或 IE,你可能会遇到链接哈希长度的问题。)
实际效果与预期不符,程序变得非常混乱并执行了错误的操作:它关闭了你打开的评论列表,并且在你打开评论的帖子之后的帖子会持续显示加载状态,即使程序认为评论已经加载完毕。这是因为 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')
);
})
),
];
},
};
}
这段代码看起来很简单,但可以尝试一个在线示例。在这个例子中,点击创建几个方块,然后选择其中一个,观察它的大小变化。我们希望大小和旋转与方块(以颜色标识)相关联,而不是与网格中的位置相关联。您会注意到,大小会突然变化,但位置保持不变。这意味着我们需要为它们添加 key
。
在这种情况下,为它们添加唯一的 key
非常简单:只需创建一个计数器,并在每次使用时递增即可。
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')
);
})
),
];
},
};
}
使用仅包含一个子元素的 key
片段重新初始化视图
当您在模型等中使用状态化实体时,使用 key
来渲染模型视图通常很有用。假设有如下布局:
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', '人员未找到')
: m('.person-error', '发生错误,请稍后重试');
}
return m(
'.person',
m(
m.route.Link,
{
class: 'person-edit',
href: '/person/:id/edit',
params: { id: personId },
},
'Edit'
),
m('.person-name', 'Name: ', person.name)
// ...
);
},
};
}
假设您添加了从该组件链接到其他人的功能,例如添加一个“经理”字段。
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
方法,因此 DOM 树被保留,您只是从 m(Layout, m(Person, {id: "1"}))
更改为 m(Layout, m(Person, {id: "2"}))
。在此过程中,Person
组件没有发生变化,因此它不会重新初始化。但这并不是我们期望的结果,因为这意味着组件不会重新加载新的用户信息。这正是 key
的作用所在。可以通过以下方式修改路由解析器来解决此问题:
m.route(rootElem, '/', {
'/': Home,
'/person/:id': {
render: function () {
return m(
Layout,
// 将其包裹在一个数组中,以便后续添加其他元素。
// 记住:片段中的子元素要么全部带有 `key`,要么全部不带 `key`。
[m(Person, { id: m.route.param('id'), key: m.route.param('id') })]
);
},
},
// ...
});
常见问题
人们在使用 key 时经常会遇到一些常见的问题。这里列出了一些,以帮助你理解它们为什么不起作用。
包裹带 key 的元素
以下两个代码片段的工作方式不同:
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 })]);
});
第一个代码片段将 key 绑定到 User
组件,但由 users.map(...)
生成的外部片段完全没有 key。以这种方式包裹带 key 的元素不起作用,可能导致各种问题,例如每次列表更改时都会发出额外的请求,或者内部表单输入丢失其状态。最终行为会类似于帖子列表的错误示例,但不会出现状态损坏的问题。
第二个代码片段将其绑定到 .wrapper
元素,确保外部片段_是_带 key 的。这实现了你可能一直想做的事情,并且删除用户不会对其他用户实例的状态造成任何问题。
将 key 放在组件内部
假设在人员示例中,你这样做:
// 避免
function Person(vnode) {
var personId = vnode.attrs.id;
// ...
return {
view: function () {
return m.fragment(
{ key: personId }
// 你之前在视图中拥有的内容
);
},
};
}
这不起作用,因为 key 不适用于整个组件。它仅适用于视图,因此你不会像期望的那样重新获取数据。
最好采用原文的解决方案,将 key 放在_使用_组件的 vnode 中,而不是放在组件内部。
// 推荐
return [m(Person, { id: m.route.param('id'), key: m.route.param('id') })];
不必要地使用 key
人们常常误以为 key 本身就是标识。Mithril.js 强制要求所有片段的子元素必须全部具有 key 或全部缺少 key,如果你忘记了这一点,它将抛出一个错误。假设你有以下布局:
m('.page', m('.header', { key: 'header' }), m('.body'), m('.footer'));
显然,这将抛出错误,因为 .header
具有 key,而 .body
和 .footer
都缺少 key。但关键是:你不需要为此使用 key。如果你发现自己为类似的事情使用 key,解决方案不是添加 key,而是删除它们。只有在你真正需要它们时才添加它们。是的,底层的 DOM 节点具有标识,但 Mithril.js 不需要跟踪这些标识来正确地修补它们。它实际上从不需要这样做。只有当列表中的每个条目都具有某种关联状态,且 Mithril.js 本身不跟踪这些状态(无论这些状态存在于模型、组件还是 DOM 中)时,才需要使用 key。
最后一件事:避免静态 key。它们总是没有必要的。如果你没有计算你的 key
属性,你可能做错了什么。
请注意,如果确实需要一个单独的带 key 的元素,可以使用单子带 key 的片段。它只是一个数组,其中只有一个子元素是一个带 key 的元素,例如 [m("div", {key: foo})]
。
混合 key 类型
Key 被读取为对象属性名称。这意味着 1
和 "1"
被视为相同。为了避免不必要的麻烦,请尽量不要混合使用不同类型的 key。如果你这样做,你可能会遇到重复的 key 和意外的行为。
// 避免
var things = [
{ id: '1', name: 'Book' },
{ id: 1, name: 'Cup' },
];
如果你绝对必须这样做,并且你无法控制它,请使用一个前缀来表示它的类型,以便它们保持不同。
things.map(function (thing) {
return m(
'.thing',
{ key: typeof thing.id + ':' + thing.id }
// ...
);
});
使用空洞隐藏带 key 的元素
像 null
、undefined
和布尔值这样的值会被视为未设置 key 的 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 });
});
重复的 key
片段项的 key _必须_是唯一的,否则将导致逻辑混乱,无法确定哪个 key 应该对应哪个元素。你可能还会遇到元素没有像它们应该的那样移动的问题。
// 避免
var things = [
{ id: '1', name: 'Book' },
{ id: '1', name: 'Cup' },
];
Mithril.js 使用一个空对象将 key 映射到索引,以了解如何正确地修补带 key 的片段。当存在重复的 key 时,Mithril.js 无法确定元素应该移动到哪个位置,这会导致程序出错,并在列表更新时产生不可预测的结果。Mithril.js 需要不同的 key 才能正确地将旧节点连接到新节点,因此你必须选择一些在本地唯一的东西作为 key。
使用对象作为 key
片段项的 key 被视为属性 key。使用对象作为 key 可能无法达到预期的效果。
// 避免
things.map(function (thing) {
return m(Thing, { key: thing, thing: thing });
});
如果你的对象上有一个 toString
方法,它将被调用,并且其结果将不可控,你可能没有意识到该方法正在被调用。如果没有 toString
方法,所有对象都会被转换为字符串 "[object Object]"
,从而导致重复 key问题。