Skip to content
Mithril.js 2
Main Navigation 指南API

简体中文

English
繁體中文
Español
Français
Русский
Português – Brasil
Deutsch
日本語
한국어
Italiano
Polski
Türkçe
čeština
magyar

简体中文

English
繁體中文
Español
Français
Русский
Português – Brasil
Deutsch
日本語
한국어
Italiano
Polski
Türkçe
čeština
magyar

主题

Sidebar Navigation

入门

安装

简单应用

资源

JSX

在旧版浏览器上使用 ES6+

动画

测试

示例

第三方库集成

路径处理

关键概念

虚拟 DOM

组件

生命周期方法

键(Key)

自动重绘系统

杂项

框架对比

从 v1.x 迁移

从 v0.2.x 迁移

API

页面导航

键(Key) ​

什么是键(Key)? ​

键(Key)用于追踪元素的身份标识。你可以通过 key 属性将它们添加到 元素、组件和片段 vnode 中。使用方法如下:

javascript
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 提供必要的信息来追踪它们。

假设我们有一个简单的社交媒体帖子列表,你可以在帖子下发表评论,也可以隐藏帖子(例如进行举报)。

javascript
// 为了简洁,省略了 `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 });
                })
              )
            )
      );
    },
  };
}

以上代码展示了一个社交媒体动态的例子。现在,我们重点关注以下两部分:

javascript
// 在 `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 就能知道在必要时移动状态。这是一个已修复的所有内容的在线示例。

javascript
// 在 `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 });
  })
);

请注意,对于评论,即使在这种情况下技术上不使用键也能工作,但如果你添加任何嵌套评论或编辑它们的功能,同样会发生错误,并且你必须向它们添加键。

保持动画对象集合流畅无卡顿 ​

在某些情况下,您可能需要为列表、方框等元素创建动画。我们从以下简单代码开始:

javascript
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 非常简单:只需创建一个计数器,并在每次使用时递增即可。

javascript
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 来渲染模型视图通常很有用。假设有如下布局:

javascript
function Layout() {
  // ...
}

function Person() {
  // ...
}

m.route(rootElem, '/', {
  '/': Home,
  '/person/:id': {
    render: function () {
      return m(Layout, m(Person, { id: m.route.param('id') }));
    },
  },
  // ...
});

您的 Person 组件可能如下所示:

javascript
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)
        // ...
      );
    },
  };
}

假设您添加了从该组件链接到其他人的功能,例如添加一个“经理”字段。

javascript
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 的作用所在。可以通过以下方式修改路由解析器来解决此问题:

javascript
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 的元素 ​

以下两个代码片段的工作方式不同:

javascript
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 放在组件内部 ​

假设在人员示例中,你这样做:

javascript
// 避免
function Person(vnode) {
  var personId = vnode.attrs.id;
  // ...

  return {
    view: function () {
      return m.fragment(
        { key: personId }
        // 你之前在视图中拥有的内容
      );
    },
  };
}

这不起作用,因为 key 不适用于整个组件。它仅适用于视图,因此你不会像期望的那样重新获取数据。

最好采用原文的解决方案,将 key 放在_使用_组件的 vnode 中,而不是放在组件内部。

javascript
// 推荐
return [m(Person, { id: m.route.param('id'), key: m.route.param('id') })];

不必要地使用 key ​

人们常常误以为 key 本身就是标识。Mithril.js 强制要求所有片段的子元素必须全部具有 key 或全部缺少 key,如果你忘记了这一点,它将抛出一个错误。假设你有以下布局:

javascript
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 和意外的行为。

javascript
// 避免
var things = [
  { id: '1', name: 'Book' },
  { id: 1, name: 'Cup' },
];

如果你绝对必须这样做,并且你无法控制它,请使用一个前缀来表示它的类型,以便它们保持不同。

javascript
things.map(function (thing) {
  return m(
    '.thing',
    { key: typeof thing.id + ':' + thing.id }
    // ...
  );
});

使用空洞隐藏带 key 的元素 ​

像 null、undefined 和布尔值这样的值会被视为未设置 key 的 vnode,因此以下代码无法正常工作:

javascript
// 避免
things.map(function (thing) {
  return shouldShowThing(thing)
    ? m(Thing, { key: thing.id, thing: thing })
    : null;
});

相反,在返回列表之前对其进行过滤处理,Mithril.js 会正确处理。大多数情况下,Array.prototype.filter 正是你所需要的,你应该尝试一下。

javascript
// 推荐
things
  .filter(function (thing) {
    return shouldShowThing(thing);
  })
  .map(function (thing) {
    return m(Thing, { key: thing.id, thing: thing });
  });

重复的 key ​

片段项的 key _必须_是唯一的,否则将导致逻辑混乱,无法确定哪个 key 应该对应哪个元素。你可能还会遇到元素没有像它们应该的那样移动的问题。

javascript
// 避免
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 可能无法达到预期的效果。

javascript
// 避免
things.map(function (thing) {
  return m(Thing, { key: thing, thing: thing });
});

如果你的对象上有一个 toString 方法,它将被调用,并且其结果将不可控,你可能没有意识到该方法正在被调用。如果没有 toString 方法,所有对象都会被转换为字符串 "[object Object]",从而导致重复 key问题。

Pager
上一页生命周期方法
下一页自动重绘系统

基于 MIT 许可证 发布。

版权所有 (c) 2024 Mithril Contributors

https://mithril.js.org/keys.html

基于 MIT 许可证 发布。

版权所有 (c) 2024 Mithril Contributors