키
키란 무엇인가요?
키는 Mithril.js가 vnode를 추적하는 데 사용하는 식별자입니다. key
속성을 통해 element, component, fragment vnode에 추가할 수 있습니다.
m('.user', { key: user.id }, [
/* ... */
]);
키는 다음과 같은 경우에 유용합니다.
- 모델 데이터 또는 상태 저장 데이터를 렌더링할 때, 로컬 상태를 올바른 하위 트리에 연결해야 하는 경우
- CSS를 사용하여 인접한 여러 노드를 독립적으로 애니메이션 처리하고 개별적으로 제거할 때, 애니메이션이 요소에 고정되어 예기치 않게 다른 노드로 이동하는 것을 방지해야 하는 경우
- 특정 조건에 따라 하위 트리를 다시 초기화해야 하는 경우, 키를 추가하고 다시 초기화할 때마다 키 값을 변경한 다음 다시 렌더링해야 하는 경우
키 제한 사항
중요: fragment 내에서 자식 요소는 키가 있는 vnode(keyed fragment) 또는 키가 없는 vnode(unkeyed fragment)만 포함해야 합니다. 키 속성은 원래 속성을 지원하는 vnode, 즉 element, component, fragment vnode에서만 사용할 수 있습니다. null
, undefined
, 문자열과 같은 vnode는 속성을 가질 수 없으므로 키 속성을 사용할 수 없고, 따라서 keyed fragment에서도 사용할 수 없습니다.
예를 들어, [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 });
})
)
)
);
},
};
}
위 코드에서 많은 기능이 캡슐화되어 있지만, 다음 두 부분을 자세히 살펴보겠습니다.
// `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가 키가 없는 fragment를 패치할 때, 매우 단순한 방식으로 각 요소를 순차적으로 비교하기 때문입니다. 따라서 이 경우, 변경 사항 비교(diff)는 다음과 같습니다.
- 이전:
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
를 사용하여 모델 뷰를 렌더링하는 것이 유용할 수 있습니다. 다음과 같은 레이아웃이 있다고 가정해 보겠습니다.
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
메서드를 사용했기 때문에 트리가 유지되고 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') })]
);
},
},
// ...
});
흔히 발생하는 문제
키와 관련하여 자주 발생하는 몇 가지 문제를 소개합니다. 이러한 문제가 발생하는 이유를 이해하는 데 도움이 될 몇 가지 사례를 제시합니다.
키가 지정된 요소 감싸기
다음 두 코드 조각은 동일하게 작동하지 않습니다.
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(...)
으로 생성된 외부 프래그먼트에는 키가 지정되지 않습니다. 키가 지정된 요소를 이렇게 감싸면 의도대로 작동하지 않을 수 있으며, 목록 변경 시 불필요한 추가 요청이 발생하거나 form 입력 상태가 초기화되는 등 다양한 문제가 발생할 수 있습니다. 결과적으로 게시물 목록의 잘못된 예와 비슷한 동작이 나타날 수 있지만, 상태 손상 문제는 발생하지 않습니다.
두 번째 예제에서는 키를 .wrapper
요소에 바인딩하여 외부 프래그먼트에도 키가 적용되도록 했습니다. 이렇게 하면 의도한 대로 동작하며, 사용자를 제거하더라도 다른 사용자 인스턴스의 상태에는 영향을 미치지 않습니다.
컴포넌트 내부에 키 지정
사람 예제에서 다음과 같이 작성했다고 가정해 보겠습니다.
// 피해야 함
function Person(vnode) {
var personId = vnode.attrs.id;
// ...
return {
view: function () {
return m.fragment(
{ key: personId }
// 이전에 view에 있던 내용
);
},
};
}
이렇게 하면 키가 컴포넌트 전체에 적용되지 않으므로 제대로 작동하지 않습니다. 키는 view에만 적용되므로 원하는 대로 데이터를 다시 가져오지 않습니다.
컴포넌트 내부에 키를 정의하는 대신, 컴포넌트를 사용하는 vnode에 키를 지정하는 것이 좋습니다.
// 선호
return [m(Person, { id: m.route.param('id'), key: m.route.param('id') })];
불필요한 요소에 키 지정
키를 ID 자체로 오해하는 경우가 많습니다. Mithril.js는 fragment의 자식 요소들이 모두 키를 갖거나, 모두 키가 없도록 강제합니다. 이를 위반하면 오류가 발생합니다. 다음과 같은 레이아웃이 있다고 가정해 보겠습니다.
m('.page', m('.header', { key: 'header' }), m('.body'), m('.footer'));
.header
에는 키가 지정되었지만 .body
와 .footer
에는 키가 없으므로 오류가 발생합니다. 하지만 이 경우에는 키가 불필요합니다. 이러한 상황에서는 키를 추가하는 대신 제거하는 것이 좋습니다. 정말 필요한 경우에만 키를 사용하십시오. 물론 기본 DOM 노드에는 ID가 있지만 Mithril.js는 이를 추적하여 패치할 필요가 거의 없습니다. 각 항목이 모델, 컴포넌트 또는 DOM 자체에 저장된 상태를 가지고 있고, Mithril.js가 이 상태를 직접 추적하지 않는 경우에만 키가 필요합니다.
마지막으로, 정적 키는 피해야 합니다. 항상 불필요합니다. key
속성을 계산하지 않는다면 잘못된 작업을 하고 있을 가능성이 높습니다.
격리된 상태에서 키가 지정된 단일 요소가 정말로 필요한 경우 단일 자식 키가 지정된 fragment를 사용하십시오. 이는 키가 지정된 요소인 단일 자식이 있는 배열일 뿐입니다(예: [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 }
// ...
);
});
빈 값으로 키 지정된 요소 숨기기
null
, undefined
, boolean 값은 키가 없는 vnode로 취급되므로, 아래와 같은 코드는 예상대로 동작하지 않습니다.
// 피해야 함
things.map(function (thing) {
return shouldShowThing(thing)
? m(Thing, { key: thing.id, thing: thing })
: null;
});
목록을 반환하기 전에 filter
함수를 사용하여 필요한 요소만 남기면 Mithril.js가 올바르게 처리합니다. 대부분의 경우 Array.prototype.filter
를 사용하는 것이 가장 적절한 해결책입니다.
// 선호
things
.filter(function (thing) {
return shouldShowThing(thing);
})
.map(function (thing) {
return m(Thing, { key: thing.id, thing: thing });
});
중복 키
Fragment 내의 키는 반드시 고유해야 합니다. 그렇지 않으면 어떤 요소에 어떤 키를 적용해야 하는지 불분명해지고, 요소가 올바르게 이동하지 않는 문제가 발생할 수 있습니다.
// 피해야 함
var things = [
{ id: '1', name: 'Book' },
{ id: '1', name: 'Cup' },
];
Mithril.js는 빈 객체를 사용하여 키와 인덱스를 매핑함으로써 키가 지정된 fragment를 효율적으로 패치합니다. 하지만 중복된 키가 존재하면 요소의 위치를 정확히 파악할 수 없게 되어 Mithril.js가 오작동하거나, 목록이 변경될 때 예기치 않은 결과가 발생할 수 있습니다. Mithril.js가 이전 노드를 새 노드에 올바르게 연결하려면 해당 범위에서 고유한 키를 선택하여 사용해야 합니다.
객체를 키로 사용
Fragment 항목의 키는 객체의 속성 키처럼 취급됩니다. 따라서 아래와 같은 코드는 예상과 다르게 동작할 수 있습니다.
// 피해야 함
things.map(function (thing) {
return m(Thing, { key: thing, thing: thing });
});
객체에 toString
메서드가 정의되어 있다면, 해당 메서드가 호출되어 반환된 값이 키로 사용됩니다. 이 경우 의도치 않게 toString
메서드가 호출될 수 있습니다. toString
메서드가 없다면 모든 객체가 "[object Object]"
로 변환되어 중복 키 문제가 발생할 수 있습니다.