Keys
什麼是 Keys?
Keys 代表被追蹤的識別身分。您可以透過特殊的 key
屬性將它們新增到 元素、元件和片段 vnode 中,使用方式如下:
m('.user', { key: user.id }, [
/* ... */
]);
它們在以下情境中很有用:
- 當您渲染模型資料或其他有狀態的資料時,您需要 Keys 來保持本地狀態與正確的子樹綁定。
- 當您使用 CSS 獨立地為多個相鄰節點實現動畫效果,並且可以單獨移除其中任何一個節點時,您需要 Keys 來確保動畫與元素保持一致,並且不會意外地跳轉到其他節點。
- 當您需要透過指令重新初始化子樹時,您需要新增一個 Key,然後在想要重新初始化它時更改它並重新繪製。
Keys 的限制
重要: 對於所有片段,其子節點必須僅包含帶有 key 屬性的 vnode (keyed 片段),或僅包含沒有 key 屬性的 vnode (unkeyed 片段)。Key 屬性只能存在於首先支援屬性的 vnode 上,即 元素、元件和片段 vnode。其他 vnode,例如 null
、undefined
和字串,不能有任何類型的屬性,因此它們不能有 key 屬性,因此不能在 keyed 片段中使用。
這表示像 [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);
},
},
"I don't like this"
)
);
},
};
}
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);
},
},
"I don't like this"
)
),
showComments
? m(
'.post-comments',
comments == null
? m('.comment-list-loading', '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', 'Feed'),
posts == null
? m('.post-list-loading', '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 comment(s)」以顯示留言,在底部的留言撰寫框中輸入內容,然後點擊上面貼文上的「I don't like this」。這是一個線上示範,您可以試用,其中包含一個模擬模型 (請注意:如果您使用 Edge 或 IE,可能會因為連結的雜湊長度而遇到問題)。
它並未如預期運作,反而變得非常混亂並產生錯誤:它關閉了您已開啟的留言列表,且在您開啟留言的貼文之後的貼文,現在持續顯示「Loading...」,即使它認為已經載入了留言。這是因為留言是延遲載入的,它們假設每次都傳遞相同的留言 (這聽起來還算合理),但在這種情況下並非如此。這是因為 Mithril.js 修補未設定鍵 (Key) 的片段的方式:它以迭代方式逐個修補它們。所以在這種情況下,差異比對 (diff) 可能如下所示:
- 之前:
A, B, C, D, E
- 已修補:
A, B, C -> D, D -> E, E -> (已移除)
並且由於元件保持不變(它始終是 Comment
),因此只有屬性會更改,而不會被替換。
要修正此錯誤,您只需新增一個鍵 (Key),讓 Mithril.js 知道在必要時移動狀態以解決問題。這是一個已修正所有內容,且可運作的線上範例。
// In the `Feed` component
m(
'.post-list',
posts.map(function (post) {
return m(Post, { key: post.id, post: post });
})
);
// In the `Post` component
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', '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
沒有變更,因此它不會重新初始化元件。 但對於我們的情況來說,這是不可取的,因為這意味著沒有取得新使用者資料。 這就是鍵值 (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 的元素
以下兩個程式碼片段的運作方式不同:
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 }
// 你先前在 view 中擁有的內容
);
},
};
}
這樣做無效,因為 key 不適用於整個元件。它僅適用於 view,因此你不會像希望的那樣重新獲取資料。
建議採用範例中的解決方案,將 key 放在_使用_元件的 vnode 中,而不是元件內部。
// 建議
return [m(Person, { id: m.route.param('id'), key: m.route.param('id') })];
不必要地使用 key
常見的誤解是 key 本身帶有識別碼。Mithril.js 強制要求所有片段的子元素必須全部都有 key,或者全部都沒有,否則會拋出錯誤。假設你有以下佈局:
m('.page', m('.header', { key: 'header' }), m('.body'), m('.footer'));
這顯然會拋出錯誤,因為 .header
有 key,而 .body
和 .footer
都沒有 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。這樣的東西不會如你所想的那樣運作。
// 避免
things.map(function (thing) {
return m(Thing, { key: thing, thing: thing });
});
如果物件具有 toString
方法,則會被調用,且結果將取決於該方法的回傳值,即使您可能沒有意識到該方法正在被調用。如果沒有 toString
方法,所有物件都會被轉換為字串 "[object Object]"
,導致重複 key的問題。