
2주차 과제는 1주차와 같이 프레임워크 없이 spa 페이지 구현하기지만, 더 자세하게는 가상 돔과 diff 알고리즘을 사용하여 이벤트 관리를 최적화하고, 불필요한 렌더링을 줄이는 것이었다.
실제 DOM의 복사본 - 변경된 것만 실제의 DOM에 반영하여 불필요한 렌더링을 줄임
과제 시작 직전, 내가 가상 돔에 대해 알고 있는 그대로 쓴 문장 하나이다. 기본 지식이라곤 이 한 문장이 전부였기 때문에 이번 과제는 또 어떻게 헤쳐나가야 할지 너무 막막했다.. 하지만 걱정과 달리 1주차 과제보다 훨씬 재밌게 느껴졌다. 저번주 과제는... 백틱에 둘러싸인 html 코드가 너무 보기가 싫었다...🥹 나가
우선 이번 회고에서는 과제를 하면서 막혔던 부분을 크게 3가지 정도 작성해보려 한다.
createVNode나 createElement, updateElement와 같은 함수는 함수명만 봐도 어떤 역할인지 대강 알 수 있었는데, normalizeVNode는 정확히 어떤 역할의 함수인지 감이 잡히지 않았다. VNode를 정규화하는 건 어떤 과정일까..
renderElement 함수는 인자로 받은 vNode를 normalizeVNode 함수를 사용해 정규화하고, 그 반환값을 createElement 함수를 사용하여 요소로 만든다. 즉, VNode를 렌더링 가능한 일관된 형태로 변환해주는 것이다. createElement가 정제된 vNode를 사용해 요소를 만들 수 있도록 도움을 주는 함수이다!
추가로 normalizeVNode 함수의 테스트에는 null, undefined, boolean 값은 빈 문자열로 변환되어야 한다. 와 Falsy 값 (null, undefined, false)은 자식 노드에서 제거되어야 한다.가 있는데, 처음에 이 둘의 차이를 정확히 이해하지 못했다.
normalizeVNode(null) => "" )if (typeof vNode === "boolean" || vNode === null || vNode === undefined) {
vNode = "";
}
<div>{null}</div> => <div></div>를 위한 과정)if (typeof vNode === "object") {
const children = vNode.children ?? [];
const normalizedChildren = children
.map(normalizeVNode)
.filter((child) => child !== "" && child !== null && child !== undefined && typeof child !== "boolean");
vNode = {
...vNode,
children: normalizedChildren,
};
}
위의 함수는 createElement 함수에서 새 노드에 속성을 세팅할 때, updateElement 함수에서 이전 노드와 새 노드를 비교하여 속성을 업데이트할 때 사용하는 함수이다.
// src/lib/updateElement.js
// props 업데이트 및 추가
Object.entries(originNewProps).forEach(([attr, value]) => {
if (attr === "className") {
target.setAttribute("class", value);
} else if (typeof value === "boolean") {
value && target.setAttribute(attr, ""); // ⚠️
} else if (attr.startsWith("on")) {
const eventType = attr.slice(2).toLowerCase();
const oldHandler = originOldProps[attr];
if (oldHandler) {
// 이벤트를 제거하지 않으면 누적됨
removeEvent(target, eventType, oldHandler);
}
addEvent(target, eventType, value);
} else {
target.setAttribute(attr, value);
}
});
위의 코드는 내가 처음 작성했던 코드이고, 문제가 됐던 부분은 value가 boolean 타입일 때의 처리였다. createElement와 같은 코드로 작성하면 정상 작동할 줄 알았는데, boolean type props가 property로 직접 업데이트되어야 한다.의 checked, disabled, selected 테스트를 모두 실패했다.
내가 쓴 코드에는 두 가지 문제가 있는데, 첫번째는 value가 false일 때 아무 처리도 하지 않는다는 것이고, 두번째는 이미 attr가 존재할 때 직접적으로 업데이트를 해주지 않는다는 것이다.
else if (typeof value === "boolean") {
if (attr in target) {
// target에 attr가 존재하는지 확인
target[attr] = value; // 있으면 직접 업데이트
if (!value) {
// attr가 존재하는데 값이 false
// 해당 attr 삭제
target.removeAttribute(attr);
}
} else {
// attr가 존재하지 않음
// true이면 빈 문자열로 추가, false이면 제거
value ? target.setAttribute(attr, "") : target.removeAttribute(attr);
}
}
위와 같이 직접적으로 값을 업데이트하게 수정하여 테스트를 통과했다!
지금 보니까 target[attr] = value;만 작성해도 될 것 같은 느낌..
함수 작동 테스트인 basic.test와 advanced.test를 모두 통과하고, e2e 테스트를 시작하기 전에 페이지를 열고 기본 동작들을 확인해보니 제일 기본적인 페이지 라우트가 동작하지 않았다. (테스트 다 통과했는데 대체 왜)
// src/components/ProductCard.jsx
<div className="cursor-pointer product-info mb-3" onClick={handleClick}>
<h3 className="text-sm font-medium text-gray-900 line-clamp-2 mb-1">{title}</h3>
<p className="text-xs text-gray-500 mb-2">{brand}</p>
<p className="text-lg font-bold text-gray-900">{price.toLocaleString()}원</p>
</div>
위 코드는 ProductCard 컴포넌트의 일부분인데, div 범위에는 포함되지만 h3, p 태그의 범위는 아닌 아주 애매한 부분을 클릭해야만 클릭 이벤트가 작동했다...
이딴 현상 처음봤다.
이는 setupEventListeners 함수에서의 문제였다.
// src/lib/eventManager.js (setupEventListeners())
const handleEvent = (event) => {
const target = event.target;
if (!handlerMap.has(target)) return;
const handler = handlerMap.get(target);
if (handler) handler(event);
};
내가 처음 작성했던 setupEventListeners 함수 코드의 일부분이다. 이 코드는 클릭한 요소에 등록되어있는 이벤트를 동작시키기 때문에, 부모 요소에 등록된 이벤트가 동작하지 않는다.
const handleEvent = (event) => {
let target = event.target;
// 클릭된 요소와 이벤트가 연결된 요소가 다름
while (target && target !== event.currentTarget) {
if (handlerMap.has(target)) {
// 등록된 이벤트 함수
const handler = handlerMap.get(target);
// 실행시킨 후 종료
if (handler) handler(event);
break;
}
// 부모 요소로 올라가며 반복
target = target.parentElement;
}
};
바로 부모 요소를 event.currentTarget으로 확인하며 이벤트를 연결해주는 코드가 빠져있었기 때문이었다. 현재 클릭된 요소와 이벤트가 연결된 요소가 다를 경우에 부모 요소를 하나씩 검사하면서 이벤트 함수가 있으면 해당 이벤트를 실행하도록 코드를 수정하여 테스트에 통과했다. 💪
diff 알고리즘은 두 개의 데이터를 비교해서 어떤 부분이 다른지, 어떤 부분이 변경됐는지를 찾아내는 알고리즘이다. 처음 diff 알고리즘 테스트를 통과해야 한다는 말을 듣고 추가로 엄청난 어려운 과제가 남아있는 줄 알았는데 그냥 내가 구현한 모든 코드 자체가 diff 알고리즘이었다..😅💦
// src/lib/updateElement.js
const newChildren = newNode.children ?? [];
const oldChildren = oldNode.children ?? [];
// 공통 길이까지 자식 요소를 비교하며 요소 업데이트
for (let i = 0; i < Math.min(newChildren.length, oldChildren.length); i++) {
updateElement(target, newChildren[i], oldChildren[i], i);
}
// newChildren가 더 많으면 추가
for (let i = oldChildren.length; i < newChildren.length; i++) {
target.appendChild(createElement(newChildren[i]));
}
// oldChildren가 더 많으면 역순으로 삭제
for (let i = oldChildren.length - 1; i >= newChildren.length; i--) {
const child = target.childNodes[i];
if (child) target.removeChild(child);
}
자식 노드끼리 비교하며 재귀 업데이트를 하는 부분이 조금 헷갈렸었다.
우선 현재 자식 노드와 이전 자식 노드의 공통 길이를 Math.min으로 계산한 뒤에 순서대로 updateElement 함수를 통해 업데이트해주었다.
그 이후 추가될 자식 노드가 있는 경우엔 appendChild로 노드를 요소로 추가해주었고, 이전 자식 노드가 더 많은 경우엔 순서(인덱스)의 영향을 받지 않기 위해 역순으로 삭제해주었다!
처음에 과제를 하면서 테스트 코드를 보며 각 함수를 작성하면서 근데 이런 함수들이 어떻게 연결되는거지...? 라는 생각이 들었다. 각 함수의 기능도 알겠고 역할도 알겠는데 결국 이 함수들이 어느 순서를 거쳐 페이지에 컴포넌트를 보여주는 건지 감이 잡히지 않았다.
// src/lib/renderElement.js
// 최초 렌더링시에는 createElement로 DOM을 생성하고
// 이후에는 updateElement로 기존 DOM을 업데이트한다.
// 렌더링이 완료되면 container에 이벤트를 등록한다.
const prevVNode = new WeakMap();
export function renderElement(vNode, container) {
// 이전 VNode
const currentNode = prevVNode.get(container);
// 새로운 VNode를 정규화
const normalizedVNode = normalizeVNode(vNode);
if (!currentNode) {
// 최초 렌더링 시 DOM 생성
const element = createElement(normalizedVNode);
container.appendChild(element);
} else {
// 기존 DOM 업데이트
updateElement(container, normalizedVNode, currentNode);
}
// 렌더링한 VNode 저장 후 이벤트 위임
prevVNode.set(container, normalizedVNode);
setupEventListeners(container);
}
친절한 주석 덕분에 위의 순서 그대로 구현할 수 있었다👍
이번 과제는 시작하기 전 학습 자료를 정독하는 시간을 가졌다.
테스트 코드도 미리 보면서 어떤 기능을 구현해야 하는지 알고 시작하니 저번 주보다 훨씬 수월하게 진행된 느낌이었다...
테스트를 통과해서 코드가 지저분해보이는데도 그냥 넘어간 부분이 몇 있다.
(updateElement나 setupEventListener 함수 부분)
테스트 통과한건 좋지만, 시간이 남으면 리팩토링 미루지 말고 꼭 해보기.
정확히 모르겠는 개념은 질문하기, 다른 팀원의 코드도 살펴보기!!
저번 주부터 지금까지 계속 모든 걸 너무 나 혼자 하는 것 같다.
물론 개인 과제라 혼자 해야 하지만 부트캠프 내에서 할 수 있는 일을 안하고 있는 느낌..? 훨씬 어려워보이는 3주차 과제부턴 꼭 질문 많이하기.. 😶
아름님 2주차 고생 많으셨어요! 저도 자꾸 혼자 하려는 습관이 있어서 반성되네욧.. 😢 같은 팀끼리 으쌰으쌰 해봐요!