해당 포스팅은 위키북스의 모던 자바스크립트 Deep Dive라는 책을 독학하며 기록하는 글입니다.

38장에서 브라우저 렌더링 과정을 살펴보고 DOM이 무엇인지 간단하게 살펴봤다. 여기서는 DOM에 대한 정확한 정의와 브라우저 환경에서 사용할 수 있는 DOM API에 대해 알아보자.

DOM

DOM은 Document Object Model의 약자로 HTML 문서의 계측적 구조와 정보를 표현하며 이를 제어할 수 있는 API, 즉 프로퍼티와 메서드를 제공하는 트리 자료구조이다.

DOM은 노드 객체들의 계층적 구조로 이루어져 있는데 노드 객체들의 종류는 12가지이고 그 중 중요한 4가지는 다음과 같다.

  1. 문서 노드
    DOM 트리의 최상위에 존재하는 루트 노드로서 document 객체를 가리킨다. document 객체는 브라우저가 렌더링한 HTML 문서 전체를 가리키는 객체로 window.document또는 documnet로 접근 가능하다.

  2. 요소 노드
    HTML 요소를 가리리는 객체로 HTML 요소 간의 중첩에 의해 부자 관계를 가져 정보를 구조화한다.

  3. 어트리뷰트 노드
    HTML 요소의 어트리뷰트를 가리키는 객체이다. 어트리뷰트가 지정된 HTML 요소의 요소 노드와 연결되어 있으며, 따라서 어트리뷰트를 참조하거나 변경하기 위해서는 먼저 요소 노드에 접근해야 한다.

  4. 텍스트 노드
    HTML 요소의 텍스트를 가리키는 객체이다. 요소 노드가 문서의 구조를 나타낸다면 텍스트 노드는 문서의 내용을 나타낸다. 항상 DOM 트리의 최종단이다.

최종적으로 DOM은 HTML 문서의 계층적 구조와 정보를 표현하는 것은 물론 노드 객체의 종류, 즉 노드 타입에 따라 필요한 기능을 프로퍼티와 메서드 집합인 DOM API로 제공한다. 이 DOM API를 통해 HTML의 구조나 내용 또는 스타일 등을 동적으로 조작할 수 있다.

요소 노드의 취득

요소 노드를 취득할 수 있는 방법은 총 4가지가 있다.

  1. id를 통한 취득
// 인수로 전달된 id명을 갖는 요소 노드를 element에 할당
// 문서에서 찾을 수 없는 경우 null을 할당
const element = document.getElementById('id명');
  1. 태그명을 통한 취득
// 인수로 전달된 태그명을 갖는 모든 요소 노드를 element에 DOM컬렉션 객체중 하나인 HTMLCollection객체형태로 할당
// 문서에서 찾을 수 없는 경우 빈 HTMLCollection 객체를 할당
const element = document.getElementsByTagName('태그명');

//다음과 같이 element요소노드 아래에 있는 원하는 태그명을 가지는 요소 노드들을 취득할 수도 있다.
const list = element.getElementsByTagName('태그명');
  1. 클래스를 통한 취득
// 인수로 전달된 class명을 갖는 모든 요소 노드를 element에 DOM컬렉션 객체중 하나인 HTMLCollection객체형태로 할당
// 문서에서 찾을 수 없는 경우 빈 HTMLCollection 객체를 할당
const element = document.getElementByClassName('class명');
  1. CSS 선택자를 통한 취득
// 인수로 전달된 CSS 선택자를 갖는 첫 요소 노드를 element에 할당
// 문서에서 찾을 수 없는 경우 null을 할당
const element = document.querySelector('CSS선택자');

// 인수로 전달된 CSS 선택자를 갖는 모든 요소 노드를 element에 DOM컬렉션 객체인 NodeList 객체형태로 할당
// 문서에서 찾을 수 없는 경우 빈 NodeList 객체를 할당
const element = document.querySelectorAll('CSS선택자');

HTML컬렉션과 NodeList객체

위의 메서드를 통해 요소 노드를 취득할 때 여러 개의 결과값을 반환하는 경우 DOM 컬렉션 객체에 담겨 반환된다. DOM 컬렉션 객체인 HTMLCollection 객체와 NodeList 객체는 모두 유사 배열 객체이면서 이터러블이다.

둘의 가장 큰 특징은 노드 객체의 상태 변화를 실시간으로 반영하는 살아 있는 객체라는 것이다. HTMLCollection은 항상 live 객체로 동작하고 NodeList는 대부분의 경우 non-live 객체로 동작하지만 겨웅에 따라 live 객체로 동작한다. 하지만 live 객체로 동작하는 경우 여러가지 오류가 발생하거나 사용자가 의도한대로 동작하지 않는 경우가 생길수도 있기 때문에 non-live 객체의 사용을 권장한다.

NodeList 객체의 경우 childNodes 프로퍼티가 반환하는 NodeList 객체는 HTMLCollection 객체와 같이 live 객체로 동작한다.

방금 말한 예기치 못한 오류를 피하기 위해서는 HTMLCollection 객체나 NodeList 객체에 담긴 값을 스프레드 문법이나 Array.from 메서드를 통해 배열로 변경해서 사용하는 것이다.

요소 노드의 주변 노드 취득

요소 노드를 얻었다면 해당 요소 노드의 부모나 자식, 앞 형제나 뒤 형제의 요소 노드를 손 쉽게 취득할 수 있다.

// 한 요소노드를 취득
const element = document.qureySelector("CSS선택자");

// 부모노드 취득
const parent = element.parentNode;

// 형제노드 취득(텍스트 노드 포함)
const pre = element.previousSibling;
const next = element.nextSibling;
// 형제노드 취득(요소 노드들 중에서만)
const pre = element.previousElementSibling;
const next = element.nextElementSibling;

// 자식노드 취득(텍스트 노드 포함), NodeList에 담아 반환
const child = element.childNodes;
// 자식노드 취득(요소 노드들 중에서만), HTMLCollection에 담아 반환
const next = element.children;

// 첫, 마지막 자식노드 취득(텍스트 노드 포함)
const first = element.firstChild;
const last = element.lastChild;
// 첫, 마지막 자식노드 취득(요소 노드들 중에서만)
const first = element.firstElementChild;
const last = element.lastElementChild;

// 자식 노드가 있는지(텍스트 노드 포함)
const isChild = element.hasChildNodes();  // true 또는 false 반환
// 자식 노드가 있는지(요소 노드들 중에서만)
const isChild = !!element.childElementCount;  // true 또는 false 반환

요소 노드의 텍스트 조작

어떤 요소 노드를 취득했을 때 해당 요소 노드의 텍스트를 조작할 수 있는 방법은 크게 두 가지가 있다.

  1. nodeValue
    nodeValue는 노드 객체의 값을 반환하는데 여기서 노드 객체의 값은 텍스트 노드의 텍스트를 의미한다. 따라서 요소노드에 nodeValue를 사용하면 null을 반환한다. nodeValue를 사용하기 위해서는 반드시 텍스트 노드에 접근해서 사용해야 한다.
    nodeValue같은 경우에는 getter와 setter 성질을 모두 가지고 있기 때문에 값을 취득할 수도 있고 할당할 수도 있다.
// 요소 노드 취득
const ele = document.querySelector("CSS선택자");
// 해당 요소의 텍스트 노드 취득
const eleText = ele.firstChild;
// 텍스트값 취득
const text = eleText.nodeValue;
// 텍스트값 수정
eleText.nodeValue = "새로 바뀐 텍스트값";
  1. textContent
    textContent는 nodeValue와 같이 getter와 setter 성질을 모두 가지고 있어 값을 취득할 수도 있고 할당할 수도 있다. 다만 다른 점은 textContent는 사용한 요소 노드 아래의 HTML 마크업을 제외한 모든 텍스트를 반환한다. 따라서 텍스트 노드에 접근해서 텍스트 값을 취득하는 nodeValue보다는 간편하다고 할 수 있다.
// 요소 노드 취득
const ele = document.querySelector("CSS선택자");
// 텍스트값 취득
const text = ele.textContent;
ele.textContent = "새로 바뀐 텍스트값";

DOM 조작

DOM API가 제공하는 메서드를 이용하면 DOM을 직접 조작해 새로운 노드를 추가하거나 기존의 노드를 삭제하거나 교체하는 것이 가능해 웹 페이지의 구조를 변경할 수 있다.

innerHTML

요소 노드에 사용하는 innerHTML 프로퍼티는 getter와 setter 성질을 모두 가지고 있으며 해당 요소 노드내의 모든 HTML 마크업을 문자열 형태로 반환한다. 다음을 보자.

<ul id="list">
  <li>1.</li>
  <li>2.</li>
  <li>3.</li>
</ul>

console.log(document.quertSelector('#id').innerHTML);

위와 같은 HTML 문서에 id값으로 list를 가지는 요소 노드를 취득해서 innerHTML값을 출력하면 <li>1.</li><li>2.</li><li>3.</li>와 같이 태그들을 모든 포함한 HTML 마크업 문자열을 얻을 수 있다.

또한 위에서 말했든 setter의 성질도 가지고 있기 때문에 해당 값을 원하는 문자열로 바꾸면 문서 구조 자체를 변경할 수도 있다. 다음을 보자.

<ul id="list">
  <li>1.</li>
  <li>2.</li>
  <li>3.</li>
</ul>

document.quertSelector('#id').innerHTML = '<li>1.</li><li>2.</li>';

위와 같은 코드를 실행시키면 기존에 3개의 리스트가 있던 ul태그를 2개의 리스트를 가지게 할 수 있다.

하지만 이러한 DOM 조작은 크로스 사이트 스크립팅 공격(XSS)에 취약하므로 위험하다. 만약 HTML 마크업 내에 자바스크립트 악성 코드가 포함되어 있다면 파싱 과정에서 그대로 실행될 가능성이 있기 때문이다.

insertAdjacentHTML

insertAdjacentHTML 메서드는 새로운 HTML마크업 요소를 넣을 위치와 새로운 HTML마크업 요소를 인자로 받는다. 요소를 넣을 위치는 4가지로 각 위치를 의미하는 키워드가 정의되어 있다.

// beforebegin
<li class="alpha">
  // afterbegin
  A
  // beforeend
</li>
// afterend

위와 같이 alpha를 클래스로 갖는 요소 주변에 새로운 HTML 마크업 문자열을 추가하고 싶다면 해당 요소가 시작하기 직전인 beforebegin, 시작한 직후인 afterbegin, 끝나기 직전인 beforeend, 끝난 직후인 afterend키워드를 사용할 수 있다.

<li class="alpha">
  A
</li>

const ele = document.querySelector('.alpha');
ele.insertAdjacentHTML('afterend', '<li class="alpha">B</li>');

하지만 insertAdjacentHTML 메서드 또한 크로스 사이트 스크립팅 공격(XSS)에 취약하다.

요소 노드의 생성과 추가

앞서 살펴본 innerHTML 프로퍼티와 insertAdjasentHTML 메서드는 HTML 마크업 문자열을 파싱하여 노드를 생서하고 DOM에 반영한다. 하지만 DOM은 노드를 직접 생성/삽입/삭제/치환하는 메서드도 제공한다.

// 요소 노드의 생성
const newNode = document.createElement('li');

// 텍스트 노드의 생성
const textNode = document.createTextNode('Banana');

// 요소 노드와 텍스트 노드의 연결
newNode.appendChild(textNode);

위는 li요소 노드와 Banana를 내용으로 갖는 텍스트 노드를 생성해서 연결해준 코드이다. 텍스트 노드는 요소 노드의 첫 번째 자식노드이기 때문에 appendChild메서드를 사용해서 요소 노드의 자식 노드로 추가해준 것을 볼 수 있다. 하지만 아직은 이미 있는 문서와는 연결이 되지 않고 Banana를 텍스트 값으로 갖는 li요소 노드가 따로 동떨어져 있는 상태이다. 이를 본 문서와 연결해주기 위해서는 다음의 과정을 거쳐야 한다.

// 본 문서에서 CSS선택자를 통해 ul태그를 갖는 요소를 가져온다.
const list = document.querySelector('ul');

// ul요소의 자식 노드로 위의 newNode를 추가해준다.
list.appendChild(newNode);

앞의 내용은 하나의 노드를 추가하는 내용으로 위 과정을 통하면 브라우저는 한 번의 리플로우와 리페인트 과정을 거친다. 그럼 만약 추가해야하는 list의 내용으 100개라고 하면 어떨까?

방금의 방법처럼 li 요소 노드를 100개 만들어 ul 요소 노드의 자식으로 100번 추가해주면 100번의 리플로우와 리페인트가 일어나는데 이는 굉장히 비효울적인 방법이다. 따라서 복수의 요소 노드들을 담을 하나의 컨테이너를 만들어 본 문서와 연결해주기 전에 해당 컨테이너에 연결할 모든 요소 노드들을 담은 다음, 한번에 연결해 단 한 번의 리플로우와 리페인트만 일어나게 한다. 아래 그림으로 이해하도록 하자.

하지만 이 경우에도 container 역할을 하는 불필요한 요소 노드가 생기게 된다. (보통 div) 따라서 container 역할을 하면서도 본 문서와 결합할 때는 사라지는 노드를 지원하는데 바로 DocumentFragment 노드이다. DocumentFragment 노드는 container 와 같이 요소 노드들이 본 문서에 결합되기 전에 한 번에 모아 둘 수 있고 DocumentFragment 노드를 본 문서에 연결하면 DocumentFragment 노드는 사라지고 DocumentFragment 노드 아래에 있던 노드들만이 본 문서에 결합된다. 다음 코드와 그림을 보고 이해하자.

// 본 문서에서 CSS선택자를 통해 ul태그를 갖는 요소를 가져온다.
const list = document.querySelector('ul');

// DocumentFragment 노드 생성
const container = document.createDocumentFragment();

// 반복문을 통해 DocumentFragment 노드에 100개의 li 요소 노드 추가
// 이때는 리플로우와 리페인트가 일어나지 않는다.
for(let i = 0; i < 100; i++) {
  const listNode = document.createElement('li');
  container.appendChild(listNode);
}

// 본 ul 요소에 DocumentFragment 노드를 추가
// DocumentFragment 노드는 사라지면서 100개의 li 요소 노드만 ul 요소의 자식 요소로 추가된다.
// 이때 한 번의 리플로우와 리페인트가 일어난다.
list.appendChild(container);

지정한 위치에 노드 추가

앞의 appendChild 메서드를 통한 노드의 추가는 부모 요소 노드의 마지막 자식으로 자식 요소 노드가 추가된다. 만약 부모 요소 노드의 자식 요소 노드가 여러 개일 때 원하는 위치에 새로운 요소 노드를 추가하기 위해서는 insertBefore 메서드를 사용하면 된다.

  • 사용법 : parentNode.insertBefore(newNode, childNode);
  • 첫 번째 인자로 받은 새로운 노드를 두 번째 인자로 받은 기존의 자식 노드 앞에 추가한다.
  • 이때 childNode는 반드시 insetBefore 메서드를 호출한 parentNode의 자식 노드여야 한다.

노드 복사

앞서 살펴본 appendChild나 insertBefore 메서드를 기존에 존재하던 노드에 사용하면 기존의 위치에서 메서드를 통해 지정한 위치로 이동한다. 만약 새로운 노드를 추가하는 것이 아니라 기존의 존재하는 노드를 여러 개를 만들어 원하는 위치로 이동시키기 위해서는 기존 노드를 복사하는 것이 우선이다. 이때 cloneNode 메서드를 사용하면 된다.

  • 사용법 : node.cloneNode([deep]);
  • 해당 메서드를 호출한 노드의 사본을 생성한다.
  • 매개변수 : 선택적으로 deep이라는 매개변수로 true, false를 줄 수 있는데 true를 줄 경우 해당 노드의 자손 노드까지 복사하는 깊은 복사를, false를 주면 해당 노드만 복사하는 얕은 복사를 한다.(이 경우 텍스트 노드도 없다)

노드 교체

그럼 기존에 노드가 있던 위치에 해당 노드를 없애고 새로운 노드로 교체하고 싶을 땐 어떻해야 할까? 바로 replaceChild 메서드를 사용하면 된다.

  • 사용법 : parendNode.replaceChild(newNode, oldNode);
  • 첫 번째 인자로 받은 새로운 노드를 두 번째 인자로 받은 기존의 자식 노드를 제거하고 대신 추가한다.
  • 이때 oldNode 반드시 replaceChild 메서드를 호출한 parentNode의 자식 노드여야 한다.

노드 삭제

어떤 노드를 삭제하고 싶을 때는 removeChild 메서드를 사용하면 된다.

  • 사용법 : parendNode.removeChild(node);
  • 인자로 받은 노드를 삭제한다.
  • 이때 node 반드시 removeChild 메서드를 호출한 parentNode의 자식 노드여야 한다.

어트리뷰트 조작

HTML에서 작성한 태그들의 어트리뷰트들은 요소 노드에 연결되어 있는 어트리뷰트 노드에 담겨 있다. 하지만 해당 어트리뷰트 노드를 통해 반환되는 NamedNodeMap 객체는 getter의 성격만 가지고 있어 값을 확인할 수는 있지만 수정을 할 순 없고 접근이 번거롭다는 단점이 있다.

어트리뷰트 값의 취득과 수정

따라서 다음과 같이 손쉽게 요소 노드의 어트리뷰트 값을 취득하고 수정할 수 있는 메서드를 제공한다.

// 요소 노드 취득
const eleNode = document.querySelector('input');

// 요소 노드의 value 어트리뷰트 취득
const value = eleNode.getAttribute('value');

// 요소 노드의 value 어트리뷰트 수정
eleNode.setAttribute('value', '수정된 값');

HTML 어트리뷰트와 DOM 어트리뷰트

요소 노드 객체들은 HTML 어트리뷰트에 대응하는 DOM 프로퍼티가 존재한다. 이 DOM 프로퍼티들은 HTML 어트리뷰트 값을 초기값으로 가지고 있다. DOM 프로퍼티 또는 getter와 setter의 성질을 모두 가지고 있어 참조와 변경이 가능한데 그럼 동일한게 두 가지를 통해 관리되고 있는게 아닌가 하는 생각을 할 수 있다.

하지만 둘은 미묘하게 다르다. 결론부터 말하면 HTML 어트리뷰트는 요소 노드의 어트리뷰트의 초기 값을 저장하고 있고, DOM 프로퍼티는 실시간으로 변경되고 있는 어트리뷰트들의 최신값을 가지고 있다.

예를 들어 어떤 로그인창을 키면 당연히 input 요소 노드가 빈 칸으로 있을 것이다. 그럼 HTML 어트리뷰트의 value값도 빈 칸, DOM 프로퍼티의 value값도 빈 칸이다. 하지만 여기에 내가 id를 입력하면 DOM 프로퍼티의 value값은 실시간으로 내가 입력한 id값으로 바뀐다. 그럼 실제로 해당 요소의 value 어트리뷰트의 값이 바뀐건가? 페이지를 새로고침하면 id값을 입력하는 input 요소 노드가 다시 빈칸으로 돌아간 것을 확인할 수 있다. 바로 HTML 어트리뷰트가 저장하고 있는 초기값때문이다. 이게 바로 HTML 어트리뷰트는 요소 노드의 어트리뷰트의 초기 값을 저장하고 있고, DOM 프로퍼티는 실시간으로 변경되고 있는 어트리뷰트들의 최신값을 가지고 있다는 말이다.

setAttribute 메서드를 사용해서 어트리뷰트의 값을 변경하면 HTML 어트리뷰트의 값이 변경된다. 페이지를 새로고침했을 때 표현되는 초기값 자체가 변경된다는 말이다.

사용자 정의 어트리뷰트

개발을 하다보면 여러가지 조건에 맞춰 환경을 적용하거나 해제해야 하는 상황이 빈번하게 발생한다. 따라서 HTML에서 기본적으로 제공하는 어트리뷰트만으로는 조건을 세분화하기 어려울 수 있는데 이때 사용자가 임의로 원하는 어트리뷰트를 생성하고 사용할 수있다.

먼저 주의해야 할 점은 사용자 정의 어트리뷰트의 경우 HTML에서는 케밥케이스를 통해 표현되고, 자바스크립트를 통해 사용할 때는 카멜케이스를 통해 표현된다. 적용은 자동으로 변환되어 적용되니 사용할 때만 주의하면 된다.

사용자 정의 어트리뷰트 적용

사용자 정의 어트리뷰트는 data를 시작으로 케밥케이스를 통해 표현된다. 만약 내가 count라는 어트리뷰트를 사용하고 싶으면 다음과 같다.

<div class="btn" data-count="0"></div>

// 어트리뷰트명이 길다면 케밥케이스를 통해 표현
<div class="btn" data-new-year="0"></div>

사용자 정의 어트리뷰트 사용

사용자 정의 어트리뷰트를 자바스크립트상에서 참조하고 수정하려면 dataset을 사용하면 된다.

const btn = document.querySelector('.btn');
const count = btn.dataset.count;
console.log(count);  // 0

// 어트리뷰트명이 길다면 카멜케이스를 통해 표현
const year = btn.dataset.newYear;

스타일 조작

요소의 스타일을 조작할 수 있는 방법은 크게 두가지가 있다. 인라인 방식으로 요소 노드의 style 프로퍼티를 통해 직접 변경하는 방법과 미리 스타일이 정의된 클래스를 만들어 놓고 요소 노드에 클래스를 추가하거나 삭제하는 방법을 통해 변경하는 방법이 있다.

인라인 스타일 조작

요소 노드의 style 프로퍼티를 참조하면 CSSStyleDeclaration 타입의 객체를 반환한다. 이때 CSSStyleDeclaration 객체는 CSS 프로퍼티에 대응하는 프로퍼티들을 가지고 있으며 이를 통해 CSS를 변경할 수 있다. 여기서 또 주의할 점은 CSS 프로퍼티는 -를 통해 단어를 연결하는 케밥케이스를 사용하지만(backgound-color) CSSStyleDeclaration 객체의 프로퍼티는 카멜케이스를 사용한다는 점이다.(backgoundColor) 만약 CSS에서 사용하는 프로퍼티명을 그대로 사용하고 싶다면 대괄호([])를 사용하면 된다. 아래 코드를 보자.

const btn = document.querySelector('.btn');
const btnColor1 = btn.style.backgoundColor;
count btnColor2 = btn.style[backgound-color];

console.log(btnColor1 == btnColor2);  // true;

// btn 요소 노드의 배경색을 빨간색으로 변경
btn.style.backgoundColor = 'red';

클래스 스타일 조작

요소 노드가 가지고 있는 클래스들이 무엇인지 알 수 있는 방법은 두 가지가 있다.

  • className : 해당 요소가 가지고 있는 class 어트리뷰트의 값을 문자열 형태로 반환한다.
  • classList : 해당 요소가 가지고 있는 class들을 유사 배열 형태로 담고 있는 DOMTokenList 객체를 반환한다.
// 해당 div태그가 있을 때
<div class="box btn"></div>

const btn = document.querySelector('.btn');

console.log(btn.className);  // 'box btn'
console.log([...btn.classList]);  // [box, btn]

classList의 경우 DOMTokenList 객체에서 class들을 추가하고 수정할 수있는 몇 가지 유요한 메서드를 제공한다.

node.classList.add('클래스명')

인자로 들어온 클래스를 추가한다. 여러 개를 추가할 경우 ,를 통해 구분한다.

node.classList.remove('클래스명')

인자로 들어온 클래스를 삭제한다. 여러 개를 삭제할 경우 ,를 통해 구분한다. 삭제하고자 하는 클래스가 이미 없다면 오류가 발생하지 않고 무시된다.

node.classList.toggle('클래스명'[, force])

인자로 들어온 클래스가 있다면 삭제하고, 없다면 추가한다. 이때 선택적으로 두 번째 인자로 원하는 작업만을 강제할 수 있다. boolean값으로 평가되는 값을 줄 수 있는데 만약 true로 평가되면 추가하는 작업만을 하고(해당 클래스가 이미 있어도 삭제X), false로 평가되면 삭제하는 작업만을 한다.(해당 클래스가 없어도 추가X)

node.classList.contain('클래스명')

인자로 들어온 클래스를 포함하고 있는지를 boolean값으로 반환한다.

node.classList.replace('삭제될클래스명', '추가될클래스명')

인자로 들어온 클래스들을 교체한다.

node.classList.index(index)

인자로 들어온 인덱스에 해당하는 순서의 클래스를 반환한다.

profile
I Will be Relaxed Person

0개의 댓글