프레임워크 없는 프론트엔드 개발 2장(렌더링)
모든 웹 애플리케이션에서 가장 중요한 기능 중 하나는 데이터의 표시라고 합니다. 데이터를 표시한다는 것은 요소를 화면이나 출력 장치에 렌더링하는 것을 의미합니다. W3C는 프로그래밍 방식으로 요소를 렌더링하는 방식을 문서 객체 모델(Document Object Model, DOM)로 정의했습니다.
문서 객체 모델이란?
DOM은 웹 애플리케이션을 구성하는 요소를 조작할 수 있는 API입니다. HTML 페이지는 트리로 구성되는데 아래와 같은 HTML 코드는 다음과 같은 DOM으로 표현됩니다.
<html>
<body>
<table>
<tr>
<th>Framework</th>
<th>GitHub Stars</th>
</tr>
<tr>
<td>Vue</td>
<td>118917</td>
</tr>
<tr>
<td>React</td>
<td>115392</td>
</tr>
</table>
</body>
</html>
이 예제를에서 DOM이 HTML 요소로 정의된 트리를 관리하는 방법임을 알 수 있습니다.
다음은 리액트 셀의 배경색을 변경하는 방법에 대해 알아보겠습니다.
const SELECTOR = 'tr:nth-child(3) > td'
const cell = document.querySelector(SELECTOR)
cell.style.backgroundColor = 'red'
표준 CSS 선택자를 사용해 querySelector 메서드로 올바른 셀을 선택한 다음 셀 노드의 style 속성을 변경한다. 이 때 querySelector는 Node 메서드인데 Node는 HTML 트리에서 노드를 나타내는 기본 인터페이스이다. 아래 사이트에서 모든 Node 메서드와 속성을 확인할 수 있습니다.
참고 : https://developer.mozilla.org/en-US/docs/Web/API/Node
렌더링 함수
순수하게 함수를 사용해 요소를 DOM에 렌더링하는 다양한 방법을 알아보겠습니다. 순수 함수로 요소를 렌더링한다는 것은 DOM 요소가 애플리케이션의 상태에만 의존한다는 것을 의미합니다. 순수 함수를 사용하면 테스트 가능성이나 구성 가능성 같은 많은 장점이 있지만 몇 가지 문제도 있다고 하는데 뒤에서 알아보겠습니다.
TodoMVC
TodoMVC 템플릿을 통해 학습을 진행하는데 TodoMVC(http://todomvc.com)는 다양한 프레임워크로 작성된 to-do 리스트의 구현을 모아놓은 프로젝트입니다.
일단 항목과 툴바를 렌더링해보겠습니다.
순수 함수 렌더링
첫 번째 예제에서는 문자열을 사용해 요소를 렌더링합니다. 다음 코드(https://github.com/Apress/frameworkless-front-end-development/blob/master/Chapter02/01)에서 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-lsit"></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>
Doubl-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
}
이 뷰 함수는 기본으로 사용되는 타깃 DOM 요소를 받습니다. 그런 다음 원래 노드를 복제하고 state 매개변수를 사용해 업데이트합니다. 그런 다음 새 노드를 반환합니다.
이 DOM 수정은 가상임을 명심해야합니다. 여기서는 분리된 요소로 작업을 하고 있는데 분리된 요소를 생성하고자 cloneNode메서드를 사용해 기존 노드를 복제합니다. 새로 생성된 이 DOM 요소는 실제 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는 메인 스레드를 차단하지 않으며 당므 다시 그리기가 이벤트 루프에서 스케줄링되기 직전에 실행됩니다.
여기서 작성하는 데이터 모델은 랜덤 데이터 생성에 유용한 작은 라이브러리인 Faker.js로 생성된 랜덤 배열입니다.
여기서 렌더링 방식은 requestAnimationFrame과 가상 노드 조작을 사용해 충분한 성능을 보여줍니다. 하지만 뷰 함수는 읽기가 쉽지 않습니다. 위 코드는 두 가지 문제를 가지고 있습니다.
- 하나의 거대한 함수 : 여러 DOM 요소를 조작하는 함수가 단 하나뿐입니다. 이는 상황을 쉽게 복잡하게 만들 수 있습니다.
- 동일한 작업을 수행하는 여러 방법 : 문자열을 통해 리스트 항목을 생성합니다. todo count 요소의 경우 단순히 기존 요소에 테스트를 추가하기만 하면 됩니다. 필터의 경우 classList를 관리합니다.
다음 코드는 뷰를 좀 더 작은 함수로 나누고 일관성 문제를 해결해보는 예제(https://github.com/Apress/frameworkless-front-end-development/blob/master/Chapter02/02)입니다.
/*작은 뷰 함수로 작성된 APP 뷰 함수*/
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
.querySelector('.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
}
/*TodoMVC 필터를 렌더링하는 뷰 함수*/
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
}
하나의 뷰 함수를 이제 세 개의 개별 함수로 분리했습니다. 이 함수는 이 책에서 작성하는 구성 요소 라이브러리의 첫 번째 초안이 됩니다.
구성요소 함수
App.js의 코드를 확인해보면 올바른 함수를 수동으로 호출해야 한다는 것을 알 수 있습니다. 구성 요소 기반의 애플리케이션을 작성하려면 구성 요소간의 상호작용에 선언적 방식을 사용해야 합니다. 시스템은 모든 부분을 자동으로 연결해줄 것 입니다.
다음 예제는(https://github.com/Apress/frameworkless-front-end-development/tree/master/Chapter02/03) 구성 요소 레지스트리를 갖는 렌더링 엔진입니다. 예제에서는 todos, counters, filters의 세 가지 구성 요소를 가집니다. 데이터 속성을 사용해 어떤 구성 요소를 사용할지 결정할 수 있습니다.
/*구성 요소를 사용하고자 데이터 속성을 사용하는 앱*/
<html>
<head>
<link rel="shortcut icon" href="../favicon.ico" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/todomvc-common@1.0.5/base.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/todomvc-app-css@2.1.2/index.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/Faker/3.1.0/faker.js"></script>
<title>
Frameworkless Frontend Development: Rendering
</title>
</head>
<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" data-component="todos">
</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>
</section>
<footer class="info">
<p>Double-click to edit a todo</p>
<p>Created by <a href="http://twitter.com/thestrazz86">Francesco Strazzullo</a></p>
<p>Thanks to <a href="http://todomvc.com">TodoMVC</a></p>
</footer>
<script type="module" src="index.js"></script>
</body>
</html>
구성 요소의 name을 data-component 속성에 넣었습니다. 이 속성은 뷰 함수의 필수 호출을 대체합니다. 구성 요소 라이브러리를 생성하기 위한 또 다른 필수 조건은 레지스트리로, 레지스트리는 애플리케이션에서 사용할 수 있는 모든 구성 요소의 인덱스입니다. 여기서 구현할 수 있는 가장 간단한 레지스트리는 아래와 같은 자바스크립트 객체입니다.
const registry = {
'todos' : todosView,
'counter' : counterView,
'filters' : filtersView
}
레지스트리의 키는 data-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
}
}
이 함수는 원래 구성 요소를 가져와 동일한 서명의 새로운 구성 요소를 반환합니다. 시스템에서 두 함수는 동일합니다. 래퍼는 레지스트리에서 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)
}
마지막으로 해야 할 일은 컨트롤러에서 모든 요소를 혼합하는 것입니다.
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초마다 상태를 무작위로 변경시켜보겠습니다.
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 요소가 다음과 같은 간단한 리스트라고 가정해봅니다.
<ul>
<li>First Item</li>
</ul>
이를 다음과 같은 새 요소의 리스트로 변경해봅니다.
<ul>
<li>First Item</li>
<li>Second Item</li>
</ul>
이전 알고리즘에서는 전체 ul을 교체했습니다. 가상 DOM 방법을 사용하면 시스템은 추가된 마지막 li가 실제 DOM에 필요한 유일한 작업임을 동적으로 이해하고 바꿔줍니다. 이러한 차이를 가장 빠르게 알아내는 알고리즘을 diff 알고리즘이라고 합니다.
간단한 가상 DOM 구현
메인 컨트롤러에서 replceWith 대신 사용할 아주 간단한 diff 알고리즘을 작성해봅시다.
const render = () => {
window.requestAnimationFrame(() => {
const main = document.querySelector('.todoapp')
const newMain = registry.renderRoot(main, state)
applyDiff(document.body, main, newMain)
})
}
apllyDiff 함수 매개변수는 현재 DOM 노드와 실제 DOM 노드, 새로운 가상 DOM 노드의 부모입니다.
먼저 새 노드가 정의되지 않은 경우 실제 노드를 삭제합니다.
if(realNode && !virtualNode){
realNode.remove()
}
실제 노드가 정의되지 않았지만 가상 노드가 존재하는 경우 부모 노드에 추가 합니다.
if(!realNode && virtualNode){
parentNode.appendChild(virtualNode)
}
두 노드가 모두 정의된 경우 두 노드 간에 차이가 있는지 확인합니다
if(realNode && virtualNode){
parentNode.appendChild(virtualNode)
}
먼저 모든 하위 노드에 대해 동일한 diff 알고리즘을 적용해야 합니다.
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]
)
}
}
/*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]
)
}
}
export default applyDiff
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 (differentAttribute) {
return true
}
if (node1.children.length === 0 &&
node2.children.length === 0 &&
node1.textContent !== node2.textContent) {
return true
}
return false
}
이 diff 알고리즘 구현에서는 노드를 다른 노드와 비교해 노드가 변경됐는지 확인합니다.
- 속성 수가 다른 경우
- 하나 이상의 속성이 변경됐을 경우
- 노드에는 자식이 없으며, textContent가 다른 경우
개선된 검사 수행으로 성능을 높일 수 있지만 렌더링 엔진을 최대한 간단하게 유지하는 것이 좋습니다.