DOM 조작
DOM 조작 (DOM Manipulation)
HTMLElement 타입 탐험
JavaScript는 표준화된 이후 20년 넘게 아주 멀리 왔습니다. 2020년 기준으로 JavaScript는 서버, 데이터 과학, 심지어 IoT 기기에서도 쓰이지만, 여전히 가장 대중적인 사용처는 웹 브라우저라는 점을 기억하는 게 중요해요. 웹사이트는 HTML과/또는 XML 문서로 이루어지는데, 이 문서들은 정적이라 스스로 변하지 않아요. DOM(Document Object Model) 은 브라우저가 구현한 프로그래밍 인터페이스로, 정적인 웹사이트에 동작을 부여합니다. DOM API로 문서의 구조, 스타일, 내용을 바꿀 수 있고, 이 API가 워낙 강력해서 수많은 프론트엔드 프레임워크(jQuery, React, Angular 등)가 이 주위에 만들어졌어요.
TypeScript는 JavaScript의 타입 있는 슈퍼셋이고, DOM API에 대한 타입 정의를 함께 제공합니다. 이 정의는 기본 TypeScript 프로젝트 어디서나 바로 쓸 수 있어요. lib.dom.d.ts 의 20,000줄이 넘는 정의 중에서도 특히 눈에 띄는 타입이 하나 있는데, 바로 HTMLElement입니다. 이 타입은 TypeScript로 DOM을 조작하는 일의 중추 역할을 해요.
DOM 타입 정의의 소스 코드를 직접 탐험해 볼 수도 있어요.
출처: TypeScript 핸드북
기본 예시 (Basic Example)
간단한 index.html 파일이 있다고 해 보죠.
<!DOCTYPE html>
<html lang="en">
<head><title>TypeScript Dom Manipulation</title></head>
<body>
<div id="app"></div>
<!-- Assume index.js is the compiled output of index.ts -->
<script src="index.js"></script>
</body>
</html>
#app 요소에 <p>Hello, World!</p> 요소를 추가하는 TypeScript 스크립트를 살펴볼게요.
// 1. Select the div element using the id property
const app = document.getElementById("app");
// 2. Create a new <p></p> element programmatically
const p = document.createElement("p");
// 3. Add the text content
p.textContent = "Hello, World!";
// 4. Append the p element to the div element
app?.appendChild(p);
index.html 페이지를 컴파일하고 실행하면 결과 HTML은 다음과 같아집니다.
<div id="app">
<p>Hello, World!</p>
</div>
Document 인터페이스
TypeScript 코드의 첫 줄은 전역 변수 document를 사용합니다. 이 변수를 들여다보면 lib.dom.d.ts 파일의 Document 인터페이스로 정의되어 있음을 알 수 있어요. 이 코드 조각에는 getElementById와 createElement 두 메서드 호출이 들어 있습니다.
Document.getElementById
이 메서드의 정의는 다음과 같습니다.
getElementById(elementId: string): HTMLElement | null;
요소 id 문자열을 넘기면 HTMLElement 또는 null을 반환해요. 이 메서드는 가장 중요한 타입 중 하나인 HTMLElement를 소개합니다. HTMLElement는 다른 모든 요소 인터페이스의 기본 인터페이스 역할을 합니다. 예를 들어 코드 예시의 p 변수는 HTMLParagraphElement 타입입니다. 그리고 이 메서드가 null을 반환할 수 있다는 점을 꼭 기억하세요. 메서드가 런타임 전에는 지정된 요소를 실제로 찾을 수 있을지 확신할 수 없기 때문이에요. 코드 조각의 마지막 줄에서는 새로 나온 옵셔널 체이닝(optional chaining) 연산자로 appendChild를 호출합니다.
Document.createElement
이 메서드의 정의는(여기서 deprecated 정의는 생략했어요):
createElement<K extends keyof HTMLElementTagNameMap>(tagName: K, options?: ElementCreationOptions): HTMLElementTagNameMap[K];
createElement(tagName: string, options?: ElementCreationOptions): HTMLElement;
이건 오버로드된 함수 정의입니다. 두 번째 오버로드가 가장 단순하고 getElementById 메서드와 아주 비슷하게 동작해요. 아무 string이나 넘기면 표준 HTMLElement를 반환하죠. 이 정의 덕분에 개발자가 독특한 HTML 요소 태그를 만들 수 있게 됩니다.
예를 들어 document.createElement('xyz')는 <xyz></xyz> 요소를 반환하는데, 이는 분명 HTML 명세에 지정된 요소가 아니에요.
관심 있는 분이라면
document.getElementsByTagName으로 커스텀 태그 요소와 상호작용할 수 있습니다.
createElement의 첫 번째 정의는 다소 고급 제네릭 패턴을 사용합니다. 이건 조각으로 나눠서 이해하는 게 가장 좋은데, 제네릭 표현식 <K extends keyof HTMLElementTagNameMap>부터 시작해 볼게요. 이 표현식은 HTMLElementTagNameMap 인터페이스의 키로 제한(constrained) 된 제네릭 파라미터 K를 정의합니다. 이 map 인터페이스는 HTML에 지정된 모든 태그 이름과 그에 대응하는 타입 인터페이스를 담고 있어요. 예를 들어 매핑된 값의 처음 5개는 다음과 같습니다.
interface HTMLElementTagNameMap {
"a": HTMLAnchorElement;
"abbr": HTMLElement;
"address": HTMLElement;
"applet": HTMLAppletElement;
"area": HTMLAreaElement;
...
}
일부 요소는 고유한 프로퍼티가 없어서 그냥 HTMLElement를 반환하지만, 다른 타입들은 고유한 프로퍼티와 메서드가 있어서 각자의 특정 인터페이스(HTMLElement를 확장하거나 구현하는)를 반환합니다.
이제 createElement 정의의 나머지 부분을 볼게요: (tagName: K, options?: ElementCreationOptions): HTMLElementTagNameMap[K]. 첫 번째 인자 tagName은 제네릭 파라미터 K로 정의됩니다. TypeScript 인터프리터는 이 인자에서 제네릭 파라미터를 추론 할 만큼 똑똑해요. 즉 개발자는 이 메서드를 쓸 때 제네릭 파라미터를 명시할 필요가 없고, tagName 인자에 넘긴 값이 K로 추론되어 정의의 나머지 전체에서 쓰일 수 있다는 뜻입니다. 실제로 정확히 그렇게 동작해요. 반환 값 HTMLElementTagNameMap[K]가 tagName 인자를 받아 그에 해당하는 타입을 반환하는 겁니다. 코드 조각의 p 변수가 HTMLParagraphElement 타입을 얻는 이유가 바로 이 정의 덕분이에요. 그리고 코드가 document.createElement('a')였다면 HTMLAnchorElement 타입의 요소가 됐을 거예요.
Node 인터페이스
document.getElementById 함수는 HTMLElement를 반환합니다. HTMLElement 인터페이스는 Element 인터페이스를 확장하고, Element는 다시 Node 인터페이스를 확장합니다. 이 프로토타입 확장 덕분에 모든 HTMLElement가 표준 메서드의 부분집합을 활용할 수 있어요. 코드 조각에서 우리는 Node 인터페이스에 정의된 프로퍼티를 사용해 새 p 요소를 웹사이트에 추가했습니다.
Node.appendChild
코드 조각의 마지막 줄은 app?.appendChild(p)입니다. 앞선 document.getElementById 섹션에서 자세히 설명했듯, app이 런타임에 null일 수 있기 때문에 여기서 옵셔널 체이닝 연산자를 쓰는 거예요. appendChild 메서드는 다음과 같이 정의됩니다.
appendChild<T extends Node>(newChild: T): T;
이 메서드는 createElement 메서드와 비슷하게 동작하는데, 제네릭 파라미터 T가 newChild 인자에서 추론되기 때문이에요. T는 또 다른 기본 인터페이스인 Node로 제한 됩니다.
children과 childNodes의 차이
앞서 이 문서는 HTMLElement 인터페이스가 Element를 확장하고 Element가 Node를 확장한다고 설명했죠. DOM API에는 자식(children) 요소라는 개념이 있습니다. 예를 들어 다음 HTML에서 p 태그들은 div 요소의 자식입니다.
<div>
<p>Hello, World</p>
<p>TypeScript!</p>
</div>;
const div = document.getElementsByTagName("div")[0];
div.children;
// HTMLCollection(2) [p, p]
div.childNodes;
// NodeList(2) [p, p]
div 요소를 잡은 뒤, children prop은 HTMLParagraphElement를 담은 HTMLCollection 목록을 반환합니다. childNodes 프로퍼티는 비슷한 NodeList 노드 목록을 반환하죠. 각 p 태그는 여전히 HTMLParagraphElements 타입이지만, NodeList는 HTMLCollection 목록이 담을 수 없는 추가적인 HTML 노드를 포함할 수 있어요.
하나의 p 태그를 제거하되 텍스트는 남기도록 HTML을 수정해 볼게요.
<div>
<p>Hello, World</p>
TypeScript!
</div>;
const div = document.getElementsByTagName("div")[0];
div.children;
// HTMLCollection(1) [p]
div.childNodes;
// NodeList(2) [p, text]
두 목록이 어떻게 변하는지 보이시나요? children은 이제 <p>Hello, World</p> 요소만 담고, childNodes는 두 개의 p 노드 대신 text 노드를 담습니다. NodeList의 text 부분은 텍스트 TypeScript!를 담은 말 그대로의 Node예요. children 목록에는 이 Node가 없는데, 그것이 HTMLElement로 간주되지 않기 때문입니다.
querySelector와 querySelectorAll 메서드
이 두 메서드 모두 더 독특한 제약 조건 집합에 맞는 DOM 요소 목록을 얻기 좋은 도구입니다. lib.dom.d.ts 에서 다음과 같이 정의돼 있어요.
/**
* Returns the first element that is a descendant of node that matches selectors.
*/
querySelector<K extends keyof HTMLElementTagNameMap>(selectors: K): HTMLElementTagNameMap[K] | null;
querySelector<K extends keyof SVGElementTagNameMap>(selectors: K): SVGElementTagNameMap[K] | null;
querySelector<E extends Element = Element>(selectors: string): E | null;
/**
* Returns all element descendants of node that match selectors.
*/
querySelectorAll<K extends keyof HTMLElementTagNameMap>(selectors: K): NodeListOf<HTMLElementTagNameMap[K]>;
querySelectorAll<K extends keyof SVGElementTagNameMap>(selectors: K): NodeListOf<SVGElementTagNameMap[K]>;
querySelectorAll<E extends Element = Element>(selectors: string): NodeListOf<E>;
querySelectorAll 정의는 getElementsByTagName과 비슷하지만, 새 타입 NodeListOf를 반환한다는 점이 달라요. 이 반환 타입은 기본적으로 표준 JavaScript 목록 요소의 커스텀 구현입니다. NodeListOf<E>를 E[]로 바꿔도 사용자 경험은 매우 비슷할 거예요. NodeListOf는 length, item(index), forEach((value, key, parent) => void), 그리고 숫자 인덱싱만 구현합니다. 추가로, 이 메서드는 NodeList(.childNodes 메서드가 반환하던 것)가 아니라 요소(elements) 목록, 즉 노드(nodes) 목록을 반환해요. 이게 불일치처럼 보일 수 있지만, Element 인터페이스는 Node를 확장한다는 점을 기억하세요.
이 메서드들이 실제로 동작하는 걸 보려면 기존 코드를 이렇게 바꿔 봅시다.
<ul>
<li>First :)</li>
<li>Second!</li>
<li>Third times a charm.</li>
</ul>;
const first = document.querySelector("li"); // returns the first li element
const all = document.querySelectorAll("li"); // returns the list of all li elements
더 알아보고 싶다면?
lib.dom.d.ts 타입 정의의 가장 좋은 점은, Mozilla Developer Network(MDN) 문서 사이트에 어노테이션된 타입들을 그대로 반영한다는 것입니다. 예를 들어 HTMLElement 인터페이스는 MDN의 HTMLElement 페이지에 문서화되어 있어요. 이런 페이지들은 사용 가능한 모든 프로퍼티와 메서드, 때로는 예시까지 나열합니다. 또 다른 훌륭한 점은 페이지들이 해당 표준 문서로 가는 링크를 제공한다는 거예요. 여기 HTMLElement에 대한 W3C 권고안 링크가 있습니다.
출처: