할 일 목록의 줄(<li>)은 추가하거나 지울 때마다 새로 그려집니다. 줄마다 일일이 클릭 리스너를 붙이는 대신, 변하지 않는 부모 <ul id="todo-list"> 하나에만 리스너를 붙여서 모든 자식 줄의 클릭을 받아 처리하는 방법을 **이벤트 위임(event delegation)**이라고 합니다.
1이벤트 버블링과 closest('li')
브라우저에서 안쪽 자식 요소(<button class="delete">)를 누르면, 클릭 이벤트는 그 단추에서 끝나지 않고 부모 <li>를 거쳐 부모의 부모 <ul id="todo-list">까지 물거품처럼 위로 올라갑니다(이벤트 버블링).
<ul id="todo-list">
<li data-id="1">
<span class="text">
우유
</span>
<button class="delete">
×
</button>
</li>
</ul>덕분에 부모 #todo-list 하나에만 'click' 리스너를 달아 두면, 나중에 새로 만들어 붙인 <li> 줄을 눌러도 리스너를 다시 달 필요 없이 모두 잡힙니다.
'use strict';
const list = document
.querySelector('#todo-list');
이어서 list에 클릭 리스너를 등록합니다.
list.addEventListener(
'click',
(event) => {
const li = event.target
.closest('li');
if (!li) return;
console.log(
'눌린 줄:',
li.dataset.id,
);
},
);
둘째 줄의 × 단추를 누르면:
Console 탭: 눌린 줄: 2event.target: 사용자가 실제로 누른 가장 안쪽 요소(<button class="delete">또는<span class="text">)입니다.event.target.closest('li'):event.target자신부터 시작해 위로 올라가며 가장 가까운<li>요소를 찾아 돌려줍니다(못 찾으면null).
2dataset과 Number(li.dataset.id)
HTML 여는 태그에 적어 둔 data-id="2" 속성은 자바스크립트에서 **요소.dataset.id**로 읽고 쓸 수 있습니다(data-filter="done"이면 요소.dataset.filter).
주의할 점은 dataset에서 꺼낸 값은 언제나 문자열이라는 것입니다! 할 일 배열 todos의 id는 숫자(1, 2)인데 li.dataset.id는 문자열('1', '2')이므로, Number()로 바꾸지 않고 !==로 비교하면 어떻게 되는지 Node로 확인해 봅니다.
const todos = [
{ id: 1, text: '우유 사기' },
{ id: 2, text: '복습하기' },
];
const datasetId = '2';
const wrong = todos.filter(
(t) => t.id !== datasetId,
);
console.log(wrong.length);
이어서 Number(datasetId)로 숫자로 바꾼 뒤 걸러 봅니다.
const id = Number(datasetId);
const right = todos.filter(
(t) => t.id !== id,
);
console.log(right.length);
2
1wrong: 숫자2와 문자열'2'를2 !== '2'로 비교하면 타입이 달라 **언제나true**가 됩니다. 결국 2번 할 일도 걸러지지 않고2개가 그대로 남습니다!right: **Number(li.dataset.id)**로 먼저 숫자2로 바꾼 뒤t.id !== id를 비교해야2 !== 2가false가 되어 2번 할 일이 정확히 지워집니다.
3삭제 단추인지 체크박스인지 구분하기
같은 <li> 줄 안에서도 삭제 단추(.delete)를 눌렀을 때와 나머지(체크박스 · 글자)를 눌렀을 때 할 일이 다릅니다. 이때는 closest('.delete')로 삭제 단추가 눌렸는지 확인합니다.
'use strict';
list.addEventListener(
'click',
(event) => {
const li = event.target
.closest('li');
if (!li) return;
const id = Number(
li.dataset.id,
);
이어서 삭제 단추인지 확인해 분기합니다.
const del = event.target
.closest('.delete');
if (del) {
console.log(id, '삭제');
} else {
console.log(id, '토글');
}
},
);
첫째 줄 글자 클릭: 1 토글
첫째 줄 × 클릭: 1 삭제예제
결과를 먼저 생각한 뒤 펼쳐 보세요. 맞혀 보기는 점수에 들지 않습니다.
필터 단추 묶음 #filters에 이벤트 위임을 걸어 눌린 단추의 data-filter 값을 읽으려고 합니다. 아래 코드의 시뮬레이션 출력 두 줄은 무엇일까요?
const btn = {
dataset: { filter: 'active' },
};
const f = btn.dataset.filter;
console.log(typeof f, f);
console.log(f === 'active');
결과 보기
string active
truedata-filter="active"는 btn.dataset.filter로 읽으며, 값은 문자열 'active'이므로 'active'와 바로 ===로 비교할 수 있습니다.
자주 틀리는 곳
li.dataset.id를Number()로 바꾸지 않고 숫자t.id와 비교한다:t.id === li.dataset.id는2 === '2'가 되어 늘false이고,t.id !== li.dataset.id는 늘true여서 완료 토글도 삭제도 동작하지 않습니다.- 새로 그린
<li>마다 리스너를 따로 붙이려다 빼먹는다: 처음 페이지가 열릴 때 한 번만querySelectorAll('li')로 리스너를 붙이면, 나중에 추가된 할 일 줄이나render()로 다시 그린 줄에는 리스너가 사라집니다. 부모#todo-list에 위임하면 다시 그려도 리스너가 그대로 유지됩니다. li.dataset.dataId처럼data-까지 또 적는다:data-id는dataset.id로,data-filter는dataset.filter로 읽습니다(data-접두사는dataset이 대신합니다).
확인 문제
<li data-id="3">요소가 변수li에 들어 있습니다. 이 요소의data-id값을 숫자3으로 꺼내 상수id에 담는 식을 쓰세요.
정답
Number(li.dataset.id)를 상수 id에 대입합니다.
- 자식
<li>들을 매번 새로 비우고 다시 그리는render()함수를 쓸 때, 각<li>대신 부모<ul id="todo-list">에 클릭 리스너를 하나만 붙이는(이벤트 위임) 장점을 쓰세요.
정답
부모 <ul>은 사라지지 않으므로 render()로 자식 <li>들을 지우고 새로 만들어 붙여도 리스너를 다시 등록할 필요 없이 모든 줄의 클릭을 그대로 받을 수 있습니다.
t.id가 숫자1이고li.dataset.id가'1'일 때,t.id !== li.dataset.id의 계산 결과는 무엇인가요?
정답
숫자 1과 문자열 '1'은 타입이 달라 같지 않으므로 true입니다.