[REAL Deep Dive into JS] 39. DOM

young_pallete·2022년 10월 21일
0

REAL JavaScript Deep Dive

목록 보기
40/46

🚦 본론

필요성

DOM은 HTML에서 파싱한 결과로 나온다고 했어요.
그렇다면 우리는 이걸 생각해봐야 돼요.

💡 그럼, HTML만 잘 나오면 되지, 왜 굳이 또 객체를 만들까?

이는 JavaScript 때문에 그렇습니다.

데이터를 동적으로 제어하여 화면에 출력해야 하는 경우에는, 정적인 HTML 언어로는 불가능했습니다.

그렇지만, javascript가 HTML에 반영하기 위한 코드를 적용하기 위해서는, 납득이 되어야 하는 중간 다리가 필요했어요.

그것이 바로 DOM입니다. 즉, 우리가 자바스크립트로 뭔가를 렌더링하게 된 이유이기도 하죠.

노드

우리, HTML을 파싱하는 과정에서 DOM Tree가 만들어진다는 것은 이전 파트에서 알게 됐어요.

이때, HTML 요소는 노드 객체로 변환하게 되는데, 다음과 같이 변환됩니다.

<div class="text">HELLO!</div>
  • 요소 노드: div
  • 어트리뷰트 노드: class="text"
  • 텍스트 노드: HELLO!

DOM은 '트리'다.

DOM의 핵심은 트리 자료구조라는 겁니다.
따라서, 계층적으로 노드를 관리한다는 것을 기억해주어야 해요.
이런 특성이 나중에 이벤트를 설명할 때 영향을 주기도 합니다.

책에서는 최상위 노드, 부모 노드, 자식 노드... 이런 말들 많이 쓰기는 하는데, 그냥 직감적으로 와닿잖아요? 그렇게 이해하는 게 편하니, 넘어가도록 하죠.

✅ 필요하지 않다는 것이 아닙니다. 그냥 마주보면 본능적으로 이해하게 되는 용어라 넘어가는 거에요!

문서 노드

우리, 전역 객체 기억나시나요?
전역 객체는 직접 드러나지는 않아도, 암묵적으로 전체 자바스크립트 코드에 엄청난 영향을 주죠. 전역이라는 건 그래서 중요한 겁니다.

그렇다면 이러한 DOM의 최상위에는 무엇이 있을까요?
바로 Document 객체가 존재합니다.

Object -EventTarget - Node에 걸친 다음 상속으로, 바로 Document라는 객체가 존재하게 되는 것이죠.

이 친구는, 전역에 유일하게 존재하는데요. 이유는 window 객체가 생성되면, 생성 과정에서 document라는 프로퍼티에 반영하기 때문입니다.

console.log(window.document) // #document

여기서 흥미로운 점은 ElementDocument가 결국 Node라는 객체를 상속받는다는 것입니다.
따라서 두 친구 모두, Node 객체의 특성을 지니고 있기에, querySelector같은 것이 가능한 거였군요!

주목할 것은, 노드 객체의 특성이겠어요.

  • Object: JavaScript의 객체 특성을 갖습니다.
  • EventTarget: 이벤트를 트리거할 수 있습니다.
  • Node: 트리 자료구조에 속합니다.
  • Element: 웹 문서로써의 요소 기능을 합니다(XML, SVG, HTML)
  • HTMLElement: HTML 요소를 갖고 있습니다.

요소 취득

자, 이제 자바스크립트를 통해 DOM을 갖고 놀아볼까요?

다음과 같은 방법으로 요소를 취득할 수 있어요!

// 다음 두 문은 동일하게 id가 'id'인 노드를 찾아냅니다.

const elem1 = document.getElementById('id');
const elem2 = document.querySelector('#id');

// 다음 두 문은 동일하게 class에 'class'가 포함된 노드를 찾아냅니다.
const elem1 = document.getElementsByClassName('class'); // HTMLCollection[포함된 노드 개수]
const elem2 = document.querySelector('.class'); // 계층적으로 탐색하여 일치하는 노드 하나 할당

// 이런 식으로 하위 노드에 대한 탐색 조건을 추가할 수 있습니다.
const elem2 = document.querySelector('#root > .class');

// 다음 두 문은 동일하게 태그가 'div'인 노드를 찾아냅니다.
const elem1 = document.getElementsByTagName('DIV'); // HTMLCollection[포함된 노드 개수]
const elem2 = document.querySelector('div');

// getElementsByTagName, querySelector(All)에서는 와일드카드 문자 '*'을 허용합니다.
document.body.querySelector('*') // 루트 객체
document.body.getElementsByTagName('*') // HTMLCollection(Node 개수)

HTMLCollection, NodeList

우리, DOM에 서식하는 친구들 중 유사배열객체가 있다고 했죠?
이 친구들이 그렇습니다. 이터레이션 프로토콜로 이루어져 있으며, 순회가 가능한 유사배열객체죠!

특이한 것은, DOM API를 통해 가져온 결과물로, 실시간으로 노드 객체의 상태를 반영해주고 있습니다.

이러한 특성 때문에, 이 친구들을 살아 있는 객체라고 부르기도 한다고 하네요!

다만 NodeList의 경우에는 조건부로 살아 있는 상태이지, 실제로는 과거의 상태를 유지하는 Non-live한 특성을 갖고 있습니다.

주의점: 살아 있는 객체이므로, 순회시, 변경에 유의하자

이 친구들을 다룰 때에는 각별한 유의가 필요해요.

만약 다음과 같이 document node가 구성되었다고 가정합시다.

<div class = "red"></div>
<div class = "red"></div>
<div class = "red"></div>
const $elems = document.getElementsByClassName('red');

for (let i = 0; i < $elems.length; i += 1) {
	const now = $elems[i];
    $elems[i].classList.remove('red');
}

결과는 이렇습니다.

<div class></div>
<div class = "red"></div>
<div class></div>

이유가 무엇일까요?
HTMLCollection은 정~말 완전히 살아있는 객체에요. 변경 시 그 변경 사항이 즉시 변경이 된답니다. 따라서, i = 0일 때, 이미 red가 삭제되어, HTMLCollection[0]에 위치한 노드가 조건에 맞지 않으니, 바로 제거해버린 거에요.

해결 방법은 아~주 많은데, NodeList랑 비교해주기 위해 다음 해결방법을 제시합니다.

const $elems = document.querySelectorAll('.red');

for (let i = 0; i < $elems.length; i += 1) {
	const now = $elems[i];
    $elems[i].classList.remove('red');
}

어때요, 모든 결과가 잘 수행되었나요?

<div class></div>
<div class></div>
<div class></div>

이유는, NodeList의 경우 실시간으로 반영하기는 하지만, 때에 따라 이처럼 실시간으로 반영해주지 않는 경우가 있는데요.

querySelectorAll 메서드는 이러한 NodeList를 반환하는 친구라, 클래스 삭제 시에도 삭제된 노드를 기억하고, 나머지를 수행하게 해준 것이에요!

저는 이런 특성 때문에, querySelector을 이용하는 편입니다.

노드 탐색

음... 자식 노드 탐색, 형제 노드 탐색, 부모 노드 탐색 모두 다 나와 있네요.

하지만, 이는 실제로 응용할 때 한 번씩 보시길 추천드려요.
생각보다 안 쓰는 것들도 많고, 괜히 이걸 하나하나 외웠다가는 과부하가 걸릴 것 같습니다.

다만, 생각보다 잘 쓰는 것들을 추천드릴게요.
이 키워드들만 외우면, 나중에 정말 유용할 거에요!

  • Node.prototype.childNodes: 자식 노드들 NodeList에 담아 반환
  • Node.prototype.parentNode: 부모 노드 탐색
  • Node.prototype.previousElementSibling(nextElementSibling): 형제 HTML 요소 탐색

텍스트 조작

nodeValue는 나와 있지만, 저의 경우 잘 안 씁니다.
아무래도 텍스트 노드만 가능하다는 측면이, 생각보다 아쉽기 때문입니다.

textContent의 경우, 전체를 바꾼다는 점이 약간 불안정해보이지만, document.createElement가 빈번한 상황에서는 해당 값을 변수로 캐싱하고 바로 textContent를 작성하는 경우가 많아서 크게 걱정하지는 않는 편입니다.

const div = document.createElement('div');
div.textContent = '...';

const div1 = document.createElement('div');
div.appendChild(div1); // 텍스트가 `div1`에 반영되지 않음.

그렇지만 또 innerText라는 것도 있죠. 이것까지 생각하면 이제 이중택일인데요.
저는 그래도 textContent를 선호합니다. 이유는 성능이 더 좋기 때문입니다.

DOM 조작

innerHTML

정말 많이 쓰는 프로퍼티입니다. 이 역시 접근자 프로퍼티로 구성되어 있는데요.
HTML 형식에 맞는 문자열을 입력하면, 이를 호출한 노드 객체의 하위에 반영해줍니다!

이때, innerHTML은 하위 노드의 마크업까지 모~두 리셋해버릴 거에요.
만약 순전히 추가만 한다면 xxx.innerHTML += '추가할 마크업 코드'을 입력해주면 된답니다! 그러면 기존 자식 노드의 끝에 추가돼요!

단점: XSS 공격

여기서도 잘 나와 있네요.
결국 크로스 사이트 스크립팅으로 인해 사용에 유의해야 하는데요.
일반적인 해결 방법으로는 HTML sanitization이 있어요.
이는 호출을 유도하는 태그들을 살균함으로써 위험한 코드 실행을 방지하는 거에요!
대개는 DOMPurifysanitize-html 등의 라이브러리를 사용하는 편입니다!

insertAdjacentHTML

innerHTML에는 단점 중에 하나가, 어느 순서에 넣을 것인지를 정하지 못한다는 거에요.
순전히 자식 노드에 해당하는 HTML 마크업을 변경하는 용도로 사용하는 용도입니다.
이런 단점을, 이 친구가 해결해주어요! 크게 4가지 옵션이 있는데요.

각 옵션에 따라 추가될 위치는 밑의 코드에 주석으로 달아놨어요!

<!-- beforebegin -->
<div class="parent">
	<!-- afterbegin -->
	<div class="child">CHILD</div>
    <!-- beforeend -->
</div>
<!-- afterend -->
const $parent = document.querySelector('.parent');
$parent.insertAdjacentHTML('beforebegin', '<div>NEW CHILD</div>');
$parent.insertAdjacentHTML('afterbegin', '<div>NEW CHILD</div>');
$parent.insertAdjacentHTML('beforeend', '<div>NEW CHILD</div>');
$parent.insertAdjacentHTML('afterend', '<div>NEW CHILD</div>');

createElement

말 그대로 요소를 생성하기 위한 메서드에요.
저는 innerHTML보다는 이 친구를 많이 애용하는 편이랍니다!

참고로, innerHTML이 더 편한 점도 많아요. 마크업 구조를 볼 수 있기 때문에 좀 더 직관적인 특성이 있고, 코드가 짧다는 장점이 존재합니다. <template>과 사용할 때 재사용성 역시 높은 편이구요.

다만 제가 이를 좋아하는 이유는, 선언 후 다시 해당 변수로 요소를 캐싱하여 재사용하는 점이 꽤나 효율적이더라구요. documentFragment와 함께 조합하면 충분히 리페인트와 리플로우 신경을 덜 쓰게 되기도 하구요. 그저 취향과 전략에 따른 선택이라는 점, 유념하시길 바라요!

노드 삽입

appendChild

이 친구는 인수로 전달받은 노드의 맨 마지막 자식 요소로 추가해줘요!

const parentNode = document.querySelector('.parent');

// 이 코드는 밑의 코드와 동일한 역할을 수행합니다.
parentNode.innerHTML += '<div></div>'

const parentsLastChild = document.createElement('div');
parentNode.appendChild(parentsLastChild);

insertBefore

이 친구는 insertAdjacentHTML과 비슷한 친구라 생각하면 돼요!
단지, Node로 두 번째 인자에 전달하여, 그 친구의 앞에 추가합니다.

const parentNode = document.querySelector('.parent');

const parentsLastChild = document.createElement('div');
parentNode.appendChild(parentsLastChild);

const newBeforeChild = document.createElement('div');
parentNode.appendChild(newBeforeChild, parentsLastChild);

cloneNode(deep?: boolean)

노드를 복사해줄 수 있어요.
정~말 가끔 가다 쓰기는 하는데, 이런 복사 용도의 메서드도 있다!라는 것만 생각하면 될 것 같아요.

removeChild()

사실 통째로 지우는 게 innerHTML로 하는 게 더 편리해서 많이 잘 안 쓰기는 합니다.
다만, 원하는 요소만 콕 찝어서 지울 때 사용하는 편입니다!

parentNode.removeChild(childNode)

어트리뷰트

한 노드는 크게 3가지의 노드로 나뉘었는데, 그 중 하나가 바로 어트리뷰트 노드였죠? 그 친구입니다.

HTML에서 어트리뷰트의 키는 대개 정해져 있는 편이에요.
당장 많이 쓰는 것만 따져봐도, id, class, style, tabindex, data 등이 그렇죠.

이러한 어트리뷰트는 NamedNodeMap 객체에 담기게 됩니다.
이는 Element.prototype.attributes로 알 수 있죠.

document.body.attributes // NamedNodeMap

HTML 어트리뷰트 탐색

hasAttribute를 통해 엘리먼트가 해당 어트리뷰트를 갖고 있는지 알 수 있습니다.

또한, getAttribute 메서드를 통해 엘리먼트의 어트리뷰트 키가 어떤 값을 갖고 있는지를 알 수 있죠.

HTML 어트리뷰트 조작

setAttribute를 통해 엘리먼트의 어트리뷰트를 조작할 수 있어요.
결과적으로, 초기 상태 값을 변경함으로써 요소를 다시 정의해주는 것입니다.

$urlLink = document.querySelector('a');
$urlLink.setAttribute('href', 'https://velog.io');

DOM 프로퍼티

(DOM 프로퍼티 !== HTML 어트리뷰트) = true

둘은 항상 같지 않습니다. 물론 classNameid 등의 어트리뷰트는 즉각 반응하기도 합니다. 그렇지만, inputElement.value와 같은 프로퍼티를 동적으로 변경하면 HTML 어트리뷰트에서는 반응하지 않는데요.

하지만 둘의 쓰임새는 다릅니다.

  • HTML 어트리뷰트는 초기 상태를 기억하는 것이고,
  • DOM 프로퍼티는 최신 상태의 값을 기억하는 데 그 목적을 두고 있습니다.

data 어트리뷰트, dataset 프로퍼티

정말 많이 쓰는 친구죠.
데이터 속성을 정의해줄 수 있는데요. 이를 통해 해당 요소가 특정 값을 가질 수 있습니다. 이때 저장되는 값은, string으로 변환된다는 것에 유념합시다.

const $elem = document.querySeletor('div');
$elem.dataset.nowArr = [1,2,3];

console.log($elem.dataset.nowArr); // 1,2,3
<!-- 어트리뷰트의 이름의 경우, 케밥케이스로 변환됩니다. -- >
<div data-now-arr="1,2,3"></div>

class 조작

classList로 편하게 모든 것을 다룰 수 있습니다.

className은 이름을 지정하기 편하기는 하지만, 복수의 클래스 이름을 지정할 때는 매우 불편하여, classList를 잘 기억하면 됩니다!

$elem.classList.add('name') // 추가
$elem.classList.contains('name') // true; 탐색
$elem.classList.replace('name', 'id') // name이라는 클래스 이름을 id로 변경
$elem.classList.toggle('name') // class="id name"; id라는 클래스 이름 존재 시 제거, 아니면 추가
$elem.classList.remove('id') // class="name"; 클래스 이름 제거

getComputedStyle(element)

이건 이번에 저도 새롭게 알게 되었고, 덕분에 언러닝하게 되었어요 🎉

저는 항상 착각하고 있었던 게, cssstyle 프로퍼티로 가져온다고 생각했어요.

그런데 항상 style로 가져올 경우, css 에서 정의한 스타일은 가져오지 않더라구요!
이를 통해 잘못된 가설을 세웠었습니다. 바로 우리가 개발자 도구로 보는 DOM은 DOM Tree라고 말이죠!

그런데 생각해보니... 개발자 도구에는 Style을 볼 수가 있죠? 따라서 우리가 보는 건 모든 레이아웃 결과를 반영한 결과입니다. 즉 제 가설이 틀린 것이죠.

따라서 스타일을 가져올 수 있어요.
어떻게 말이죠? 우리, 브라우저에서는 HTML보다 상위에 있는 친구가 있잖아요!
실제로 HTML은 Element 객체이죠. 이보다 상위에 있는 객체에는, 바로 전역 객체, Window가 있습니다.

window.getComputedStyle(document.body); // CSSDeclaration {...}
window.getComputedStyle(document.body).width // '811px'

// 길이도 가져올 수 있어요.
window.getComputedStyle(document.body).length // 343

이런 식으로 가져올 수 있어요!

DOM 표준

DOM은 2018년 4월부터 완전히 표준을 제공하게 되었어요.
아무래도 DOM이 달라지면, 또 벤더마다 코드를 따로 짜야 하니... 매우 골치 아픈 상황이 발생하기 때문이죠.

현재는 구글, 애플, MS, 모질라가 주도하는 WHATWG을 표준으로 하기로 했으며, 현재까지 4개의 DOM 레벨이 존재한답니다 🎉

🔥마치며

와...
간단히 쓰려고 엄청 간결하게 썼다 자부하는데, 이정도의 양이 나왔네요.
확실히 DOM... 파면 팔 수록 매우 깊다는 것을 다시금 체감하네요.

그렇지만, 정말 얻은 게 많았어요.
특히 CSS의 결과 값을 DOM에서 가져올 수 있다는 것을 통해, 모든 렌더링 과정의 결과물을 DOM에서 업데이트한다는 것까지 이해할 수 있었어요.

덕분에, HTML Attribute와 DOM Property가 다르다는 것 역시 쉽게 납득할 수 있었답니다.

역시 공부라는 것은 힘들 수록 얻는 게 많은 것 같아요.☺️ 이상! 🌈

📁 참고자료

innerText vs textContent

profile
People are scared of falling to the bottom but born from there. What they've lost is nth. 😉

0개의 댓글