thenullpage.com
항목이 백 개인 목록에서 삭제 버튼마다 리스너를 붙이면 리스너도 백 개다. 나는 한동안 그게 당연한 줄 알았다. 클릭이 눌린 자리에 가만히 있는 게 아니라 위로 올라온다는 걸 알고 나서야 리스너 한 개로 끝나는 코드가 눈에 들어왔다.
새로 추가한 항목만 삭제가 안 됐다. 처음부터 화면에 있던 것들은 잘 지워져서 한참을 엉뚱한 데서 헤맸다.
const list = document.querySelector("#todo");
document.querySelectorAll(".delete").forEach((btn) => {
btn.addEventListener("click", () => console.log("삭제 클릭"));
});
const li = document.createElement("li");
li.insertAdjacentHTML("beforeend", '<button class="delete">삭제</button>');
list.append(li);
// 이 버튼을 눌러도 콘솔은 조용하다
querySelectorAll은 부른 순간에 있던 요소만 골라온다. 그 뒤에 만들어진 버튼은 리스너를 받은 적이 없으니 눌러도 아무 일이 없다. 새로 만들 때마다 붙이는 방법도 있지만 항목이 늘면 리스너도 같이 늘고 뗄 때도 챙겨야 한다. 부모 하나에만 붙이는 길이 따로 있다.
클릭은 눌린 자리에서 위로 올라간다. 상자 안에 상자를 넣고 찍어보면 순서가 그대로 보인다.
<div class="outer"><div class="inner">안쪽</div></div>
const outer = document.querySelector(".outer");
const inner = document.querySelector(".inner");
outer.addEventListener("click", () => console.log("바깥 캡처"), { capture: true });
outer.addEventListener("click", () => console.log("바깥 버블"));
inner.addEventListener("click", () => console.log("안쪽"));
// 안쪽을 클릭하면 바깥 캡처, 안쪽, 바깥 버블 순으로 찍힌다
이벤트는 문서 꼭대기에서 눌린 요소까지 내려갔다가 다시 위로 되돌아 올라온다. 내려가는 구간이 캡처, 올라오는 구간이 버블이다. 옵션 없이 붙인 리스너는 전부 올라오는 길에 실행된다. 안쪽을 눌러도 바깥 리스너가 불린다는 뜻이고, 위임은 이걸 그대로 쓰는 것이다.
버튼을 눌렀는데 target이 버튼이 아니었다. 버튼 안에 아이콘을 넣어둔 게 원인이었다.
<ul id="todo"><li><button class="delete"><span class="icon">X</span> 삭제</button></li></ul>
list.addEventListener("click", (e) => {
console.log(e.target.className); // icon, 실제로 눌린 건 안쪽 span이다
console.log(e.currentTarget.id); // todo, 리스너가 붙어 있는 요소
if (e.target.classList.contains("delete")) console.log("삭제"); // 아이콘을 누르면 안 걸린다
});
target은 사건이 처음 난 자리고 currentTarget은 지금 이 리스너가 붙어 있는 요소다. 위임에서는 둘이 거의 항상 다르다. 눌린 게 그대로 target이라 클래스를 직접 비교하면 새는 클릭이 생긴다.
closest가 그 구멍을 메운다. 눌린 자리에서 위로 올라가며 조건에 맞는 첫 요소를 찾아준다.
list.addEventListener("click", (e) => {
const btn = e.target.closest(".delete"); // 자기 자신부터 부모 쪽으로 훑는다
if (!btn) return; // 삭제 버튼 쪽이 아니면 여기서 끝
const li = btn.closest("li");
console.log("삭제", li.dataset.id); // 삭제 7
li.remove();
});
closest는 자기 자신을 먼저 검사하고 아니면 부모로 올라간다. 끝까지 못 찾으면 null이라 if (!btn) return 한 줄이 그대로 필터가 된다. 이 리스너 하나로 지금 있는 항목이든 십 분 뒤에 추가된 항목이든 다 처리된다. 요소 자체만 확인하면 될 때는 e.target.matches(".delete")도 있다.
버튼 종류가 늘어도 리스너는 하나면 된다. 속성에 동작 이름을 적어두고 안에서 갈라 쓴다.
<li data-id="7"><button data-action="edit">수정</button><button data-action="del">삭제</button></li>
list.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-action]");
if (!btn) return;
const id = btn.closest("li").dataset.id;
if (btn.dataset.action === "edit") console.log("수정 열기", id); // 수정 열기 7
if (btn.dataset.action === "del") console.log("삭제 요청", id); // 삭제 요청 7
});
stopPropagation을 뿌렸다가 바깥 클릭 닫기가 죽었다. 메뉴 안을 눌러도 메뉴가 닫히길래 전파부터 끊었더니 엉뚱한 기능들이 조용히 멈췄다.
menu.addEventListener("click", (e) => e.stopPropagation()); // 이러지 말 것
document.addEventListener("click", (e) => {
if (e.target.closest(".menu")) return; // 메뉴 안쪽이면 그냥 둔다
console.log("바깥을 눌렀다");
menu.classList.remove("open");
});
stopPropagation은 위로 가는 길 자체를 끊는다. 내가 막고 싶던 하나만 막는 게 아니라 그 위에 붙은 남의 리스너와 바깥 클릭 감지까지 같이 죽는다. 게다가 안 불린다는 증상은 에러가 안 나서 원인이 잘 안 보인다. 전파를 끊는 대신 closest로 내 영역인지 물어보고 넘어가면 된다.
포커스는 위로 안 올라온다. 폼 하나에 리스너를 붙였는데 입력칸을 눌러도 콘솔이 비어 있었다.
form.addEventListener("focus", (e) => console.log("여긴 안 찍힌다"));
form.addEventListener("focusin", (e) => console.log("들어옴", e.target.name)); // 들어옴 email
form.addEventListener("focusout", (e) => console.log("나감", e.target.name)); // 나감 email
이벤트마다 위로 올라오는지가 정해져 있다. focus와 blur, mouseenter와 mouseleave는 안 올라온다. 대신 올라오는 짝인 focusin, focusout, mouseover, mouseout이 있다. 위임이 안 먹으면 이것부터 확인한다.
돌아보면 위임은 요령이라기보다 이벤트가 올라온다는 사실을 그냥 쓰는 것이다. 그래서 지금은 목록을 그릴 때 항목마다 리스너를 붙이지 않는다. 부모에 하나 붙이고 첫 줄에 console.log(e.target)을 찍어 뭐가 잡히는지부터 본다. 예상과 다른 게 찍히면 closest에 넣을 선택자만 고치면 대부분 끝났다.