본문으로 건너뛰기

이벤트 위임과 data 속성

자바스크립트 목차웹 페이지 움직이기레슨 71 / 76

목차

할 일 목록의 줄(<li>)은 추가하거나 지울 때마다 새로 그려집니다. 줄마다 일일이 클릭 리스너를 붙이는 대신, 변하지 않는 부모 <ul id="todo-list"> 하나에만 리스너를 붙여서 모든 자식 줄의 클릭을 받아 처리하는 방법을 **이벤트 위임(event delegation)**이라고 합니다.

5단계 중 1단계. 삭제 단추(×)를 누르면 클릭 이벤트가 부모 <li>를 거쳐 <ul id="todo-list">의 리스너까지 올라갑니다(버블링).

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 탭: 눌린 줄: 2
  • event.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
1
  1. wrong: 숫자 2와 문자열 '2'를 2 !== '2'로 비교하면 타입이 달라 **언제나 true**가 됩니다. 결국 2번 할 일도 걸러지지 않고 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
true

data-filter="active"는 btn.dataset.filter로 읽으며, 값은 문자열 'active'이므로 'active'와 바로 ===로 비교할 수 있습니다.

자주 틀리는 곳

  1. li.dataset.id를 Number()로 바꾸지 않고 숫자 t.id와 비교한다: t.id === li.dataset.id⁠는 2 === '2'가 되어 늘 false이고, t.id !== li.dataset.id⁠는 늘 true여서 완료 토글도 삭제도 동작하지 않습니다.
  2. 새로 그린 <li>마다 리스너를 따로 붙이려다 빼먹는다: 처음 페이지가 열릴 때 한 번만 querySelectorAll('li')⁠로 리스너를 붙이면, 나중에 추가된 할 일 줄이나 render()로 다시 그린 줄에는 리스너가 사라집니다. 부모 #todo-list에 위임하면 다시 그려도 리스너가 그대로 유지됩니다.
  3. li.dataset.dataId처럼 data-까지 또 적는다: data-id는 dataset.id로, data-filter는 dataset.filter로 읽습니다(data- 접두사는 dataset이 대신합니다).

확인 문제

  1. <li data-id="3"> 요소가 변수 li에 들어 있습니다. 이 요소의 data-id 값을 숫자 3으로 꺼내 상수 id에 담는 식을 쓰세요.
정답

Number(li.dataset.id)⁠를 상수 id에 대입합니다.

  1. 자식 <li>들을 매번 새로 비우고 다시 그리는 render() 함수를 쓸 때, 각 <li> 대신 부모 <ul id="todo-list">에 클릭 리스너를 하나만 붙이는(이벤트 위임) 장점을 쓰세요.
정답

부모 <ul>은 사라지지 않으므로 render()로 자식 <li>들을 지우고 새로 만들어 붙여도 리스너를 다시 등록할 필요 없이 모든 줄의 클릭을 그대로 받을 수 있습니다.

  1. t.id가 숫자 1이고 li.dataset.id가 '1'일 때, t.id !== li.dataset.id⁠의 계산 결과는 무엇인가요?
정답

숫자 1과 문자열 '1'은 타입이 달라 같지 않으므로 true입니다.

끝까지 내려오면 저절로 표시됩니다. 이 기기에만 저장합니다.

자바스크립트 목차로