DOM 조작이란 새로운 노드를 생성하여 DOM에 추가하거나 기존 노드를 삭제 또는 교체하는 것을 말한다. DOM 조작에 의해 새로운 DOM을 추가하거나 삭제하게 되면 리플로우와 리페인트가 발생한다.
Repaint : 웹 페이지의 렌더링 엔진이 해당 엘리먼트의 새로운 스타일을 계산하고 화면에 다시 그리는 것을 의미한다.
Reflow : 웹 페이지의 렌더링 엔진이 엘리먼트의 새로운 레이아웃을 계산하고 다시 배치하는 것을 의미한다.
innerHTML 프로퍼티를 참조하면 요소 노드의 콘텐츠 영역(시작 태그와 종료 태그 사이) 내에 포함된 모든 HTML 마크업을 문자열로 반환한다.
textContent 프로퍼티는 HTML 마크업을 무시하고 텍스트만 반홚자미나 innerHTML 프로퍼티는 HTML 마크업까지 모두 포함한다.
innerHTML 프로퍼티에 문자열을 할당하면 요소 노드의 모든 자식 노드가(위 사진에 빨간 박스 영역) 제거된다. 또한 할당한 문자열에 포함되어 있는 HTML 마크업이 파싱되어 DOM 요소로 대체된다.
innerHTML은 사용자로부터 입력받은 문자열을 자식 요소 노드에 그대로 반영되기에 직관적이고 구현이 간단하다는 장점이 있지만 크로스 사이트 스크립팅 공격(XSS) 에 취약하다는 치명적인 단점이 존재한다.
크로스 사이트 스크립팅 공격이란?
공격자가 악의적인 스크립트를 웹 페이지에 삽입하는 공격이다. 주로 사용자의 개인 정보를 탈취하거나 세션을 위조하여 세션 하이재킹을 수행하는 데 사용된다. 아래는 XSS공격의 유형이다.
- DOM 기반 XSS (DOM-based XSS): Document Object Model(DOM)을 조작하여 공격자가 제어하는 데이터를 페이지에 삽입하거나 조작하여 스크립트를 실행시키는 공격
- 저장된 XSS (Stored XSS): 공격자가 악의적인 코드를 웹 애플리케이션의 데이터베이스나 파일에 저장하고, 이를 다른 사용자가 웹 페이지를 방문할 때 실행하도록 함.
- 반사된 XSS (Reflected XSS): 공격자가 특정 링크를 통해 악의적인 코드를 희생자에게 전달하고, 희생자가 해당 링크를 클릭할 때만 스크립트가 실행되도록 함.
innerHTML의 또 다른 단점으로는 요소 노드의 innerHTML 프로퍼티티에 HTML 마크업 문자열을 할당할 경우 요소 노드의 기존 자식 노드들이 모두 제거되고 할당한 HTML 마크업 문자열만이 대체되어 변경된다는 점이 있다.
<ul id="fruits">
<li class="apple"> Apple </li>
<li class="orange"> Orange </li>
</ul>
위 코드에서 li.apple 요소와 li.orange 요소 사이에 새로운 요소를 삽입하고 싶다고 가정해보자. innerHTML으로 과연 가능할까? 불가능하다. 이처럼 innerHTML 프로퍼티는 복잡하지 않은 요소를 새롭게 추가할 때 유용하지만 기존 요소를 제거하지 않으면서 동시에 위치를 지정해주고 싶을 때에는 사용하지 않는 것이 좋다. 이러한 단점을 보완해서 나온 것이 insertAdjacentHTML
메서드이다.
insertAdjacentHTML 메서드는 새로운 요소를 삽입하는데 기존 요소를 제거하지 않으면서 동시에 위치까지 지정해주고 싶을 때 사용한다.
이 메소드는 두 번째 인수로 전달한 HTML 마크업 문자열을 첫 번째 인수로 전달한 위치에 삽입하여 DOM에 반영한다. 고로 첫 번째 인수로 전달되는 문자열들의 위치만 잘 기억하면 되는데 자세한 것은 아래와 같다.
하지만 insertAdjacentHTML 메서드 또한 HTML 마크업 문자열을 그대로 DOM 요소에 추가하는 것이기에 크로스 사이트 스키립팅 공격에 취약하다는 점은 동일하다.
DOM은 노드를 직접 생성/삽입/삭제/치환하는 메서드도 제공한다. 아래는 예시 코드이다.
createElement(tagName)
메서드는 요소 노드를 생성하여 반환한다. tagName에 태그 이름을 나타내는 문자열을 인수로 전달한다.
const $li = document.createElement('li');
주의할 점은 createElement로 새로운 DOM 요소 노드를 생성했다고 해서 기존에 DOM에 "추가" 까지 되는 것은 아니다. 위에 사진처럼 개별적인 공간에 홀로 존재하는 상태이다. 즉 기존에 DOM에 추가하는 처리는 별도로 해주어야 한다. 이는 뒤에서 나오는 appendChild
메서드를 사용하면 된다.
앞서 createElement
메서드는 요소 노드를 생성하는 메서드였다면 이번createTextNode(text)
메서드는 요소 노드에 자식 노드로 삽입되는 텍스트 노드를 생성한다. 텍스트 노드란 HTML 요소의 content 요소를 의미한다.
사용방법은 createElement 노드와 동일하게 텍스트 값으로 사용할 문자열을 인수로 전달해주면 된다.
앞서 다루었던 요소노드와, 텍스트 노드를 새롭게 생성할 경우 텍스트 노드는 기존에 요소노드에, 요소 노드는 기존에 DOM 요소에 어떻게든 추가해야 된다.
이 때 사용하는 것이 바로 appendChild
메서드이다. 이 메서드는 인수로 전달한 노드를 appendChild 메서드를 호출한 노드의 마지막 자식 노드로 추가한다. 아래는 코드 예시이다.
$fruits.appendChild($li);
이 과정을 통해 비로소 새롭게 생성한 요소 노드가 DOM에 추가되는 것이다. 이 때 기존에 DOM에 변경사항이 생겼기에 리페인트와 리플로우 같은 것들이 발생하는 데 위 코드에서는 DOM 요소에 새로운 DOM 요소 노드를 "한번" 추가했으므로 리플로우와 리페인트 역시 한번 실행된다.
이번에는 여러 개의 요소 노드를 생성하여 DOM에 추가해 보겠다.
<!DOCTYPE html>
<html>
<body>
<ul id="fruits"></ul>
</body>
<script>
const $fruits = dcument.getElementById('fruits');
['Apple', 'Banana', 'Orange'].forEach(text => {
//1.요소 노드 생성
const $li = document.createElement('li');
//2.텍스트 노드 생성
const textNode = document.createTextNode(text);
//3.텍스트 노드를 $li 요소 노드의 자식 노드로 추가
$li.appendChild(textNode);
//4.$li 요소 노드를 $fruits 요소 노드의 마지막 자식 노드로 추가
$fruits.appendChild($li);
</script>
</html>
위 예시 코드는 3개의 요소 노드를 생성하여 DOM에 총 3번 추가했기에 DOM 또한 3번 변경된다. 그 말은 즉슨, 리플로우와 리페인트 또한 3번 실행된다는 이야기이다.
이처럼 DOM을 변경하는 것은 높은 비용이 드는 처리이므로 가급적 횟수를 줄이는 편이 성능에 유리하다. 따라서 위 예제와 같이 기존 DOM에 요소 노드를 반복하여 추가하는 것은 비효율적이다.
이를 개선하여 사용하는 방안이 있는데 바로 컨테이너 요소를 사용하는 것이다. 컨테이너 요소를 미리 생성한 다음, DOM에 추가해야 할 3개의 요소 노드를 기존의 DOM에 일일히 추가하는 것이 아니라 컨테이너 요소에 자식 노드로 추가한 뒤, 마지막에 컨테이너 요소 한번만 DOM에 추가한다면 최종적으로 DOM은 한번만 변경되는 것이다.
하지만 이 또한 container요소를 생성할 때 사용한 Tag 요소가 기존 DOM에 같이 삽입된다는 단점이 있는데 이를 보완한 것이 바로DocumentFragment
노드이다.
DocumentFragment노드는 기존 DOM과는 별도로 존재한다. 고로 DocumentFragment노드에 자식 노드를 추가하여도 기존 DOM에는 어떠한 변경도 발생하지 않는다.
또한 DocumentFragment 노드를 DOM에 추가할 시 자신은 제거되고 자신의 자식 녿므나 DOM에 추가된다.
<!DOCTYPE html>
<html>
<body>
<ul id="fruits"></ul>
</body>
<script>
const $fruits = dcument.getElementById('fruits');
//DocumentFragment 노드 생성
const $fragment = document.createDocumentFragment();
['Apple', 'Banana', 'Orange'].forEach(text => {
//1.요소 노드 생성
const $li = document.createElement('li');
//2.텍스트 노드 생성
const textNode = document.createTextNode(text);
//3.텍스트 노드를 $li 요소 노드의 자식 노드로 추가
$li.appendChild(textNode);
//4.$li 요소 노드를 Documentfragment 요소 노드의 마지막 자식 노드로 추가
$fragment.appendChild($li);
//5.DocumentFragement 노드를 $fruits 요소 노드의 마지막 자식 노드로 추가
$fruits.appendChild($fragment);
</script>
</html>
기존 코드와 달라진 것은 DocumentFragement를 생성하고 DOM에 추가할 요소 노도를 생성한 후에 DocumentFragement노드에 자식 노드로 추가해주고 최종적으로 DocumentFragement 노드를 기존 DOM에 추가해준 점이다.
이 때 실제로 DOM 벼녁ㅇ이 발생한 것은 마지막 5번 한번 뿐이며 리플로우와 리페인트 역시 한 번만 실행된다. 따라서 여러 개의 요소 노드를 DOM에 추가해주고 싶은 경우에는 DocumentFragement 노드를 사용하는 것이 훨씬 효율적이다.
appendChild 메서드는 인수로 전달받은 노드를 자신을 호출한 노드의 마지막 자식 노드로 DOM에 추가한다. 이 떄 노드를 추가할 위치를 지정할 수 없고 언제나 마지막 노드로 추가된다.
인수로 전달받은 노드를 개발자가 원하는 위치에 삽입하고 싶을 때 사용하는 메서드이다. insertBefore(newNode, childNode) 메서드의 첫 번쨰 인수로 전닯다은 노드를 두번째 인수로 전달받은 노드 바로 직전에 삽입한다.
만약 두 번째 인수로 전달받은 노드가 null이면 첫 번째 인수로 전달받은 노드를 마지막 자식 노드로 추가한다. appendChild 메서드처럼 작용한다는 소리이다.
기존에 DOM에 존재하는 노드를 이동시켜주고 싶을 때에는 기존에 존재하는 노드를 취득한 뒤(getElementById...) 앞서 배운 appendChild 또는 inserBefore 삽입 메서드를 통해 다시 추가해주기만 하면 된다. 기존에 노드는 제거되고 새로운 위치에 노드를 다시 추가한다. 결과론적으로 이동하는 모습이 되는 것이다. 예시 코드는 아래와 같다.
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li>Apple</li>
<li>Banana</li>
<li>Orange</li>
</ul>
</body>
<script>
const $fruits = document.getElementById('fruits');
//1. $fruits의 요소 노드의 자식 노드를 취득
const [$apple, $banana] = $fruits.children;
//2. 이미 존재하는 $apple 요소 노드를 $fruits 요소 노드의 마지막 노드로 이동
$fruits.appendChild($apple); // Banana-Orange-Apple
//3. 이미 존재하는 $Banana 요소 노드를 $fruits 요소 노드의 마지막 노드로 이동
$fruits.inserBefore($banana, $fruits.lastElementChild);
//Orange-Banana-Apple
</script>
</html>
cloneNode([deep:true | flase])메서드는 노드의 사본을 생성하여 반환한다. 매개변수 deep 에 true를 인수로 전달하면 노드를 깊은 복사를 하여 사본을 생성하고, false를 인수로 전달하거나 아무런 인수로 전달하지 않을 시 노드를 얕은 복사하여 사본을 생성한다.
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li>Apple</li>
</ul>
</body>
<script>
const $fruits = document.getElementById('fruits');
const $apple = $fruits.firstElementChild;
const $shallowClone = $apple.coneNode();
$shallowClone.textContent="Banana";
$fruits.appendChild($shallowClone);
const $deepClone = $fruits.cloneNode(true);
$fruits.appendChild($deepClone);
</script>
</html>
reaplceChild(newChild, oldChild) 메서드는 자신을 호출한 노드의 자식 노드를 다른 노드로 교체한다. 첫 번째 매개변수 newChild에는 교체할 새로운 노드를 인수로 전달하고, 두 번째 매개변수 oldChild에는 이미 존재하는 교체될 노드를 인수로 전달한다.
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li>Apple</li>
</ul>
</body>
<script>
const $fruits = document.getElementById('fruits');
const $newChild = document.createElement('li');
$newchild.textContent = 'Banana';
$fruits.replace($newChild, $fruits.firstElementChild);
</script>
</html>
removeChild(child) 메서드는 child 매개변수에 인수로 전달한 노드를 DOM에서 삭제한다.
attributes 노드와 attributes 프로퍼티... 말이 헷갈린다. 둘은 분명히 다른 것 같은데 어떠한 차이가 있을까??
<input id='user' type='text' value='ungmo2'>
HTML 문서가 파싱되면 HTML 요소의 어트리뷰트는 어트리뷰트 노드로 변환되어 요소 노드와 연결된다. 이 때 어트리뷰트당 하나의 어트리뷰트 노드가 생성이 된다. 위에 코드 input은 id, type, value 총 3개의 attributes 노드가 생성된다.
이 때 모든 어트리뷰트 노드의 참조는 유사 배열 객체이자, 이터러블인 NamedNodeMap 객체에 담겨서 attributes 프로퍼티에 저장된다.
어트리뷰트 노드... 어트리뷰트 프로퍼티.. 단어를 혼용해서 쓰지 않게 주의하자😢
따라서 모든 어트리뷰트 노드는 요소 노드의 attribute 프로퍼티로 접근할 수 있다.
HTML 어트리뷰르 값을 참조하려면 getAttribute(attributeName)
메서드를 사용하고, 값을 변경하려면 setAttribute(attributeName, attributeValue)
메서드를 사용하면 된다.
또 해당하는 attribute가 DOM 요소 노드에 존재하는지 확인하려면 hasAttribute(attributeName)
메서드를 사용하고, 특정 attribute를 삭제하고 싶다면 removeAttribute
메서드를 사용하면 된다. 예시 코드는 생략한다.
HTML 어트리뷰트와 DOM 프로퍼티 말이 헷갈리는데 ㅎㅎ... 서로의 사용목적과 접근방식의 차이만 이해하고 넘어가면 될 것 같다.
HTML 어트리뷰트는 DOM 요소에 attribute 프로퍼티, 즉 NamedNodeMap 객체에 담긴 정보들을 의미한다. 이 객체에 담긴 어트리뷰드 값들을 접근/조작하기 위해서는 오직 getAttribute
메서드와 setAttribute
메서드만 사용해야 했다.
DOM 프로퍼티는 앞서 이야기 한 HTML 어트리뷰트와 대응하는 프로퍼티로서, 해당하는 HTML 어트리뷰트의 값을 초기값으로 가지고 있다. 또한 참조와 변경이 가능하기에 "." 연산자를 사용하여 접근이 가능하고 재할당도 가능하다.
왜 똑같은 이름의 프로퍼티가 NamedNodeMap 객체에도 있고 DOM 자체 프로퍼티에도 존재할까? 그 이유는 HTML 어트리뷰트의 역할은 HTML 요소의 초기 상태를 지정하는 것이다. 처음 렌더링 되었을 때의 값 말이다.
그렇다면 DOM 프로퍼티는 왜 있을까? DOM 프로퍼티는 개발자가 javascript를 통해 HTML 요소 노드의 프로퍼티에 접근하고 조작하기 위해 존재하는 것이다. input과 같이 사용자가 입력값을 넣을 때마다 그 값이 달라지는 살아있는 요소 노드라면 초기 값에 의미는 더이상 없어지기 때문이다. 그렇다 DOM 프로퍼티는 요소 노드의 최신 상태를 관리해주기 위해 존재하는 것이다.
그냥 get/setAttribute 메서드를 통해 접근 조작하면 되는 것 아닌가요??🤔
element.getAttribute("href")
보다는element.href
라고 쓰는 것이 더 간결하며 또한 DOM 프로퍼티를 통해 HTML 을 조작하면 자동으로 타입이 변환되는 용이함이 있다고 합니 (+@ 표준화 및 최적화도 된다고 해요~)
사용자가 자기 입맛대로 HTML 요소에 추가정보를 저장하고 이를 접근/조작하고 싶을 때 사용하는 어트리뷰트가 있다. 바로 이름 앞에 data-
접두사를 붙혀주는 data 어트리뷰트
이다.
사용자가 정의한 dat 어트리뷰트의 값은 dataset
프로퍼티로 접근/조작할 수 있는데, 이는 data-
접두사 다음에 붙인 임의의 이름을 카멜 케이스(ex.userId)로 변환하여 가지고 있다. 아래는 예시 코드이다.
HTMLElement.prototype.style
프로퍼티는 인라인 스타일을 취득하거나 추가 또는 변경 가능하게 해준다.
주의할 점은 css에서 작성하는 프로퍼티는 케밥 케이스로 (kebab-case)와 같이 작성해주어야 하지만 HTML 요소 노드에서 스타일에 접근하고자 사용하는 style 프로퍼티는 카멜케이스(camelCase)를 사용한다는 점이다.
$div.style.backgroundColor = 'yellow';
또한 단위 지정이 필요한 CSS의 값은 반드시 단위를 지정해주어야 한다.
$div.style.width = '100px';
HTML 요소 노드에 클래스를 접근 조작하고 싶다면 어떻게 해야할까? 바로 className과 classList 프로퍼티가 존재한다. class가 아닌 이유는 예약어인 class는 프로퍼티 이름으로 지정할 수 없기 때문이다.
요소 노드의 className 프로퍼티를 참조하면 class 어트리뷰트 값을 문자열로 반환하고 요소 노드의 className 프로퍼티에 문자열을 할당하면 해당 문자열로 변경된다. 아래는 예시 코드이다.
하지만 className 프로퍼티는 문자열을 반환하므로 공백으로 구분된 여러 개의 클래스를 반환하는 경우 다루기가 불편하다는 단점이 있다.
Element.prototype.classList 프로퍼티는 class 어트리뷰트의 정보를 담은 DOMTokenList 객체를 반환한다. DOMTokenList는 아래 여러 유용한 메서드들을 제공한다.
1️⃣ add(...className)
인수로 전달한 1개 이상의 문자열을 class 어트리뷰트 값으로 추가한다.
$box.classList.add('foo'); // -> class = "box red foo"
$box.classList.add('bar', 'baz'); // -> class = "box red foo bar baz"
2️⃣ remove(...className)
인수로 전달한 1개 이사으이 문자열과 일치하는 클래스를 해당 class 어트리뷰트 값에서 삭제한다. 해당하는 클래스가 없다면 아무 일도 일어나지 않는다.
$box.classList.remove('foo'); // -> class = "box red bar baz"
$box.classList.remove('bar', 'baz'); // -> class = "box red"
$box.classList.remove('x'); // -> class = "box red"
3️⃣ item(index)
인수로 전달한 indexdp 해당하는 클래스를 class 어트리뷰트에서 반환한다.
$box.classList.item(0); // -> "box"
$box.classList.item(1); // -> "red"
4️⃣ contains(className)
인수로 전달한 문자열과 일치하는 클래스가 class 어트리뷰트에 포함되어 있는지 확인한다. 있다면 true를 없다면 false를 반환한다.
$box.classList.contains('box'); // -> true
$box.classList.contains('x'); // -> false
5️⃣ replace(oldClassName, newClassName)
class 어트리뷰트에서 첫 번째 인수로 전달한 문자열을 두 번째 인수로 전달한 문자열로 대체한다.
$box.classList.replace('red', 'baz'); // -> "box baz"
6️⃣ toggle(className[force])
class 어트리뷰트에 인수로 전달한 문자열과 일치하는 클래스가 존재하면 제거하고, 존재하지 않으면 추가한다.
두 번째 인수로 불리언 값으로 평가되는 조건식을 전달할 수 있다. 이 때 조건식의 평가 결과가 true이면 class 어트리뷰트에 강제로 첫 번째 인수로 전달받은 문자열을 추가하고, flase이면 class 어트리뷰트에서 강제로 첫 번째 인수로 전달받은 문자열을 제거한다.
style 프로퍼티는 인라인 스타일만 반환하기에 클래스를 적용한 스타일이나 상속을 통해 암묵적으로 적용된 스타일은 style 프로퍼티를 통해 참조할 수 없다. HTML 요소에 적용되어 있는 모든 CSS 스타일을 참조해야 할 경우에는 getComputedStyle
메서드를 사용해야 한다.
window.getCoumputedStyle(element, [, pseudo]) 메서드는 첫 번째 인수로 전달한 요소 노드에 적용되어 있는 스타일을 CSSStyleDeclaration
이라는 이름의 객체에 담아 반환한다.
평가된 스타일일이란 노드에 적용되어 있는 모든 스타일(링크 스타일, 임베딩 스타일, 인라인 스타일, 자바스크립트로 적용한 스타일, 상속된 스타일, 기본 스타일...)을 의미한다. 아래는 예시 코드이다.
<!DOCTYPE html>
<html>
<head>
<style>
body {
color : red;
}
.box {
width : 100px;
height : 50px;
background-color : corsilk;
border : 1px solid black;
}
</style>
</head>
<body>
<div class="box">Box</div>
<script>
const $box = document.querySelector('.box');
// .box 요소에 적용된 모든 CSS 스타일을 담고있는 CSSStyleDeclaration 객체를 취득
const computedStyle = window.getComputedStyle($box);
console.log(computedStyle); // CSSStyleDeclaration
//임베딩 스타일
console.log(computedStyle.width); //100px
console.log(computedStyle.height); // 50px
console.log(computedStyle.backgroundColor); // rgb(255,248,220)
console.log(computedStyle.border); //1px solid rgb(0,0,0)
//상속 스타일 (body->.box)
console.log(computedStyle.color) // rgb(255,0,0)
//기본 스타일
console.log(computedStyle.display) //block
</script>
</body>
</html>
HTML과 DOM 표준은 W3C와 WHATWG라는 단체에서 협력하면서 공통된 표준을 만들어왔다고 한다. 아래는 여태까지의 버전이다.