Key(キー)
Keyとは?
キーは、追跡対象となる要素の一意な識別子です。key
属性を要素、コンポーネント、フラグメント vnodeに追加することで指定でき、以下のように使用します。
m('.user', { key: user.id }, [
/* ... */
]);
キーは、いくつかのシナリオで役立ちます。
- モデルデータや状態を持つデータをレンダリングする場合、キーはローカルな状態を正しいサブツリーに関連付けるために必要です。
- CSS を使用して複数の隣接するノードを個別にアニメーション化し、それらのいずれかを個別に削除する場合、キーはアニメーションが要素に紐づき、意図せず他のノードにジャンプしないようにするために必要です。
- サブツリーを強制的に再初期化したい場合は、キーを追加し、再初期化時にそのキーを変更して再描画する必要があります。
Keyの制限
注意: フラグメントの子要素は、key
属性を持つ vnode (キー付きフラグメント) のみ、または key
属性を持たない vnode (キーなしフラグメント) のみを含む必要があります。key
属性は、属性をサポートする vnode、つまり要素、コンポーネント、フラグメント vnode にのみ指定できます。null
、undefined
、文字列などの vnode は属性を持つことができないため、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 ? '件' : '件'
),
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 });
})
)
)
);
},
};
}
多くの機能がカプセル化されていますが、ここでは次の2点に焦点を当てます。
// `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を使用している場合、リンクのハッシュ長のせいで問題が発生する可能性があります。)
期待どおりの動作をする代わりに、非常に混乱して誤った動作をします。開いていたコメントリストが閉じられ、コメントを開いていた投稿の次の投稿が、コメントをすでにロードしたと認識しているにもかかわらず、「ロード中...」と表示され続けます。これは、コメントが遅延ロードされ、毎回同じコメントが渡されると想定しているためです (ここでは比較的健全に聞こえます)。しかし、実際にはそうではありません。
これは、Mithril.js がキーなしフラグメントを差分適用する方法、つまり非常に単純な方法で反復的に 1 つずつ差分適用することに起因します。したがって、この場合、差分は次のようになります。
- Before:
A, B, C, D, E
- Patched:
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)
// ...
);
},
};
}
たとえば、このコンポーネントから他の人にリンクする方法を追加したとします。具体的には、「マネージャー」フィールドを追加する例を考えます。
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
メソッド を使用しているため、Person
コンポーネントに渡される id
が "1"
から "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') })]
);
},
},
// ...
});
よくある落とし穴
key
を使用する際によくある間違いがいくつかあります。それらがなぜうまくいかないのかを理解するために、いくつか紹介します。
key
付き要素のラッピング
以下の2つのコードは、同じようには動作しません。
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
付きの要素をラップしても効果はなく、リストが変更されるたびに不要なリクエストが発生したり、内部のフォーム入力の状態が失われたりするなど、さまざまな問題が起こる可能性があります。結果として生じる動作は、投稿リストの壊れた例と似ていますが、状態が破損することはありません。
2番目の例では、.wrapper
要素に key
がバインドされているため、外側のフラグメントが key
付きになります。これは意図した通りの動作であり、ユーザーを削除しても、他のユーザーインスタンスの状態に問題は発生しません。
コンポーネント内に key
を配置する
人物の例で、代わりに次のように記述したと仮定しましょう。
// 回避
function Person(vnode) {
var personId = vnode.attrs.id;
// ...
return {
view: function () {
return m.fragment(
{ key: personId }
// 以前に view に記述していたもの
);
},
};
}
これはうまくいきません。key
はコンポーネント全体ではなく、view
にのみ適用されるため、コンポーネントの再初期化が起こらず、期待どおりにデータを再取得できません。
代わりに、コンポーネント自体の中ではなく、コンポーネントを 使用する vnode に key
を配置する、そこで使用されている解決策を推奨します。
// 推奨
return [m(Person, { id: m.route.param('id'), key: m.route.param('id') })];
不必要に要素に key
を設定する
key
そのものが ID であるという誤解がよくあります。Mithril.js では、フラグメントの子要素はすべて key
を持つか、すべて key
を持たないかのどちらかでなければならず、このルールに違反するとエラーが発生します。次のレイアウトがあるとします。
m('.page', m('.header', { key: 'header' }), m('.body'), m('.footer'));
これは明らかにエラーになります。.header
には key
があり、.body
と .footer
には key
がないためです。しかし、重要なのは、これには key
は必要ないということです。このような場合に key
を使っていると感じたら、key
を追加するのではなく、削除することが解決策になります。本当に、本当に 必要な場合だけ追加してください。確かに、DOM ノードにはそれぞれ ID がありますが、Mithril.js はそれらを正しくパッチするために、必ずしも ID を追跡する必要はありません。ほとんどの場合、追跡は不要です。各エントリに、モデル、コンポーネント、または DOM 自体にあるかどうかに関わらず、Mithril.js 自体が追跡しない何らかの関連付けられた状態があるリストでのみ、key
が必要になります。
最後に、静的な key
は避けてください。それらは常に不要です。key
属性を計算していない場合は、おそらく何か間違ったことをしています。
分離された単一の key
付き要素が本当に必要な場合は、単一の子を持つ key
付きフラグメントを使用してください。これは、[m("div", {key: foo})]
のように、key
付き要素である単一の子を持つ配列です。
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
は、プロパティキーとして扱われます。次のようなものは、期待通りには動作しません。
// 回避
things.map(function (thing) {
return m(Thing, { key: thing, thing: thing });
});
オブジェクトに toString
メソッドがある場合、それが暗黙的に呼び出され、その戻り値が key
として使用されます。toString
メソッドが呼び出されていることに気づかない場合もあります。toString
メソッドがない場合、すべてのオブジェクトは "[object Object]"
という文字列に変換されるため、重複する key
の問題が発生します。