항해 2주차가 뚝딱 지나갔다.
이번 주차에서는 항해가 커리큘럼을 정말 잘 짰다고 느꼈다. 매주 과제를 통과해야되는데 단계적으로 작성해놓은 테스트코드를 통과하는 과정이 자연스럽게 성장할 수 있는 발판이 된다.
1주차에선 자바스크립트로 라우팅과 이벤트 주입, 컴포넌트 기반 설계등을 구현해보면서 기본을 다지는 시간이었고,
2주차는 자바스크립트로 가상돔을 만들고 React처럼 동작하게 하면서 React의 내부 동작 원리와 이벤트 관리에 대해 더 깊게 이해해보는 시간이었다.
처음엔 가상돔 만드는 주제 보면서 '잉? 그걸 어떻게 만들지?' 라는 생각이 들었다
Hello, Virtual DOM
가상돔은 거창한게 아니라 DOM을 복사한 객체 덩어리다.
객체 덩어리 A, B를 만들어서 변경된 부분만 다시 렌더링해줘서 성능을 올리는 컨셉이다.
DOM에 변경 사항이 있을 때마다 매번 DOM을 새롭게 그리는게 아닌
가상돔으로 비교해 변경된 부분을 모아서 DOM을 새롭게 그린다.
처음엔 그냥 DOM끼리 비교해도 되지 않나 싶었는데 Real DOM도 객체 덩어리인건 맞지만 덩어리 규모차이가 상당하다.
아래 코드처럼 간단한 div를 만들어 콘솔에 찍어보면 매우 많은 객체들이 있는것을 확인할 수 있다.
const $divElement = document.createElement('div');
console.log($divElement);
때문에 DOM 자체를 만들어 비교하려면 그만큼 비용이 많이 들고 DOM 자체가 브라우저 기능이어서 런타임 환경에서야 비교가 가능했다. (가상돔은 순수한 객체)
그리고 가상돔의 다른 이점은 개발자 경험 향상에 있는데
자바스크립트의 명령형 코드로 UI를 만드는 것이 아닌 jsx를 통해 선언적으로 UI를 만들 수 있게 해준다.
// 선언형
<button onClick={console.log("버튼입니다")}>버튼</button>
const $btn = document.createElement("button");
$btn.textContent = "버튼";
$btn.addEventListener("click", () => console.log("버튼입니다"));
추가로 가상돔이 항상 성능 향상에 기여하지는 않는다. 애플리케이션 규모나 기능에 따라 Real DOM을 조작하는 방식이 성능이 좋을 수 있으니 'React가 정답이야' 라는 것보다는 조금 객관적으로 트레이드오프를 고려해 볼 필요가 있다.
자바스크립트로 가상돔을 만들어 React처럼 동작하게 하는 과정은 크게 5가지 과정을 거친다.
1. 가상DOM 생성
RealDOM을 가상돔으로 변환해보자.
// Real DOM
<div id="parent">
<span className="child">Child</span>
</div>
위의 Real DOM을 가상돔으로 구성하면 아래와 같이된다.
function createVDOM(type, props, ...children) {
return { type, props, children.flat() }
}
createVDOM('div', { id: 'parent' },
createVDOM('span', { className: 'child' }, 'Child');
);
그렇게 변환된 객체 덩어리는 아래와 같이된다.
// 가상돔
{
type: "div",
props: { id: "parent" },
children: [
{
type: "span",
props: { className: "child" },
children: ["Child"],
},
],
},
2. 가상DOM 정규화
정규화 함수를 통해 일관된 형식의 가상DOM을 반환하도록 한다
function normalizeVDOM(vDom) {
// vDom이 null, undefined 또는 boolean 타입일 경우 빈 문자열을 반환합니다.
if (vDom === null || vDom === undefined || typeof vDom === "boolean") {
return "";
}
// vDom 문자열 또는 숫자일 경우 문자열로 변환하여 반환
if (typeof vDom === "string" || typeof vDom === "number") {
return String(vDom);
// VDom이 함수일 경우 해당 함수를 호출해 반환된 결과를 재귀적으로 정규화
if (type vDom.type === "function") {
const props = { ...vDom.props, children: vDom:children }
return normalizeVDOM(vDom.type(props))
}
// 그 외의 경우 vDom의 자식 요소들을 재귀적으로 정규화
const normalizedChildren = vDom.children.map((child) => normalizeVDOM(child))
return { ...vDom, children: normalizedChildren }
}
아래 컴포넌트화 시킨 함수를 정규화해보자
const TestComponent = () => (
<ul>
<li id="item-1">Item 1</li>
<li id="item-2">Item 2</li>
<li id="item-3" className="last-item">Item 3</li>
</ul>
);
const normalized = normalizeVNode(<TestComponent />);
이런 결과가 나온다
{
type: "ul",
props: {
},
children: [
{
type: "li",
props: {
id: "item-1",
className: "list-item ",
},
children: [
"- ",
"Item 1",
],
},
{
type: "li",
props: {
id: "item-2",
className: "list-item ",
},
children: [
"- ",
"Item 2",
],
},
{
type: "li",
props: {
id: "item-3",
className: "list-item last-item",
},
children: [
"- ",
"Item 3",
],
},
],
}
3. 가상DOM을 Real DOM으로 변환
function createDOM(vDom) {
// vDom이 null, undefined, boolean일 경우, 빈 텍스트 노드 생성하여 반환
// vDom이 문자열이나 숫자면 텍스트 노드 생성하여 반환
// vDom이 배열이면 DocumentFragment 생성 후 각 자식에 대해 createDOM 재귀 호출하여 추가
// 그 외의 경우 (vDom이 컴포넌트인 경우) DOM 생성
const { type, props, children } = vDom;
const $el = document.createElement(type);
// $el에 props 주입
updateAttributes($el, props);
// children 있는 경우 (부모와 동일하게 DOM생성, props 주입)
children.forEach((child) => {
const $child = createElement(child);
updateAttributes($child, child?.props);
$el.appendChild($child);
});
return $el;
}
// DOM에 props 주입
function updateAttributes($el, props) {
Object.entries(props).forEach(([key, value] => {
// 이벤트인 경우 jsx -> js 로 변경 (ex. onClick -> click)
if (key.startsWith("on") && typeof value === "function") {
const event = key.toLowerCase().slice(2);
// 이벤트는 별도로 저장해놨다가 이벤트 위임방식으로 등록
addEvent($el, event, value);
} else {
$el.setAttribute();
}
});
}
4. 이벤트 주입
createDOM 함수가 실행될 때 이벤트인 경우에 별도로 저장해놨다가 DOM이 생성된 후에 저장해둔 이벤트를
부모 DOM에 등록한다 (이벤트 위임 방식으로 주입)
const eventStore = new Map();
function addEvent(element, eventType, handler) {
let depthMap = eventStore.get(eventType);
if (!depthMap) {
depthMap = new Map();
eventStore.set(eventType, depthMap);
}
depthMap.set(element, handler);
}
function setupEventListeners(root) {
if (eventStore.size < 1) return;
Array.from(eventStore.entries()).forEach(([eventType]) => {
const depthMap = eventStore.get(eventType);
Array.from(depthMap.entries()).forEach(([element, handler]) => {
if (rootStore.get(element)?.has(handler)) {
return;
}
const eventHandler = (e) => {
if (element === e.target) {
handler(e);
}
};
root.addEventListener(eventType, eventHandler);
});
});
}
5. 렌더링
그동안 작성한 코드를 이용해 SPA 처럼 렌더링시켜준다.
function renderDOM(vDom, container) {.
const normalizedvDom = normalizeVNode(vDom);
const $el = createElement(normalizedvDom);
// 최초 렌더링시에는 createElement로 DOM을 생성
if (!container.__oldVDom) {
container.appendChild($el); // 최초 렌더링 시 DOM 생성
// 이후에는 변경된 부분만 기존 DOM을 업데이트한다.
} else {
container.replaceChild($el); // 대충 퉁쳤는데 변경된 가상돔을 check해서 update하는 부분 필요
}
container.__oldVDom = normalizedVDom;
// 렌더링이 완료되면 container에 이벤트를 등록한다
setupEventListeners(container);
}
'항해99 ➕플러스' 카테고리의 다른 글
프레임워크 없이 SPA 만들기 (항해 1주차 회고) (0) | 2025.04.04 |
---|