0%

이벤트

이벤트 드리븐 프로그래밍

이벤트가 발생했을 때 호출될 함수를 이벤트 핸들러(event handler)라 하고, 이벤트가 발생했을 때 브라우저에게 이벤트 핸들러의 호출을 위임하는 것을 이벤트 핸들러 등록이라 한다. 프로그램의 흐름을 이벤트 중심으로 제어하는 프로그래밍 방식을 **이벤트 드리븐 프로그래밍(event-driven programming)**이라 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<button>Hello</button>
<script>
document.querySelector("button").onclick = () => {
console.log("Hello");
};
</script>
</body>
</html>

이벤트 핸들러 등록

이벤트 핸들러 어트리뷰트 방식

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<body>
<button onclick="sayHi('Lee')">Click me!</button>
<!-- <button onclick="console.log(`Hi!`);">Click me!</button -->
<script>
function sayHi(name) {
console.log(`Hi! ${name}.`);
}
</script>
</body>
</html>
이벤트 핸들러 어트리뷰트 값으로 함수 참조가 아닌 인수 전달을 위힌 함수 호출문 등의 문을 할당한다. onclick 어트리뷰트 값은 onclick이란 함수 이름을 가진 함수 몸체 안에 들어오는 문들을 넣어주는 것과 마찬가지다.

이벤트 핸들러 프로퍼티 방식

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<input id="username" type="text" placeholder="username">
<span class="display"></span>
<script>
const $username = document.getElementById('username');
const $display = document.querySelector('.display');

$username.oninput = () => {
$display.textContent = $username.value;
};
</script>
</body>
</html>

addEventListener 메서드 방식

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!DOCTYPE html>
<html>
<body>
<button>Click me!</button>
<script>
const $button = document.querySelector("button");

// 이벤트 핸들러 프로퍼티 방식
$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 메서드를 통해 참조가 동일한 이벤트 핸들러를 중복 등록하면 하나의 이벤트 핸들러만 등록된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html>
<body>
<button>Click me!</button>
<script>
const $button = document.querySelector("button");

const handleClick = () => console.log("button click");

// 참조가 동일한 이벤트 핸들러를 중복 등록하면 하나의 핸들러만 등록된다.
$button.addEventListener("click", handleClick);
$button.addEventListener("click", handleClick);
</script>
</body>
</html>

이벤트 핸들러 제거

addEventListener 메서드로 등록한 이벤트 핸들러를 제거하려면 EventTarget.prototype.removeEventListener 메서드를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<body>
<button>Click me!</button>
<script>
const $button = document.querySelector("button");

const handleClick = () => console.log("button click");

// 이벤트 핸들러 등록
$button.addEventListener("click", handleClick);

// 이벤트 핸들러 제거
// addEventListener 메서드에 전달한 인수와 removeEventListener 메서드에
// 전달한 인수가 일치하지 않으면 이벤트 핸들러가 제거되지 않는다.
$button.removeEventListener("click", handleClick, true); // 실패
$button.removeEventListener("click", handleClick); // 성공
</script>
</body>
</html>

이벤트 핸들러 프로퍼티 방식으로 등록한 이벤트 핸들러는 removeEventListener 메서드로 제거할 수 없다. 이벤트 핸들러 프로퍼티 방식으로 등록한 이벤트 핸들러를 제거하려면 이벤트 핸들러 프로퍼티에 null을 할당한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<body>
<button>Click me!</button>
<script>
const $button = document.querySelector("button");

const handleClick = () => console.log("button click");

// 이벤트 핸들러 프로퍼티 방식으로 이벤트 핸들러 등록
$button.onclick = handleClick;

// removeEventListener 메서드로 이벤트 핸들러를 제거할 수 없다.
$button.removeEventListener("click", handleClick);

// 이벤트 핸들러 프로퍼티에 null을 할당하여 이벤트 핸들러를 제거한다.
$button.onclick = null;
</script>
</body>
</html>

이벤트 객체

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html>
<body>
<p>클릭하세요. 클릭한 곳의 좌표가 표시됩니다.</p>
<em class="message"></em>
<script>
const $msg = document.querySelector(".message");

// 클릭 이벤트에 의해 생성된 이벤트 객체는 이벤트 핸들러의 첫 번째 인수로 전달된다.
function showCoords(e) {
$msg.textContent = `clientX: ${e.clientX}, clientY: ${e.clientY}`;
}

document.onclick = showCoords;
</script>
</body>
</html>

이벤트 핸들러 어트리뷰트 방식으로 이벤트 핸들러를 등록했다면 다음과 같이 event를 통해 이벤트 객체를 전달받을 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!DOCTYPE html>
<html>
<head>
<style>
html,
body {
height: 100%;
}
</style>
</head>
<!-- 이벤트 핸들러 어트리뷰트 방식의 경우 event가 아닌 다른 이름으로는 이벤트 객체를
전달받지 못한다. -->
<body onclick="showCoords(event)">
<p>클릭하세요. 클릭한 곳의 좌표가 표시됩니다.</p>
<em class="message"></em>
<script>
const $msg = document.querySelector(".message");

// 클릭 이벤트에 의해 생성된 이벤트 객체는 이벤트 핸들러의 첫 번째 인수로 전달된다.
function showCoords(e) {
$msg.textContent = `clientX: ${e.clientX}, clientY: ${e.clientY}`;
}
</script>
</body>
</html>

이벤트 핸들러 어트리뷰트 방식의 경우 이벤트 객체를 전달받으려면 이벤트 핸들러의 첫 번째 매개변수 이름이 반드시 event이어야 한다.

이벤트 전파

  • 캡처링 단계(capturing phase) : 이벤트가 상위 요소에서 하위 요소 방향으로 전파
  • 타깃 단계(target phase) : 이벤트가 이벤트 타깃에 도달
  • 버블링 단계(bubbling phase) : 이벤트가 하위 요소에서 상위 요소 방향으로 전파
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li id="apple">Apple</li>
<li id="banana">Banana</li>
<li id="orange">Orange</li>
</ul>
<script>
const $fruits = document.getElementById("fruits");
const $banana = document.getElementById("banana");

// #fruits 요소의 하위 요소인 li 요소를 클릭한 경우 캡처링 단계의 이벤트를 캐치한다.
$fruits.addEventListener(
"click",
(e) => {
console.log(`이벤트 단계: ${e.eventPhase}`); // 1: 캡처링 단계
console.log(`이벤트 타깃: ${e.target}`); // [object HTMLLIElement]
console.log(`커런트 타깃: ${e.currentTarget}`); // [object HTMLUListElement]
},
true
);

// 타깃 단계의 이벤트를 캐치한다.
$banana.addEventListener("click", (e) => {
console.log(`이벤트 단계: ${e.eventPhase}`); // 2: 타깃 단계
console.log(`이벤트 타깃: ${e.target}`); // [object HTMLLIElement]
console.log(`커런트 타깃: ${e.currentTarget}`); // [object HTMLLIElement]
});

// 버블링 단계의 이벤트를 캐치한다.
$fruits.addEventListener("click", (e) => {
console.log(`이벤트 단계: ${e.eventPhase}`); // 3: 버블링 단계
console.log(`이벤트 타깃: ${e.target}`); // [object HTMLLIElement]
console.log(`커런트 타깃: ${e.currentTarget}`); // [object HTMLUListElement]
});
</script>
</body>
</html>

li 요소를 클릭하면 클릭 이벤트가 발생하여 클릭 이벤트 객체가 생성되고 클릭된 li 요소가 이벤트 타깃이 된다. 이때 클릭 이벤트 객체는 window에서 시작해서 이벤트 타깃 방향으로 전파된다. 이것이 캡처링 단계다. 이후 이벤트 객체는 이벤트를 발생시킨 이벤트 타깃에 도달한다. 이것이 타깃 단계다. 이후 이벤트 객체는 이벤트 타깃에서 시작해서 window 방향으로 전파된다. 이것이 버블링 단계다.

이벤트 핸들러 어트리뷰트/프로퍼티 방식으로 등록한 이벤트 핸들러는 타깃 단계와 버블링 단계의 이벤트만 캐치할 수 있다. 하지만 addEventListener 메서드 방식으로 등록한 이벤트 핸들러는 타깃 단계와 버블링 단계뿐만 아니라 캡처링 단계의 이벤트도 선별적으로 캐치할 수 있다. 캡처링 단계의 이벤트를 캐치하려면 addEventListener 메서드의 3번째 인수로 true를 전달해야 한다. 3번째 인수를 생략하거나 false를 전달하면 타깃 단계와 버블링 단계의 이벤트만 캐치할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!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 요소에 이벤트 핸들러를 등록할 필요가 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<!DOCTYPE html>
<html>
<head>
<style>
#fruits {
display: flex;
list-style-type: none;
padding: 0;
}

#fruits li {
width: 100px;
cursor: pointer;
}

#fruits .active {
color: red;
text-decoration: underline;
}
</style>
</head>
<body>
<nav>
<ul id="fruits">
<li id="apple" class="active">Apple</li>
<li id="banana">Banana</li>
<li id="orange">Orange</li>
</ul>
</nav>
<div>선택된 내비게이션 아이템: <em class="msg">apple</em></div>
<script>
const $fruits = document.getElementById("fruits");
const $msg = document.querySelector(".msg");

// 사용자 클릭에 의해 선택된 내비게이션 아이템(li 요소)에 active 클래스를 추가하고
// 그 외의 모든 내비게이션 아이템의 active 클래스를 제거한다.
function activate({ target }) {
// 이벤트를 발생시킨 요소(target)가 ul#fruits의 자식 요소가 아니라면 무시한다.
if (!target.matches("#fruits > li")) return;

[...$fruits.children].forEach(($fruit) => {
$fruit.classList.toggle("active", $fruit === target);
$msg.textContent = target.id;
});
}

// 이벤트 위임: 상위 요소(ul#fruits)는 하위 요소의 이벤트를 캐치할 수 있다.
$fruits.onclick = activate;
</script>
</body>
</html>

DOM 요소의 기본 동작 조작

  1. DOM 요소의 기본 동작 중단
1
2
3
4
document.querySelector("input[type=checkbox]").onclick = (e) => {
// checkbox 요소의 기본 동작을 중단한다.
e.preventDefault();
};
  1. 이벤트 전파 방지
    stopPropagation 메서드는 하위 DOM 요소의 이벤트를 개별적으로 처리하기 위해 이벤트의 전파를 중단시킨다.
1
2
3
4
5
// .btn2 요소는 이벤트를 전파하지 않으므로 상위 요소에서 이벤트를 캐치할 수 없다.
document.querySelector(".btn2").onclick = (e) => {
e.stopPropagation(); // 이벤트 전파 중단
e.target.style.color = "blue";
};

이벤트 핸들러 내부의 this

이벤트 핸들러 어트리뷰트 방식

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html>
<body>
<button onclick="handleClick()">Click me</button>
<script>
function handleClick() {
console.log(this); // window
}
</script>
</body>
</html>

handleClick 함수는 이벤트 핸들러에 의해 일반 함수로 호출된다. 일반 함수로서 호출되는 함수 내부의 this는 전역 객체를 가리킨다.

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<body>
<button onclick="handleClick(this)">Click me</button>
<script>
function handleClick(button) {
console.log(button); // 이벤트를 바인딩한 button 요소
console.log(this); // window
}
</script>
</body>
</html>

이벤트 핸들러를 호출할 때 인수로 전달한 this는 이벤트를 바인딩한 DOM 요소를 가리킨다.
위 예제에서 handleClick 함수에 전달한 this는 암묵적으로 생성된 이벤트 핸들러 내부의 this다. 즉, 이벤트 핸들러 어트리뷰트 방식에 의해 암묵적으로 생성된 이벤트 핸들러 내부의 this는 이벤트를 바인딩한 DOM 요소를 가리킨다. 이는 이벤트 핸들러 프로퍼티 방식과 동일하다.

이벤트 핸들러 프로퍼티 방식과 addEventListener 메서드 방식

이벤트 핸들러 프로퍼티 방식과 addEventListener 메서드 방식 모두 이벤트 핸들러 내부의 this는 이벤트를 바인딩한 DOM 요소를 가리킨다. 즉, 이벤트 핸들러 내부의 this는 이벤트 객체의 currentTarget 프로퍼티와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!DOCTYPE html>
<html>
<body>
<button class="btn1">0</button>
<button class="btn2">0</button>
<script>
const $button1 = document.querySelector(".btn1");
const $button2 = document.querySelector(".btn2");

// 이벤트 핸들러 프로퍼티 방식
$button1.onclick = function (e) {
// this는 이벤트를 바인딩한 DOM 요소를 가리킨다.
console.log(this); // $button1
console.log(e.currentTarget); // $button1
console.log(this === e.currentTarget); // true

// $button1의 textContent를 1 증가시킨다.
++this.textContent;
};

// addEventListener 메서드 방식
$button2.addEventListener("click", function (e) {
// this는 이벤트를 바인딩한 DOM 요소를 가리킨다.
console.log(this); // $button2
console.log(e.currentTarget); // $button2
console.log(this === e.currentTarget); // true

// $button2의 textContent를 1 증가시킨다.
++this.textContent;
});
</script>
</body>
</html>

화살표 함수로 정의한 이벤트 핸들러 내부의 this는 상위 컨텍스트의 this를 가리킨다. 화살표 함수는 함수 자체의 this 바인딩을 갖지 않는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!DOCTYPE html>
<html>
<body>
<button class="btn1">0</button>
<button class="btn2">0</button>
<script>
const $button1 = document.querySelector(".btn1");
const $button2 = document.querySelector(".btn2");

// 이벤트 핸들러 프로퍼티 방식
$button1.onclick = (e) => {
// 화살표 함수 내부의 this는 상위 스코프의 this를 가리킨다.
console.log(this); // window
console.log(e.currentTarget); // $button1
console.log(this === e.currentTarget); // false

// this는 window를 가리키므로 window.textContent에 NaN(undefined + 1)을 할당한다.
++this.textContent;
};

// addEventListener 메서드 방식
$button2.addEventListener("click", (e) => {
// 화살표 함수 내부의 this는 상위 스코프의 this를 가리킨다.
console.log(this); // window
console.log(e.currentTarget); // $button2
console.log(this === e.currentTarget); // false

// this는 window를 가리키므로 window.textContent에 NaN(undefined + 1)을 할당한다.
++this.textContent;
});
</script>
</body>
</html>

클래스에서 이벤트 핸들러를 바인딩하는 경우 this에 주의해야 한다. 다음 예제에서는 이벤트 핸들러 프로퍼티 방식을 사용하고 있으나 addEventListener 메서드 방식을 사용하는 경우와 동일하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!DOCTYPE html>
<html>
<body>
<button class="btn">0</button>
<script>
class App {
constructor() {
this.$button = document.querySelector(".btn");
this.count = 0;

// 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가 클래스가 생성할 인스턴스를 가리키도록 해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!DOCTYPE html>
<html>
<body>
<button class="btn">0</button>
<script>
class App {
constructor() {
this.$button = document.querySelector(".btn");
this.count = 0;

// increase 메서드를 이벤트 핸들러로 등록
// this.$button.onclick = this.increase;

// increase 메서드 내부의 this가 인스턴스를 가리키도록 한다.
this.$button.onclick = this.increase.bind(this);
}

increase() {
this.$button.textContent = ++this.count;
}
}

new App();
</script>
</body>
</html>

또는 클래스 필드에 할당한 화살표 함수를 이벤트 핸들러로 등록하여 이벤트 핸들러 내부의 this가 인스턴스를 가리키도록 할 수도 있다. 다만 이때 이벤트 핸들러 increase는 프로토타입 메서드가 아닌 인스턴스 메서드가 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html>
<body>
<button class="btn">0</button>
<script>
class App {
constructor() {
this.$button = document.querySelector(".btn");
this.count = 0;

// 화살표 함수인 increase를 이벤트 핸들러로 등록
this.$button.onclick = this.increase;
}

// 클래스 필드 정의
// increase는 인스턴스 메서드이며 내부의 this는 인스턴스를 가리킨다.
increase = () => (this.$button.textContent = ++this.count);
}
new App();
</script>
</body>
</html>

참고 도서: 모던 자바스크립트 Deep Dive

Nyong’s GitHub

오늘 배운 것

DOM

노드

HTML을 파싱하면 결과물로 DOM이 형성된다.

요소 노드의 어트리뷰트 노드는 요소 노드와 연결되었을 뿐, 부모 노드가 없기 때문에 엄격히 말하면 DOM 트리의 구성요소가 아니라고 말할 수 있다.

요소 노드 취득

CSS 선택자를 이용한 요소 노드 취득

살아있는 객체 메서드(getElements___)는 상태를 확인하는 것이 어렵기 때문에 사용하지 않는 것이 좋다. 웬만하면 querySelector를 사용하자.

`querySelector/All``도 live 객체일 때가 있기 때문에 제일 좋은 방법은 배열로 바꿔서 사용하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html>
<body>
<ul>
<li id="apple">Apple</li>
<li id="banana">Banana</li>
<li id="orange">Orange</li>
</ul>
<script>
const $elem = document.querySelector("#apple");
$elem.style.color = "blue";
</script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html>
<body>
<ul>
<li class="green">Apple</li>
<li class="green">Banana</li>
<li class="green">Orange</li>
</ul>
<script>
const $elems = document.querySelectorAll(".green");
$elems.forEach((elem) => {
elem.style.color = "green";
});
// [...$elems].forEach(elem => { elem.style.color = 'green'; });
</script>
</body>
</html>

HTMLCollection과 NodeList

둘 다 유사배열 객체이며 이터러블이다. 둘 다 살아있는 객체지만 NodeList는 경우에 따라 다르다(childNodes 사용 시). 때문에 가장 좋은 방법은 배열로 바꾸어 사용하는 것이다.

요소 노드의 텍스트 조작

  • nodeValue

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <!DOCTYPE html>
    <html>
    <body>
    <div id="foo">Hello</div>
    </body>
    <script>
    const $foo = document.getElementById("foo");
    console.log($foo.firstChild);

    // $foo.firstChild.nodeValue = 'Foo';
    $foo.textContent = "Foo";
    </script>
    </html>

DOM 조작

  1. innerHTML

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <!DOCTYPE html>
    <html>
    <body>
    <div id="fruits">
    <li>Apple</li>
    </div>
    </body>
    <script>
    const $fruits = document.getElementById("fruits");
    $fruits.innerHTML += "<li>Banana</li>";
    </script>
    </html>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <!DOCTYPE html>
    <html>
    <body>
    <div id="fruits">
    <li>Apple</li>
    <li>Grape</li>
    </div>
    </body>
    <script>
    const $fruits = document.getElementById("fruits");
    $fruits.innerHTML = "<li>Banana</li>" + $fruits.innerHTML;
    </script>
    </html>

    하지만 위와 같이 innerHTML을 사용하며 재할당하게 되면 DOM을 다시 구성해야한다. 만약 ul안에 li가 많이 있을 경우 그 크기만큼의 DOM을 재구성하기 때문에 데이터 낭비로 직결될 수 있다.

    모던 브라우저에서는 보안 이슈(크로스 사이트 스크립팅 공격)로 innerHTML에 스크립트 태그 할당을 금지한다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <!DOCTYPE html>
    <html>
    <body>
    <div id="foo">Hello</div>
    </body>
    <script>
    // innerHTML 프로퍼티로 스크립트 태그를 삽입하여 자바스크립트가 실행되도록 한다.
    // HTML5는 innerHTML 프로퍼티로 삽입된 script 요소 내의 자바스크립트 코드를 실행하지 않는다.
    document.getElementById('foo').innerHTML
    = '<script>alert(document.cookie)</script>';
    </script>
    </html>

    하지만 script 요소 없이도 크로스 사이트 스크립팅 공격은 가능하다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <!DOCTYPE html>
    <html>
    <body>
    <div id="foo">Hello</div>
    </body>
    <script>
    // 에러 이벤트를 강제로 발생시켜서 자바스크립트 코드가 실행되도록 한다.
    document.getElementById(
    "foo"
    ).innerHTML = `<img src="x" onerror="alert(document.cookie)">`;
    </script>
    </html>

    innerHTML을 굳이 사용하려면 HTML 새니티제이션을 같이 사용해야한다. DOMPurify.sanitize('<img src="x" onerror="alert(document.cookie)">'); // => <img src="x">

  2. insertAdjacentHTML 메서드

    Element.prototype.insertAdjacentHTML(position, DOMString) 메서드는 기존 요소를 제거하지 않으면서 위치를 지정해 새로운 요소를 삽입한다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    <!DOCTYPE html>
    <html>
    <body>
    <!-- beforebegin -->
    <div id="foo">
    <!-- afterbegin -->
    text
    <!-- beforeend -->
    </div>
    <!-- afterend -->
    </body>
    <script>
    const $foo = document.getElementById("foo");

    $foo.insertAdjacentHTML("beforebegin", "<p>beforebegin</p>");
    $foo.insertAdjacentHTML("afterbegin", "<p>afterbegin</p>");
    $foo.insertAdjacentHTML("beforeend", "<p>beforeend</p>");
    $foo.insertAdjacentHTML("afterend", "<p>afterend</p>");
    </script>
    </html>

    insertAdjacentHTML 메서드는 기존 요소에는 영향을 주지 않고 새롭게 삽입될 요소만을 파싱하여 자식 요소로 추가하므로 기존의 자식 노드를 모두 제거하고 다시 처음부터 새롭게 자식 노드를 생성하여 자식 요소로 추가하는 innerHTML 프로퍼티보다 효율적이고 빠르다.

  3. 노드 생성과 추가

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    <!DOCTYPE html>
    <html>
    <body>
    <ul id="fruits">
    <li>Apple</li>
    </ul>
    </body>
    <script>
    const $fruits = document.getElementById("fruits");

    // 1. 요소 노드 생성
    const $li = document.createElement("li");

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

    // 3. 텍스트 노드를 $li 요소 노드의 자식 노드로 추가
    $li.appendChild(textNode);

    // 4. $li 요소 노드를 #fruits 요소 노드의 마지막 자식 노드로 추가
    $fruits.appendChild($li);
    </script>
    </html>

    위 예제는 단 하나의 요소 노드를 생성하여 DOM에 한번 추가하므로 DOM은 한 번 변경된다. 이때 리플로우와 리페인트가 실행된다.

  4. 복수의 노드 생성과 추가

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    <!DOCTYPE html>
    <html>
    <body>
    <ul id="fruits"></ul>
    </body>
    <script>
    const $fruits = document.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을 변경하는 것은 높은 비용이 드는 처리이므로 가급적 횟수를 줄이는 편이 성능에 유리하다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    <!DOCTYPE html>
    <html>
    <body>
    <ul id="fruits"></ul>
    </body>
    <script>
    const $fruits = document.getElementById("fruits");

    // 컨테이너 요소 노드 생성
    const $container = document.createElement("div");

    ["Apple", "Banana", "Orange"].forEach((text) => {
    // 1. 요소 노드 생성
    const $li = document.createElement("li");

    // 2. 텍스트 노드 생성
    const textNode = document.createTextNode(text);

    // 3. 텍스트 노드를 $li 요소 노드의 자식 노드로 추가
    $li.appendChild(textNode);

    // 4. $li 요소 노드를 컨테이너 요소의 마지막 자식 노드로 추가
    $container.appendChild($li);
    });

    // 5. 컨테이너 요소 노드를 #fruits 요소 노드의 마지막 자식 노드로 추가
    $fruits.appendChild($container);
    </script>
    </html>

    위 예제는 DOM을 한 번만 변경하므로 성능에 유리하기는 하지만 다음과 같이 불필요한 컨테이너 요소(div)가 DOM에 추가되는 부작용이 있다. 이는 바람직하지 않다.

    1
    2
    3
    4
    5
    6
    7
    <ul id="fruits">
    <div>
    <li>apple</li>
    <li>banana</li>
    <li>orange</li>
    </div>
    </ul>

    이러한 문제는 DocumentFragment 노드를 통해 해결할 수 있다. 위의 div 를 대체하는 것이다. DocumentFragment는 appendChild를 사용할 때 사라진다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    <!DOCTYPE html>
    <html>
    <body>
    <ul id="fruits"></ul>
    </body>
    <script>
    const $fruits = document.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. DocumentFragment 노드를 #fruits 요소 노드의 마지막 자식 노드로 추가
    $fruits.appendChild($fragment);
    </script>
    </html>

노드 삽입

  1. 마지막 노드로 추가

    appendChild 사용

  2. 지정한 위치에 노드 삽입

    insertBefore 메서드는 첫 번째 인수로 전달받은 노드를 두 번째 인수로 전달받은 노드 앞에 삽입한다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    <!DOCTYPE html>
    <html>
    <body>
    <ul id="fruits">
    <li>Apple</li>
    <li>Banana</li>
    </ul>
    </body>
    <script>
    const $fruits = document.getElementById("fruits");

    // 요소 노드 생성
    const $li = document.createElement("li");

    // 텍스트 노드를 $li 요소 노드의 마지막 자식 노드로 추가
    $li.appendChild(document.createTextNode("Orange"));

    // $li 요소 노드를 #fruits 요소 노드의 마지막 자식 요소 앞에 삽입
    $fruits.insertBefore($li, $fruits.lastElementChild);
    // Apple - Orange - Banana
    </script>
    </html>

노드 이동

DOM에 이미 존재하는 노드를 appendChild 또는 insertBefore 메서드를 사용하여 DOM에 다시 추가하면 현재 위치에서 노드를 제거하고 새로운 위치에 노드를 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li>Apple</li>
<li>Banana</li>
<li>Orange</li>
</ul>
</body>
<script>
const $fruits = document.getElementById("fruits");

// 이미 존재하는 요소 노드를 취득
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>

노드 복사

Node.prototype.cloneNode([deep: true | false]) 메서드는 노드의 사본을 생성하여 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li>Apple</li>
</ul>
</body>
<script>
const $fruits = document.getElementById("fruits");
const $apple = $fruits.firstElementChild;

// $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에는 이미 존재하는 교체될 노드를 인수로 전달한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!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 요소 노드의 첫 번째 자식 요소 노드를 $newChild 요소 노드로 교체
$fruits.replaceChild($newChild, $fruits.firstElementChild);
</script>
</html>

노드 삭제

Node.prototype.removeChild(child) 메서드는 child 매개변수에 인수로 전달한 노드를 DOM에서 삭제한다. 인수로 전달한 노드는 removeChild 메서드를 호출한 노드의 자식 노드이어야 한다. (여기서 삭제는 객체 프로퍼티 참조 값을 지우는 것을 말한다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li>Apple</li>
<li>Banana</li>
</ul>
</body>
<script>
const $fruits = document.getElementById("fruits");

// #fruits 요소 노드의 마지막 요소를 DOM에서 삭제
$fruits.removeChild($fruits.lastElementChild);
</script>
</html>

어트리뷰트

어트리뷰트 노드와 attributes 프로퍼티

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!DOCTYPE html>
<html>
<body>
<input id="foo" type="text" />
</body>
<script>
console.dir(document.getElementById("foo"));
/*
attributes: NamedNodeMap
0: id
1: type
length: 2
id: id
type: type
__proto__: NamedNodeMap
autocapitalize: ""
autocomplete: ""
autofocus: false
baseURI: "http://localhost:5500/index.html"
checked: false
childElementCount: 0
*/
</script>
</html>

요소 노드의 어트리뷰트 프로퍼티를 통해 요소 속성을 조작할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html>
<body>
<input id="foo" type="text" />
</body>
<script>
const $foo = document.getElementById("foo");
// console.dir($foo.attributes.id === $foo.attributes[0]); // true

// $foo.attributes.id.value = 'bar';
// console.dir($foo.attributes.id.value); // bar

console.log($foo.getAttribute("id")); // foo
console.log($foo.getAttribute("type")); // text

$foo.setAttribute("id", "bar");
$foo.setAttribute("type", "password");

console.log($foo.getAttribute("id")); // bar
console.log($foo.getAttribute("type")); // password
</script>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<body>
<input id="user" type="text" value="hun" />
</body>
<script>
const $input = document.getElementById("user");
console.log($input.value); // hun

$input.oninput = () => console.log($input.value);
</script>
</html>

oninput을 사용하여 console에 value 값을 실시간으로 출력할 수 있다.

Nyong’s GitHub

DOM

DOM 조작

innerHTML

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<body>
<div id="fruits">
<li>Apple</li>
</div>
</body>
<script>
const $fruits= document.getElementById('fruits');
$fruits.innerHTML += '<li>Banana</li>';
</script>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html>
<body>
<div id="fruits">
<li>Apple</li>
<li>Grape</li>
</div>
</body>
<script>
const $fruits = document.getElementById('fruits');
$fruits.innerHTML = '<li>Banana</li>' + $fruits.innerHTML;
</script>
</html>
하지만 위와 같이 innerHTML을 사용하며 재할당하게 되면 DOM을 다시 구성해야한다. 만약 `ul`안에 `li`가 많이 있을 경우 그 크기만큼의 DOM을 재구성하기 때문에 데이터 낭비로 직결될 수 있다. 모던 브라우저에서는 보안 이슈(크로스 사이트 스크립팅 공격)로 innerHTML에 스크립트 태그 할당을 금지한다.
1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<body>
<div id="foo">Hello</div>
</body>
<script>
// innerHTML 프로퍼티로 스크립트 태그를 삽입하여 자바스크립트가 실행되도록 한다.
// HTML5는 innerHTML 프로퍼티로 삽입된 script 요소 내의 자바스크립트 코드를 실행하지 않는다.
document.getElementById('foo').innerHTML
= '<script>alert(document.cookie)</script>';
</script>
</html>
하지만 script 요소 없이도 크로스 사이트 스크립팅 공격은 가능하다.
1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html>
<body>
<div id="foo">Hello</div>
</body>
<script>
// 에러 이벤트를 강제로 발생시켜서 자바스크립트 코드가 실행되도록 한다.
document.getElementById('foo').innerHTML
= `<img src="x" onerror="alert(document.cookie)">`;
</script>
</html>
innerHTML을 굳이 사용하려면 HTML 새니티제이션을 같이 사용해야한다. `DOMPurify.sanitize('<img src="x" onerror="alert(document.cookie)">'); // => <img src="x">`

insertAdjacentHTML 메서드

Element.prototype.insertAdjacentHTML(position, DOMString) 메서드는 기존 요소를 제거하지 않으면서 위치를 지정해 새로운 요소를 삽입한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<body>
<!-- beforebegin -->
<div id="foo">
<!-- afterbegin -->
text
<!-- beforeend -->
</div>
<!-- afterend -->
</body>
<script>
const $foo = document.getElementById('foo');

$foo.insertAdjacentHTML('beforebegin', '<p>beforebegin</p>');
$foo.insertAdjacentHTML('afterbegin', '<p>afterbegin</p>');
$foo.insertAdjacentHTML('beforeend', '<p>beforeend</p>');
$foo.insertAdjacentHTML('afterend', '<p>afterend</p>');
</script>
</html>
insertAdjacentHTML 메서드는 기존 요소에는 영향을 주지 않고 새롭게 삽입될 요소만을 파싱하여 자식 요소로 추가하므로 기존의 자식 노드를 모두 제거하고 다시 처음부터 새롭게 자식 노드를 생성하여 자식 요소로 추가하는 innerHTML 프로퍼티보다 효율적이고 빠르다.

노드 생성과 추가

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li>Apple</li>
</ul>
</body>
<script>
const $fruits = document.getElementById('fruits');

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

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

// 3. 텍스트 노드를 $li 요소 노드의 자식 노드로 추가
$li.appendChild(textNode);

// 4. $li 요소 노드를 #fruits 요소 노드의 마지막 자식 노드로 추가
$fruits.appendChild($li);
</script>
</html>
위 예제는 단 하나의 요소 노드를 생성하여 DOM에 한번 추가하므로 DOM은 한 번 변경된다. 이때 리플로우와 리페인트가 실행된다.

복수의 노드 생성과 추가

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html>
<body>
<ul id="fruits"></ul>
</body>
<script>
const $fruits = document.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을 변경하는 것은 높은 비용이 드는 처리이므로 가급적 횟수를 줄이는 편이 성능에 유리하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<!DOCTYPE html>
<html>
<body>
<ul id="fruits"></ul>
</body>
<script>
const $fruits = document.getElementById('fruits');

// 컨테이너 요소 노드 생성
const $container = document.createElement('div');

['Apple', 'Banana', 'Orange'].forEach(text => {
// 1. 요소 노드 생성
const $li = document.createElement('li');

// 2. 텍스트 노드 생성
const textNode = document.createTextNode(text);

// 3. 텍스트 노드를 $li 요소 노드의 자식 노드로 추가
$li.appendChild(textNode);

// 4. $li 요소 노드를 컨테이너 요소의 마지막 자식 노드로 추가
$container.appendChild($li);
});

// 5. 컨테이너 요소 노드를 #fruits 요소 노드의 마지막 자식 노드로 추가
$fruits.appendChild($container);
</script>
</html>
위 예제는 DOM을 한 번만 변경하므로 성능에 유리하기는 하지만 다음과 같이 불필요한 컨테이너 요소(div)가 DOM에 추가되는 부작용이 있다. 이는 바람직하지 않다.
1
2
3
4
5
6
7
<ul id="fruits">
<div>
<li>apple</li>
<li>banana</li>
<li>orange</li>
</div>
</ul>
**이러한 문제는 DocumentFragment 노드를 통해 해결할 수 있다.** 위의 `div` 를 대체하는 것이다. DocumentFragment는 appendChild를 사용할 때 사라진다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<!DOCTYPE html>
<html>
<body>
<ul id="fruits"></ul>
</body>
<script>
const $fruits = document.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. DocumentFragment 노드를 #fruits 요소 노드의 마지막 자식 노드로 추가
$fruits.appendChild($fragment);
</script>
</html>

노드 삽입

마지막 노드로 추가

appendChild 사용

지정한 위치에 노드 삽입

insertBefore 메서드는 첫 번째 인수로 전달받은 노드를 두 번째 인수로 전달받은 노드 앞에 삽입한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li>Apple</li>
<li>Banana</li>
</ul>
</body>
<script>
const $fruits = document.getElementById('fruits');

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

// 텍스트 노드를 $li 요소 노드의 마지막 자식 노드로 추가
$li.appendChild(document.createTextNode('Orange'));

// $li 요소 노드를 #fruits 요소 노드의 마지막 자식 요소 앞에 삽입
$fruits.insertBefore($li, $fruits.lastElementChild);
// Apple - Orange - Banana
</script>
</html>

노드 이동

DOM에 이미 존재하는 노드를 appendChild 또는 insertBefore 메서드를 사용하여 DOM에 다시 추가하면 현재 위치에서 노드를 제거하고 새로운 위치에 노드를 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li>Apple</li>
<li>Banana</li>
<li>Orange</li>
</ul>
</body>
<script>
const $fruits = document.getElementById("fruits");

// 이미 존재하는 요소 노드를 취득
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>

노드 복사

Node.prototype.cloneNode([deep: true | false]) 메서드는 노드의 사본을 생성하여 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li>Apple</li>
</ul>
</body>
<script>
const $fruits = document.getElementById("fruits");
const $apple = $fruits.firstElementChild;

// $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에는 이미 존재하는 교체될 노드를 인수로 전달한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!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 요소 노드의 첫 번째 자식 요소 노드를 $newChild 요소 노드로 교체
$fruits.replaceChild($newChild, $fruits.firstElementChild);
</script>
</html>

노드 삭제

Node.prototype.removeChild(child) 메서드는 child 매개변수에 인수로 전달한 노드를 DOM에서 삭제한다. 인수로 전달한 노드는 removeChild 메서드를 호출한 노드의 자식 노드이어야 한다. (여기서 삭제는 객체 프로퍼티 참조 값을 지우는 것을 말한다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li>Apple</li>
<li>Banana</li>
</ul>
</body>
<script>
const $fruits = document.getElementById("fruits");

// #fruits 요소 노드의 마지막 요소를 DOM에서 삭제
$fruits.removeChild($fruits.lastElementChild);
</script>
</html>

어트리뷰트

어트리뷰트 노드와 attributes 프로퍼티

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!DOCTYPE html>
<html>
<body>
<input id="foo" type="text" />
</body>
<script>
console.dir(document.getElementById("foo"));
/*
attributes: NamedNodeMap
0: id
1: type
length: 2
id: id
type: type
__proto__: NamedNodeMap
autocapitalize: ""
autocomplete: ""
autofocus: false
baseURI: "http://localhost:5500/index.html"
checked: false
childElementCount: 0
*/
</script>
</html>

요소 노드의 어트리뷰트 프로퍼티를 통해 요소 속성을 조작할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html>
<body>
<input id="foo" type="text" />
</body>
<script>
const $foo = document.getElementById("foo");
// console.dir($foo.attributes.id === $foo.attributes[0]); // true

// $foo.attributes.id.value = 'bar';
// console.dir($foo.attributes.id.value); // bar

console.log($foo.getAttribute("id")); // foo
console.log($foo.getAttribute("type")); // text

$foo.setAttribute("id", "bar");
$foo.setAttribute("type", "password");

console.log($foo.getAttribute("id")); // bar
console.log($foo.getAttribute("type")); // password
</script>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<body>
<input id="user" type="text" value="hun" />
</body>
<script>
const $input = document.getElementById("user");
console.log($input.value); // hun

$input.oninput = () => console.log($input.value);
</script>
</html>

oninput을 사용하여 console에 value 값을 실시간으로 출력할 수 있다.

HTML 어트리뷰트 vs. DOM 프로퍼티

요소 노드 객체에는 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 프로퍼티 값도 변하고 그 반대도 마찬가지다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <!DOCTYPE html>
    <html>
    <body>
    <input id="user" type="text" value="ungmo2" />
    <script>
    const $input = document.getElementById("user");

    // id 어트리뷰트와 id 프로퍼티는 사용자 입력과 관계없이 항상 동일한 값으로 연동한다.
    $input.id = "foo";

    console.log($input.id); // foo
    console.log($input.getAttribute("id")); // foo
    </script>
    </body>
    </html>

    이처럼 사용자 입력에 의한 상태 변화와 관계있는 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).

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html>
<body>
<ul class="todos">
<li id="1" data-user-id="1">HTML</li>
<li id="2" data-user-id="2">CSS</li>
<li id="2" data-user-id="3">JS</li>
</ul>
<script>
document.querySelector(".todos > li").dataset.customerId = "0"; // 해당 어트리뷰트가 없으면 생성한다.
console.log(document.querySelector(".todos > li").dataset.userId); // 1
</script>
</body>
</html>

스타일

인라인 스타일 조작

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html>
<body>
<ul class="todos">
<li id="1">HTML</li>
<li id="2">CSS</li>
<li id="2">JS</li>
</ul>
<script>
document.querySelector(".todos > li").style.color = "red";
</script>
</body>
</html>

클래스 조작

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html>
<head>
<style>
.red {
color: red;
}
.big {
font-size: 10em;
}
</style>
</head>
<body>
<ul class="todos">
<li id="1">HTML</li>
<li id="2">CSS</li>
<li id="2">JS</li>
</ul>
<script>
document.querySelector(".todos > li").className = "red big";
</script>
</body>
</html>

className은 문자열로 class를 보관하기 때문에 유사배열 객체로 구성된 classList가 사용하기 더 편리하다. classList는 메서드도 제공한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<!DOCTYPE html>
<html>
<head>
<style>
.red {
color: red;
}
.big {
font-size: 10em;
}
.small {
font-size: 1em;
}
</style>
</head>
<body>
<ul class="todos">
<li id="1">HTML</li>
<li id="2">CSS</li>
<li id="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 메서드를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<!DOCTYPE html>
<html>
<head>
<style>
body {
color: red;
}
.box {
width: 100px;
height: 50px;
background-color: cornsilk;
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>

getComputedStyle 메서드의 두 번째 인수(pseudo)로 :after, :before와 같은 의사 요소 를 지정하는 문자열을 전달할 수 있다. 의사 요소가 아닌 일반 요소의 경우 두 번째 인수는 생략한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<head>
<style>
.box:before {
content: "Hello";
}
</style>
</head>
<body>
<div class="box">Box</div>
<script>
const $box = document.querySelector(".box");

// 의사 요소 :before의 스타일을 취득한다.
const computedStyle = window.getComputedStyle($box, ":before");
console.log(computedStyle.content); // "Hello"
</script>
</body>
</html>

참고 도서: 모던 자바스크립트 Deep Dive

Nyong’s GitHub

DOM

노드

HTML 요소와 노드 객체

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>
<li id="apple">Apple</li>
<li id="banana">Banana</li>
<li id="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>
<div id="foo"></div>
<script>
// id 값과 동일한 이름의 전역 변수가 암묵적으로 선언되고 해당 노드 객체가 할당된다.
console.log(foo === document.getElementById("foo")); // true

// 암묵적 전역으로 생성된 전역 프로퍼티는 삭제되지만 전역 변수는 삭제되지 않는다.
delete foo;
console.log(foo); // <div id="foo"></div>
</script>
</body>
</html>

단, id 값과 동일한 이름의 전역 변수가 이미 선언되어 있으면 이 전역 변수에 노드 객체가 재할당되지 않는다.

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<body>
<div id="foo"></div>
<script>
let foo = 1;

// id 값과 동일한 이름의 전역 변수가 이미 선언되어 있으면 노드 객체가 재할당되지 않는다.
console.log(foo); // 1
</script>
</body>
</html>

태그 이름을 이용한 요소 노드 취득

Document.prototype/Element.prototype.getElementsByTagName 메서드는 인수로 전달한 태그 이름을 갖는 모든 요소 노드들을 탐색하여 여러 개의 요소 노드 객체를 갖는 DOM 컬렉션 객체인 HTMLCollection 객체(유사 배열 객체이면서 이터러블)를 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html>
<body>
<ul>
<li id="apple">Apple</li>
<li id="banana">Banana</li>
<li id="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>

HTML 문서의 모든 요소 노드를 취득하려면 태그 이름을 *로 지정한다.

1
2
3
// 모든 요소 노드를 탐색하여 반환한다.
const $all = document.getElementsByTagName("*");
// -> HTMLCollection(8)&nbsp;[html, head, body, ul, li#apple, li#banana, li#orange, script, apple: li#apple, banana: li#banana, orange: li#orange]

getElementsByTagName 메서드는 Document.prototype에 정의된 메서드와 Element.prototype에 정의된 메서드가 있다. Document.prototype.getElementsByTagName 메서드는 DOM의 루트 노드인 문서 노드, 즉 document를 통해 호출하며 DOM 전체에서 요소 노드를 탐색하여 반환한다. 하지만 Element.prototype.getElementsByTagName 메서드는 특정 요소 노드를 통해 호출하며, 특정 요소 노드의 자손 노드 중에서 요소 노드를 탐색하여 반환한다. 만약 태그 이름을 갖는 요소가 존재하지 않는 경우 getElementsByTagName 메서드는 빈 HTMLCollection 객체를 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html>
<body>
<ul id="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 객체를 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!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 메서드는 특정 요소 노드를 통해 호출하며 특정 요소 노드의 자손 노드 중에서 요소 노드를 탐색하여 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li class="apple">Apple</li>
<li class="banana">Banana</li>
<li class="orange">Orange</li>
</ul>
<div class="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");

console.log($bananasFromFruits); // HTMLCollection [li.banana]
</script>
</body>
</html>

CSS 선택자를 이용한 요소 노드 취득

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>
<li class="apple">Apple</li>
<li class="banana">Banana</li>
<li class="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 객체를 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html>
<body>
<ul>
<li class="apple">Apple</li>
<li class="banana">Banana</li>
<li class="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 선택자를 통해 특정 요소 노드를 취득할 수 있는지 확인한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li class="apple">Apple</li>
<li class="banana">Banana</li>
<li class="orange">Orange</li>
</ul>
</body>
<script>
const $apple = document.querySelector(".apple");

// $apple 노드는 '#fruits > li.apple'로 취득할 수 있다.
console.log($apple.matches("#fruits > li.apple")); // true

// $apple 노드는 '#fruits > li.banana'로 취득할 수 없다.
console.log($apple.matches("#fruits > li.banana")); // false
</script>
</html>

HTMLCollection과 NodeList

DOM API가 여러 개의 결과값을 반환하기 위한 DOM 컬렉션 객체인 HTMLCollection과 NodeList는 모두 유사 배열 객체이면서 이터러블이다.

HTMLCollection

HTMLCollection 객체는 노드 객체의 상태 변화를 실시간으로 반영하는 살아 있는(live) DOM 컬렉션 객체다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!DOCTYPE html>
<head>
<style>
.red {
color: red;
}
.blue {
color: blue;
}
</style>
</head>
<html>
<body>
<ul id="fruits">
<li class="red">Apple</li>
<li class="red">Banana</li>
<li class="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번 반복된다.

  1. 첫 번째 반복 (i === 0)
    $elems[0]은 첫 번째 li 요소다. 이 요소는 className 프로퍼티에 의해 class 값이 ‘red’에서 ‘blue’로 변경된다. 이때 첫 번째 li 요소는 class 값이 ‘red’에서 ‘blue’로 변경되었으므로 getElementsByClassName 메서드의 인자로 전달한 ‘red’와 더는 일치하지 않기 때문에 $elems에서 실시간으로 제거된다. 이처럼 HTMLCollection 객체는 실시간으로 노드 객체의 상태 변경을 반영하는 살아 있는(live) DOM 컬렉션 객체다.

  2. 두 번째 반복 (i === 1)
    첫 번째 반복에서 첫 번째 li 요소는 $elems에서 제거되었다. 따라서 $elems[1]은 세 번째 li 요소다. 이 세 번째 li 요소의 class 값도 ‘blue’로 변경되고 마찬가지로 HTMLCollection 객체인 $elems에서 실시간으로 제외된다.

  3. 세 번째 반복 (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) 객체다.

1
2
3
4
5
// querySelectorAll은 DOM 컬렉션 객체인 NodeList를 반환한다.
const $elems = document.querySelectorAll(".red");

// NodeList 객체는 NodeList.prototype.forEach 메서드를 상속받아 사용할 수 있다.
$elems.forEach((elem) => (elem.className = "blue"));

NodeList 객체는 대부분 non-live 객체로 동작하지만, childNodes 프로퍼티가 반환하는 NodeList 객체는 live 객체로 동작한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li>Apple</li>
<li>Banana</li>
</ul>
</body>
<script>
const $fruits = document.getElementById("fruits");

// childNodes 프로퍼티는 NodeList 객체(live)를 반환한다.
const { childNodes } = $fruits;
console.log(childNodes instanceof NodeList); // true

// $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 메서드를 사용하여 간단히 배열로 변환할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li>Apple</li>
<li>Banana</li>
</ul>
</body>
<script>
const $fruits = document.getElementById("fruits");

// childNodes 프로퍼티는 NodeList 객체(live)를 반환한다.
const { childNodes } = $fruits;

// 스프레드 문법을 사용하여 NodeList 객체를 배열로 변환한다.
[...childNodes].forEach((childNode) => {
$fruits.removeChild(childNode);
});

// $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 프로퍼티는 요소 노드만 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li class="apple">Apple</li>
<li class="banana">Banana</li>
<li class="orange">Orange</li>
</ul>
</body>
<script>
// 노드 탐색의 기점이 되는 #fruits 요소 노드를 취득한다.
const $fruits = document.getElementById("fruits");

// #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>
<ul id="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>
<ul id="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>
<div id="foo">Hello</div>
<script>
// 요소 노드의 텍스트 노드는 firstChild 프로퍼티로 접근할 수 있다.
console.log(document.getElementById("foo").firstChild); // #text
</script>
</body>
</html>

부모 노드 탐색

부모 노드를 탐색하려면 Node.prototype.parentNode 프로퍼티를 사용한다. 텍스트 노드는 DOM 트리의 최종단 노드인 리프 노드(leaf node)이므로 부모 노드가 텍스트 노드인 경우는 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li class="apple">Apple</li>
<li class="banana">Banana</li>
<li class="orange">Orange</li>
</ul>
</body>
<script>
// 노드 탐색의 기점이 되는 .banana 요소 노드를 취득한다.
const $banana = document.querySelector(".banana");

// .banana 요소 노드의 부모 노드를 탐색한다.
console.log($banana.parentNode); // ul#fruits
</script>
</html>

형제 노드 탐색

단, 어트리뷰트 노드는 요소 노드와 연결되어 있지만 부모 노드가 같은 형제 노드가 아니기 때문에 반환되지 않는다. 즉, 아래 프로퍼티는 텍스트 노드 또는 요소 노드만 반환한다.

  • Node.prototype.previousSibling
    부모 노드가 같은 형제 노드 중에서 자신의 이전 형제 노드를 탐색하여 반환한다. previousSibling 프로퍼티가 반환하는 형제 노드는 요소 노드뿐만 아니라 텍스트 노드일 수도 있다.

  • Node.prototype.nextSibling
    부모 노드가 같은 형제 노드 중에서 자신의 다음 형제 노드를 탐색하여 반환한다. nextSibling 프로퍼티가 반환하는 형제 노드는 요소 노드뿐만 아니라 텍스트 노드일 수도 있다.

  • Element.prototype.previousElementSibling
    부모 노드가 같은 형제 요소 노드 중에서 자신의 이전 형제 요소 노드를 탐색하여 반환한다. previousElementSibling 프로퍼티는 요소 노드만 반환한다.

  • Element.prototype.nextElementSibling
    부모 노드가 같은 형제 요소 노드 중에서 자신의 다음 형제 요소 노드를 탐색하여 반환한다. nextElementSibling 프로퍼티는 요소 노드만 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<!DOCTYPE html>
<html>
<body>
<ul id="fruits">
<li class="apple">Apple</li>
<li class="banana">Banana</li>
<li class="orange">Orange</li>
</ul>
</body>
<script>
// 노드 탐색의 기점이 되는 #fruits 요소 노드를 취득한다.
const $fruits = document.getElementById("fruits");

// #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에 정의되어 있다.

    • Node.ELEMENT_NODE: 요소 노드 타입을 나타내는 상수 1을 반환
    • Node.TEXT_NODE: 텍스트 노드 타입을 나타내는 상수 3을 반환
    • Node.DOCUMENT_NODE: 문서 노드 타입을 나타내는 상수 9를 반환
  • Node.prototype.nodeName
    노드의 이름을 문자열로 반환한다.

    • 요소 노드: 대문자 문자열로 태그 이름(“UL”, “LI” 등)을 반환
    • 텍스트 노드: 문자열 “#text”를 반환
    • 문서 노드: 문자열 “#document”를 반환
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!DOCTYPE html>
<html>
<body>
<div id="foo">Hello</div>
</body>
<script>
// 문서 노드의 노드 정보를 취득한다.
console.log(document.nodeType); // 9
console.log(document.nodeName); // #document

// 요소 노드의 노드 정보를 취득한다.
const $foo = document.getElementById("foo");
console.log($foo.nodeType); // 1
console.log($foo.nodeName); // DIV

// 텍스트 노드의 노드 정보를 취득한다.
const $textNode = $foo.firstChild;
console.log($textNode.nodeType); // 3
console.log($textNode.nodeName); // #text
</script>
</html>

요소 노드의 텍스트 조작

nodeValue

Node.prototype.nodeValue 프로퍼티는 setter와 getter 모두 존재하는 접근자 프로퍼티다. 따라서 nodeValue 프로퍼티는 참조와 할당 모두 가능하다.

노드 객체의 nodeValue 프로퍼티를 참조하면 노드 객체의 값을 반환한다. 노드 객체의 값이란 텍스트 노드의 텍스트다. 따라서 텍스트 노드가 아닌 노드, 즉 문서 노드나 요소 노드의 nodeValue 프로퍼티를 참조하면 null을 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html>
<body>
<div id="foo">Hello</div>
</body>
<script>
// 문서 노드의 nodeValue 프로퍼티를 참조한다.
console.log(document.nodeValue); // null

// 요소 노드의 nodeValue 프로퍼티를 참조한다.
const $foo = document.getElementById("foo");
console.log($foo.nodeValue); // null

// 텍스트 노드의 nodeValue 프로퍼티를 참조한다.
const $textNode = $foo.firstChild;
console.log($textNode.nodeValue); // Hello
</script>
</html>

텍스트 노드의 nodeValue 프로퍼티에 값을 할당하면 텍스트 노드의 값, 즉 텍스트를 변경할 수 있다.

  1. 텍스트를 변경할 요소 노드를 취득한 다음, 취득한 요소 노드의 텍스트 노드를 탐색한다. 텍스트 노드는 요소 노드의 자식 노드이므로 firstChild 프로퍼티를 사용하여 탐색한다.
  2. 탐색한 텍스트 노드의 nodeValue 프로퍼티를 사용하여 텍스트 노드의 값을 변경한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html>
<body>
<div id="foo">Hello</div>
</body>
<script>
// 1. #foo 요소 노드의 자식 노드인 텍스트 노드를 취득한다.
const $textNode = document.getElementById("foo").firstChild;

// 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>
<div id="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>
<div id="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>
<!-- 요소 노드의 콘텐츠 영역에 다른 요소 노드가 없고 텍스트만 존재 -->
<div id="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>
<div id="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 프로퍼티보다 느리다.

참고 도서: 모던 자바스크립트 Deep Dive

Nyong’s GitHub

브라우저의 렌더링 과정

브라우저는 다음과 같은 과정을 거쳐 렌더링을 수행한다.

  1. 브라우저는 HTML, CSS, 자바스크립트, 이미지, 폰트 파일 등 렌더링에 필요한 리소스를 요청하고 서버로부터 응답을 받는다.
  2. 브라우저의 렌더링 엔진은 서버로부터 응답된 HTML과 CSS를 파싱하여 DOM과 CSSOM을 생성하고 이들을 결합하여 렌더 트리를 생성한다.
  3. 브라우저의 자바스크립트 엔진은 서버로부터 응답된 자바스크립트를 파싱하여 AST(Abstract Syntax Tree)를 생성하고 바이트코드로 변환하여 실행한다. 이때 자바스크립트는 DOM API를 통해 DOM이나 CSSOM을 변경할 수 있다. 변경된 DOM과 CSSOM은 다시 렌더 트리로 결합된다.
  4. 렌더 트리를 기반으로 HTML 요소의 레이아웃(위치와 크기)을 계산하고 브라우저의 화면에 HTML 요소를 페인팅한다.

요청과 응답

브라우저의 핵심 기능은 필요한 리소스(HTML, CSS, 자바스크립트, 이미지, 폰트 등의 정적 파일 또는 서버가 동적으로 생성한 데이터)를 서버에 요청(request)하고 서버로부터 응답(response) 받아 브라우저에 시각적으로 렌더링하는 것이다.

브라우저의 주소창에 https://poiemaweb.com을 입력하고 엔터 키를 누르면 루트 요청(/, 스킴(scheme)과 호스트(host) 만으로 구성된 URI에 의한 요청)이 poiemaweb.com 서버로 전송된다. 루트 요청에는 명확히 리소스를 요청하는 내용이 없지만 일반적으로 서버는 루트 요청에 대해 암묵적으로 index.html을 응답하도록 기본 설정되어 있다. 즉, https://poiemaweb.com은 https://poiemaweb.com/index.html과 같은 요청이다.

만약 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)을 생성한다.

  1. 서버에 존재하던 HTML 파일이 브라우저의 요청에 의해 응답된다. 이때 서버는 브라우저가 요청한 HTML 파일을 읽어 들여 메모리에 저장한 다음 메모리에 저장된 바이트(2진수)를 인터넷을 경유하여 응답한다.
  2. 브라우저는 서버가 응답한 HTML 문서를 바이트(2진수) 형태로 응답받는다. 그리고 바이트 형태의 HTML 문서는 meta 태그의 charset 어트리뷰트에 의해 지정된 인코딩 방식(예: UTF-8)을 기준으로 문자열로 변환된다. 참고로 meta 태그의 charset 어트리뷰트에 선언된 인코딩 방식(예: UTF-8)은 content-type: text/html; charset=utf-8과 같이 응답 헤더(response header)에 담겨 응답된다. 브라우저는 이를 확인하고 문자열로 변환한다.
  3. 문자열로 변환된 HTML 문서를 읽어 들여 문법적 의미를 갖는 코드의 최소 단위인 토큰(token)들로 분해한다.
  4. 각 토큰들을 객체로 변환하여 노드(node)들을 생성한다. 토큰의 내용에 따라 문서 노드, 요소 노드, 어트리뷰트 노드, 텍스트 노드가 생성된다. 노드는 이후 DOM을 구성하는 기본 요소가 된다.
  5. 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의 생성이 완료되지 않은 상태라면 문제가 발생할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<link rel="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>
<li id="apple">Apple</li>
<li id="banana">Banana</li>
<li id="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 요소들의 렌더링에 지장받는 일이 발생하지 않아 페이지 로딩 시간이 단축된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<ul>
<li id="apple">Apple</li>
<li id="banana">Banana</li>
<li id="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 어트리뷰트가 없는 인라인 자바스크립트에는 사용할 수 없다.

1
2
<script async src="extern.js"></script>
<script defer src="extern.js"></script>

async와 defer 어트리뷰트를 사용하면 HTML 파싱과 외부 자바스크립트 파일의 로드가 비동기적(asynchronous)으로 동시에 진행된다. 하지만 자바스크립트의 실행 시점에 차이가 있다.

  • async 어트리뷰트
    HTML 파싱과 외부 자바스크립트 파일의 로드가 비동기적으로 동시에 진행된다. 단, 자바스크립트의 파싱과 실행은 자바스크립트 파일의 로드가 완료된 직후 진행되며, 이때 HTML 파싱이 중단된다.

여러 개의 script 태그에 async 어트리뷰트를 지정하면 script 태그의 순서와는 상관없이 로드가 완료된 자바스크립트부터 먼저 실행되므로 순서가 보장되지 않는다. 따라서 순서 보장이 필요한 script 태그에는 async 어트리뷰트를 지정하지 않아야 한다.

  • defer 어트리뷰트
    async 어트리뷰트와 마찬가지로 HTML 파싱과 외부 자바스크립트 파일의 로드가 비동기적으로 동시에 진행된다. 단, 자바스크립트의 파싱과 실행은 HTML 파싱이 완료된 직후, 즉 DOM 생성이 완료된 직후(이때 DOMContentLoaded 이벤트가 발생한다) 진행된다. 따라서 DOM 생성이 완료된 이후 실행되어야 할 자바스크립트에 유용하다.

참고 도서: 모던 자바스크립트 Deep Dive

Nyong’s GitHub

오늘 배운 것

String

replace

1
2
3
4
const str = "Hello world";

// 특수한 교체 패턴을 사용할 수 있다. ($& => 검색된 문자열)
str.replace("world", "<strong>$&</strong>"); // Hello <strong>world</strong>

$& 는 replace를 이용하여 검색한 문자열을 의미한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 카멜 케이스를 스네이크 케이스로 변환하는 함수
function camelToSnake(camelCase) {
// /.[A-Z]/g는 임의의 한 문자와 대문자로 이루어진 문자열에 매치한다.
// 치환 함수의 인수로 매치 결과가 전달되고, 치환 함수가 반환한 결과와 매치 결과를 치환한다.
return camelCase.replace(/.[A-Z]/g, (match) => {
console.log(match); // 'oW'
return match[0] + "_" + match[1].toLowerCase();
});
}

const camelCase = "helloWorld";
camelToSnake(camelCase); // -> 'hello_world'

// 스네이크 케이스를 카멜 케이스로 변환하는 함수
function snakeToCamel(snakeCase) {
// /_[a-z]/g는 _와 소문자로 이루어진 문자열에 매치한다.
// 치환 함수의 인수로 매치 결과가 전달되고, 치환 함수가 반환한 결과와 매치 결과를 치환한다.
return snakeCase.replace(/_[a-z]]/g, (match) => {
console.log(match); // '_w'
return match[1].toUpperCase();
});
}

const snakeCase = "hello_world";
snakeToCamel(snakeCase); // -> 'helloWorld'

split

문자열 또는 정규 표현식을 검색하여 문자열을 구분한 후 분리된 각 문자열로 이루어진 배열을 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
const str = "How are you doing?";

// 공백으로 구분(단어로 구분)하여 배열로 반환한다.
str.split(" "); // -> ["How", "are", "you", "doing?"]

// \s는 여러 가지 공백 문자(스페이스, 탭 등)를 의미한다. 즉, [\t\r\n\v\f]와 같은 의미다.
str.split(/\s/); // -> ["How", "are", "you", "doing?"]

// 인수로 빈 문자열을 전달하면 각 문자를 모두 분리한다.
str.split(""); // -> ["H", "o", "w", " ", "a", "r", "e", " ", "y", "o", "u", " ", "d", "o", "i", "n", "g", "?"]

// 인수를 생략하면 대상 문자열 전체를 단일 요소로 하는 배열을 반환한다.
str.split(); // -> ["How are you doing?"]

두 번째 인수로 배열의 길이를 지정할 수 있다.

1
2
// 공백으로 구분하여 배열로 반환한다. 단, 배열의 길이는 3이다
str.split(" ", 3); // -> ["How", "are", "you"]

문자열을 역순으로 뒤집을 수 있다.

1
2
3
4
5
6
// 인수로 전달받은 문자열을 역순으로 뒤집는다.
function reverseString(str) {
return str.split("").reverse().join("");
}

reverseString("Hello world!"); // -> '!dlrow olleH'

숫자로 이루어진 문자열을 나눠 합을 구하는 함수

1
2
3
4
5
const num = 1234;

const sum = (n) => (n + "").split("").reduce((p, c) => p + +c, 0);

console.log(sum(num));

화살표 함수를 이용한 인수를 모두 더하는 가변인자 함수

1
2
3
4
// 화살표 함수는 arg가 없음
const sum = (...args) => args.reduce((p, c) => p + c, 0);

console.log(sum(1, 2, 3));

스프레드 문법

스프레드 문법을 사용할 수 있는 대상은 Array, String, Map, Set, DOM 컬렉션(NodeList, HTMLCollection), arguments와 같이 for…of 문으로 순회할 수 있는 이터러블에 한정된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ...[1, 2, 3]은 [1, 2, 3]을 개별 요소로 분리한다(→ 1, 2, 3)
console.log(...[1, 2, 3]); // 1 2 3

// 문자열은 이터러블이다.
console.log(...'Hello'); // H e l l o

// Map과 Set은 이터러블이다.
console.log(...new Map([['a', '1'], ['b', '2']])); // [ 'a', '1' ] [ 'b', '2' ]
console.log(...new Set([1, 2, 3])); // 1 2 3

// 이터러블이 아닌 일반 객체는 스프레드 문법의 대상이 될 수 없다.
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

배열 리터럴 내부에서 사용하는 경우

concat

1
2
3
4
5
6
7
// ES5
var arr = [1, 2].concat([3, 4]);
console.log(arr); // [1, 2, 3, 4]

// ES6
const arr = [...[1, 2], ...[3, 4]];
console.log(arr); // [1, 2, 3, 4]

splice

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ES5
var arr1 = [1, 4];
var arr2 = [2, 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]

// ES6
const arr1 = [1, 4];
const arr2 = [2, 3];

arr1.splice(1, 0, ...arr2);
console.log(arr1); // [1, 2, 3, 4]

객체 리터럴 내부에서 사용하는 경우

1
2
3
4
5
6
7
8
9
10
// 스프레드 프로퍼티
// 객체 복사(얕은 복사)
const obj = { x: 1, y: 2 };
const copy = { ...obj };
console.log(copy); // { x: 1, y: 2 }
console.log(obj === copy); // false

// 객체 병합
const merged = { x: 1, y: 2, ...{ a: 3, b: 4 } };
console.log(merged); // { x: 1, y: 2, a: 3, b: 4 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ES5 Object.assign 사용
// assign 인수로 빈 객체를 주지 않으면 부수효과로 첫 번째 객체를 변경함.
// 객체 병합. 프로퍼티가 중복되는 경우, 뒤에 위치한 프로퍼티가 우선권을 갖는다.
const merged = Object.assign({}, { x: 1, y: 2 }, { y: 10, z: 3 });
console.log(merged); // { x: 1, y: 10, z: 3 }

// 특정 프로퍼티 변경
const changed = Object.assign({}, { x: 1, y: 2 }, { y: 100 });
console.log(changed); // { x: 1, y: 100 }

// 프로퍼티 추가
const added = Object.assign({}, { x: 1, y: 2 }, { z: 0 });
console.log(added); // { x: 1, y: 2, z: 0 }

// 스프레드 프로퍼티
// 객체 병합. 프로퍼티가 중복되는 경우, 뒤에 위치한 프로퍼티가 우선권을 갖는다.
const merged = { ...{ x: 1, y: 2 }, ...{ y: 10, z: 3 } };
console.log(merged); // { x: 1, y: 10, z: 3 }

// 특정 프로퍼티 변경
const changed = { ...{ x: 1, y: 2 }, y: 100 };
// changed = { ...{ x: 1, y: 2 }, ...{ y: 100 } }
console.log(changed); // { x: 1, y: 100 }

// 프로퍼티 추가
const added = { ...{ x: 1, y: 2 }, z: 0 };
// added = { ...{ x: 1, y: 2 }, ...{ z: 0 } }
console.log(added); // { x: 1, y: 2, z: 0 }

디스트럭처링 할당

배열 디스트럭처링 할당

배열 디스트럭처링 할당의 기준은 배열의 인덱스다.

1
2
3
4
5
6
7
8
9
10
11
const [a, b] = [1, 2];
console.log(a, b); // 1 2

const [c, d] = [1];
console.log(c, d); // 1 undefined

const [e, f] = [1, 2, 3];
console.log(e, f); // 1 2

const [g, , h] = [1, 2, 3];
console.log(g, h); // 1 3

객체 디스트럭처링 할당

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

객체의 프로퍼티 키와 다른 변수 이름으로 프로퍼티 값을 할당받으려면 다음과 같이 변수를 선언한다.

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(fn, ln); // Ungmo Lee

객체 디스트럭처링 할당을 위한 변수에 기본값을 설정할 수 있다.

1
2
3
4
5
const { firstName = "Ungmo", lastName } = { lastName: "Lee" };
console.log(firstName, lastName); // Ungmo Lee

const { firstName: fn = "Ungmo", lastName: ln } = { lastName: "Lee" };
console.log(fn, ln); // Ungmo Lee

객체 디스트럭처링 할당은 객체에서 프로퍼티 키로 필요한 프로퍼티 값만 추출하여 변수에 할당하고 싶을 때 유용하다.

1
2
3
4
5
6
7
8
9
const str = "Hello";
// String 래퍼 객체로부터 length 프로퍼티만 추출한다.
const { length } = str;
console.log(length); // 5

const todo = { id: 1, content: "HTML", completed: true };
// todo 객체로부터 id 프로퍼티만 추출한다.
const { id } = todo;
console.log(id); // 1

객체 디스트럭처링 할당은 객체를 인수로 전달받는 함수의 매개변수에도 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function printTodo(todo) {
console.log(
`할일 ${todo.content}${todo.completed ? "완료" : "비완료"} 상태입니다.`
);
}

printTodo({ id: 1, content: "HTML", completed: true });
// 할일 HTML은 완료 상태입니다.

// 객체를 인수로 전달받는 매개변수 todo에 객체 디스트럭처링 할당을 사용할 수 있다.
function printTodo({ content, completed }) {
console.log(`할일 ${content}${completed ? "완료" : "비완료"} 상태입니다.`);
}

printTodo({ id: 1, content: "HTML", completed: true });
// 할일 HTML은 완료 상태입니다.

배열의 요소가 객체인 경우 배열 디스트럭처링 할당과 객체 디스트럭처링 할당을 혼용할 수 있다.

1
2
3
4
5
6
7
8
9
const todos = [
{ id: 1, content: "HTML", completed: true },
{ id: 2, content: "CSS", completed: false },
{ id: 3, content: "JS", completed: false },
];

// todos 배열의 두 번째 요소인 객체로부터 id 프로퍼티만 추출한다.
const [, { id }] = todos;
console.log(id); // 2

중첩 객체의 경우는 다음과 같이 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
const user = {
name: "Lee",
address: {
zipCode: "03068",
city: "Seoul",
},
};

// address 프로퍼티 키로 객체를 추출하고 이 객체의 city 프로퍼티 키로 값을 추출한다.
const {
address: { city },
} = user;
console.log(city); // 'Seoul'

객체 디스트럭처링 할당을 중첩한 것이다.

Nyong’s GitHub

디스트럭처링 할당

배열과 같은 이터러블 또는 객체를 destructuring(비구조화, 구조 파괴)하여 1개 이상의 변수에 개별적으로 할당하는 것을 말한다.

배열 디스트럭처링 할당

ES6의 배열 디스트럭처링 할당은 배열의 각 요소를 배열로부터 추출하여 1개 이상의 변수에 할당한다. 이때 배열 디스트럭처링 할당의 대상(할당문의 우변)은 이터러블이어야 하며, 할당 기준은 배열의 인덱스다. 즉, 순서대로 할당된다.

1
2
3
4
5
6
7
8
const arr = [1, 2, 3];

// ES6 배열 디스트럭처링 할당
// 변수 one, two, three를 선언하고 배열 arr을 디스트럭처링하여 할당한다.
// 이때 할당 기준은 배열의 인덱스다.
const [one, two, three] = arr;

console.log(one, two, three); // 1 2 3

배열 디스트럭처링 할당을 위해서는 할당 연산자 왼쪽에 값을 할당받을 변수를 선언해야 한다. 이때 변수를 배열 리터럴 형태로 선언한다.

1
2
3
4
5
6
7
8
9
const [x, y] = [1, 2];

// const로 선언할 수 없어 권장하지 않는다.
let x, y;
[x, y] = [1, 2];

const [x, y]; // SyntaxError: Missing initializer in destructuring declaration

const [a, b] = {}; // TypeError: {} is not iterable

배열 디스트럭처링 할당의 기준은 배열의 인덱스다. 즉, 순서대로 할당된다. 이때 변수의 개수와 이터러블의 요소 개수가 반드시 일치할 필요는 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const [a, b] = [1, 2];
console.log(a, b); // 1 2

const [c, d] = [1];
console.log(c, d); // 1 undefined

const [e, f] = [1, 2, 3];
console.log(e, f); // 1 2

const [g, , h] = [1, 2, 3];
console.log(g, h); // 1 3

// 기본값
const [a, b, c = 3] = [1, 2];
console.log(a, b, c); // 1 2 3

// 기본값보다 할당된 값이 우선한다.
const [e, f = 10, g = 3] = [1, 2];
console.log(e, f, g); // 1 2 3

배열 디스트럭처링 할당을 위한 변수에 Rest 파라미터와 유사하게 Rest 요소(Rest element) …을 사용할 수 있다. Rest 요소는 Rest 파라미터와 마찬가지로 반드시 마지막에 위치해야 한다.

1
2
3
// Rest 요소
const [x, ...y] = [1, 2, 3];
console.log(x, y); // 1 [ 2, 3 ]

객체 디스트럭처링 할당

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

배열 디스트럭처링 할당과 마찬가지로 객체 디스트럭처링 할당을 위해서는 할당 연산자 왼쪽에 프로퍼티 값을 할당받을 변수를 선언해야 한다. 이때 변수를 객체 리터럴 형태로 선언한다.

1
2
3
4
5
6
7
const { lastName, firstName } = { firstName: "Ungmo", lastName: "Lee" };

const { lastName, firstName };
// SyntaxError: Missing initializer in destructuring declaration

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(fn, ln); // Ungmo Lee

객체 디스트럭처링 할당을 위한 변수에 기본값을 설정할 수 있다.

1
2
3
4
5
const { firstName = "Ungmo", lastName } = { lastName: "Lee" };
console.log(firstName, lastName); // Ungmo Lee

const { firstName: fn = "Ungmo", lastName: ln } = { lastName: "Lee" };
console.log(fn, ln); // Ungmo Lee

객체 디스트럭처링 할당은 객체에서 프로퍼티 키로 필요한 프로퍼티 값만 추출하여 변수에 할당하고 싶을 때 유용하다.

1
2
3
4
5
6
7
8
9
const str = "Hello";
// String 래퍼 객체로부터 length 프로퍼티만 추출한다.
const { length } = str;
console.log(length); // 5

const todo = { id: 1, content: "HTML", completed: true };
// todo 객체로부터 id 프로퍼티만 추출한다.
const { id } = todo;
console.log(id); // 1

객체 디스트럭처링 할당은 객체를 인수로 전달받는 함수의 매개변수에도 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
function printTodo(todo) {
console.log(
`할일 ${todo.content}${todo.completed ? "완료" : "비완료"} 상태입니다.`
);
}

// 객체 디스트럭처링 할당
function printTodo({ content, completed }) {
console.log(`할일 ${content}${completed ? "완료" : "비완료"} 상태입니다.`);
}

printTodo({ id: 1, content: "HTML", completed: true });
// 할일 HTML은 완료 상태입니다.

배열의 요소가 객체인 경우 배열 디스트럭처링 할당과 객체 디스트럭처링 할당을 혼용할 수 있다.

1
2
3
4
5
6
7
8
9
const todos = [
{ id: 1, content: "HTML", completed: true },
{ id: 2, content: "CSS", completed: false },
{ id: 3, content: "JS", completed: false },
];

// todos 배열의 두 번째 요소인 객체로부터 id 프로퍼티만 추출한다.
const [, { id }] = todos;
console.log(id); // 2

중첩 객체의 경우는 다음과 같이 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
const user = {
name: "Lee",
address: {
zipCode: "03068",
city: "Seoul",
},
};

// address 프로퍼티 키로 객체를 추출하고 이 객체의 city 프로퍼티 키로 값을 추출한다.
const {
address: { city },
} = user;
console.log(city); // 'Seoul'

객체 디스트럭처링 할당을 위한 변수에 Rest 파라미터나 Rest 요소와 유사하게 Rest 프로퍼티 …을 사용할 수 있다. Rest 프로퍼티는 Rest 파라미터나 Rest 요소와 마찬가지로 반드시 마지막에 위치해야 한다.

1
2
3
// Rest 프로퍼티
const { x, ...rest } = { x: 1, y: 2, z: 3 };
console.log(x, rest); // 1 { y: 2, z: 3 }

참고 도서: 모던 자바스크립트 Deep Dive

Nyong’s GitHub

스프레드 문법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ...[1, 2, 3]은 [1, 2, 3]을 개별 요소로 분리한다(→ 1, 2, 3)
console.log(...[1, 2, 3]); // 1 2 3

// 문자열은 이터러블이다.
console.log(..."Hello"); // H e l l o

// Map과 Set은 이터러블이다.
console.log(
...new Map([
["a", "1"],
["b", "2"],
])
); // [ 'a', '1' ] [ 'b', '2' ]
console.log(...new Set([1, 2, 3])); // 1 2 3

// 이터러블이 아닌 일반 객체는 스프레드 문법의 대상이 될 수 없다.
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 파라미터와 스프레드 문법은 서로 반대의 개념이다.

1
2
3
4
5
6
7
8
// Rest 파라미터는 인수들의 목록을 배열로 전달받는다.
function foo(...rest) {
console.log(rest); // 1, 2, 3 -> [ 1, 2, 3 ]
}

// 스프레드 문법은 배열과 같은 이터러블을 펼쳐서 개별적인 값들의 목록을 만든다.
// [1, 2, 3] -> 1, 2, 3
foo(...[1, 2, 3]);

배열 리터럴 내부에서 사용하는 경우

concat

1
2
3
4
5
6
7
// ES5
var arr = [1, 2].concat([3, 4]);
console.log(arr); // [1, 2, 3, 4]

// ES6
const arr = [...[1, 2], ...[3, 4]];
console.log(arr); // [1, 2, 3, 4]

splice

1
2
3
4
5
6
7
8
9
10
// ES5
var arr1 = [1, 4];
var arr2 = [2, 3];

// 세 번째 인수 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]
1
2
3
4
5
6
// ES6
const arr1 = [1, 4];
const arr2 = [2, 3];

arr1.splice(1, 0, ...arr2);
console.log(arr1); // [1, 2, 3, 4]

배열 복사

ES5에서 배열을 복사하려면 slice 메서드를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// ES5
var origin = [1, 2];
var copy = origin.slice();

console.log(copy); // [1, 2]
console.log(copy === origin); // false

// ES6
const origin = [1, 2];
const copy = [...origin];

console.log(copy); // [1, 2]
console.log(copy === origin); // false

이터러블을 배열로 변환

ES5에서 이터러블, 유사 배열 객체를 배열로 변환하려면 Function.prototype.apply 또는 Function.prototype.call 메서드를 사용하여 slice 메서드를 호출해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ES5
function sum() {
// 이터러블이면서 유사 배열 객체인 arguments를 배열로 변환
var args = Array.prototype.slice.call(arguments);

return args.reduce(function (pre, cur) {
return pre + cur;
}, 0);
}

console.log(sum(1, 2, 3)); // 6

// 이터러블이 아닌 유사 배열 객체
const arrayLike = {
0: 1,
1: 2,
2: 3,
length: 3,
};

const arr = Array.prototype.slice.call(arrayLike); // -> [1, 2, 3]
console.log(Array.isArray(arr)); // true

arguments 객체는 이터러블이면서 유사 배열 객체이다. 따라서 스프레드 문법의 대상이 될 수 있다.s

1
2
3
4
5
6
7
8
9
10
11
12
13
// ES6
function sum() {
// 이터러블이면서 유사 배열 객체인 arguments를 배열로 변환
return [...arguments].reduce((pre, cur) => pre + cur, 0);
}

console.log(sum(1, 2, 3)); // 6

// 위 예제보다 나은 방법은 Rest 파라미터를 사용하는 것이다.
// Rest 파라미터 args는 함수에 전달된 인수들의 목록을 배열로 전달받는다.
const sum = (...args) => args.reduce((pre, cur) => pre + cur, 0);

console.log(sum(1, 2, 3)); // 6

단, 이터러블이 아닌 유사 배열 객체는 스프레드 문법의 대상이 될 수 없다.

1
2
3
4
5
6
7
8
9
10
// 이터러블이 아닌 유사 배열 객체
const arrayLike = {
0: 1,
1: 2,
2: 3,
length: 3,
};

const arr = [...arrayLike];
// TypeError: object is not iterable (cannot read property Symbol(Symbol.iterator))

이터러블이 아닌 유사 배열 객체를 배열로 변경하려면 ES6에서 도입된 Array.from 메서드를 사용한다. Array.from 메서드는 유사 배열 객체 또는 이터러블을 인수로 전달받아 배열로 변환하여 반환한다.

1
2
// Array.from은 유사 배열 객체 또는 이터러블을 배열로 변환한다
Array.from(arrayLike); // -> [1, 2, 3]

객체 리터럴 내부에서 사용하는 경우

스프레드 문법의 대상은 이터러블이어야 하지만 스프레드 프로퍼티 제안은 일반 객체를 대상으로도 스프레드 문법의 사용을 허용한다.

1
2
3
4
5
6
7
8
9
10
// 스프레드 프로퍼티
// 객체 복사(얕은 복사)
const obj = { x: 1, y: 2 };
const copy = { ...obj };
console.log(copy); // { x: 1, y: 2 }
console.log(obj === copy); // false

// 객체 병합
const merged = { x: 1, y: 2, ...{ a: 3, b: 4 } };
console.log(merged); // { x: 1, y: 2, a: 3, b: 4 }

스프레드 프로퍼티가 제안되기 이전에는 ES6에서 도입된 Object.assign 메서드를 사용하여 여러 개의 객체를 병합하거나 특정 프로퍼티를 변경 또는 추가했다.

1
2
3
4
5
6
7
8
9
10
11
// 객체 병합. 프로퍼티가 중복되는 경우, 뒤에 위치한 프로퍼티가 우선권을 갖는다.
const merged = Object.assign({}, { x: 1, y: 2 }, { y: 10, z: 3 });
console.log(merged); // { x: 1, y: 10, z: 3 }

// 특정 프로퍼티 변경
const changed = Object.assign({}, { x: 1, y: 2 }, { y: 100 });
console.log(changed); // { x: 1, y: 100 }

// 프로퍼티 추가
const added = Object.assign({}, { x: 1, y: 2 }, { z: 0 });
console.log(added); // { x: 1, y: 2, z: 0 }

스프레드 프로퍼티는 Object.assign 메서드를 대체할 수 있는 간편한 문법이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// 객체 병합. 프로퍼티가 중복되는 경우, 뒤에 위치한 프로퍼티가 우선권을 갖는다.
const merged = { ...{ x: 1, y: 2 }, ...{ y: 10, z: 3 } };
console.log(merged); // { x: 1, y: 10, z: 3 }

// 특정 프로퍼티 변경
const changed = { ...{ x: 1, y: 2 }, y: 100 };
// changed = { ...{ x: 1, y: 2 }, ...{ y: 100 } }
console.log(changed); // { x: 1, y: 100 }

// 프로퍼티 추가
const added = { ...{ x: 1, y: 2 }, z: 0 };
// added = { ...{ x: 1, y: 2 }, ...{ z: 0 } }
console.log(added); // { x: 1, y: 2, z: 0 }

참고 도서: 모던 자바스크립트 Deep Dive

Nyong’s GitHub

Array HOF 연습 문제

1. html 생성

아래 배열을 사용하여 html을 생성하는 함수를 작성하라.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// forEach 활용
const todos = [
{
id: 3,
content: "HTML",
completed: false,
},
{
id: 2,
content: "CSS",
completed: true,
},
{
id: 1,
content: "Javascript",
completed: false,
},
];

function render() {
let html = "";

todos.forEach((todo) => {
html += `<li id = "${todo.id}">
<label><input type ="checkbox" ${todo.completed ? "checked" : ""}>${
todo.content
}</label>
</li>\n`;
});

return html;
}

console.log(render());

// map 활용
function render() {
return todos
.map((todo) => {
return `<li id = "${todo.id}">
<label><input type ="checkbox" ${todo.completed ? "checked" : ""}>${
todo.content
}</label>
</li>\n`;
})
.join("");
}

console.log(render());

/*
<li id="3">
<label><input type="checkbox">HTML</label>
</li>
<li id="2">
<label><input type="checkbox" checked>CSS</label>
</li>
<li id="1">
<label><input type="checkbox">Javascript</label>
</li>
*/

2. 특정 프로퍼티 값 추출

요소의 프로퍼티(id, content, completed)를 문자열 인수로 전달하면 todos의 각 요소 중, 해당 프로퍼티의 값만을 추출한 배열을 반환하는 함수를 작성하라.

단, for 문이나 Array#forEach는 사용하지 않도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const todos = [
{
id: 3,
content: "HTML",
completed: false,
},
{
id: 2,
content: "CSS",
completed: true,
},
{
id: 1,
content: "Javascript",
completed: false,
},
];

function getValues(key) {
return todos.map((todo) => todo[key]);
}

console.log(getValues("id")); // [3, 2, 1]
console.log(getValues("content")); // ['HTML', 'CSS', 'Javascript']
console.log(getValues("completed")); // [false, true, false]

3. 프로퍼티 정렬

요소의 프로퍼티(id, content, completed)를 문자열 인수로 전달하면 todos의 요소를 정렬하는 함수를 작성하라.

단, todos는 변경되지 않도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
const todos = [
{
id: 3,
content: "HTML",
completed: false,
},
{
id: 2,
content: "CSS",
completed: true,
},
{
id: 1,
content: "Javascript",
completed: false,
},
];

// 함수 선언문 활용
// function sortBy(key) {
// const _todos = [...todos];
// return _todos.sort((todo1, todo2) => todo1[key] > todo2[key] ? 1 : (todo1[key] < todo2[key] ? -1 : 0));
// }

// 화살표 함수 활용
const sortBy = (key) => {
const _todos = [...todos];
return _todos.sort((todo1, todo2) =>
todo1[key] > todo2[key] ? 1 : todo1[key] < todo2[key] ? -1 : 0
);
};

console.log(sortBy("id"));
/*
[
{ id: 1, content: 'Javascript', completed: false },
{ id: 2, content: 'CSS', completed: true },
{ id: 3, content: 'HTML', completed: false }
]
*/
console.log(sortBy("content"));
/*
[
{ id: 2, content: 'CSS', completed: true },
{ id: 3, content: 'HTML', completed: false },
{ id: 1, content: 'Javascript', completed: false }
]
*/
console.log(sortBy("completed"));
/*
[
{ id: 3, content: 'HTML', completed: false },
{ id: 1, content: 'Javascript', completed: false },
{ id: 2, content: 'CSS', completed: true }
]
*/

4. 새로운 요소 추가

새로운 요소(예를 들어 { id: 4, content: 'Test', completed: false })를 인수로 전달하면 todos의 선두에 새로운 요소를 추가하는 함수를 작성하라. 단, Array#unshift나 Array#push는 사용하지 않도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
let todos = [
{
id: 3,
content: "HTML",
completed: false,
},
{
id: 2,
content: "CSS",
completed: true,
},
{
id: 1,
content: "Javascript",
completed: false,
},
];

function addTodo(newTodo) {
todos = [newTodo].concat(todos);
}

// 스프레드 문법 사용
function addTodo(newTodo) {
todos = [newTodo, ...todos];
}

addTodo({
id: 4,
content: "Test",
completed: false,
});

console.log(todos);
/*
[
{ id: 4, content: 'Test', completed: false },
{ id: 3, content: 'HTML', completed: false },
{ id: 2, content: 'CSS', completed: true },
{ id: 1, content: 'Javascript', completed: false }
]
*/

5. 특정 요소 삭제

todos에서 삭제할 요소의 id를 인수로 전달하면 해당 요소를 삭제하는 함수를 작성하라.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
let todos = [
{
id: 3,
content: "HTML",
completed: false,
},
{
id: 2,
content: "CSS",
completed: true,
},
{
id: 1,
content: "Javascript",
completed: false,
},
];

// function removeTodo(id) {
// todos = todos.filter(todo => todo.id !== id)
// }

//화살표 함수
const removeTodo = (id) => {
todos = todos.filter((todo) => todo.id !== id);
};

removeTodo(2);

console.log(todos);
/*
[
{ id: 3, content: 'HTML', completed: false },
{ id: 1, content: 'Javascript', completed: false }
]
*/

6. 특정 요소의 프로퍼티 값 반전

todos에서 대상 요소의 id를 인수로 전달하면 해당 요소의 completed 프로퍼티 값을 반전하는 함수를 작성하라.

hint) 기존 객체의 특정 프로퍼티를 변경/추가하여 새로운 객체를 생성하려면 Object.assign 또는 스프레드 문법을 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
let todos = [
{ id: 3, content: "HTML", completed: false },
{ id: 2, content: "CSS", completed: true },
{ id: 1, content: "Javascript", completed: false },
];

function toggleCompletedById(id) {
const index = todos.findIndex((todo) => todo.id === id);

// 스프레드
return (todos[index] = todos[index].completed
? { ...todos[index], completed: false }
: { ...todos[index], completed: true });

//Object.assign
// return todos[index] = todos[index].completed ? Object.assign({}, todos[index], {completed: false}) : Object.assign({}, todos[index], {completed : true});
}

toggleCompletedById(2);

console.log(todos);
/*
[
{ id: 3, content: 'HTML', completed: false },
{ id: 2, content: 'CSS', completed: false },
{ id: 1, content: 'Javascript', completed: false }
]
*/

7. 모든 요소의 completed 프로퍼티 값을 true로 설정

todos의 모든 요소의 completed 프로퍼티 값을 true로 설정하는 함수를 작성하라.

hint) 기존 객체의 특정 프로퍼티를 변경/추가하여 새로운 객체를 생성하려면 Object.assign 또는 스프레드 문법을 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
let todos = [
{ id: 3, content: "HTML", completed: false },
{ id: 2, content: "CSS", completed: true },
{ id: 1, content: "Javascript", completed: false },
];

function toggleCompletedAll() {
// 스프레드
todos = todos.map((v) => ({ ...v, completed: true }));

// Object.assign
// todos = todos.map( v => Object.assign({}, v, {completed: true}));

return todos;
}

toggleCompletedAll();

console.log(todos);
/*
[
{ id: 3, content: 'HTML', completed: true },
{ id: 2, content: 'CSS', completed: true },
{ id: 1, content: 'Javascript', completed: true }
]
*/

8. completed 프로퍼티의 값이 true인 요소의 갯수 구하기

todos에서 완료(completed: true)한 할일의 갯수를 구하는 함수를 작성하라.

단, for 문, Array#forEach는 사용하지 않도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
let todos = [
{
id: 3,
content: "HTML",
completed: false,
},
{
id: 2,
content: "CSS",
completed: true,
},
{
id: 1,
content: "Javascript",
completed: false,
},
];

// function countCompletedTodos() {
// return todos.filter(todo => todo.completed).length;
// }

//화살표 함수
const countCompletedTodos = () => todos.filter((todo) => todo.completed).length;
// 안한 일 찾기
const countUncompletedTodos = () =>
todos.filter((todo) => !todo.completed).length;

console.log(countCompletedTodos()); // 1
console.log(countUncompletedTodos()); // 2

9. id 프로퍼티의 값 중에서 최대값, 최소값 구하기

todos의 id 프로퍼티의 값 중에서 최대값을 구하는 함수를 작성하라.

단, for 문, Array#forEach는 사용하지 않도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let todos = [
{ id: 3, content: "HTML", completed: false },
{ id: 2, content: "CSS", completed: true },
{ id: 1, content: "Javascript", completed: false },
];

// ...todos.map(todo => todo.id) // -> 3, 2, 1

function getMaxId() {
// 빈 배열일 경우 방어코드 결과 -1
return Math.max(...todos.map((todo) => todo.id), -1);
// return todos.length ? Math.max(...todos.map(todo => todo.id)) : -1;
}

// 화살표 함수
// const getMaxId = () => Math.max(...todos.map(todo => todo.id), -1);

function getMinId() {
return todos.length ? Math.min(...todos.map((todo) => todo.id)) : -1;
}

console.log(getMaxId()); // 3
console.log(getMinId()); // 1

Nyong’s GitHub

오늘 배운 것

배열 고차 함수

메서드 체이닝을 사용할 때는 메서드가 this를 반환하는 뮤테이터(mutator) 메서드여야한다.

1
2
3
4
5
const fruits = ["Banana", "Orange", "Apple"];

fruits.sort().reverse();

console.log(fruits); // ['Orange', 'Banana', 'Apple']

Array.prototype.sort

객체를 요소로 갖는 배열을 정렬하는 예제는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const todos = [
{ id: 4, content: "JavaScript" },
{ id: 1, content: "HTML" },
{ id: 2, content: "CSS" },
];

// 비교 함수. 매개변수 key는 프로퍼티 키다.
function compare(key) {
// 프로퍼티 값이 문자열인 경우 - 산술 연산으로 비교하면 NaN이 나오므로 비교 연산을 사용한다.
// 비교 함수는 양수/음수/0을 반환하면 되므로 - 산술 연산 대신 비교 연산을 사용할 수 있다.
return (a, b) => (a[key] > b[key] ? 1 : a[key] < b[key] ? -1 : 0);
}

// id를 기준으로 오름차순 정렬
todos.sort(compare("id"));
console.log(todos);
/*
[
{ id: 1, content: 'HTML' },
{ id: 2, content: 'CSS' },
{ id: 4, content: 'JavaScript' }
]
*/

// content를 기준으로 오름차순 정렬
todos.sort(compare("content"));
console.log(todos);
/*
[
{ id: 2, content: 'CSS' },
{ id: 1, content: 'HTML' },
{ id: 4, content: 'JavaScript' }
]
*/

forEach

1
2
3
4
5
6
7
8
9
10
11
12
13
const todos = [
{ id: 4, content: "JavaScript" },
{ id: 1, content: "HTML" },
{ id: 2, content: "CSS" },
];

let html = "";

todos.forEach((todo) => {
html += `<li id = "${todo.id}"> ${todo.content} </li>\n`;
});

console.log(html);

reduce

콜백 함수의 반환값을 다음 순회 시에 콜백 함수의 첫 번째 인수로 전달하면서 콜백 함수를 호출하여 하나의 결과값을 만들어 반환한다.

1
2
3
4
5
6
7
// [1, 2, 3, 4]의 모든 요소의 누적을 구한다.
const sum = [1, 2, 3, 4].reduce(
(accumulator, currentValue, index, array) => accumulator + currentValue,
0
);

console.log(sum); // 10

reduce 메서드는 2개의 인수, 즉 콜백 함수와 초기값 0을 전달받아 자신을 호출한 배열의 모든 요소를 누적한 결과를 반환한다.

reduce 메서드의 콜백 함수는 4개의 인수를 전달받아 배열의 length만큼 총 4회 호출된다. 이때 콜백 함수로 전달되는 인수와 콜백 함수의 반환값은 다음과 같다.

요소의 중복 횟수 구하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const fruits = ["banana", "apple", "orange", "orange", "apple"];

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}
*/

console.log(count); // { banana: 1, apple: 2, orange: 2 }

중복 요소 제거

1
2
3
4
5
6
7
8
9
10
11
const values = [1, 2, 1, 3, 5, 4, 5, 3, 4, 4];

const result = values.reduce((acc, cur, i, arr) => {
// 순회 중인 요소의 인덱스가 자신의 인덱스라면 처음 순회하는 요소다.
// 이 요소만 초기값으로 전달받은 배열에 담아 반환한다.
// 순회 중인 요소의 인덱스가 자신의 인덱스가 아니라면 중복된 요소다.
if (arr.indexOf(cur) === i) acc.push(cur);
return acc;
}, []);

console.log(result); // [1, 2, 3, 5, 4]

reduce 메서드를 호출할 때는 초기값을 생략하지 말고 언제나 전달하는 것이 안전하다.

flatMap

1
2
3
4
5
6
7
8
9
10
11
12
const arr = ["hello", "world"];

arr.map((x) => x.split(""));
// -> [ [ 'h', 'e', 'l', 'l', 'o' ], [ 'w', 'o', 'r', 'l', 'd' ] ]

// map과 flat을 순차적으로 실행
arr.map((x) => x.split("")).flat();
// -> ['h', 'e', 'l', 'l', 'o', 'w', 'o', 'r', 'l', 'd']

// flatMap은 map을 통해 생성된 새로운 배열을 평탄화한다.
arr.flatMap((x) => x.split(""));
// -> ['h', 'e', 'l', 'l', 'o', 'w', 'o', 'r', 'l', 'd']

flatMap 메서드는 flat 메서드처럼 인수를 전달하여 평탄화 깊이를 지정할 수는 없고 1단계만 평탄화한다.

Number.EPSILON

1
2
0.1 + 0.2; // -> 0.30000000000000004
0.1 + 0.2 === 0.3; // -> false

위 결과는 실수를 이진수로 변환할 때 생기는 바이트 손상 때문에 정확한 계산이 안되며 이는 컴퓨터의 구조적 한계이다.

1 에서 (JS에서 표현 가능한 1 다음으로 큰 숫자 예를들어 1.00000000001)를 빼기 연산했을 때의 값이 EPSILON이며 이는 숫자와 다음 숫자 사이의 간극이다.

0.300000000004 - 0.3이 EPSILON보다 작다면 같은 수로 인정한다.

1
2
3
4
5
6
function isEqual(a, b) {
// a와 b를 뺀 값의 절대값이 Number.EPSILON보다 작으면 같은 수로 인정한다.
return Math.abs(a - b) < Number.EPSILON;
}

isEqual(0.1 + 0.2, 0.3); // -> true

Nyong’s GitHub