요약과 서평 | 프레임워크 없는 프론트엔드 개발
집에 쌓아두고 먼지투성이가 된 책들이 한두 권이 아니다… 더 늦기 전에 하나씩 꺼내서 읽어볼 예정이다. 첫 번째로는 프레임워크 없는 프론트엔드 개발이라는 책을 읽어봤다.
전반적으로 책을 요약하고 간단한 서평을 하려고 한다.

요약
1장 : 프레임워크에 대한 이야기
프레임워크가 무엇인지 알아보고 라이브러리와의 차이를 알아본다. 다음으로 자바스크립트 프레임워크의 역사를 알아본다.
2장 : 렌더링
데이터를 표시하는 렌더링을 프레임워크 없이 하는 방법에 대해 알아본다.
// index.js
...
registry.add('todos', todosView)
registry.add('counter', counterView)
registry.add('filters', filtersView)
const state = {
todos: getTodos(),
currentFilter: 'All'
}
const render = () => {
window.requestAnimationFrame(() => {
const main = document.querySelector('.todoapp')
const newMain = registry.renderRoot(main, state)
applyDiff(document.body, main, newMain)
})
}
...
index.html 에서 .todoapp 이라는 class를 가진 Element를 가져와서 renderRoot 를 통해 newMain를 만들고 applyDiff 함수로 특정 노드가 바뀌었는지 확인하고 바뀌었다면 특정 노드를 새로운 노드로 변경한다.
// registry.js
const registry = {}
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
}
}
const add = (name, component) => {
registry[name] = renderWrapper(component)
}
const renderRoot = (root, state) => {
const cloneComponent = root => {
return root.cloneNode(true)
}
return renderWrapper(cloneComponent)(root, state)
}
export default {
add,
renderRoot
}
내가 감탄한 부분이다. renderWrapper 함수를 재활용하며 registry.add 를 통해 추가한 View들을 렌더해주는 역할을 하고 있다.
// applyDiff.js
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
}
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
이전 Node와 새로운 Node가 Attribute 수가 같은지, Attribute 이름이 같은지, 자식 Node의 수가 같은지, Content가 같은지 비교하며 변경점이 있는지 재귀함수를 활용해서 체크한다.
위 코드들을 이해하면서 리액트의 내부 코드의 흐름과 굉장히 유사하다고 느꼈다.
3장 : DOM 이벤트 관리
사용자와 인터렉션하기 위해 2장의 코드에 이벤트 핸들러를 연결한다.
// index.js
const events = {
deleteItem: (index) => {
state.todos.splice(index, 1)
기존 코드에 이벤트를 추가해줬다. 이벤트가 발생할 때마다 render 를 호출하는 것을 주목하자.
// app.js
const addEvents = (targetElement, events) => {
targetElement
.querySelector('.new-todo')
.addEventListener('keypress', e => {
if (e.key === 'Enter') {
events.addItem(e.target.value)
e.target.value = ''
}
})
}
export default (targetElement, state, events) => {
const newApp = targetElement.cloneNode(true)
newApp.innerHTML = ''
newApp.appendChild(getTemplate())
addEvents(newApp, events)
return newApp
}
이벤트를 부착한다.
4장 : 웹 구성 요소
HTML 템플릿, 사용자 정의 요소를 소개하고 웹 컴포넌트로 Todo 앱을 만드는 방법을 소개한다. 웹 컴포넌트를 책 전반에서 다루진 않기 때문에 관련 코드는 생략한다. (아래는 관련 링크)
Using custom elements - Web APIs | MDN
One of the key features of web components is the ability to create custom elements: that is, HTML elements whose behavior is defined by the web developer, that extend the set of elements available in the browser.

5장 : HTTP 요청
AJAX가 나오면서 변화한 웹 방식에 관해 설명하고 HTTP 클라이언트를 구현하는 세 가지 방법에 대해 알아본다.
XMLHttpRequest
const setHeaders = (xhr, headers) => {
Object.entries(headers).forEach(entry => {
const [
name,
value
] = entry
xhr.setRequestHeader(
name,
value
)
})
}
const parseResponse = xhr => {
const {
status,
responseText
} = xhr
let data
if (status !== 204) {
data = JSON.parse(responseText)
}
return {
status,
data
}
}
const request = params => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const {
method = 'GET',
url,
headers = {},
body
} = params
xhr.open(method, url)
setHeaders(xhr, headers)
xhr.send(JSON.stringify(body))
xhr.onerror = () => {
reject(new Error('HTTP Error'))
}
xhr.ontimeout = () => {
reject(new Error('Timeout Error'))
}
xhr.onload = () => resolve(parseResponse(xhr))
})
}
const get = async (url, headers) => {
const response = await request({
url,
headers,
method: 'GET'
})
return response.data
}
...
export default {
get,
post,
put,
patch,
delete: deleteRequest
}
Fetch API
const parseResponse = async response => {
const { status } = response
let data
if (status !== 204) {
data = await response.json()
}
return {
status,
data
}
}
const request = async params => {
const {
method = 'GET',
url,
headers = {},
body
} = params
const config = {
method,
headers: new window.Headers(headers)
}
if (body) {
config.body = JSON.stringify(body)
}
const response = await window.fetch(url, config)
return parseResponse(response)
}
const get = async (url, headers) => {
const response = await request({
url,
headers,
method: 'GET'
})
return response.data
}
...
export default {
get,
post,
put,
patch,
delete: deleteRequest
}
Axios
const request = async params => {
const {
method = 'GET',
url,
headers = {},
body
} = params
const config = {
url,
method,
headers,
data: body
}
return axios(config)
}
const get = async (url, headers) => {
const response = await request({
url,
headers,
method: 'GET'
})
return response.data
}
...
export default {
get,
post,
put,
patch,
delete: deleteRequest
}
라이브러리를 사용할 때는 항상 이에 대한 인터페이스를 생성하라. 필요시 새로운 라이브러리로 쉽게 변경할 수 있다.
6장 : 라우팅
SPA에서 라이브러리를 사용하지 않고 클라이언트 라우팅을 하는 방법에 대해 알아본다.
프래그먼트 식별자 기반 라우팅
// router.js
export default () => {
...
const checkRoutes = () => {
const { hash } = window.location;
// 현재 프래그먼트가 경로 중 하나와 매칭되는지 확인
const currentRoute = routes.find((route) => {
const { testRegExp } = route;
return testRegExp.test(hash);
});
if (!currentRoute) {
notFound();
return;
}
const urlParams = extractUrlParams(currentRoute, window.location.hash);
// 경로에 대한 컴포넌트를 보여준다.
currentRoute.component(urlParams);
};
router.addRoute = (fragment, component) => {
const params = [];
// 정규표현식을 사용해 매개 변수를 params에 넣어준다.
const parsedFragment = fragment
.replace(ROUTE_PARAMETER_REGEXP, (match, paramName) => {
params.push(paramName);
return URL_FRAGMENT_REGEXP;
})
.replace(/\//g, "\\/");
console.log(`^${parsedFragment}$`);
routes.push({
testRegExp: new RegExp(`^${parsedFragment}$`),
component,
params,
});
return router;
};
router.setNotFound = (cb) => {
notFound = cb;
return router;
};
router.navigate = (fragment) => {
window.location.hash = fragment;
};
router.start = () => {
window.addEventListener("hashchange", checkRoutes);
if (!window.location.hash) {
window.location.hash = "#/";
}
checkRoutes();
};
...
};
// index.js
...
router
.addRoute("#/", pages.home)
.addRoute("#/list", pages.list)
.addRoute("#/list/:id", pages.detail)
.addRoute("#/list/:id/:anotherId", pages.anotherDetail)
.setNotFound(pages.notFound)
.start();
...
히스토리 API 기반 라우팅
// router.js
export default () => {
...
const checkRoutes = () => {
const { pathname } = window.location
if (lastPathname === pathname) {
return
}
...
}
router.addRoute = (path, callback) => {
const params = []
const parsedPath = path
.replace(
ROUTE_PARAMETER_REGEXP,
(match, paramName) => {
params.push(paramName)
return URL_FRAGMENT_REGEXP
})
.replace(/\//g, '\\/')
routes.push({
testRegExp: new RegExp(`^${parsedPath}$`),
callback,
params
})
return router
}
...
router.navigate = path => {
window
.history
.pushState(null, null, path)
}
router.start = () => {
checkRoutes()
window.setInterval(checkRoutes, TICKTIME)
}
...
}
바뀐 부분만 별도로 적었다. pushState 메서드는 새 URL로 이동시켜 주는 역할을 한다.
Navigo 기반 라우팅
(생략합니다 ~)
7장 : 상태 관리
프론트엔드에서 상태를 관리하는 3가지 방법을 알아본다.
모델 - 뷰 -컨트롤러
// model/state.js
export default (initalState = INITIAL_STATE) => {
const state = cloneDeep(initalState);
let listeners = [];
const addChangeListener = (listener) => {
listeners.push(listener);
listener(freeze(state));
return () => {
listeners = listeners.filter((l) => l !== listener);
};
};
const invokeListeners = () => {
const data = freeze(state);
listeners.forEach((l) => l(data));
};
const addItem = (text) => {
if (!text) {
return;
}
state.todos.push({
text,
completed: false,
});
invokeListeners();
};
...
return {
addItem,
...
};
};
대부분의 상태 관리 라이브러리 Core 코드에서 사용하는 방식이다. 어떠한 액션이 일어나면 리스너들을 실행해준다.
반응형 프로그래밍
// model/model.js
import observableFactory from './observable.js'
export default (initialState = INITIAL_STATE) => {
const state = observableFactory(initialState)
...
}
// model/observable.js
export default (initialState) => {
let listeners = []
const proxy = new Proxy(cloneDeep(initialState), {
set: (target, name, value) => {
target[name] = value
listeners.forEach(l => l(freeze(proxy)))
return true
}
})
proxy.addChangeListener = cb => {
listeners.push(cb)
cb(freeze(proxy))
return () => {
listeners = listeners.filter(l => l !== cb)
}
}
return proxy
}
기존 MVC 상태 관리 방식에서 Proxy를 활용하여 옵저버블 모델을 생성했다. Proxy에 대한 이해도가 높지 않아서 그런지 위처럼 했을 때 기존 MVC 방식에 비해 어떠한 장점이 있는지 잘 모르겠다.
이벤트 버스
노드들을 연결하는 단일 객체가 모든 이벤트를 처리한다는 컨셉이다. 다른 방식(MVC, 반응형)과 큰 차이가 있다고 느껴지지는 않았다. 우리가 Redux에서 많이 사용한 dispatch를 통해 뷰에서 이벤트 버스로 이벤트를 전달한다.
(코드 생략)
8장 : 적합한 작업을 위한 적합한 도구
(생략합니다 ~ 가볍게 읽어보세요!)
서평
책의 전반적인 내용은 프론트엔드를 개발할 때 대부분 사용하는 라이브러리, 프레임워크(React, Vue, Next 등)를 사용하지 않고 바닐라 자바스크립트로 구현하는 방법에 관한 이야기를 한다. 하지만 그렇다고 프레임워크 없는 개발이 옳아요! 좋아요! 라고 말하지 않는다. 1 ~ 7장까지 걸쳐서 우리가 프레임워크, 라이브러리를 사용하면서 편하게 활용할 수 있는 웹에서 필요한 기능 (렌더링, 상태 관리, 라우팅 등) 들이 어떻게 구현되어 있는지 원리와 내부 구현에 관해 설명한다. 그리고 마지막 장에서 어떻게 적합한 도구를 고를 수 있는가에 대한 이야기를 한다.
당연하게 사용해 왔던 기술들이 어떠한 원리로 구성되어 있는지 책을 통해 이해하면서 내가 사용하고 있는 기술들을 좀 더 깊이 이해할 수 있었다.
특히, 책 전반에 걸쳐 사용되는 핵심 코드는 함수형으로 프로그래밍 되어있는데 코드를 천천히 뜯어보며 다시 한번 함수형 프로그래밍의 매력을 느낄 수 있었다.
나에게는 가려운 부분을 많이 긁어준 책이었다. (오랜만에 재밌게 끝까지 읽어본 책이었다.) 책의 분량도 많지 않으니 웹 개발자는 한 번쯤 읽어보는 걸 추천한다!