본문으로 건너뛰기

02 렌더링

데이터를 표시한다는 것은 요소를 화면이나 다른 출력 장치에 렌더링하는 것을 의미한다. W3C는 프로그래밍 방식으로 요소를 렌더링하는 방식을 문서 객체 모델(DOM)로 정의했다. 2장의 목적은 프레임워크 없이 DOM을 효과적으로 조작하는 방법을 배우는 데 있다.

DOM

DOM은 웹 애플리케이션을 구성하는 요소를 조작할 수 있는 API다.

DOM(Document Object Model)은 HTML 및 XML 문서용 프로그래밍 API입니다. 문서의 논리적 구조와 문서에 액세스하고 조작하는 방식을 정의합니다.
문서 개체 모델을 사용하여 프로그래머는 문서를 작성 및 작성하고 구조를 탐색하고 요소와 콘텐츠를 추가, 수정 또는 삭제할 수 있습니다.
W3C 사양으로서 Document Object Model의 중요한 목표 중 하나는 다양한 환경과 애플리케이션에서 사용할 수 있는 표준 프로그래밍 인터페이스를 제공하는 것입니다.

https://www.w3.org/TR/1998/WD-DOM-19980720/introduction.html

DOM을 이해하려면 기본으로 돌아가 보자. 기술적 관점에서 보면 모든 HTML 페이지는 트리로 구성된다.

간단한 HTML 테이블
<html>
<body>
<table>
<tr>
<th>Framework</th>
<th>GitHub Stars</th>
</tr>
<tr>
<th>Vue</th>
<th>118917</th>
</tr>
<tr>
<th>React</th>
<th>115392</th>
</tr>
</table>
</body>
</html>

이 예제에서 DOM은 HTML 요소로 정의된 트리를 관리하는 방법임을 알 수 있다. 만약 리액트 셀의 배경색을 변경하려면 다음과 같이 작성하면 된다.

React 셀의 색상 변경
const SELECTOR = "tr:nth-child(3) > td";
const cell = document.querySelector(SELECTOR);
cell.style.backgroundColor = "red";

코드는 간단하다. 표준 CSS 선택자를 사용해 올바른 셀을 선택한 다음 셀 노드의 style 속성을 변경한다. querySelector 메서드는 Node 메서드다. Node는 HTML 트리에서 노드를 나타내는 기본 인터페이스다.

렌더링 함수

순수하게 함수를 사용해 요소를 DOM에 렌더링하는 다양한 방법을 분석해보자. 순수 함수로 요소를 렌더링한다는 것은 DOM 요소가 애플리케이션의 상태에만 의존한다는 것을 의미한다.

순수 함수 렌더링의 수학적 표현
view = f(state)

순수 함수를 사용하면 테스트 가능성이나 구성 가능성 같은 많은 장점이 있지만 몇 가지 문제도 있다. 이는 뒷부분에서 자세히 살펴본다.

TodoMVC

2장의 예제를 위해 TodoMVC(https://todomvc.com/) 템플릿을 사용한다.

img

순수 함수 렌더링

첫 번째 예제에서는 문자열을 사용해 요소를 렌더링한다. 다음 예제 코드에서 TodoMVC 애플리케이션의 골격을 볼 수 있다.

기본 TodoMVC 앱 구조
<body>
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<input
class="new-todo"
placeholder="what needs to be done?"
autofocus=""
/>
</header>
<section class="main">
<input id="toggle-all" class="toggle-all" type="checkbox" />
<label for="toggle-all"> Mark all as complete </label>
<ul class="todo-list"></ul>
</section>
<footer class="footer">
<span class="todo-count"></span>
<ul class="filters">
<li>
<a href="#/">All</a>
</li>
<li>
<a href="#/active">Active</a>
</li>
<li>
<a href="#/completed">Completed</a>
</li>
</ul>
<button class="clear-completed">Clear completed</button>
</footer>
</section>
<footer class="info">
<p>Double-click to edit a todo</p>
</footer>
</body>

이 어플리케이션을 동적으로 만들려면 to-do 리스트 데이터를 가져와 다음을 업데이트 한다.

  • 필터링된 todo 리스트를 가진 ul
  • 완료되지 않은 todo 수를 가진 span
  • selected 클래스를 오른쪽에 추가한 필터 유형을 가진 링크
TodoMVC 렌더링 함수의 첫 번째 버전
const getTodoElement = (todo) => {
const { text, completed } = todo;

return `
<li ${completed ? 'class="completed"' : ""}>
<div class="view">
<input
${completed ? "checked" : ""}
class="toggle"
type="checkbox">
<label>${text}</label>
<button class="destroy"></button>
</div>
<input class="edit" value="${text}"
</li>
`;
};

const getTodoCount = (todos) => {
const notCompleted = todos.filter((todo) => !todo.completed);

const { length } = notCompleted;

if (length === 1) {
return "1 Item left";
}

return `${length} Items left`;
};

export default (targetElement, state) => {
const { currentFilter, todos } = state;

const element = targetElement.cloneNode(true);
const list = element.querySelector(".todo-list");
const counter = element.querySelector(".todo-count");
const filters = element.querySelector(".filters");

list.innerHTML = todos.map(getTodoElement).join("");
counter.textContent = getTodoCount(todos);

Array.from(filters.querySelectorAll("li a")).forEach((a) => {
if (a.textContent === currentFilter) {
a.classList.add("selected");
} else {
a.classList.remove("selected");
}
});

return element;
};

이 뷰 함수는 기본으로 사용되는 target DOM 요소를 받는다. 그런 다음 원래 노드를 복제하고 state 매개변수를 사용해 업데이트한다. 그런 다음 새 노드를 반환한다. 위의 DOM 수정은 가상임을 명심하자. 원본과 동일한 복제본이지만 문서의 본문과는 전혀 관련이 없다. DOM의 실제 수정 사항이 커밋되지 않았다. 분리된 DOM 요소를 수정하면 성능이 향상된다. 이 뷰 함수를 실제 DOM에 연결하고자 다음과 같은 컨트롤러를 사용한다.

기본 컨트롤러
import getTodos from "./getTodos.js";
import view from "./view.js";

const state = {
todos: getTodos(),
currentFilter: "All",
};

const main = document.querySelector(".todoapp");

window.requestAnimationFrame(() => {
const newMain = view(main, state);
main.replaceWith(newMain);
});

작성한 간단한 '렌더링 엔진'은 requestAnimationFrame을 기반으로 한다. 모든 DOM 조작이나 애니메이션은 이 DOM API를 기반으로 해야 한다. 이 콜백 내에서 DOM 작업을 수행하면 더 효율적이 된다. 이 API는 메인 스레드를 차단하지 않으며 다음 repaint가 이벤트 루프에서 스케줄링되기 직전에 실행된다.

img

코드 리뷰

여기서읜 렌더링 방식은 requestAnimationFrame과 가상 노드 조작을 사용해 충분한 성능을 보여준다. 하지만 뷰 함수는 읽기 쉽지 않다. 코드는 두 가지 중요한 문제를 갖고 있다.

  • 하나의 거대한 함수. 여러 DOM 요소를 조작하는 함수가 단 하나뿐이다. 이는 상황을 아주 쉽게 복잡하게 만들 수 있다.
  • 동일한 작업을 수행하는 여러 방법. DOM을 수정할 때 문자열로 처리하거나 내부 text만 변경하거나 classList로 관리하는 등 여러 방법을 사용하고 있다.
작은 뷰 함수로 작성된 앱 뷰 함수
import todosView from "./todos.js";
import counterView from "./counter.js";
import filtersView from "./filters.js";

export default (targetElement, state) => {
const element = targetElement.cloneNode(true);

const list = element.querySelector(".todo-list");
const counter = element.querySelector(".todo-count");
const filters = element.qeurySelector(".filters");

list.replaceWith(todosView(list, state));
counter.replaceWith(counterView(counter, state));
filters.replaceWith(filtersView(filters, state));

return element;
};
할 일의 수를 보여주는 뷰 함수
const getTodoCount = (todos) => {
const notCompleted = todos.filter((todo) => !todo.completed);

const { length } = notCompleted;

if (length === 1) {
return "1 Item left";
}

return `${length} Items left`;
};

export default (targetElement, { todos }) => {
const newCounter = targetElement.cloneNode(true);
newCounter.textContent = getTodoCount(todos);

return newCounter;
};
필터를 렌더링하는 뷰 함수
export default (targetElement, { currentFilter }) => {
const newCounter = targetElement.cloneNode(true);

Array.from(newCounter.querySelectorAll("li a")).forEach((a) => {
if (a.textContent === currentFilter) {
a.classList.add("selected");
} else {
a.classList.remove("selected");
}
});

return newCounter;
};
리스트를 렌더링하는 뷰 함수
const getTodoElement = (todo) => {
const { text, completed } = todo;

return `
<li ${completed ? 'class="completed"' : ""}>
<div class="view">
<input
${completed ? "checked" : ""}
class="toggle"
type="checkbox">
<label>${text}</label>
<button class="destroy"></button>
</div>
<input class="edit" value="${text}"
</li>
`;
};

export default (targetElement, { todos }) => {
const newTodoList = targetElement.cloneNode(true);
const todosElements = todos.map(getTodoElement).join("");
newTodoList.innerHTML = todosElements;

return newTodoList;
};

책에서 이야기 하는 Component Library의 첫 번째 초안이다.

Component Function

Component 기반의 애플리케이션을 작성하려면 구성 요소 간의 상호작용에 선언적 방식을 사용해야 한다. 이 방식을 위해 Component를 선언하는 방법을 정의해보자. 예제에서는 todos, counters, filters의 세가지 컴포넌트를 가진다. 데이터 속성(https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes)을 이용해 사용하는 컴포넌트를 정의하는 방법을 알아보자.

컴포넌트 data attribute를 사용하는 앱
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<input
class="new-todo"
placeholder="What needs to be done?"
autofocus>
</header>
<section class="main">
<input
id="toggle=all"
class="toggle-all"
type="checkbox">
<label for="toggle-all">
Mark all as complete
</label>
<ul class="todo-list"></ul>
</section>
<footer class="footer">
<span
class="todo-count"
data-component="counter">
1 Item Left
</span>
<ul class="filters" data-component="filters">
<li>
<a href="#/">All</a>
</li>
<li>
<a href="#/active">Active</a>
</li>
<li>
<a href="#/completed">Completed</a>
</li>
</ul>
<button class="clear-completed">
Clear completed
</button>
</footer>
</body>

컴포넌트의 'name'을 data-component 속성에 넣었다. 이 속성은 뷰 함수에서의 호출에서 사용된다. 컴포넌트 라이브러리를 생성하기 위한 또 다른 필수 조건은 registry로 모든 컴포넌트의 인덱스이다.

간단한 컴포넌트 레지스트리
const registry = {
todos: todosView,
counter: counterView,
filters: filtersView,
};

레지스트리의 키는 data-component 속성의 값과 일치한다. 이것이 component 기반 렌더링 엔진의 핵심 메커니즘이다. 이 메커니즘은 루트 컨테이너 뿐만 아니라 생성할 모든 컴포넌트에 적용돼야 한다. 이렇게 하면 모든 컴포넌트가 다른 컴포넌트 안에서 불러와 사용될 수 있다. 이런 재사용성은 컴포넌트 기반 애플리케이션에서 필수적이다.

이 작업을 위해서는 모든 컴포넌트가 data-component 속성의 값을 읽고 올바른 함수를 호출할 수 있어야 한다. 이를 위해 컴포넌트를 랩핑하는 고차함수를 생성해야 한다.

고차 함수 렌더링
const renderWrapper = component => {
return (targetElement, state) => {
const element = component(targetElement, state);
const childComponents = element.querySelectorAll('[data-component']);

Array
.from(childComponents)
.forEach(target => {
const name = target.dataset.component
const child = registry[name];

if(!child) {
return;
}

target.replaceWith(child(target, state))
})

return element;
}
}

이 wrapper 함수는 원래 컴포넌트를 가져와 동일한 구성의 새로운 컴포넌트를 반환한다. wrapper는 레지스트리에서 data-component 속성을 가진 모든 DOM 요소를 찾는다. 요소가 발견되면 자식 컴포넌트를 동일한 함수로 랩핑하여 호출한다. 레지스트리에 컴포넌트를 추가하려면 다음과 같이 컴포넌트를 랩핑하는 간단한 함수가 필요하다.

레지스트리 접근자 메서드
const add = (name, component) => {
registry[name] = renderWrapper(component);
};

최초 DOM 요소에서 렌더링을 시작하려면 애플리케이션의 루트를 렌더링하는 메서드가 있어야 한다.

컴포넌트 기반 애플리케이션의 부팅 함수
const renderRoot = (root, state) => {
const cloneComponent = (root) => {
return root.cloneNode(true);
};

return renderWrapper(cloneComponent)(root, state);
};

add와 renderRoot 메서드는 컴포넌트 레지스트리의 공용 인터페이스다.

이제 컨트롤러에서 모든 요소를 합쳐보자.

컴포넌트 레지스트리를 사용하는 컨트롤러
import getTodos from "./getTodos.js";
import todosView from "./view/todos.js";
import counterView from "./view/counter.js";
import filtersView from "./view/filters.js";

import registry from "./registry.js";

registry.add("todos", todosView);
registry.add("counter", counterView);
registry.add("filters", filtersView);

const state = {
todos: getTodos(),
currentFilter: "All",
};

window.requestAnimationFrame(() => {
const main = document.querySelector(".todoapp");
const newMain = registry.renderRoot(main, state);
main.replaceWith(newMain);
});

동적 데이터 렌더링

이전 예제에서는 정적 데이터를 사용했다. 그러나 실제 애플리케이션에서는 사용자나 시스템의 이벤트에 의해 데이터가 변경된다. 예시로 5초마다 상태를 무작위로 변경해보자.

5초마다 임의의 데이터를 렌더링
const render = () => {
window.requestAnimationFrame(() => {
const main = document.querySelector(".todoapp");
const newMain = registry.renderRoot(main, state);
main.replaceWith(newMain);
});
};

window.setInterval(() => {
state.todos = getTodos();
render();
}, 5000);

render();

새 데이터가 있을 때마다 가상 루트 요소를 만든 다음 실제 요소를 새로 생성된 요소로 바꾼다. 이 방법은 소규모 애플리케이션에서는 충분한 성능을 발휘하지만 대규모 프로젝트에서는 성능을 저하시킬 수 있다.

가상 DOM

리액트에 의해 유명해진 가상 DOM 개념은 선언적 렌더링 엔진의 성능을 개선시키는 방법이다. UI 표현은 메모리에 유지되고 '실제' DOM과 동기화된다. 실제 DOM은 가능한 적은 작업을 수행한다. 이 과정을 reconciliation 이라고 부른다.

가상 DOM의 핵심은 diff 알고리즘이다. 이 알고리즘은 실제 DOM을 문서에서 분리된 새로운 DOM element의 사본으로 바꾸는 가장 빠른 방법을 찾아낸다.

간단한 가상 DOM 구현

메인 컨트롤러에서 replaceWith 대신 사용할 아주 간단한 diff 알고리즘을 작성해보자.

diff 알고리즘을 사용하는 메인 컨트롤러
const render = () => {
window.requestAnimationFrame(() => {
const main = document.querySelector(".todoapp");
const newMain = registry.renderRoot(main, state);

applyDiff(document.body, main, newMain);
});
};

applyDiff 함수 매개변수는 현재 Dom 노드와 실제 Dom 노드, 새로운 가상 Dom 노드의 부모다. 이 함수의 역할을 분석해보자.

applyDiff 함수
const applyDiff = (parentNode, realNode, virtualNode) => {
if (realNode && !virtualNode) {
realNode.remove();
return;
}

if (!realNode && virtualNode) {
parentNode.appendChild(virtualNode);
return;
}

if (isNodeChanged(virtualNode, realNode)) {
realNode.replaceWith(virtualNode);
return;
}

const realChildren = Array.from(realNode.children);
const virtualChildren = Array.from(virtualNode.children);

const max = Math.max(realChildren.length, virtualChildren.length);

for (let i = 0; i < max; i++) {
applyDiff(realNode, realChildren[i], virtualChildren[i]);
}
};
isNodeChanged 함수
const isNodeChanged = (node1, node2) => {
const n1Attributes = node1.attributes;
const n2Attributes = node2.attributes;

if (n1Attributes.length !== n2Attributes.length) {
return true;
}

const differentAttribute = Array.from(n1Attributes).find((attribute) => {
const { name } = attribute;
const attribute1 = node1.getAttribute(name);
const attribute2 = node2.getAttribute(name);

return attribute1 !== attribute2;
});

if (diffrentAttribute) {
return true;
}

if (
node1.children.length === 0 &&
node2.children.length === 0 &&
node1.textContent !== node2.textContent
) {
return true;
}

return false;
};

이 diff 알고리즘 구현에서는 노드를 다른 노드와 비교해 노드가 변경됐는지 확인한다.

  • 속성 수가 다르다.
  • 하나 이상의 속성이 변경됐다.
  • 노드에는 자식이 없으며, textContent가 다르다.

렌더링 엔진은 최대한 간단하게 유지하는 것이 좋다. 문제가 발생하면 상황에 맞게 알고리즘을 조정하면 된다. 도널드 크누스는 "시기상조의 최적화는 모든(또는 대부분의) 악의 근원이다"라고 말했다.