thenullpage.com
DOM은 브라우저가 HTML을 읽어서 만들어둔 화면의 뼈대다. 자바스크립트로 화면을 바꾼다는 건 결국 이 뼈대에서 원하는 자리를 찾아 손대는 일이다. 문법은 어려울 게 없는데 여기서 날린 시간이 제일 길었다. 코드가 틀린 게 아니라 엉뚱한 순간에 엉뚱한 걸 잡고 있어서 그랬다.
버튼이 null이라며 첫 줄부터 멈췄다. 스크립트를 head 안에 넣어둔 게 원인이었다.
<head>
<script src="app.js"></script>
</head>
<body><button id="save">저장</button></body>
const btn = document.querySelector("#save");
console.log(btn); // null
btn.textContent = "저장하기"; // TypeError: Cannot set properties of null
브라우저는 문서를 위에서 아래로 읽는다. head의 스크립트가 돌아가는 순간 body는 아직 만들어지지도 않았다. querySelector는 못 찾으면 에러 대신 null을 조용히 돌려주고, 그래서 사고는 항상 그 다음 줄에서 터진다. script 태그에 defer를 붙이면 문서를 다 읽은 뒤로 실행이 미뤄진다.
<script src="app.js" defer></script>
목록에 map을 돌렸더니 그런 함수 없다고 했다. 선택자로 여러 개를 골라온 다음이었다.
const items = document.querySelectorAll(".item");
console.log(items.length); // 3
console.log(Array.isArray(items)); // false
items.forEach((el) => console.log(el.textContent)); // 이건 된다
const texts = items.map((el) => el.textContent); // TypeError: items.map is not a function
돌아오는 NodeList는 배열을 닮았을 뿐 배열이 아니다. forEach만 따로 붙어 있고 map이나 filter는 없다. 배열로 펼치면 그때부터 전부 쓸 수 있다.
console.log([...items].map((el) => el.textContent)); // ["첫 줄", "둘째 줄", "셋째 줄"]
이름이 비슷한 getElementsByClassName은 한 술 더 뜬다. 얘가 주는 목록은 화면과 실시간으로 이어져 있어서 지우는 동안 목록이 같이 줄어든다.
const live = document.getElementsByClassName("item");
console.log(live.length); // 3
for (let i = 0; i < live.length; i++) live[i].remove();
console.log(live.length); // 1, 반쪽만 지워졌다
querySelectorAll이 주는 목록은 부른 순간을 찍은 사진이라 이런 일이 없다. 그 뒤로 선택은 querySelector로 시작하는 둘만 쓴다. CSS에서 쓰던 선택자를 그대로 넣으면 되니 외울 것도 없다.
닉네임을 화면에 찍었더니 글자가 통째로 사라졌다. 값을 innerHTML로 넣은 게 문제였다.
const box = document.querySelector("#nick");
const nick = "<b>홍길동";
box.innerHTML = nick;
console.log(box.textContent); // 홍길동, 태그가 진짜 태그로 먹혔다
box.textContent = nick;
console.log(box.textContent); // <b>홍길동, 친 그대로 보인다
innerHTML은 넘긴 문자열을 HTML로 해석해서 심는다. 사용자가 친 값을 그대로 넣으면 남이 짜둔 코드가 내 페이지에서 도는 문이 열리고, 이걸 XSS라 부른다. 사람이 입력한 값은 예외 없이 textContent다. 덧붙일 때 innerHTML +=를 쓰는 것도 위험한데, 안에 있던 걸 문자열로 뽑아 통째로 다시 만드는 방식이라 입력 중이던 값까지 같이 날아간다.
클래스 하나 켜려다 나머지를 다 날렸다. 카드에 강조 표시를 붙이는 코드였다.
const card = document.querySelector(".card");
console.log(card.className); // card wide
card.className = "active";
console.log(card.className); // active, card도 wide도 없어졌다
className은 클래스 전부를 담은 문자열 한 덩어리라 대입하는 순간 통째로 갈린다. 하나만 손대려면 classList가 따로 있다. 접었다 폈다 하는 자리는 toggle 한 줄로 끝난다.
card.className = "card wide";
card.classList.add("active");
console.log(card.className); // card wide active
card.classList.toggle("active");
console.log(card.classList.contains("active")); // false
속성에서 꺼낸 값이 끝까지 문자열이었다. 목록의 번호를 꺼내 계산하는데 답이 이상했다.
<li data-id="12">장보기</li>
const li = document.querySelector("li");
console.log(li.dataset.id); // 12
console.log(typeof li.dataset.id); // string
console.log(li.dataset.id + 1); // 121, 더한 게 아니라 붙었다
console.log(Number(li.dataset.id) + 1); // 13
li.style.width = 200;
console.log(li.style.width); // 빈 문자열, 무시됐다
li.style.width = "200px";
console.log(li.style.width); // 200px
HTML 속성에 적힌 값은 전부 문자열로 들어온다. data-로 시작하는 속성은 dataset으로 꺼낼 수 있어 편한데, 숫자로 쓸 거면 Number를 씌워야 한다. 스타일도 같은 이유로 단위까지 붙인 문자열이라야 먹힌다.
줄 백 개를 그렸더니 눈에 띄게 버벅였다. 반복문 안에서 만든 요소를 곧바로 화면에 붙이고 있었다.
const rows = document.querySelector("#rows");
const frag = document.createDocumentFragment();
for (let i = 1; i <= 100; i++) {
const div = document.createElement("div");
div.textContent = `행 ${i}`;
frag.append(div);
}
rows.append(frag);
console.log(rows.children.length); // 100, 화면에 닿은 건 한 번뿐
화면에 붙어 있는 요소를 건드릴 때마다 브라우저는 위치를 다시 계산한다. 백 번 붙이면 백 번 계산한다. createDocumentFragment는 화면 밖 임시 상자라 여기서 다 조립하고 마지막에 한 번만 넣으면 계산도 한 번이다. append는 요소든 문자열이든 여러 개든 받고, 옛날부터 쓰던 appendChild는 요소 하나만 받는다. 지울 때는 el.remove() 한 줄이면 된다.
돌아보면 DOM 사고는 두 갈래였다. 하나는 아직 없는 걸 잡으려 해서, 하나는 돌려받은 게 내가 생각한 물건이 아니라서 났다. 그래서 지금은 화면을 건드리기 전에 이 코드가 언제 실행되는지부터 확인하고, 뭔가 안 맞으면 console.log로 잡아온 값을 먼저 찍는다. null인지 빈 목록인지만 봐도 어디를 고칠지 절반은 갈린다.