과제 체크포인트
배포 링크
https://dev4n4.github.io/front_6th_chapter1-2/
기본과제
가상돔을 기반으로 렌더링하기
- createVNode 함수를 이용하여 vNode를 만든다.
- normalizeVNode 함수를 이용하여 vNode를 정규화한다.
- createElement 함수를 이용하여 vNode를 실제 DOM으로 만든다.
- 결과적으로, JSX를 실제 DOM으로 변환할 수 있도록 만들었다.
이벤트 위임
- 노드를 생성할 때 이벤트를 직접 등록하는게 아니라 이벤트 위임 방식으로 등록해야 한다
- 동적으로 추가된 요소에도 이벤트가 정상적으로 작동해야 한다
- 이벤트 핸들러가 제거되면 더 이상 호출되지 않아야 한다
심화 과제
Diff 알고리즘 구현
- 초기 렌더링이 올바르게 수행되어야 한다
- diff 알고리즘을 통해 변경된 부분만 업데이트해야 한다
- 새로운 요소를 추가하고 불필요한 요소를 제거해야 한다
- 요소의 속성만 변경되었을 때 요소를 재사용해야 한다
- 요소의 타입이 변경되었을 때 새로운 요소를 생성해야 한다
과제 셀프회고
음.. 설명이 친절해서 과제를 어렵지 않게 진행할 수 있을 줄 알았는데 생각보다 어려웠다.
디버깅을 할 줄 몰라서 초반에 조금 고생을 했고, 디버깅 하는 방법을 배우고 나서도 생각대로 로그가 찍히지 않거나 예상했던 결과가 나오지 않아서 고생을 했다.
과제 초반에는 이런 함수를 왜 구현해야 하는지 의문을 가지고 한참 찾아보면서 이해하려고 애썼고, 조금 공부한 결과 흐름이 이해 되었지만 개념이나 원리를 이해하고 진행을 하더라도 그걸 코드로 녹여내 보여주는 것이 생각보다 까다로웠다. (개념이랑 흐름, 여기서 이 함수가 왜 필요한지나 이 조건문을 왜 넣는지 같은건 알겠는데 그래서 그 다음엔 어떻게 진행해야 할지를 잘 모르겠어서 다른 분들의 PR도 많이 참고하고 많이 물어가면서 개발을 진행했던 것 같다…)
기술적 성장
이번 과제를 수행하면서 Virtual DOM에 대해 깊게 이해하게 된 것 같다.
구현한 각각의 함수의 역할에 대해 간략한 정리 & 설명을 한다면
1. createVNode
createVNode 함수는 Virtual Node를 생성하는 함수이다.
각 페이지 파일마다 존재하는 /** @jsx createVNode */ (JSX 프래그마(pragma) 주석)을 통해 JSX가 createVNode(...) 함수로 변환되도록 지시해 사용한다.
children을 평탄화하여 사용하였다.
2. normalizeVNode
normalizeVNode 함수는 주어진 가상 노드(vNode)를 표준화된 형태로 변환하는 역할을 한다.
이 함수는 다양한 타입의 입력을 처리하여 일관된 형식의 가상 노드를 반환하여 DOM 조작이나 렌더링 과정에서 일관된 데이터 구조를 사용할 수 있도록 하는 역할을 수행한다.
3. createElement
vNode를 Javascript Element로(가상돔(VirtualDOM)을 돔으로) 변환해주는 함수이다.
4. eventManager
- addEvent와 removeEvent를 통해 element에 대한 이벤트 함수를 어딘가에 저장하거나 삭제한다.
- setupEventListeners를 이용해서 이벤트 함수를 가져와서 한 번에 root에 이벤트를 등록한다.
findParent 함수를 통해 이벤트 위임(버블링) 방식으로 등록하였다.
5. renderElement
renderElement 함수는 앞에서 작성된 함수들을 조합하여 vNode를 container에 렌더링하는 작업을 수행한다.
최초 렌더링일 때(container._previousVNode == null 일 때)는 createElement 함수를 사용하여 랜더링이 되고, 리렌더링일 때는 updateElement 함수를 사용한다.
6. updateElement
updateElement 함수는 모든 태그를 비교하여 변경된 부분에 대해 수정/추가/삭제 작업을 수행해준다.
이렇게 정리가 될 수 있을 것 같다.
이 함수들의 역할과 서로 각자 무슨 연관관계가 있는지가 처음에는 잘 와닿지 않았고 리액트 내부에서 왜 이런 동작이 필요한지 잘 모르고 진행을 했었는데, 여러 자료들을 찾아보고 코드를 작성해 나가면서 이해가 되었다..
코드 품질
updateElement 함수를 구현하면서 고민을 좀 했다.
// 5. 같은 타입의 노드 업데이트
// - 속성 업데이트
// - 자식 노드 재귀적 업데이트
// - 불필요한 자식 노드 제거
if (target) {
const oldProps = oldNode.props;
const newProps = newNode.props;
updateAttributes(target, newProps ?? {}, oldProps ?? {});
const oldChildren = oldNode.children ?? [];
const newChildren = newNode.children ?? [];
for (let i = 0; i < Math.max(newChildren.length, oldChildren.length); i++) {
updateElement(target, newChildren[i], oldChildren[i], i);
}
// 이 부분!!
// ************************************************ //
if (oldChildren.length > newChildren.length) {
for (let i = oldChildren.length - 1; i >= newChildren.length; i--) {
if (target.childNodes[i]) {
target.removeChild(target.childNodes[i]);
}
}
}
}
// ************************************************ //
구현을 끝내고 코드를 다시 점검할 때, 갑자기 주석으로 표시한 부분의 코드를 최적화 할 수 있지 않을까 하는 의문이 생겼었다.
왜냐하면 상단에서 newChildren 혹은 oldChildren 중 length가 긴 쪽을 중심으로 이미 for문이 한번 돌아가면서 node들을 제거 혹은 교체를 한번씩 진행해줄텐데 굳이 아랫부분에서 한번 더 for문을 돌려서 제거를 진행할 필요가 있는지 의문이 들었다.
그래서 GPT에게 더 좋은 방법이 없을지, 저 부분이 꼭 필요한 코드일지 물어보며 답을 얻었다. 결과적으로 저 부분은 최적화를 해야하는 부분이 아니라 정확한 구현을 위해 그대로 필요한 코드였다.
예를 들어서 설명하자면 아래와 같다.
1. 초기 상태
- oldChildren: [A, B, C, D]
- newChildren: [A, C]
- 실제 DOM의
parent.childNodes도A, B, C, D순이라고 가정을 하자.
parent
├── A
├── B
├── C
└── D
2. 재귀 호출(재귀 for 루프) 진행
for (let i = 0; i < Math.max(...); i++) 루프가 i = 0에서 3까지 돌게 된다.
-
i = 0
- oldChildren[0] = A, newChildren[0] = A
- 타입·키가 같으므로 속성만 업데이트하고 건너뜀
- DOM: A, B, C, D
-
i = 1
- oldChildren[1] = B, newChildren[1] = C
- 타입이 다르므로
parent.replaceChild(createElement(C), targetAtIndex1)수행 - 이때 B가 C로 교체되어, DOM이
[A, C, C, D]가 됨.
-
i = 2
-
oldChildren[2] = C, newChildren[2] =
undefined -
newNode가 없고 oldNode가 있으므로
removeChild(targetAtIndex2)수행 -
이때 두 번째 인덱스(0-based)였던
childNodes[2](원래 교체 후의 두 번째 C)가 삭제되어DOM이
[A, C, D]가 됨.
-
-
i = 3
-
oldChildren[3] = D, newChildren[3] =
undefined -
removeChild(targetAtIndex3)을 시도하나,현재
childNodes길이는 3 (인덱스 0,1,2)라서 아무 일도 일어나지 않아 -
결과적으로 D가 여전히 남아 있는 문제가 발생.
-
DOM:
[A, C, D]
-
3. 클린업(Cleanup) 단계
if (oldChildren.length > newChildren.length) {
// oldChildren.length = 4, newChildren.length = 2
for (let i = oldChildren.length - 1; i >= newChildren.length; i--) {
if (target.childNodes[i]) {
target.removeChild(target.childNodes[i]);
}
}
}
i = 3(인덱스 3):childNodes[3]이 없으므로 건너뜀i = 2(인덱스 2):childNodes[2]은 D → 제거- 이후
i = 1이면 반복 종료 조건(i >= 2)가 false가 되어 루프 끝
최종 DOM 구조는 [A, C]가 되어 새로운 자식 배열(newChildren)과 정확히 일치한다.
어차피 newChildren의 length보다 많게 남아있는 요소들은 필요가 없으므로 이 단계에서 삭제해줘야 한다. 그래서 필요한 로직이고 앞에서 for문을 한번 돌리는 것으로 모든것을 해결하기는 어려웠을 것 같다.
결론은 처음 구현한 형태가 최선일 것 같아서 수정하지 않았다 였지만, 최적화를 시도해보려고 다시 한번 고민했던 경험이 좋았다.
학습 효과 분석
우리가 어떻게 JSX 문법을 쓸 수 있게 되었는지에 대해서 알게 되었고, 디버깅 하는 방법에 대해서도 새로 알게 되었다. 또한 구현을 위해 mdn에 들락날락 하면서 다양한 document 메서드에 대해서 읽어보는 기회도 되었던 것 같다.
막연히 마법처럼 느껴졌던 과정이 좀 더 현실적으로 와닿아서 의의가 있었다는 생각이 들었다.
아직 이러한 과정들을 흐름적으로만 설명할 수 있고 영서님께서 정리해주신 것 처럼 체계적으로 설명하기는 어려운 것 같아서 기회가 된다면 나중에 블로그에 내용을 정리해서 작성하면 더욱 좋을 것 같다는 생각이 들었다.
과제 피드백
과제의 지시사항이나 설명이 친절해서 좋았습니다!!
사실 과제를 진행하면서 초반에는 지시사항을 따라서 함수를 작성하기만 하는건데 개념이 숙지가 될까..? 하는 걱정이 조금 있었지만 주신 자료를 충실히 읽어보고 이행하니 흐름이 다 이해가 되었어요
제공해주신 자료들에 수강생들이 이 과제를 통해 배웠으면 하는 사항들이 잘 설명되어 있어서 편했고 추가적인 자료를 많이 찾아볼 필요가 없이 진행되었던 것 같아 좋았습니다!!
리뷰 받고 싶은 내용
1. updateElement 함수의 “3. 텍스트 노드 업데이트” 주석 부분 코드 최적화에 대한 질문이 있습니다.
// 3. 텍스트 노드 업데이트
const newEl = createElement(newNode);
const oldEl = createElement(oldNode);
if (newNode != null && oldNode != null && newEl.nodeType === Node.TEXT_NODE && oldEl.nodeType === Node.TEXT_NODE) {
if (newNode !== oldNode) {
const newTextNode = document.createTextNode(String(newNode));
if (target) {
parentElement.replaceChild(newTextNode, target);
} else {
parentElement.appendChild(newTextNode);
}
}
return;
}
지금은 TEXT_NODE 프로퍼티를 통해 같은 태그끼리 비교를 할 수 있게 하려고 newNode와 oldNode를 createElement 함수를 사용하여 element로 만들어서 사용을 하고 있는데요. 정작 element로 만든 다음에 그 element를 사용하지 않는 게 성능 측면에서 안좋지 않을까 하고 걱정이 되었습니다. 하지만 이 방식이 아니라면 일일히 태그명을 하드코딩해서 비교를 해주는 방식으로 개발을 해야할 것 같은데 그렇다면 코드가 상당히 지저분해 질 것 같다는 생각도 들었습니다. 혹시 다른 더 좋은 구현 방법이 있을까요? 지금 방법이 성능에 많이 안좋은 방향일까요? 태그명으로 하드코딩 하는 방식으로 진행했어야 할까요?
2. eventManager의 setupEventListeners 함수 구현에 대해서 더 나은 방법을 얻고 싶습니다.
export function setupEventListeners(root) {
const allDomEvents = [
// 마우스 이벤트
"click",
"dblclick",
"mousedown",
"mouseup",
"mousemove",
"mouseenter",
"mouseleave",
"mouseover",
"mouseout",
"contextmenu",
"auxclick",
// 키보드 이벤트
"keydown",
"keypress",
"keyup",
// 입력/폼 이벤트
"input",
"change",
"submit",
"reset",
"focus",
"blur",
"focusin",
"focusout",
"invalid",
// 터치 이벤트
"touchstart",
"touchmove",
"touchend",
"touchcancel",
// 포인터 이벤트
"pointerdown",
"pointerup",
"pointermove",
"pointercancel",
"pointerover",
"pointerout",
"pointerenter",
"pointerleave",
"gotpointercapture",
"lostpointercapture",
// 휠 및 스크롤 이벤트
"wheel",
"scroll",
// 드래그 & 드롭 이벤트
"drag",
"dragstart",
"dragend",
"dragenter",
"dragleave",
"dragover",
"drop",
// 클립보드 이벤트
"copy",
"cut",
"paste",
// 컴포지션 이벤트
"compositionstart",
"compositionupdate",
"compositionend",
// 윈도우 이벤트
"load",
"beforeunload",
"unload",
"resize",
"hashchange",
"popstate",
"DOMContentLoaded",
"visibilitychange",
"storage",
// 네트워크 이벤트
"online",
"offline",
// 미디어 이벤트
"play",
"pause",
"playing",
"waiting",
"ended",
"volumechange",
"timeupdate",
"seeking",
"seeked",
"loadeddata",
"loadedmetadata",
"canplay",
"canplaythrough",
// 애니메이션/트랜지션 이벤트
"animationstart",
"animationend",
"animationiteration",
"transitionstart",
"transitionend",
"transitionrun",
"transitioncancel",
// 오류 및 기타
"error",
"abort",
"close",
"open",
];
for (const eventType of allDomEvents) {
root.addEventListener(eventType, (e) => {
const events = eventList.filter((event) => event.eventType === eventType);
if (events.length <= 0) {
return;
}
for (const event of events) {
if (event.element === findParent(e.target, event.element)) {
event.handler(e);
break;
}
}
});
}
}
현재 allDomEvents 라는 이벤트명들을 모아둔 배열을 만들어서 여기서 이벤트 타입을 찾는 형태로 구현을 해두었습니다. 사실상 하드코딩이고 이 방식이 유지보수 측면에서 좋을 것 같다는 생각이 들진 않았는데.. 기본 제공하는 함수들 중에서 이벤트 타입들을 모아서 반환해주는 함수는 없다고 하여 미봉책으로 이렇게 개발하였지만 더 좋은 방법은 없을까 싶어 여쭙게 되었습니다. 코치님께서는 이 함수를 리팩토링 하신다면 어떻게 개선하실지가 궁금합니다.
과제 피드백
안녕하세요 산들! 수고많았습니다. 이번 과제는 Virtual DOM의 작동 원리를 머리가 아닌 손으로 직접 구현하며 체득하는 것이 목표였습니다. 회고에서 "개념과 흐름은 알겠는데 코드로 녹여내는 것이 까다로웠다"고 했던 부분이 이번 과제의 진짜 의도였죠. 사람은 적당히 이런거겠네 하면서 모호한것도 안다고 착각하고 넘어가는데 컴퓨터는 아니죠. 그렇기에 깊이를 탐구하기 위해서는 지금처럼 직접 구현하고 직접 완성을 해봐야 하는거죠. 아주 잘했습니다.
디버깅하며 고생하셨다고 하셨는데, 특히 updateElement에서 oldChildren.length > newChildren.length일 때 추가 for문이 필요한지 고민하신 과정도 참 좋아보입니다. 특히 이번 회고들이 정말로 경험에서 고민해본게 느껴져서 너무 좋았어요. 이런 세세한 고민들이 Virtual DOM의 원리를 체득하는 과정입니다.
몇 가지 추가로 개선을 시도해보면 좋겠다 하는 부분들은,
-
텍스트 노드 비교시 createElement로 변환 후 nodeType을 체크하는 현재 방식보다 그냥
typeof newNode === 'string'으로 직접 체크하는 것이 효율적인거 맞습니다. -
지금 이벤트 관리를 배열로 관리하고 있는데 key를 string이 아닌 것으로 매칭을 할때에는 Map을 사용하면 findIndex의 O(n) 탐색을 O(1)로 개선할 수 있습니다. 그리고 이때 key가 DOM이 되는 경우에는 WeakMap을 써야합니다. 그래야 key가 더 이상 쓰이지 않을때 자동으로 key를 같이 해제를 해주니까요.
-
setupEventListeners의 경우 당연히 모든 이벤트를 하드코딩을 하는 것보다는 나은 방법이 있겠죠. 이런 경우에는 필요할 때 필요한 만큼 동적으로 만들어 보는 방향으로 고민을 해보면 좋겠죠. addEvent에서 사용된 이벤트만 Set에 모아 동적으로 등록하는 방식을 통해서 확장성있게 만들어 낼 수 있습니다.
수고하셨습니다. 다음 주차도 화이팅입니다! :)
BP 선정이유: 사실 정답이 이미 있는 문제인데 과제의 취지에 맞게 필요한 것들을 몸으로 부딫혀가면서 이해도를 선명하게 만들어가는 경험을 잘 한것 같아서 그 경험을 높게 삽니다.