이벤트가 발생했을 때 호출될 함수를 이벤트 핸들러(event handler)라 하고, 이벤트가 발생했을 때 브라우저에게 이벤트 핸들러의 호출을 위임하는 것을 이벤트 핸들러 등록이라 한다. 프로그램의 흐름을 이벤트 중심으로 제어하는 프로그래밍 방식을 **이벤트 드리븐 프로그래밍(event-driven programming)**이라 한다.
// 이벤트 핸들러 프로퍼티 방식 $button.onclick = function () { console.log("[이벤트 핸들러 프로퍼티 방식]button click"); };
// addEventListener 메서드는 동일한 요소에서 발생한 동일한 이벤트에 대해 // 하나 이상의 이벤트 핸들러를 등록할 수 있다. $button.addEventListener("click", function () { console.log("[1]button click"); });
$button.addEventListener("click", function () { console.log("[2]button click"); }); </script> </body> </html>
addEventListener 메서드를 통해 참조가 동일한 이벤트 핸들러를 중복 등록하면 하나의 이벤트 핸들러만 등록된다.
<!DOCTYPE html> <html> <head> <style> html, body { height: 100%; } </style> </head> <!-- 이벤트 핸들러 어트리뷰트 방식의 경우 event가 아닌 다른 이름으로는 이벤트 객체를 전달받지 못한다. --> <bodyonclick="showCoords(event)"> <p>클릭하세요. 클릭한 곳의 좌표가 표시됩니다.</p> <emclass="message"></em> <script> const $msg = document.querySelector(".message");
// 클릭 이벤트에 의해 생성된 이벤트 객체는 이벤트 핸들러의 첫 번째 인수로 전달된다. functionshowCoords(e) { $msg.textContent = `clientX: ${e.clientX}, clientY: ${e.clientY}`; } </script> </body> </html>
이벤트 핸들러 어트리뷰트 방식의 경우 이벤트 객체를 전달받으려면 이벤트 핸들러의 첫 번째 매개변수 이름이 반드시 event이어야 한다.
이벤트 전파
캡처링 단계(capturing phase) : 이벤트가 상위 요소에서 하위 요소 방향으로 전파
타깃 단계(target phase) : 이벤트가 이벤트 타깃에 도달
버블링 단계(bubbling phase) : 이벤트가 하위 요소에서 상위 요소 방향으로 전파
li 요소를 클릭하면 클릭 이벤트가 발생하여 클릭 이벤트 객체가 생성되고 클릭된 li 요소가 이벤트 타깃이 된다. 이때 클릭 이벤트 객체는 window에서 시작해서 이벤트 타깃 방향으로 전파된다. 이것이 캡처링 단계다. 이후 이벤트 객체는 이벤트를 발생시킨 이벤트 타깃에 도달한다. 이것이 타깃 단계다. 이후 이벤트 객체는 이벤트 타깃에서 시작해서 window 방향으로 전파된다. 이것이 버블링 단계다.
이벤트 핸들러 어트리뷰트/프로퍼티 방식으로 등록한 이벤트 핸들러는 타깃 단계와 버블링 단계의 이벤트만 캐치할 수 있다. 하지만 addEventListener 메서드 방식으로 등록한 이벤트 핸들러는 타깃 단계와 버블링 단계뿐만 아니라 캡처링 단계의 이벤트도 선별적으로 캐치할 수 있다. 캡처링 단계의 이벤트를 캐치하려면 addEventListener 메서드의 3번째 인수로 true를 전달해야 한다. 3번째 인수를 생략하거나 false를 전달하면 타깃 단계와 버블링 단계의 이벤트만 캐치할 수 있다.
<!DOCTYPE html> <html> <head> <style> html, body { height: 100%; } </style> <body> <p>버블링과 캡처링 이벤트 <button>버튼</button></p> <script> // 버블링 단계의 이벤트를 캐치 document.body.addEventListener('click', () => { console.log('Handler for body.'); });
// 캡처링 단계의 이벤트를 캐치 document.querySelector('p').addEventListener('click', () => { console.log('Handler for paragraph.'); }, true);
// 버블링 단계의 이벤트를 캐치 document.querySelector('button').addEventListener('click', () => { console.log('Handler for button.'); }); </script> </body> </html>
<!-- Handler for paragraph. Handler for button. Handler for body. -->
이벤트 위임
이벤트 위임(Event delegation)은 여러 개의 하위 DOM 요소에 각각 이벤트 핸들러를 등록하는 대신 하나의 상위 DOM 요소에 이벤트 핸들러를 등록하는 방법을 말한다. 이벤트는 이벤트 타깃은 물론 상위 DOM 요소에서도 캐치할 수 있다. 이벤트 위임을 통해 상위 DOM 요소에 이벤트 핸들러를 등록하면 여러 개의 하위 DOM 요소에 이벤트 핸들러를 등록할 필요가 없다. 또한 동적으로 하위 DOM 요소를 추가하더라도 일일이 추가된 DOM 요소에 이벤트 핸들러를 등록할 필요가 없다.
// 사용자 클릭에 의해 선택된 내비게이션 아이템(li 요소)에 active 클래스를 추가하고 // 그 외의 모든 내비게이션 아이템의 active 클래스를 제거한다. functionactivate({ target }) { // 이벤트를 발생시킨 요소(target)가 ul#fruits의 자식 요소가 아니라면 무시한다. if (!target.matches("#fruits > li")) return;
// 이벤트 위임: 상위 요소(ul#fruits)는 하위 요소의 이벤트를 캐치할 수 있다. $fruits.onclick = activate; </script> </body> </html>
DOM 요소의 기본 동작 조작
DOM 요소의 기본 동작 중단
1 2 3 4
document.querySelector("input[type=checkbox]").onclick = (e) => { // checkbox 요소의 기본 동작을 중단한다. e.preventDefault(); };
이벤트 전파 방지 stopPropagation 메서드는 하위 DOM 요소의 이벤트를 개별적으로 처리하기 위해 이벤트의 전파를 중단시킨다.
1 2 3 4 5
// .btn2 요소는 이벤트를 전파하지 않으므로 상위 요소에서 이벤트를 캐치할 수 없다. document.querySelector(".btn2").onclick = (e) => { e.stopPropagation(); // 이벤트 전파 중단 e.target.style.color = "blue"; };
이벤트 핸들러를 호출할 때 인수로 전달한 this는 이벤트를 바인딩한 DOM 요소를 가리킨다. 위 예제에서 handleClick 함수에 전달한 this는 암묵적으로 생성된 이벤트 핸들러 내부의 this다. 즉, 이벤트 핸들러 어트리뷰트 방식에 의해 암묵적으로 생성된 이벤트 핸들러 내부의 this는 이벤트를 바인딩한 DOM 요소를 가리킨다. 이는 이벤트 핸들러 프로퍼티 방식과 동일하다.
이벤트 핸들러 프로퍼티 방식과 addEventListener 메서드 방식
이벤트 핸들러 프로퍼티 방식과 addEventListener 메서드 방식 모두 이벤트 핸들러 내부의 this는 이벤트를 바인딩한 DOM 요소를 가리킨다. 즉, 이벤트 핸들러 내부의 this는 이벤트 객체의 currentTarget 프로퍼티와 같다.
// 이벤트 핸들러 프로퍼티 방식 $button1.onclick = function (e) { // this는 이벤트를 바인딩한 DOM 요소를 가리킨다. console.log(this); // $button1 console.log(e.currentTarget); // $button1 console.log(this === e.currentTarget); // true
// 이벤트 핸들러 프로퍼티 방식 $button1.onclick = (e) => { // 화살표 함수 내부의 this는 상위 스코프의 this를 가리킨다. console.log(this); // window console.log(e.currentTarget); // $button1 console.log(this === e.currentTarget); // false
// increase 메서드를 이벤트 핸들러로 등록 this.$button.onclick = this.increase; }
increase() { // 이벤트 핸들러 increase 내부의 this는 DOM 요소(this.$button)를 가리킨다. // 따라서 this.$button은 this.$button.$button과 같다. this.$button.textContent = ++this.count; // -> TypeError: Cannot set property 'textContent' of undefined } }
new App(); </script> </body> </html>
위 예제의 increase 메서드 내부의 this는 클래스가 생성할 인스턴스를 가리키지 않는다. 이벤트 핸들러 내부의 this는 이벤트를 바인딩한 DOM 요소를 가리키기 때문에 increase 메서드 내부의 this는 this.$button을 가리킨다. 따라서 increase 메서드를 이벤트 핸들러로 바인딩할 때 bind 메서드를 사용해 this를 전달하여 increase 메서드 내부의 this가 클래스가 생성할 인스턴스를 가리키도록 해야 한다.
// 화살표 함수인 increase를 이벤트 핸들러로 등록 this.$button.onclick = this.increase; }
// 클래스 필드 정의 // increase는 인스턴스 메서드이며 내부의 this는 인스턴스를 가리킨다. increase = () => (this.$button.textContent = ++this.count); } new App(); </script> </body> </html>
insertAdjacentHTML 메서드는 기존 요소에는 영향을 주지 않고 새롭게 삽입될 요소만을 파싱하여 자식 요소로 추가하므로 기존의 자식 노드를 모두 제거하고 다시 처음부터 새롭게 자식 노드를 생성하여 자식 요소로 추가하는 innerHTML 프로퍼티보다 효율적이고 빠르다.
// 이미 존재하는 요소 노드를 취득 const [$apple, $banana] = $fruits.children;
// 이미 존재하는 $apple 요소 노드를 #fruits 요소 노드의 마지막 노드로 이동 $fruits.appendChild($apple); // Banana - Orange - Apple
// 이미 존재하는 $banana 요소 노드를 #fruits 요소의 마지막 자식 노드 앞으로 이동 $fruits.insertBefore($banana, $fruits.lastElementChild); // Orange - Banana - Apple </script> </html>
// $apple 요소를 얕은 복사하여 사본을 생성. 텍스트 노드가 없는 사본이 생성된다. const $shallowClone = $apple.cloneNode(); // 사본 요소 노드에 텍스트 추가 $shallowClone.textContent = "Banana"; // 사본 요소 노드를 #fruits 요소 노드의 마지막 노드로 추가 $fruits.appendChild($shallowClone);
// #fruits 요소를 깊은 복사하여 모든 자손 노드가 포함된 사본을 생성 const $deepClone = $fruits.cloneNode(true); // 사본 요소 노드를 #fruits 요소 노드의 마지막 노드로 추가 $fruits.appendChild($deepClone); </script> </html>
노드 교체
Node.prototype.replaceChild(newChild, oldChild) 메서드는 자신을 호출한 노드의 자식 노드를 다른 노드로 교체한다. 첫 번째 매개변수 newChild에는 교체할 새로운 노드를 인수로 전달하고, 두 번째 매개변수 oldChild에는 이미 존재하는 교체될 노드를 인수로 전달한다.
하지만 위와 같이 innerHTML을 사용하며 재할당하게 되면 DOM을 다시 구성해야한다. 만약 `ul`안에 `li`가 많이 있을 경우 그 크기만큼의 DOM을 재구성하기 때문에 데이터 낭비로 직결될 수 있다.
모던 브라우저에서는 보안 이슈(크로스 사이트 스크립팅 공격)로 innerHTML에 스크립트 태그 할당을 금지한다.
1 2 3 4 5 6 7 8 9 10 11 12
<!DOCTYPE html> <html> <body> <divid="foo">Hello</div> </body> <script> // innerHTML 프로퍼티로 스크립트 태그를 삽입하여 자바스크립트가 실행되도록 한다. // HTML5는 innerHTML 프로퍼티로 삽입된 script 요소 내의 자바스크립트 코드를 실행하지 않는다. document.getElementById('foo').innerHTML = '<script>alert(document.cookie)</script>'; </script> </html>
insertAdjacentHTML 메서드는 기존 요소에는 영향을 주지 않고 새롭게 삽입될 요소만을 파싱하여 자식 요소로 추가하므로 기존의 자식 노드를 모두 제거하고 다시 처음부터 새롭게 자식 노드를 생성하여 자식 요소로 추가하는 innerHTML 프로퍼티보다 효율적이고 빠르다.
// 이미 존재하는 요소 노드를 취득 const [$apple, $banana] = $fruits.children;
// 이미 존재하는 $apple 요소 노드를 #fruits 요소 노드의 마지막 노드로 이동 $fruits.appendChild($apple); // Banana - Orange - Apple
// 이미 존재하는 $banana 요소 노드를 #fruits 요소의 마지막 자식 노드 앞으로 이동 $fruits.insertBefore($banana, $fruits.lastElementChild); // Orange - Banana - Apple </script> </html>
// $apple 요소를 얕은 복사하여 사본을 생성. 텍스트 노드가 없는 사본이 생성된다. const $shallowClone = $apple.cloneNode(); // 사본 요소 노드에 텍스트 추가 $shallowClone.textContent = "Banana"; // 사본 요소 노드를 #fruits 요소 노드의 마지막 노드로 추가 $fruits.appendChild($shallowClone);
// #fruits 요소를 깊은 복사하여 모든 자손 노드가 포함된 사본을 생성 const $deepClone = $fruits.cloneNode(true); // 사본 요소 노드를 #fruits 요소 노드의 마지막 노드로 추가 $fruits.appendChild($deepClone); </script> </html>
노드 교체
Node.prototype.replaceChild(newChild, oldChild) 메서드는 자신을 호출한 노드의 자식 노드를 다른 노드로 교체한다. 첫 번째 매개변수 newChild에는 교체할 새로운 노드를 인수로 전달하고, 두 번째 매개변수 oldChild에는 이미 존재하는 교체될 노드를 인수로 전달한다.
요소 노드 객체에는 HTML 어트리뷰트에 대응하는 프로퍼티(이하 DOM 프로퍼티)가 존재한다. 이 DOM 프로퍼티들은 HTML 어트리뷰트 값을 초기값으로 가지고 있다. DOM 프로퍼티는 setter와 getter 모두 존재하는 접근자 프로퍼티다. 따라서 DOM 프로퍼티는 참조와 변경이 가능하다.
HTML 어트리뷰트의 역할은 HTML 요소의 초기 상태를 지정하는 것이다. 즉, HTML 어트리뷰트 값은 HTML 요소의 초기 상태를 의미하며 이는 변하지 않는다.
요소의 요소 노드가 생성되어 첫 렌더링이 끝난 시점까지 어트리뷰트 노드의 어트리뷰트 값과 요소 노드의 프로퍼티에 할당된 값은 HTML 어트리뷰트 값과 동일하다. 하지만 첫 렌더링 이후 사용자가 요소에 무언가를 입력하기 시작하면 상황이 달라진다. 요소 노드는 상태(state)를 가지고 있다. 요소 노드는 사용자의 입력에 의해 변경된 최신 상태를 관리해야 하는 것은 물론, HTML 어트리뷰트로 지정한 초기 상태도 관리해야 한다. 초기 상태 값을 관리하지 않으면 웹페이지를 처음 표시하거나 새로고침할 때 초기 상태를 표시할 수 없다.
이처럼 요소 노드는 2개의 상태, 즉 초기 상태와 최신 상태를 관리해야 한다. 요소 노드의 초기 상태는 어트리뷰트 노드가 관리하며, 요소 노드의 최신 상태는 DOM 프로퍼티가 관리한다.
어트리뷰트 노드 HTML 어트리뷰트로 지정한 HTML 요소의 초기 상태는 어트리뷰트 노드에서 관리한다.
DOM 프로퍼티 사용자가 입력한 최신 상태는 HTML 어트리뷰트에 대응하는 요소 노드의 DOM 프로퍼티가 관리한다. DOM 프로퍼티는 사용자의 입력에 의한 상태 변화에 반응하여 언제나 최신 상태를 유지한다. 이 최신 상태 값은 사용자의 입력에 의해 언제든지 동적으로 변경되어 최신 상태를 유지한다. 단, 모든 DOM 프로퍼티가 사용자의 입력에 의해 변경되는 최신 상태를 관리하는 것은 아니다. id 어트리뷰트에 대응하는 id 프로퍼티는 사용자의 입력과 아무런 관계가 없다.
따라서 사용자 입력에 의한 상태 변화와 관계없는 id 어트리뷰트와 id 프로퍼티는 사용자 입력과 관계없이 항상 동일한 값을 유지한다. 즉, id 어트리뷰트 값이 변하면 id 프로퍼티 값도 변하고 그 반대도 마찬가지다.
이처럼 사용자 입력에 의한 상태 변화와 관계있는 DOM 프로퍼티만 최신 상태 값을 관리한다. 그 외의 사용자 입력에 의한 상태 변화와 관계없는 어트리뷰트와 DOM 프로퍼티는 항상 동일한 값으로 연동한다.
HTML 어트리뷰트와 DOM 프로퍼티의 대응 관계
대부분의 HTML 어트리뷰트는 HTML 어트리뷰트 이름과 동일한 DOM 프로퍼티와 1:1로 대응한다. 단, 다음과 같이 HTML 어트리뷰트와 DOM 프로퍼티가 언제나 1:1로 대응하는 것은 아니며, HTML 어트리뷰트 이름과 DOM 프로퍼티 키가 반드시 일치하는 것도 아니다.
id 어트리뷰트와 id 프로퍼티는 1:1 대응하며, 동일한 값으로 연동한다.
input 요소의 value 어트리뷰트는 value 프로퍼티와 1:1 대응한다. 하지만 value 어트리뷰트는 초기 상태를, value 프로퍼티는 최신 상태를 갖는다.
class 어트리뷰트는 className, classList 프로퍼티와 대응한다(“39.8.2. 클래스 조작” 참고).
for 어트리뷰트는 htmlFor 프로퍼티와 1:1 대응한다.
td 요소의 colspan 어트리뷰트는 대응하는 프로퍼티가 존재하지 않는다.
textContent 프로퍼티는 대응하는 어트리뷰트가 존재하지 않는다.
어트리뷰트 이름은 대소문자를 구별하지 않지만 대응하는 프로퍼티 키는 카멜 케이스를 따른다(maxlength → maxLength).
<!DOCTYPE html> <html> <head> <style> .red { color: red; } .big { font-size: 10em; } .small { font-size: 1em; } </style> </head> <body> <ulclass="todos"> <liid="1">HTML</li> <liid="2">CSS</li> <liid="2">JS</li> </ul> <script> document.getElementById("1").classList.add("red", "big"); // red big 적용 document.getElementById("1").classList.remove("red"); // big 적용 document.getElementById("1").classList.contains("big"); // ture (적용 여부) document.getElementById("1").classList.replace("big", "small"); // small (교체) document.getElementById("1").classList.toggle("red"); // small red (없으면 추가) document.getElementById("1").classList.toggle("red"); // small (있으면 삭제) </script> </body> </html>
요소에 적용되어 있는 CSS 스타일 참조
style 프로퍼티는 인라인 스타일만 반환한다. 따라서 클래스를 적용한 스타일이나 상속을 통해 암묵적으로 적용된 스타일은 style 프로퍼티로 참조할 수 없다. HTML 요소에 적용되어 있는 모든 CSS 스타일을 참조해야 할 경우 getComputedStyle 메서드를 사용한다.
// .box 요소에 적용된 모든 CSS 스타일을 담고 있는 CSSStyleDeclaration 객체를 취득 const computedStyle = window.getComputedStyle($box); console.log(computedStyle); // CSSStyleDeclaration
HTML 요소(HTML element)는 HTML 문서를 구성하는 개별적인 요소를 의미한다. HTML 요소는 렌더링 엔진에 의해 파싱되어 DOM을 구성하는 요소 노드 객체로 변환된다. 이때 HTML 요소의 어트리뷰트는 어트리뷰트 노드로, HTML 요소의 텍스트 콘텐츠는 텍스트 노드로 변환된다.
HTML 요소는 요소 간 중첩 관계에 의해 계층적인 부자(parent-child) 관계가 형성된다. 이러한 HTML 요소 간의 부자 관계를 반영하여 HTML 문서의 구성 요소인 HTML 요소를 객체화한 모든 노드 객체들을 트리 자료구조로 구성한다.
노드 객체들로 구성된 트리 자료구조를 DOM(Document Object Model)이라 한다. 노드 객체의 트리로 구조화되어 있기 때문에 DOM을 DOM 트리라고 부르기도 한다.
노드 객체의 타입
노드 객체는 총 12개의 종류(노드 타입)가 있다. 이 중에서 중요한 노드 타입은 다음과 같이 4가지다.
문서 노드 문서 노드(document node)는 DOM 트리의 최상위에 존재하는 루트 노드로서 document 객체를 가리킨다. document 객체는 브라우저가 렌더링한 HTML 문서 전체를 가리키는 객체로서 전역 객체 window의 document 프로퍼티에 바인딩되어 있다. 따라서 문서 노드는 window.document 또는 document로 참조할 수 있다.
브라우저 환경의 모든 자바스크립트 코드는 script 태그에 의해 분리되어 있어도 하나의 전역 객체 window를 공유한다. 따라서 모든 자바스크립트 코드는 전역 객체 window의 document 프로퍼티에 바인딩되어 있는 하나의 document 객체를 바라본다. 즉, HTML 문서당 document 객체는 유일하다.
문서 노드, 즉 document 객체는 DOM 트리의 루트 노드이므로 DOM 트리의 노드들에 접근하기 위한 진입점(entry point) 역할을 담당한다. 즉, 요소, 어트리뷰트, 텍스트 노드에 접근하려면 문서 노드를 통해야 한다.
요소 노드 요소 노드(element node)는 HTML 요소를 가리키는 객체다. 요소 노드는 HTML 요소 간의 중첩에 의해 부자 관계를 가지며, 이 부자 관계를 통해 정보를 구조화한다. 따라서 요소 노드는 문서의 구조를 표현한다고 할 수 있다.
어트리뷰트 노드 어트리뷰트 노드(attribute node)는 HTML 요소의 어트리뷰트를 가리키는 객체다. 어트리뷰트 노드는 어트리뷰트가 지정된 HTML 요소의 요소 노드와 연결되어 있다. 단, 요소 노드는 부모 노드와 연결되어 있지만 어트리뷰트 노드는 부모 노드와 연결되어 있지 않고 요소 노드에만 연결되어 있다. 즉, 어트리뷰트 노드는 부모 노드가 없으므로 요소 노드의 형제(sibling) 노드는 아니다. 따라서 어트리뷰트 노드에 접근하여 어트리뷰트를 참조하거나 변경하려면 먼저 요소 노드에 접근해야 한다.
텍스트 노드 텍스트 노드(text node)는 HTML 요소의 텍스트를 가리키는 객체다. 요소 노드가 문서의 구조를 표현한다면 텍스트 노드는 문서의 정보를 표현한다고 할 수 있다. 텍스트 노드는 요소 노드의 자식 노드이며, 자식 노드를 가질 수 없는 리프 노드(leaf node)다. 즉, 텍스트 노드는 DOM 트리의 최종단이다. 따라서 텍스트 노드에 접근하려면 먼저 부모 노드인 요소 노드에 접근해야 한다.
위 4가지 노드 타입 이외에도 주석을 위한 Comment 노드, DOCTYPE을 위한 DocumentType 노드, 복수의 노드를 생성하여 추가할 때 사용하는 DocumentFragment 노드 등 총 12개의 노드 타입이 있다.
노드 객체의 상속 구조
DOM을 구성하는 노드 객체는 자신의 구조와 정보를 제어할 수 있는 DOM API를 사용할 수 있다. 이를 통해 노드 객체는 자신의 부모, 형제, 자식을 탐색할 수 있으며, 자신의 어트리뷰트와 텍스트를 조작할 수도 있다.
DOM을 구성하는 노드 객체는 브라우저 환경에서 추가적으로 제공하는 호스트 객체(host objects)다. 하지만 노드 객체도 자바스크립트 객체이므로 프로토타입에 의한 상속 구조를 갖는다. 노드 객체의 상속 구조는 다음과 같다.
모든 노드 객체는 Object, EventTarget, Node 인터페이스를 상속받는다. 추가로 문서 노드는 Document, HTMLDocument 인터페이스를 상속받고 어트리뷰트 노드는 Attr, 텍스트 노드는 CharacterData 인터페이스를 각각 상속받는다. 요소 노드는 Element 인터페이스를 상속받는다. 또한 요소 노드는 추가적으로 HTMLElement와 태그의 종류별로 세분화된 HTMLHtmlElement, HTMLHeadElement, HTMLBodyElement, HTMLUListElement 등의 인터페이스를 상속받는다.
예를 들어, input 요소를 파싱하여 객체화한 input 요소 노드 객체는 HTMLInputElement, HTMLElement, Element, Node, EventTarget, Object의 prototype에 바인딩되어 있는 프로토타입 객체를 상속받는다. 즉, input 요소 노드 객체는 프로토타입 체인에 있는 모든 프로토타입의 프로퍼티나 메서드를 상속받아 사용할 수 있다.
노드 객체에는 모든 노드 객체가 공통으로 갖는 기능과 노드 타입에 따라 고유한 기능도 있다. 예를 들어, 모든 노드 객체는 이벤트를 발생시킬 수 있다. 이벤트 관련 기능은 EventTarge 인터페이스가 제공한다. 또한 모든 노드 객체는 트리 자료구조의 노드로서 트리 탐색 기능이나 노드 정보 제공 기능이 필요하며, 이와 같은 노드 관련 기능은 Node 인터페이스가 제공한다.
HTML 요소가 객체화된 요소 노드 객체는 HTML 요소가 같은 공통적인 기능이 있다.(style 프로퍼티 등) 이런 HTML 요소의 공통 기능은 HTMLElement 인터페이스가 제공한다. 하지만 요소 노드 객체는 HTML 요소의 종류에 따라 고유한 기능도 있다.(input에는 value 프로퍼티가 있지만 div에는 필요없다.) 따라서 필요한 기능을 제공하는 인터페이스가 HTML 요소에 따라 다르다.
이처럼 노드 객체는 공통된 기능일수록 프로토타입 체인의 상위에, 개별적인 고유 기능일수록 프로토타입 체인의 하위에 프로토타입 체인을 구축하여 노드 객체에 필요한 기능, 즉 프로퍼티와 메서드를 제공하는 상속 구조를 갖는다.
DOM은 HTML 문서의 계층적 구조와 정보를 표현하는 것은 물론 노드 객체의 종류, 즉 노드 타입에 따라 필요한 기능을 프로퍼티와 메서드의 집합인 DOM API(Application Programming Interface)로 제공한다. 이 DOM API를 통해 HTML의 구조나 내용 또는 스타일 등을 동적으로 조작할 수 있다.
요소 노드 취득
HTML의 구조나 내용 또는 스타일 등을 동적으로 조작하려면 먼저 요소 노드를 취득해야 한다. 텍스트 노드는 요소 노드의 자식 노드이고, 어트리뷰트 노드는 요소 노드와 연결되어 있기 때문에 텍스트 노드나 어트리뷰트 노드를 조작하고자 할 때도 마찬가지다. 요소 노드의 취득은 HTML 요소를 조작하는 시작점이다.
id를 이용한 요소 노드 취득
Document.prototype.getElementById는 인수로 전달한 id 값을 갖는 하나의 요소 노드를 탐색하여 반환한다. getElementById 메서드는 Document.prototype의 프로퍼티다. 따라서 반드시 문서 노드인 document를 통해 호출해야 한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
<!DOCTYPE html> <html> <body> <ul> <liid="apple">Apple</li> <liid="banana">Banana</li> <liid="orange">Orange</li> </ul> <script> // id 값이 'banana'인 요소 노드를 탐색하여 반환한다. // 두 번째 li 요소가 파싱되어 생성된 요소 노드가 반환된다. const $elem = document.getElementById("banana");
// 취득한 요소 노드의 style.color 프로퍼티 값을 변경한다. $elem.style.color = "red"; </script> </body> </html>
만약 중복된 id가 존재한다면 첫 번째 요소 노드만 반환한다. 만약 인수로 전달된 id 값을 갖는 HTML 요소가 존재하지 않는 경우 getElementById 메서드는 null을 반환한다.
HTML 요소에 id 어트리뷰트를 부여하면 id 값과 동일한 이름의 전역 변수가 암묵적으로 선언되고 해당 노드 객체가 할당되는 부수 효과가 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
<!DOCTYPE html> <html> <body> <divid="foo"></div> <script> // id 값과 동일한 이름의 전역 변수가 암묵적으로 선언되고 해당 노드 객체가 할당된다. console.log(foo === document.getElementById("foo")); // true
// 암묵적 전역으로 생성된 전역 프로퍼티는 삭제되지만 전역 변수는 삭제되지 않는다. delete foo; console.log(foo); // <divid="foo"></div> </script> </body> </html>
단, id 값과 동일한 이름의 전역 변수가 이미 선언되어 있으면 이 전역 변수에 노드 객체가 재할당되지 않는다.
1 2 3 4 5 6 7 8 9 10 11 12
<!DOCTYPE html> <html> <body> <divid="foo"></div> <script> let foo = 1;
// id 값과 동일한 이름의 전역 변수가 이미 선언되어 있으면 노드 객체가 재할당되지 않는다. console.log(foo); // 1 </script> </body> </html>
태그 이름을 이용한 요소 노드 취득
Document.prototype/Element.prototype.getElementsByTagName 메서드는 인수로 전달한 태그 이름을 갖는 모든 요소 노드들을 탐색하여 여러 개의 요소 노드 객체를 갖는 DOM 컬렉션 객체인 HTMLCollection 객체(유사 배열 객체이면서 이터러블)를 반환한다.
<!DOCTYPE html> <html> <body> <ul> <liid="apple">Apple</li> <liid="banana">Banana</li> <liid="orange">Orange</li> </ul> <script> // 태그 이름이 li인 요소 노드를 모두 탐색하여 반환한다. // 탐색된 요소 노드들은 HTMLCollection 객체에 담겨 반환된다. // HTMLCollection 객체는 유사 배열 객체이면서 이터러블이다. const $elems = document.getElementsByTagName("li");
// 취득한 모든 요소 노드의 style.color 프로퍼티 값을 변경한다. // HTMLCollection 객체를 배열로 변환하여 순회하며 color 프로퍼티 값을 변경한다. [...$elems].forEach((elem) => { elem.style.color = "red"; }); </script> </body> </html>
getElementsByTagName 메서드는 Document.prototype에 정의된 메서드와 Element.prototype에 정의된 메서드가 있다. Document.prototype.getElementsByTagName 메서드는 DOM의 루트 노드인 문서 노드, 즉 document를 통해 호출하며 DOM 전체에서 요소 노드를 탐색하여 반환한다. 하지만 Element.prototype.getElementsByTagName 메서드는 특정 요소 노드를 통해 호출하며, 특정 요소 노드의 자손 노드 중에서 요소 노드를 탐색하여 반환한다. 만약 태그 이름을 갖는 요소가 존재하지 않는 경우 getElementsByTagName 메서드는 빈 HTMLCollection 객체를 반환한다.
<!DOCTYPE html> <html> <body> <ulid="fruits"> <li>Apple</li> <li>Banana</li> <li>Orange</li> </ul> <ul> <li>HTML</li> </ul> <script> // DOM 전체에서 태그 이름이 li인 요소 노드를 모두 탐색하여 반환한다. const $lisFromDocument = document.getElementsByTagName("li"); console.log($lisFromDocument); // HTMLCollection(4) [li, li, li, li]
// #fruits 요소의 자손 노드 중에서 태그 이름이 li인 요소 노드를 모두 탐색하여 반환한다. const $fruits = document.getElementById("fruits"); const $lisFromFruits = $fruits.getElementsByTagName("li"); console.log($lisFromFruits); // HTMLCollection(3) [li, li, li] </script> </body> </html>
class를 이용한 요소 노드 취득
Document.prototype/Element.prototype.getElementsByClassName 메서드는 인수로 전달한 class 어트리뷰트 값(이하 class 값)을 갖는 모든 요소 노드 들을 탐색하여 반환한다. 인수로 전달할 class 값은 공백으로 구분하여 여러 개의 class를 지정할 수 있다. HTMLCollection 객체를 반환한다. class 값을 갖는 요소가 존재하지 않는 경우 빈 HTMLCollection 객체를 반환한다.
<!DOCTYPE html> <html> <body> <ul> <li class="fruit apple">Apple</li> <li class="fruit banana">Banana</li> <li class="fruit orange">Orange</li> </ul> <script> // class 값이 'fruit'인 요소 노드를 모두 탐색하여 HTMLCollection 객체에 담아 반환한다. const $elems = document.getElementsByClassName('fruit');
// 취득한 모든 요소의 CSS color 프로퍼티 값을 변경한다. [...$elems].forEach(elem => { elem.style.color = 'red'; });
// class 값이 'fruit apple'인 요소 노드를 모두 탐색하여 HTMLCollection 객체에 담아 반환한다. const $apples = document.getElementsByClassName('fruit apple');
// 취득한 모든 요소 노드의 style.color 프로퍼티 값을 변경한다. [...$apples].forEach(elem => { elem.style.color = 'blue'; }); </script> </body> </html>
Document.prototype에 정의된 메서드와 Element.prototype에 정의된 메서드가 있다. Document.prototype.getElementsByClassName 메서드는 DOM의 루트 노드인 문서 노드, 즉 document를 통해 호출하며 DOM 전체에서 요소 노드를 탐색하여 반환하고 Element.prototype.getElementsByClassName 메서드는 특정 요소 노드를 통해 호출하며 특정 요소 노드의 자손 노드 중에서 요소 노드를 탐색하여 반환한다.
<!DOCTYPE html> <html> <body> <ulid="fruits"> <liclass="apple">Apple</li> <liclass="banana">Banana</li> <liclass="orange">Orange</li> </ul> <divclass="banana">Banana</div> <script> // DOM 전체에서 class 값이 'banana'인 요소 노드를 모두 탐색하여 반환한다. const $bananasFromDocument = document.getElementsByClassName("banana"); console.log($bananasFromDocument); // HTMLCollection(2) [li.banana, div.banana]
// #fruits 요소의 자손 노드 중에서 class 값이 'banana'인 요소 노드를 모두 탐색하여 반환한다. const $fruits = document.getElementById("fruits"); const $bananasFromFruits = $fruits.getElementsByClassName("banana");
Document.prototype/Element.prototype.querySelector 메서드는 인수로 전달한 CSS 선택자를 만족시키는 하나의 첫 번째 요소 노드를 탐색하여 반환한다. 조건을 만족시키는 요소 노드가 존재하지 않는 경우 null을 반환한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
<!DOCTYPE html> <html> <body> <ul> <liclass="apple">Apple</li> <liclass="banana">Banana</li> <liclass="orange">Orange</li> </ul> <script> // class 어트리뷰트 값이 'banana'인 첫 번째 요소 노드를 탐색하여 반환한다. const $elem = document.querySelector(".banana");
// 취득한 요소 노드의 style.color 프로퍼티 값을 변경한다. $elem.style.color = "red"; </script> </body> </html>
Document.prototype/Element.prototype.querySelectorAll 메서드는 인수로 전달한 CSS 선택자를 만족시키는 모든 요소 노드를 탐색하여 반환한다. querySelectorAll 메서드는 여러 개의 요소 노드 객체를 갖는 DOM 컬렉션 객체인 NodeList 객체(유사 배열 객체이면서 이터러블)를 반환한다. 조건을 만족시키는 요소 노드가 존재하지 않는 경우 빈 NodeList 객체를 반환한다.
<!DOCTYPE html> <html> <body> <ul> <liclass="apple">Apple</li> <liclass="banana">Banana</li> <liclass="orange">Orange</li> </ul> <script> // ul 요소의 자식 요소인 li 요소를 모두 탐색하여 반환한다. const $elems = document.querySelectorAll("ul > li"); // 취득한 요소 노드들은 NodeList 객체에 담겨 반환된다. console.log($elems); // NodeList(3) [li.apple, li.banana, li.orange]
// 취득한 모든 요소 노드의 style.color 프로퍼티 값을 변경한다. // NodeList는 forEach 메서드를 제공한다. $elems.forEach((elem) => { elem.style.color = "red"; }); </script> </body> </html>
querySelector, querySelectorAll 메서드는 Document.prototype에 정의된 메서드와 Element.prototype에 정의된 메서드가 있다. Document.prototype에 정의된 메서드는 DOM의 루트 노드인 문서 노드, 즉 document를 통해 호출하며, DOM 전체에서 요소 노드를 탐색하여 반환한다. Element.prototype에 정의된 메서드는 특정 요소 노드를 통해 호출하며 특정 요소 노드의 자손 노드 중에서 요소 노드를 탐색하여 반환한다.
querySelector, querySelectorAll 메서드는 getElementById, getElementsBy*** 메서드보다 다소 느린 것으로 알려져 있다. 따라서 id 어트리뷰트가 있는 요소 노드를 취득하는 경우에는 getElementById 메서드를 사용하는 것을 권장한다.
특정 요소 노드를 취득할 수 있는지 확인
Element.prototype.matches 메서드는 인수로 전달한 CSS 선택자를 통해 특정 요소 노드를 취득할 수 있는지 확인한다.
<!DOCTYPE html> <head> <style> .red { color: red; } .blue { color: blue; } </style> </head> <html> <body> <ulid="fruits"> <liclass="red">Apple</li> <liclass="red">Banana</li> <liclass="red">Orange</li> </ul> <script> // class 값이 'red'인 요소 노드를 모두 탐색하여 HTMLCollection 객체에 담아 반환한다. const $elems = document.getElementsByClassName("red"); // 이 시점에 HTMLCollection 객체에는 3개의 요소 노드가 담겨 있다. console.log($elems); // HTMLCollection(3) [li.red, li.red, li.red]
// HTMLCollection 객체의 모든 요소의 class 값을 'blue'로 변경한다. for (let i = 0; i < $elems.length; i++) { $elems[i].className = "blue"; }
// HTMLCollection 객체의 요소가 3개에서 1개로 변경되었다. console.log($elems); // HTMLCollection(1) [li.red] </script> </body> </html>
위 예제를 실행해 보면 예상대로 동작하지 않는다. 다음 그림처럼 두 번째 li 요소만 class 값이 변경되지 않는다.
위 예제가 예상대로 동작하지 않은 이유를 알아보자. $elems.length는 3이므로 for 문의 코드 블록은 3번 반복된다.
첫 번째 반복 (i === 0) $elems[0]은 첫 번째 li 요소다. 이 요소는 className 프로퍼티에 의해 class 값이 ‘red’에서 ‘blue’로 변경된다. 이때 첫 번째 li 요소는 class 값이 ‘red’에서 ‘blue’로 변경되었으므로 getElementsByClassName 메서드의 인자로 전달한 ‘red’와 더는 일치하지 않기 때문에 $elems에서 실시간으로 제거된다. 이처럼 HTMLCollection 객체는 실시간으로 노드 객체의 상태 변경을 반영하는 살아 있는(live) DOM 컬렉션 객체다.
두 번째 반복 (i === 1) 첫 번째 반복에서 첫 번째 li 요소는 $elems에서 제거되었다. 따라서 $elems[1]은 세 번째 li 요소다. 이 세 번째 li 요소의 class 값도 ‘blue’로 변경되고 마찬가지로 HTMLCollection 객체인 $elems에서 실시간으로 제외된다.
세 번째 반복 (i === 2) 첫 번째, 두 번째 반복에서 첫 번째, 세 번째 li 요소가 $elems에서 제거되었다. 따라서 $elems에는 두 번째 li 요소 노드만 남았다. 이때 $elems.length는 1이므로 for 문의 조건식 i < $elems.length가 false로 평가되어 반복이 종료된다. 따라서 $elems에 남아 있는 두 번째 li 요소의 class 값은 변경되지 않는다.
HTMLCollection 객체는 실시간으로 노드 객체의 상태 변경을 반영하여 요소를 제거할 수 있기 때문에 HTMLCollection 객체를 for 문으로 순회하면서 노드 객체의 상태를 변경해야 할 때 주의해야 한다. 이와 같은 부작용은 아래와 같은 방법으로 회피할 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// for 문을 역방향으로 순회 for (let i = $elems.length - 1; i >= 0; i--) { $elems[i].className = "blue"; }
// while 문으로 HTMLCollection에 요소가 남아 있지 않을 때까지 무한 반복 let i = 0; while ($elems.length > i) { $elems[i].className = "blue"; }
// 더 간단한 해결책은 부작용을 발생시키는 원인인 HTMLCollection 객체를 사용하지 않는 것이다. // 유사 배열 객체이면서 이터러블인 HTMLCollection을 배열로 변환하여 순회 [...$elems].forEach((elem) => (elem.className = "blue"));
NodeList
부작용이 존재하는 HTMLCollection 객체의 getElementsByTagName, getElementsByClassName 메서드 대신 NodeList를 반환하는 querySelectorAll를 사용할 수 있다. NodeList 객체는 실시간으로 노드 객체의 상태 변경을 반영하지 않는(non-live) 객체다.
// $fruits 요소의 자식 노드는 공백 텍스트 노드(39.3.1절 "공백 텍스트 노드" 참고)를 포함해 모두 5개다. console.log(childNodes); // NodeList(5) [text, li, text, li, text]
for (let i = 0; i < childNodes.length; i++) { // removeChild 메서드는 $fruits 요소의 자식 노드를 DOM에서 삭제한다. // (39.6.9절 "노드 삭제" 참고) // removeChild 메서드가 호출될 때마다 NodeList 객체인 childNodes가 실시간으로 변경된다. // 따라서 첫 번째, 세 번째 다섯 번째 요소만 삭제된다. $fruits.removeChild(childNodes[i]); }
// 예상과 다르게 $fruits 요소의 모든 자식 노드가 삭제되지 않는다. console.log(childNodes); // NodeList(2) [li, li] </script> </html>
노드 객체의 상태 변경과 상관없이 안전하게 DOM 컬렉션을 사용하려면 HTMLCollection이나 NodeList 객체를 배열로 변환하여 사용하는 것을 권장한다. HTMLCollection과 NodeList 객체는 모두 유사 배열 객체이면서 이터러블이다. 따라서 스프레드 문법이나 Array.from 메서드를 사용하여 간단히 배열로 변환할 수 있다.
// $fruits 요소의 모든 자식 노드가 모두 삭제되었다. console.log(childNodes); // NodeList [] </script> </html>
노드 탐색
parentNode, previousSibling, firstChild, childNodes 프로퍼티는 Node.prototype이 제공하고, 프로퍼티 키에 Element가 포함된 previousElementSibling, nextElementSibling과 children 프로퍼티는 Element.prototype이 제공한다. 노드 탐색 프로퍼티는 모두 읽기 전용 접근자 프로퍼티이다.
공백 텍스트 노드
지금까지 언급하지 않았지만 HTML 요소 사이의 스페이스, 탭, 줄바꿈(개행) 등의 공백(white space) 문자는 텍스트 노드를 생성한다.
자식 노드 탐색
Node.prototype.childNodes 자식 노드를 모두 탐색하여 DOM 컬렉션 객체인 NodeList에 담아 반환한다. childNodes 프로퍼티가 반환한 NodeList에는 요소 노드뿐만 아니라 텍스트 노드도 포함되어 있을 수 있다.
Element.prototype.children 자식 노드 중에서 요소 노드만 모두 탐색하여 DOM 컬렉션 객체인 HTMLCollection에 담아 반환한다. children 프로퍼티가 반환한 HTMLCollection에는 텍스트 노드가 포함되지 않는다.
Node.prototype.firstChild 첫 번째 자식 노드를 반환한다. firstChild 프로퍼티가 반환한 노드는 텍스트 노드이거나 요소 노드다.
Node.prototype.lastChild 마지막 자식 노드를 반환한다. lastChild 프로퍼티가 반환한 노드는 텍스트 노드이거나 요소 노드다.
Element.prototype.firstElementChild 첫 번째 자식 요소 노드를 반환한다. firstElementChild 프로퍼티는 요소 노드만 반환한다.
Element.prototype.lastElementChild 마지막 자식 요소 노드를 반환한다. lastElementChild 프로퍼티는 요소 노드만 반환한다.
// #fruits 요소의 모든 자식 노드를 탐색한다. // childNodes 프로퍼티가 반환한 NodeList에는 요소 노드뿐만 아니라 텍스트 노드도 포함되어 있다. console.log($fruits.childNodes); // NodeList(7) [text, li.apple, text, li.banana, text, li.orange, text]
// #fruits 요소의 모든 자식 노드를 탐색한다. // children 프로퍼티가 반환한 HTMLCollection에는 요소 노드만 포함되어 있다. console.log($fruits.children); // HTMLCollection(3) [li.apple, li.banana, li.orange]
// #fruits 요소의 첫 번째 자식 노드를 탐색한다. // firstChild 프로퍼티는 텍스트 노드를 반환할 수도 있다. console.log($fruits.firstChild); // #text
// #fruits 요소의 마지막 자식 노드를 탐색한다. // lastChild 프로퍼티는 텍스트 노드를 반환할 수도 있다. console.log($fruits.lastChild); // #text
// #fruits 요소의 첫 번째 자식 노드를 탐색한다. // firstElementChild 프로퍼티는 요소 노드만 반환한다. console.log($fruits.firstElementChild); // li.apple
// #fruits 요소의 마지막 자식 노드를 탐색한다. // lastElementChild 프로퍼티는 요소 노드만 반환한다. console.log($fruits.lastElementChild); // li.orange </script> </html>
자식 노드 존재 확인
자식 노드가 존재하는지 확인하려면 Node.prototype.hasChildNodes 메서드를 사용한다. hasChildNodes 메서드는 자식 노드가 존재하면 true, 자식 노드가 존재하지 않으면 false를 반환한다. 단, hasChildNodes 메서드는 childNodes 프로퍼티와 마찬가지로 텍스트 노드를 포함하여 자식 노드의 존재를 확인한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
<!DOCTYPE html> <html> <body> <ulid="fruits"></ul> </body> <script> // 노드 탐색의 기점이 되는 #fruits 요소 노드를 취득한다. const $fruits = document.getElementById("fruits");
// #fruits 요소에 자식 노드가 존재하는지 확인한다. // hasChildNodes 메서드는 텍스트 노드를 포함하여 자식 노드의 존재를 확인한다. console.log($fruits.hasChildNodes()); // true </script> </html>
자식 노드 중에 텍스트 노드가 아닌 요소 노드가 존재하는지는 확인하려면 children.length 또는 Element 인터페이스의 childElementCount 프로퍼티를 사용한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
<!DOCTYPE html> <html> <body> <ulid="fruits"></ul> </body> <script> // 노드 탐색의 기점이 되는 #fruits 요소 노드를 취득한다. const $fruits = document.getElementById("fruits");
// hasChildNodes 메서드는 텍스트 노드를 포함하여 자식 노드의 존재를 확인한다. console.log($fruits.hasChildNodes()); // true
// 자식 노드 중에 텍스트 노드가 아닌 요소 노드가 존재하는지는 확인한다. console.log(!!$fruits.children.length); // 0 -> false // 자식 노드 중에 텍스트 노드가 아닌 요소 노드가 존재하는지는 확인한다. console.log(!!$fruits.childElementCount); // 0 -> false </script> </html>
요소 노드의 텍스트 노드 탐색
요소 노드의 텍스트 노드는 요소 노드의 자식 노드다. 따라서 요소 노드의 텍스트 노드는 firstChild 프로퍼티로 접근할 수 있다. firstChild 프로퍼티가 반환한 노드는 텍스트 노드이거나 요소 노드다.
1 2 3 4 5 6 7 8 9 10
<!DOCTYPE html> <html> <body> <divid="foo">Hello</div> <script> // 요소 노드의 텍스트 노드는 firstChild 프로퍼티로 접근할 수 있다. console.log(document.getElementById("foo").firstChild); // #text </script> </body> </html>
부모 노드 탐색
부모 노드를 탐색하려면 Node.prototype.parentNode 프로퍼티를 사용한다. 텍스트 노드는 DOM 트리의 최종단 노드인 리프 노드(leaf node)이므로 부모 노드가 텍스트 노드인 경우는 없다.
// #fruits 요소의 첫 번째 자식 노드를 탐색한다. // firstChild 프로퍼티는 요소 노드뿐만 아니라 텍스트 노드를 반환할 수도 있다. const { firstChild } = $fruits; console.log(firstChild); // #text
// #fruits 요소의 첫 번째 자식 노드(텍스트 노드)의 다음 형제 노드를 탐색한다. // nextSibling 프로퍼티는 요소 노드뿐만 아니라 텍스트 노드를 반환할 수도 있다. const { nextSibling } = firstChild; console.log(nextSibling); // li.apple
// li.apple 요소의 이전 형제 노드를 탐색한다. // previousSibling 프로퍼티는 요소 노드뿐만 아니라 텍스트 노드를 반환할 수도 있다. const { previousSibling } = nextSibling; console.log(previousSibling); // #text
// #fruits 요소의 첫 번째 자식 요소 노드를 탐색한다. // firstElementChild 프로퍼티는 요소 노드만 반환한다. const { firstElementChild } = $fruits; console.log(firstElementChild); // li.apple
// #fruits 요소의 첫 번째 자식 요소 노드(li.apple)의 다음 형제 노드를 탐색한다. // nextElementSibling 프로퍼티는 요소 노드만 반환한다. const { nextElementSibling } = firstElementChild; console.log(nextElementSibling); // li.banana
// li.banana 요소의 이전 형제 요소 노드를 탐색한다. // previousElementSibling 프로퍼티는 요소 노드만 반환한다. const { previousElementSibling } = nextElementSibling; console.log(previousElementSibling); // li.apple </script> </html>
노드 정보 취득
Node.prototype.nodeType 노드 객체의 종류, 즉 노드 타입을 나타내는 상수를 반환한다. 노드 타입 상수는 Node에 정의되어 있다.
// 2. nodeValue 프로퍼티를 사용하여 텍스트 노드의 값을 변경한다. $textNode.nodeValue = "World";
console.log($textNode.nodeValue); // World </script> </html>
textContent
Node.prototype.textContent 프로퍼티는 setter와 getter 모두 존재하는 접근자 프로퍼티로서 요소 노드의 텍스트와 모든 자손 노드의 텍스트를 모두 취득하거나 변경한다. 요소 노드의 textContent 프로퍼티를 참조하면 요소 노드의 콘텐츠 영역(시작 태그와 종료 태그 사이) 내의 텍스트를 모두 반환한다. 이때 HTML 마크업은 무시된다.
1 2 3 4 5 6 7 8 9 10
<!DOCTYPE html> <html> <body> <divid="foo">Hello <span>world!</span></div> </body> <script> // #foo 요소 노드의 텍스트를 모두 취득한다. 이때 HTML 마크업은 무시된다. console.log(document.getElementById("foo").textContent); // Hello world! </script> </html>
텍스트 노드가 아닌 노드의 nodeValue 프로퍼티는 null을 반환하므로 의미가 없고 텍스트 노드의 nodeValue 프로퍼티를 참조할 때만 텍스트 노드의 값을 반환한다. 또한, nodeValue 프로퍼티를 사용하면 textContent 프로퍼티를 사용할 때와 비교해서 코드가 더 복잡하다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
<!DOCTYPE html> <html> <body> <divid="foo">Hello <span>world!</span></div> </body> <script> // #foo 요소 노드는 텍스트 노드가 아니다. console.log(document.getElementById("foo").nodeValue); // null // #foo 요소 노드의 자식 노드인 텍스트 노드의 값을 취득한다. console.log(document.getElementById("foo").firstChild.nodeValue); // Hello // span 요소 노드의 자식 노드인 텍스트 노드의 값을 취득한다. console.log(document.getElementById("foo").lastChild.firstChild.nodeValue); // world! </script> </html>
만약 요소 노드의 콘텐츠 영역에 자식 요소 노드가 없고 텍스트만 존재한다면 firstChild.nodeValue와 textContent 프로퍼티는 같은 결과를 반환한다. 이 경우 textContent 프로퍼티를 사용하는 편이 코드가 더 간단하다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
<!DOCTYPE html> <html> <body> <!-- 요소 노드의 콘텐츠 영역에 다른 요소 노드가 없고 텍스트만 존재 --> <divid="foo">Hello</div> </body> <script> const $foo = document.getElementById("foo");
// 요소 노드의 콘텐츠 영역에 자식 요소 노드가 없고 텍스트만 존재한다면 // firstChild.nodeValue와 textContent는 같은 결과를 반환한다. console.log($foo.textContent === $foo.firstChild.nodeValue); // true </script> </html>
요소 노드의 textContent 프로퍼티에 문자열을 할당하면 요소 노드의 모든 자식 노드가 제거되고 할당한 문자열이 텍스트로 추가된다. 이때 할당한 문자열에 HTML 마크업이 포함되어 있더라도 파싱되지 않고 문자열 그대로 인식되어 텍스트로 취급된다.
1 2 3 4 5 6 7 8 9 10 11
<!DOCTYPE html> <html> <body> <divid="foo">Hello <span>world!</span></div> </body> <script> // #foo 요소 노드의 모든 자식 노드가 제거되고 할당한 문자열이 텍스트로 추가된다. // 이때 HTML 마크업이 파싱되지 않는다. document.getElementById("foo").textContent = "Hi <span>there!</span>"; // Hi <span>there!</span> </script> </html>
textContent 프로퍼티와 유사한 동작을 하는 innerText 프로퍼티가 있다.
innerText 프로퍼티는 CSS에 순종적이다. 예를 들어, innerText 프로퍼티는 CSS에 의해 비표시(visibility: hidden;)로 지정된 요소 노드의 텍스트를 반환하지 않는다.
innerText 프로퍼티는 CSS를 고려해야 하므로 textContent 프로퍼티보다 느리다.
브라우저는 HTML, CSS, 자바스크립트, 이미지, 폰트 파일 등 렌더링에 필요한 리소스를 요청하고 서버로부터 응답을 받는다.
브라우저의 렌더링 엔진은 서버로부터 응답된 HTML과 CSS를 파싱하여 DOM과 CSSOM을 생성하고 이들을 결합하여 렌더 트리를 생성한다.
브라우저의 자바스크립트 엔진은 서버로부터 응답된 자바스크립트를 파싱하여 AST(Abstract Syntax Tree)를 생성하고 바이트코드로 변환하여 실행한다. 이때 자바스크립트는 DOM API를 통해 DOM이나 CSSOM을 변경할 수 있다. 변경된 DOM과 CSSOM은 다시 렌더 트리로 결합된다.
렌더 트리를 기반으로 HTML 요소의 레이아웃(위치와 크기)을 계산하고 브라우저의 화면에 HTML 요소를 페인팅한다.
요청과 응답
브라우저의 핵심 기능은 필요한 리소스(HTML, CSS, 자바스크립트, 이미지, 폰트 등의 정적 파일 또는 서버가 동적으로 생성한 데이터)를 서버에 요청(request)하고 서버로부터 응답(response) 받아 브라우저에 시각적으로 렌더링하는 것이다.
만약 index.html이 아닌 다른 정적 파일을 서버에 요청하려면 브라우저의 주소창에 https://poiemaweb.com/assets/data/data.json과 같이 요청할 정적 파일의 경로(서버의 루트 폴더 기준)와 파일 이름을 URI의 호스트 뒤의 패스(path)에 기술하여 서버에 요청한다. 그러면 서버는 루트 폴더의 assets/data 폴더 내에 있는 정적 파일 data.json을 응답할 것이다.
HTTP 1.1과 HTTP 2.0
HTTP/1.1은 기본적으로 커넥션(connection)당 하나의 요청과 응답만 처리한다. 즉, 여러 개의 요청을 한 번에 전송할 수 없고 응답 또한 마찬가지다. 따라서 HTML 문서 내에 포함된 여러 개의 리소스 요청, 즉 CSS 파일을 로드하는 link 태그, 이미지 파일을 로드하는 img 태그, 자바스크립트를 로드하는 script 태그 등에 의한 리소스 요청이 개별적으로 전송되고 응답 또한 개별적으로 전송된다. 이처럼 HTTP/1.1은 리소스의 동시 전송이 불가능한 구조이므로 요청할 리소스의 개수에 비례하여 응답 시간도 증가하는 단점이 있다.
HTTP/2는 커넥션당 여러 개의 요청과 응답, 즉 다중 요청/응답이 가능하다. 따라서 HTTP/2.0은 여러 리소스의 동시 전송이 가능하므로 HTTP/1.1에 비해 페이지 로드 속도가 약 50% 정도 빠르다고 알려져 있다.
HTML 파싱과 DOM 생성
순수한 텍스트인 HTML 문서를 브라우저에 시각적인 픽셀로 렌더링하려면 HTML 문서를 브라우저가 이해할 수 있는 자료구조(객체)로 변환하여 메모리에 저장해야 한다. 브라우저의 렌더링 엔진은 다음 그림과 같은 과정을 통해 응답받은 HTML 문서를 파싱하여 브라우저가 이해할 수 있는 자료구조인 DOM(Document Object Model)을 생성한다.
서버에 존재하던 HTML 파일이 브라우저의 요청에 의해 응답된다. 이때 서버는 브라우저가 요청한 HTML 파일을 읽어 들여 메모리에 저장한 다음 메모리에 저장된 바이트(2진수)를 인터넷을 경유하여 응답한다.
브라우저는 서버가 응답한 HTML 문서를 바이트(2진수) 형태로 응답받는다. 그리고 바이트 형태의 HTML 문서는 meta 태그의 charset 어트리뷰트에 의해 지정된 인코딩 방식(예: UTF-8)을 기준으로 문자열로 변환된다. 참고로 meta 태그의 charset 어트리뷰트에 선언된 인코딩 방식(예: UTF-8)은 content-type: text/html; charset=utf-8과 같이 응답 헤더(response header)에 담겨 응답된다. 브라우저는 이를 확인하고 문자열로 변환한다.
문자열로 변환된 HTML 문서를 읽어 들여 문법적 의미를 갖는 코드의 최소 단위인 토큰(token)들로 분해한다.
각 토큰들을 객체로 변환하여 노드(node)들을 생성한다. 토큰의 내용에 따라 문서 노드, 요소 노드, 어트리뷰트 노드, 텍스트 노드가 생성된다. 노드는 이후 DOM을 구성하는 기본 요소가 된다.
HTML 문서는 HTML 요소들의 집합으로 이루어지며 HTML 요소는 중첩 관계를 갖는다. 즉, HTML 요소의 콘텐츠 영역(시작 태그와 종료 태그 사이)에는 텍스트뿐만 아니라 다른 HTML 요소도 포함될 수 있다. 이때 HTML 요소 간에는 중첩 관계에 의해 부자 관계가 형성된다. 이러한 HTML 요소 간의 부자 관계를 반영하여 모든 노드들을 트리 자료구조로 구성한다. 이 노드들로 구성된 트리 자료구조를 DOM(Document Object Model)이라 부른다.
DOM은 HTML 문서를 파싱한 결과물이다.
CSS 파싱과 CSSOM 생성
렌더링 엔진은 DOM을 생성해 나가다가 CSS를 로드하는 link 태그나 style 태그를 만나면 DOM 생성을 일시 중단한다.
그리고 link 태그의 href 어트리뷰트에 지정된 CSS 파일을 서버에 요청하여 로드한 CSS 파일이나 style 태그 내의 CSS를 HTML과 동일한 파싱 과정(바이트 → 문자 → 토큰 → 노드 → CSSOM)을 거치며 해석하여 CSSOM(CSS Object Model)을 생성한다. 이후 CSS 파싱을 완료하면 HTML 파싱이 중단된 지점부터 다시 HTML을 파싱하기 시작하여 DOM 생성을 재개한다.
렌더 트리 생성
렌더링 엔진은 서버로부터 응답된 HTML과 CSS를 파싱하여 각각 DOM과 CSSOM를 생성한다. 그리고 DOM과 CSSOM은 렌더링을 위해 **렌더 트리(render tree)**로 결합된다.
렌더 트리는 렌더링을 위한 트리 구조의 자료구조다. 따라서 브라우저 화면에 렌더링되지 않는 노드(예: meta 태그, script 태그 등)와 CSS에 의해 비표시(예: display: none)되는 노드들은 포함하지 않는다. 다시 말해, 렌더 트리는 브라우저 화면에 렌더링되는 노드만으로 구성된다.
이후 완성된 렌더 트리는 각 HTML 요소의 레이아웃(위치와 크기)을 계산하는 데 사용되며 브라우저 화면에 픽셀을 렌더링하는 페인팅(painting) 처리에 입력된다.
지금까지 살펴본 브라우저의 렌더링 과정은 반복해서 실행될 수 있다. 예를 들어, 다음과 같은 경우 반복해서 레이아웃 계산과 페인팅이 재차 실행된다.
자바스크립트에 의한 노드 추가 또는 삭제
브라우저 창의 리사이징에 의한 뷰포트(viewport) 크기 변경
HTML 요소의 레이아웃(위치, 크기)에 변경을 발생시키는 width/height, margin, padding, border, display, position, top/right/bottom/left 등의 스타일 변경
레이아웃 계산과 페인팅을 다시 실행하는 리렌더링은 비용이 많이 드는, 즉 성능에 악영향을 주는 작업이다. 따라서 가급적 리렌더링이 빈번하게 발생하지 않도록 주의할 필요가 있다.
자바스크립트 파싱과 실행
자바스크립트 코드에서 DOM API를 사용하면 이미 생성된 DOM을 동적으로 조작할 수 있다.
CSS 파싱 과정과 마찬가지로 렌더링 엔진은 HTML을 한 줄씩 순차적으로 파싱하며 DOM을 생성해 나가다가 자바스크립트 파일을 로드하는 script 태그나 자바스크립트 코드를 콘텐츠로 담은 script 태그를 만나면 DOM 생성을 일시 중단한다. 그 후 자바스크립트 파일을 서버에 요청하여 로드한 자바스크립트 파일이나 script 태그 내의 자바스크립트 코드를 파싱하기 위해 자바스크립트 엔진에 제어권을 넘긴다. 이후 자바스크립트 파싱과 실행이 종료되면 렌더링 엔진으로 다시 제어권을 넘겨 HTML 파싱이 중단된 지점부터 다시 HTML 파싱을 시작하여 DOM 생성을 재개한다.
자바스크립트 파싱과 실행은 브라우저의 렌더링 엔진이 아닌 자바스크립트 엔진이 처리한다. 자바스크립트 엔진은 자바스크립트 코드를 파싱하여 CPU가 이해할 수 있는 저수준 언어(low-level language)로 변환하고 실행하는 역할을 한다.
렌더링 엔진으로부터 제어권을 넘겨받은 자바스크립트 엔진은 자바스크립트를 해석하여 AST(Abstract Syntax Tree, 추상적 구문 트리)를 생성한다. 그리고 AST를 기반으로 인터프리터가 실행할 수 있는 중간 코드(intermediate code)인 바이트코드를 생성하여 실행한다.
토크나이징(tokenizing) 단순한 문자열인 자바스크립트 소스코드를 어휘 분석(lexical analysis)하여 문법적 의미를 갖는 코드의 최소 단위인 토큰(token)들로 분해한다. 이 과정을 렉싱(lexing)이라고 부르기도 하지만 토크나이징과 미묘한 차이가 있다.
파싱(parsing) 토큰들의 집합을 구문 분석(syntactic analysis)하여 AST(Abstract Syntax Tree, 추상적 구문 트리)를 생성한다. AST는 토큰에 문법적 의미와 구조를 반영한 트리 구조의 자료구조다. AST는 인터프리터나 컴파일러만이 사용하는 것은 아니다. AST를 사용하면 TypeScript, Babel, Prettier 같은 트랜스파일러(transpiler)를 구현할 수도 있다. AST Explorer 웹사이트에 방문하면 다양한 오픈소스 자바스크립트 파서를 사용하여 AST를 생성해 볼 수 있다.
바이트코드 생성과 실행 파싱의 결과물로서 생성된 AST는 인터프리터가 실행할 수 있는 중간 코드인 바이트코드로 변환되고 인터프리터에 의해 실행된다. 참고로 V8 엔진의 경우 자주 사용되는 코드는 터보팬(TurboFan)이라 불리는 컴파일러에 의해 최적화된 머신 코드(optimized machine code)로 컴파일되어 성능을 최적화한다. 만약 코드의 사용 빈도가 적어지면 다시 디옵티마이징(deoptimizing)하기도 한다.
리플로우와 리페인트
자바스크립트 코드에 DOM이나 CSSOM을 변경하는 DOM API가 사용된 경우 DOM이나 CSSOM이 변경된다. 이때 변경된 DOM과 CSSOM은 다시 렌더 트리로 결합되고 변경된 렌더 트리를 기반으로 레이아웃과 페인트 과정을 거쳐 브라우저의 화면에 다시 렌더링한다. 이를 리플로우(reflow), 리페인트(repaint)라 한다. 래아아웃 변경(리플로우)이 많아지면 성능이 저하된다. 대부분 상속되지 않는 속성이 리플로우를 발생시킨다.
리플로우는 레이아웃 계산을 다시 하는 것을 말하며, 노드 추가/삭제, 요소의 크기/위치 변경, 윈도우 리사이징 등 레이아웃에 영향을 주는 변경이 발생한 경우에 한하여 실행된다. 리페인트는 재결합된 렌더 트리를 기반으로 다시 페인트를 하는 것을 말한다. 리플로우와 리페인트가 반드시 순차적으로 동시에 실행되는 것은 아니다. 레이아웃에 영향이 없는 변경은 리플로우 없이 리페인트만 실행된다.
자바스크립트 파싱에 의한 HTML 파싱 중단
렌더링 엔진과 자바스크립트 엔진은 직렬적으로 파싱을 수행한다.
브라우저는 동기적(synchronous)으로, 즉 위에서 아래 방향으로 순차적으로 HTML, CSS, 자바스크립트를 파싱하고 실행한다. 이것은 script 태그의 위치에 따라 HTML 파싱이 블로킹되어 DOM 생성이 지연될 수 있다는 것을 의미한다. 따라서 script 태그의 위치는 중요한 의미를 갖는다. 자바스크립트 코드(app.js)에서 DOM이나 CSSOM을 변경하는 DOM API를 사용할 경우 DOM이나 CSSOM이 이미 생성되어 있어야 한다. 만약 DOM을 변경하는 DOM API를 사용할 때 DOM의 생성이 완료되지 않은 상태라면 문제가 발생할 수 있다.
<!DOCTYPE html> <html> <head> <metacharset="UTF-8" /> <linkrel="stylesheet"href="style.css" /> <script> /* DOM API인 document.getElementById는 DOM에서 id가 'apple'인 HTML 요소를 취득한다. 아래 DOM API가 실행되는 시점에는 아직 id가 'apple'인 HTML 요소를 파싱하지 않았기 때문에 DOM에는 id가 'apple'인 HTML 요소가 포함되어 있지 않다. 따라서 아래 코드는 정상적으로 id가 'apple'인 HTML 요소를 취득하지 못한다. */ const $apple = document.getElementById("apple");
// id가 'apple'인 HTML 요소의 css color 프로퍼티 값을 변경한다. // 이때 DOM에는 id가 'apple'인 HTML 요소가 포함되어 있지 않기 때문에 에러가 발생한다. $apple.style.color = "red"; // TypeError: Cannot read property 'style' of null </script> </head> <body> <ul> <liid="apple">Apple</li> <liid="banana">Banana</li> <liid="orange">Orange</li> </ul> </body> </html>
DOM API인 document.getElementById('apple')은 DOM에서 id가 ‘apple’인 HTML 요소를 취득한다. 하지만 document.getElementById('apple')을 실행하는 시점에는 아직 id가 ‘apple’인 HTML 요소를 파싱하지 않았기 때문에 DOM에는 id가 ‘apple’인 HTML 요소가 포함되어 있지 않은 상태다. 따라서 위 예제는 정상적으로 동작하지 않는다.
이러한 문제를 회피하기 위해 body 요소의 가장 아래에 자바스크립트를 위치시키는 것은 좋은 아이디어다. 그 이유는 다음과 같다.
DOM이 완성되지 않은 상태에서 자바스크립트가 DOM을 조작하면 에러가 발생할 수 있다.
자바스크립트 로딩/파싱/실행으로 인해 HTML 요소들의 렌더링에 지장받는 일이 발생하지 않아 페이지 로딩 시간이 단축된다.
<!DOCTYPE html> <html> <head> <metacharset="UTF-8" /> <linkrel="stylesheet"href="style.css" /> </head> <body> <ul> <liid="apple">Apple</li> <liid="banana">Banana</li> <liid="orange">Orange</li> </ul> <script> /* DOM API인 document.getElementById는 DOM에서 id가 'apple'인 HTML 요소를 취득한다. 아래 코드가 실행되는 시점에는 id가 'apple'인 HTML 요소의 파싱이 완료되어 DOM에 포함되어 있기 때문에 정상적으로 동작한다. */ const $apple = document.getElementById("apple");
// apple 요소의 css color 프로퍼티 값을 변경한다. $apple.style.color = "red"; </script> </body> </html>
자바스크립트가 실행될 시점에는 이미 렌더링 엔진이 HTML 요소를 모두 파싱하여 DOM 생성을 완료한 이후다. 따라서 DOM이 완성되지 않은 상태에서 자바스크립트가 DOM을 조작하는 에러가 발생할 우려도 없다. 또한 자바스크립트가 실행되기 이전에 DOM 생성이 완료되어 렌더링되므로 페이지 로딩 시간이 단축되는 이점도 있다.
script 태그의 async / defer 어트리뷰트
자바스크립트 파싱에 의한 DOM 생성이 중단(blocking)되는 문제를 근본적으로 해결하기 위해 HTML5부터 script 태그에 async와 defer 어트리뷰트가 추가되었다.
async와 defer 어트리뷰트는 다음과 같이 src 어트리뷰트를 통해 외부 자바스크립트 파일을 로드하는 경우에만 사용할 수 있다. 즉, src 어트리뷰트가 없는 인라인 자바스크립트에는 사용할 수 없다.
async와 defer 어트리뷰트를 사용하면 HTML 파싱과 외부 자바스크립트 파일의 로드가 비동기적(asynchronous)으로 동시에 진행된다. 하지만 자바스크립트의 실행 시점에 차이가 있다.
async 어트리뷰트 HTML 파싱과 외부 자바스크립트 파일의 로드가 비동기적으로 동시에 진행된다. 단, 자바스크립트의 파싱과 실행은 자바스크립트 파일의 로드가 완료된 직후 진행되며, 이때 HTML 파싱이 중단된다.
여러 개의 script 태그에 async 어트리뷰트를 지정하면 script 태그의 순서와는 상관없이 로드가 완료된 자바스크립트부터 먼저 실행되므로 순서가 보장되지 않는다. 따라서 순서 보장이 필요한 script 태그에는 async 어트리뷰트를 지정하지 않아야 한다.
defer 어트리뷰트 async 어트리뷰트와 마찬가지로 HTML 파싱과 외부 자바스크립트 파일의 로드가 비동기적으로 동시에 진행된다. 단, 자바스크립트의 파싱과 실행은 HTML 파싱이 완료된 직후, 즉 DOM 생성이 완료된 직후(이때 DOMContentLoaded 이벤트가 발생한다) 진행된다. 따라서 DOM 생성이 완료된 이후 실행되어야 할 자바스크립트에 유용하다.
// 이터러블이 아닌 일반 객체는 스프레드 문법의 대상이 될 수 없다. console.log(...{ a: 1, b: 2 }); // TypeError: Found non-callable @@iterator
// 스프레드 문법의 결과는 값이 아니다. const list = ...[1, 2, 3]; // SyntaxError: Unexpected token ...
쉼표로 구분한 값의 목록을 사용하는 문맥에서만 사용할 수 있다.
함수 호출문의 인수 목록
배열 리터럴의 요소 목록
객체 리터럴의 프로퍼티 목록
함수 호출문의 인수 목록에서 사용하는 경우
1 2 3 4 5 6 7 8
var arr = [1, 2, 3];
Math.max(arr); // -> NaN
// apply 함수의 2번째 인수(배열)는 apply 함수가 호출하는 함수의 인수 목록이다. // 따라서 배열이 펼쳐져서 인수로 전달되는 효과가 있다. // null인 이유는 어차피 Math.max가 정적 메소드이기 때문이다. 사실 아무거나 줘도 어차피 안 본다. var max = Math.max.apply(null, arr); // -> 3
스프레드 문법을 사용
1 2 3 4 5
const arr = [1, 2, 3];
// 스프레드 문법을 사용하여 배열 arr을 1, 2, 3으로 펼쳐서 Math.max에 전달한다. // Math.max(...[1, 2, 3])은 Math.max(1, 2, 3)과 같다. const max = Math.max(...arr); // -> 3
// 세 번째 인수 arr2를 해체하여 전달해야 한다. // 그렇지 않으면 arr1에 arr2 배열 자체가 추가된다. arr1.splice(1, 0, arr2);
// 기대한 결과는 [1, [2, 3], 4]가 아니라 [1, 2, 3, 4]다. console.log(arr1); // [1, [2, 3], 4]
/* apply 메서드의 2번째 인수(배열)는 apply 메서드가 호출한 splice 메서드의 인수 목록이다. apply 메서드의 2번째 인수 [1, 0].concat(arr2)는 [1, 0, 2, 3]으로 평가된다. 따라서 splice 메서드에 apply 메서드의 2번째 인수 [1, 0, 2, 3]이 해체되어 전달된다. 즉, arr1[1]부터 0개의 요소를 제거하고 그 자리(arr1[1])에 새로운 요소(2, 3)를 삽입한다. */ Array.prototype.splice.apply(arr1, [1, 0].concat(arr2)); console.log(arr1); // [1, 2, 3, 4]
const user = { firstName: "Ungmo", lastName: "Lee" };
// ES6 객체 디스트럭처링 할당 // 변수 lastName, firstName을 선언하고 user 객체를 디스트럭처링하여 할당한다. // 이때 프로퍼티 키를 기준으로 디스트럭처링 할당이 이루어진다. 순서는 의미가 없다. const { lastName, firstName } = user;
console.log(firstName, lastName); // Ungmo Lee
객체의 프로퍼티 키와 다른 변수 이름으로 프로퍼티 값을 할당받으려면 다음과 같이 변수를 선언한다.
1 2 3 4 5 6 7 8
const user = { firstName: "Ungmo", lastName: "Lee" };
// 프로퍼티 키를 기준으로 디스트럭처링 할당이 이루어진다. // 프로퍼티 키가 lastName인 프로퍼티 값을 ln에 할당하고, // 프로퍼티 키가 firstName인 프로퍼티 값을 fn에 할당한다. const { lastName: ln, firstName: fn } = user;
ES5에서 객체의 각 프로퍼티를 객체로부터 디스트럭처링하여 변수에 할당하기 위해서는 프로퍼티 키를 사용해야 한다.
1 2 3 4 5 6 7
// ES5 var user = { firstName: "Ungmo", lastName: "Lee" };
var firstName = user.firstName; var lastName = user.lastName;
console.log(firstName, lastName); // Ungmo Lee
ES6의 객체 디스트럭처링 할당은 객체의 각 프로퍼티를 객체로부터 추출하여 1개 이상의 변수에 할당한다. 이때 객체 디스트럭처링 할당의 대상(할당문의 우변)은 객체이어야 하며, 할당 기준은 프로퍼티 키다. 즉, 순서는 의미가 없으며 선언된 변수 이름과 프로퍼티 키가 일치하면 할당된다.
1 2 3 4 5 6 7 8
const user = { firstName: "Ungmo", lastName: "Lee" };
// ES6 객체 디스트럭처링 할당 // 변수 lastName, firstName을 선언하고 user 객체를 디스트럭처링하여 할당한다. // 이때 프로퍼티 키를 기준으로 디스트럭처링 할당이 이루어진다. 순서는 의미가 없다. const { lastName, firstName } = user;
console.log(firstName, lastName); // Ungmo Lee
배열 디스트럭처링 할당과 마찬가지로 객체 디스트럭처링 할당을 위해서는 할당 연산자 왼쪽에 프로퍼티 값을 할당받을 변수를 선언해야 한다. 이때 변수를 객체 리터럴 형태로 선언한다.
const { lastName, firstName } = null; // TypeError: Cannot destructure property 'lastName' of 'null' as it is null.
위 예제에서 객체 리터럴 형태로 선언한 변수는 lastName, firstName이다. 이는 프로퍼티 축약 표현을 통해 선언한 것이다.
1 2 3
const { lastName, firstName } = user; // 위와 아래는 동치다. const { lastName: lastName, firstName: firstName } = user;
따라서 객체의 프로퍼티 키와 다른 변수 이름으로 프로퍼티 값을 할당받으려면 다음과 같이 변수를 선언한다.
1 2 3 4 5 6 7 8
const user = { firstName: "Ungmo", lastName: "Lee" };
// 프로퍼티 키를 기준으로 디스트럭처링 할당이 이루어진다. // 프로퍼티 키가 lastName인 프로퍼티 값을 ln에 할당하고, // 프로퍼티 키가 firstName인 프로퍼티 값을 fn에 할당한다. const { lastName: ln, firstName: fn } = user;
// 이터러블이 아닌 일반 객체는 스프레드 문법의 대상이 될 수 없다. console.log(...{ a: 1, b: 2 }); // TypeError: Found non-callable @@iterator
...[1, 2, 3]은 이터러블인 배열을 펼쳐서 요소들을 개별적인 값들의 목록 1 2 3으로 만든다. 이때 1 2 3은 값이 아니라 값들의 목록이다. 즉, 스프레드 문법의 결과는 값이 아니다. 이는 스프레드 문법 …이 피연산자를 연산하여 값을 생성하는 연산자가 아님을 의미한다. 따라서 스프레드 문법의 결과는 변수에 할당할 수 없다.
이처럼 스프레드 문법의 결과물은 값으로 사용할 수 없고, 다음과 같이 쉼표로 구분한 값의 목록을 사용하는 문맥에서만 사용할 수 있다.
함수 호출문의 인수 목록
배열 리터럴의 요소 목록
객체 리터럴의 프로퍼티 목록
함수 호출문의 인수 목록에서 사용하는 경우
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Math.max([1, 2, 3]); // -> NaN
// 스프레드 문법 제공 전 var arr = [1, 2, 3];
// apply 함수의 2번째 인수(배열)는 apply 함수가 호출하는 함수의 인수 목록이다. // 따라서 배열이 펼쳐져서 인수로 전달되는 효과가 있다. var max = Math.max.apply(null, arr); // -> 3
// 스프레드 문법 const arr = [1, 2, 3];
// 스프레드 문법을 사용하여 배열 arr을 1, 2, 3으로 펼쳐서 Math.max에 전달한다. // Math.max(...[1, 2, 3])은 Math.max(1, 2, 3)과 같다. const max = Math.max(...arr); // -> 3
Rest 파라미터는 함수에 전달된 인수들의 목록을 배열로 전달받기 위해 매개변수 이름 앞에 …을 붙이는 것이다. 스프레드 문법은 여러 개의 값이 하나로 뭉쳐 있는 배열과 같은 이터러블을 펼쳐서 개별적인 값들의 목록을 만드는 것이다. 따라서 Rest 파라미터와 스프레드 문법은 서로 반대의 개념이다.
// 세 번째 인수 arr2를 해체하여 전달해야 한다. // 그렇지 않으면 arr1에 arr2 배열 자체가 추가된다. arr1.splice(1, 0, arr2);
// 기대한 결과는 [1, [2, 3], 4]가 아니라 [1, 2, 3, 4]다. console.log(arr1); // [1, [2, 3], 4]
Function.prototype.apply 메서드를 사용하여 splice 메서드를 호출해야 한다. apply 메서드의 두 번째 인수(배열)는 apply 메서드가 호출하는 함수에 해체되어 전달된다.
1 2 3 4 5 6 7 8 9 10 11 12
// ES5 var arr1 = [1, 4]; var arr2 = [2, 3];
/* apply 메서드의 2번째 인수(배열)는 apply 메서드가 호출한 splice 메서드의 인수 목록이다. apply 메서드의 2번째 인수 [1, 0].concat(arr2)는 [1, 0, 2, 3]으로 평가된다. 따라서 splice 메서드에 apply 메서드의 2번째 인수 [1, 0, 2, 3]이 해체되어 전달된다. 즉, arr1[1]부터 0개의 요소를 제거하고 그 자리(arr1[1])에 새로운 요소(2, 3)를 삽입한다. */ Array.prototype.splice.apply(arr1, [1, 0].concat(arr2)); console.log(arr1); // [1, 2, 3, 4]
// 비교 함수. 매개변수 key는 프로퍼티 키다. functioncompare(key) { // 프로퍼티 값이 문자열인 경우 - 산술 연산으로 비교하면 NaN이 나오므로 비교 연산을 사용한다. // 비교 함수는 양수/음수/0을 반환하면 되므로 - 산술 연산 대신 비교 연산을 사용할 수 있다. return(a, b) => (a[key] > b[key] ? 1 : a[key] < b[key] ? -1 : 0); }
const count = fruits.reduce((acc, cur) => { // 첫 번째 순회 시 acc는 초기값인 {}이고 cur은 첫 번째 요소인 'banana'다. // 초기값으로 전달받은 빈 객체에 요소값인 cur을 프로퍼티 키로, 요소의 개수를 프로퍼티 값으로 // 할당한다. 만약 프로퍼티 값이 undefined(처음 등장하는 요소)이면 프로퍼티 값을 1로 초기화한다. acc[cur] = (acc[cur] || 0) + 1; return acc; }, {});
// 콜백 함수는 총 5번 호출되고 다음과 같이 결과값을 반환한다. /* {banana: 1} => {banana: 1, apple: 1} => {banana: 1, apple: 1, orange: 1} => {banana: 1, apple: 1, orange: 2} => {banana: 1, apple: 2, orange: 2} */
const result = values.reduce((acc, cur, i, arr) => { // 순회 중인 요소의 인덱스가 자신의 인덱스라면 처음 순회하는 요소다. // 이 요소만 초기값으로 전달받은 배열에 담아 반환한다. // 순회 중인 요소의 인덱스가 자신의 인덱스가 아니라면 중복된 요소다. if (arr.indexOf(cur) === i) acc.push(cur); return acc; }, []);