Amelia-Shin 님의 상세페이지[1팀 신희원] Chapter 1-2. 프레임워크 없이 SPA 만들기

과제 체크포인트

배포 링크

기본과제

가상돔을 기반으로 렌더링하기

  • createVNode 함수를 이용하여 vNode를 만든다.
  • normalizeVNode 함수를 이용하여 vNode를 정규화한다.
  • createElement 함수를 이용하여 vNode를 실제 DOM으로 만든다.
  • 결과적으로, JSX를 실제 DOM으로 변환할 수 있도록 만들었다.

이벤트 위임

  • 노드를 생성할 때 이벤트를 직접 등록하는게 아니라 이벤트 위임 방식으로 등록해야 한다
  • 동적으로 추가된 요소에도 이벤트가 정상적으로 작동해야 한다
  • 이벤트 핸들러가 제거되면 더 이상 호출되지 않아야 한다

심화 과제

Diff 알고리즘 구현

  • 초기 렌더링이 올바르게 수행되어야 한다
  • diff 알고리즘을 통해 변경된 부분만 업데이트해야 한다
  • 새로운 요소를 추가하고 불필요한 요소를 제거해야 한다
  • 요소의 속성만 변경되었을 때 요소를 재사용해야 한다
  • 요소의 타입이 변경되었을 때 새로운 요소를 생성해야 한다

과제 셀프회고

VirtualDOM 에 대해 특징과 개념을 알게 되었다. 이벤트 위임하는 부분이 진짜 어려웠는데... 어찌저찌 했다... 다시 공부가 필요할 것 같다. 이번에도 많은 개념들을 배웠다. (weakmap , set , 평탄화/정규화)

기술적 성장

createVNode 평탄화를 왜 해주는가? : 중첩된 배열을 단일 배열로 펼쳐서 다루기 쉽게 만들기 위해서

아래 코드를 보면

안에 자식 노드가 있다. map은 배열을 반환하고

<div>
   <div>hello</div>
   Array.map((name) => { <div>${name}</div> });
</div>
----
위와 같은 코드
<div>
   <div>hello</div>
   <button>name1</button>
   <button>name2</button>
</div>
---
[{ type: div , props: '', children: 'hello'}, [{.type: button, props: '', children: name1 }, { .type: button, props: '', children: name2  }]]

nomalizeVNode 정규화를 해주는 이유 : 객체/배열 구조 데이터를 평탄하고 효율적으로 구성하는 것

// 정규화 전 (비정규화 상태)
// company 정보가 사용자마다 중복되어 있다. 이렇게 되면 수정 시 모든 곳을 찾아서 바꿔야 한다.
const users = [
  {
    id: 1,
    name: "Alice",
    company: { id: 100, name: "OpenAI" }
  },
  {
    id: 2,
    name: "Bob",
    company: { id: 100, name: "OpenAI" }
  }
];
// 회사 정보가 한 곳에만 있어서 변경, 추적이 쉽고 실수도 줄어든다.
const users = {
  1: { id: 1, name: "Alice", companyId: 100 },
  2: { id: 2, name: "Bob", companyId: 100 }
};

const companies = {
  100: { id: 100, name: "OpenAI" }
};

createElement entries 사용하여 attr , value 로 배열화하여 element 요소 넣어주기.

function updateAttributes($el, props) {
    Object.entries(props).forEach(([attr, value]) => {
      if (attr.startsWith("on") && typeof value === "function") {
        const eventType = attr.toLowerCase().slice(2);
        addEvent($el, eventType, value);
      } else if (["checked", "disabled", "selected", "readOnly"].includes(attr)) {
        $el[attr] = Boolean(value);
      } else if (attr === "className") {
        $el.setAttribute("class", value);
      } else if (attr === "style" && typeof value === "object") {
        Object.assign($el.style, value);
      } else {
        $el.setAttribute(attr, value);
      }
    });
  }

eventManager

  • 이벤트 위임 Map, eventMap JavaScript에서 키-값 쌍을 저장하는 자료구조지만, 사용 목적과 내부 동작 방식에 큰 차이

📊 Map vs WeakMap 차이 요약

항목MapWeakMap
키 타입객체, 원시값 모두 가능객체만 가능
반복가능불가능
크기 확인.size 가능불가능
GC 영향수동 제거 필요자동 제거 (키가 없으면 값도 제거됨)
사용 목적일반적인 키-값 저장프라이빗 데이터나 메모리 누수 방지 등
  • WeakMap 을 사용하는 이유 메모리 누수 방지 (가비지 컬렉션) DOM 요소가 삭제되면 WeakMap의 키(= 요소)에 대한 참조도 사라짐 그러면 JavaScript 엔진이 해당 entry를 자동으로 메모리에서 제거함
const el = document.createElement("div");
addEvent(el, "click", handler);
document.body.removeChild(el); // el DOM에서 제거됨

만약 Map을 사용했다면 eventMap이 el을 계속 참조 → 메모리 누수 발생 하지만 WeakMap은 el이 참조되지 않으면 자동으로 해당 데이터 제거됨

updateElement

  • diffing 알고리즘 두 데이터 구조 간의 차이점(Difference)을 찾아내는 알고리즘
  • 주로 DOM을 효율적으로 업데이트하기 위해 사용
  • 성능 최적화를 위해 필요 (전체 DOM 대신 변경된 부분만 업데이트)

renderElement

  • 이전 Node 와 현재 Node
const vNodeMap = new WeakMap();

export function renderElement(vNode, container) {
  const normalizedVNode = normalizeVNode(vNode);
  const oldNode = vNodeMap.get(container);
  
  if (!oldNode) {
    // 최초 렌더링시에는 createElement로 DOM을 생성하고
    const element = createElement(normalizedVNode);
    container.appendChild(element);
  } else {
    // 이후에는 updateElement로 기존 DOM을 업데이트한다.
    updateElement(container, normalizedVNode, oldNode, 0);
  }

  // 렌더링이 완료되면 container에 이벤트를 등록한다.
  setupEventListeners(container);
  vNodeMap.set(container, normalizedVNode);
}

코드 품질

코드 리팩토링 필요한 부분

  • flat 메서드 사용 (코드가 더 간결해짐)
  • flat 메서드의 존재를 모르고 AI를 통해 함수로 작성하였다.
export function createVNode(type, props, ...children) {
  // Helper to flatten deeply nested arrays
  function flatten(arr) {
    return arr.reduce((acc, val) => {
      if (Array.isArray(val)) {
        acc.push(...flatten(val));
      } else {
        acc.push(val);
      }
      return acc;
    }, []);
  }

  // Remove null, undefined, boolean (except 0/number)
  function filterValid(child) {
    return !(child === null || child === undefined || typeof child === "boolean");
  }

  // Flatten and filter children
  const flatChildren = flatten(children).filter(filterValid);

  return {
    type,
    props,
    children: flatChildren,
  };
}
// children.flat(Infinity) 사용
// flat(depth)는 배열을 얼마나 깊이까지 평탄화할지를 지정하는데, Infinity를 넣으면 모든 깊이까지 완전히 펼치라는 의미
export function createVNode(type, props, ...children) {
  return {
    type,
    props,
    children: children.flat(Infinity).filter((value) => value === 0 || Boolean(value)),
  };

학습 효과 분석

  • 이벤트 위임, 직접 바인딩의 차이점 addEvent (이벤트 위임) 과 addEventListener (직접 바인딩) 의 차이 요약표
항목addEventaddEventListener
리스너 수1개 (이벤트 종류별 1개)요소마다 1개씩
메모리 사용량적음많아질 수 있음
동적 요소 대응자동수동 등록 필요
이벤트 타겟 정확성event.target 기반, 부모 탐색 필요명확하게 해당 요소
이벤트 종류 제한기본적으로 버블링되는 이벤트만 가능캡처링 포함 모든 이벤트 가능
성능 (많은 요소일 때)우수성능 저하 가능
구현 난이도약간 복잡간단함

과제 피드백

createElement.js 파일에서 createDocumentFragment 사용 이유 알아보기..!

  • VirtualDOM 을 직접 구현해보면서 VirtualDOM에 대해 알아갈 수 있어 좋은 기회가 된거 같습니다. [바뀐 부분만 업데이트 해주어서 효율적]

리뷰 받고 싶은 내용

  1. 평탄화를 해줄 때 "" 으로 return 해주는 이유와 falsy한 값을 왜 필터링 해주는 지 궁금합니다.
export function normalizeVNode(vNode) {  
  if (vNode == null || vNode == undefined || typeof(vNode) == "boolean") {
    return "";
  }
  
  if (typeof(vNode) == "number" || typeof(vNode) == "string") {
    return String(vNode);
  }
  
  if (typeof vNode.type === "function") {
    return normalizeVNode(vNode.type({ ...vNode.props, children : vNode.children }));
  }
 
  return {
    type: vNode.type,
    props: vNode.props || null,
    children: vNode.children.map(normalizeVNode).filter(Boolean),
  };
}
  1. handleEvent 함수에서 elementEvents 를 디버그 찍어보면 아래 내용이 나오는데, 어떤 것인지 궁금합니다. [Function: spy] { 안에 있는 내용은 이벤트 핸들러들인가요 ? 무엇인지 궁금합니다. }
Map(1) {
  'click' => Set(1) {
    [Function: spy] {
      getMockName: [Function (anonymous)],
      mockName: [Function (anonymous)],
      mockClear: [Function (anonymous)],
      mockReset: [Function (anonymous)],
      mockRestore: [Function (anonymous)],
      getMockImplementation: [Function (anonymous)],
      mockImplementation: [Function (anonymous)],
      mockImplementationOnce: [Function (anonymous)],
      withImplementation: [Function: withImplementation],
      mockReturnThis: [Function (anonymous)],
      mockReturnValue: [Function (anonymous)],
      mockReturnValueOnce: [Function (anonymous)],
      mockResolvedValue: [Function (anonymous)],
      mockResolvedValueOnce: [Function (anonymous)],
      mockRejectedValue: [Function (anonymous)],
      mockRejectedValueOnce: [Function (anonymous)],
      [Symbol(nodejs.dispose)]: [Function (anonymous)]
    }
  }
}
function handleEvent(event) {
    let target = event.target;

    while (target && target !== rootElement) {
        const elementEvents = eventMap.get(target);
        if (elementEvents) {
            const handlers = elementEvents.get(event.type);
            if (handlers) {
                handlers.forEach((handler) => handler(event));
                return;
            }
        }
        target = target.parentElement;
    }
}

과제 피드백

안녕하세요 희원~, 1-2 과제 수고했습니다. 이번 과제는 React와 같은 프레임워크가 내부적으로 어떻게 동작하는지 직접 구현해보면서 Virtual DOM의 핵심 개념을 체득하는 것이 목표였습니다. 특히 Virtual DOM을 통한 효율적인 렌더링과 이벤트 위임을 직접 구현해보면서 프레임워크의 원리와 꼭 필요한 브라우저 API들의 개념들이 무엇인지 알게되었기를 바래요

코드를 살펴보니 특히 WeakMap을 활용한 메모리 관리와 이벤트 위임 구현을 잘 이해하고 작성해주셨네요. 정규화 과정에서 함수형 컴포넌트를 재귀적으로 처리하는 부분도 올바르게 구현하셨습니다. 잘하셨습니다.

createElement 함수에서 updateAttributes가 내부 함수로 정의되어 있는데, 이는 함수가 호출될 때마다 새로 생성되므로 별도의 유틸리티 함수로 분리하면 좋겠네요. 또한 updateElement에서 타입 체크 시 문자열/숫자 비교 로직이 약간 불명확한데, 두 노드가 모두 primitive type일 때만 처리하도록 수정하면 더 안정적일 것 같습니다.

이렇게 직접 내가 사용하고 있는 저수준 라이브러리들을 구현해보게 되면 단순한 이론의 형태가 아니라 구조와 흐름을 이해한채로 개념을 이해하게 됩니다. 아마 기존에 내가 알고 있던 VDOM과 지금의 VDOM의 개념이 많이 달라졌을거라 생각해요. 앞으로 zustnad나 tanstack query와 같은 상태관리 라이브러리도 핵심만 직접 구현을 해본다면 큰 도움이 될거에요.


Q) 평탄화를 해줄 때 "" 으로 return 해주는 이유와 falsy한 값을 왜 필터링 해주는 지 궁금합니다.

=> 나름의 전략적 결정입니다. 화면상 해당 값들이 실제로 노출이 되어야 하는 경우는 거의 없기 때문이죠. 은행 어플등에서 null, 혹은 undefined라는 글자가 보이면 다들 개발적 버그라는게 너무 티가나는 상황일테니까요. 게다가 React에 하는 조건부 렌더링({isVisible && })에서 그대로 출력하게 했다면 false가 뜰텐데 매번 {isVisible ? : ""} 라고 적는 것도 불편했을테지요. 그래서 꼭 표기가 되어야 하는 0을 제외하고는 falsy값을 출력하지 않기로 한 전략적 결정을 반영한 코드입니다.

Q) handleEvent 함수에서 elementEvents를 디버그 찍어보면 나오는 [Function: spy]는 무엇인가요?

=> 이는 Vitest의 테스트 환경에서 생성된 mock 함수입니다. spy는 함수 호출을 추적하고 검증하기 위한 테스트 도구로, 실제 환경에서는 일반 함수가 저장됩니다. getMockName, mockClear 등은 테스트에서 함수 호출 횟수나 인자를 확인하는 메서드들입니다.

Q) createElement.js에서 createDocumentFragment 사용 이유 알아보기

=> DOM이 변경하는 코드에는 화면을 다시 그리도록 하는 코드가 포함되어 있습니다. 그래서 DOM Element들을 하나씩 변경하고 하나씩 appendChild를 하게 되면 그때마다 내부에서 렌더링을 하는 코드가 호출이 되는데 이는 비효율적이죠. 그렇지만 appendChild는 하나의 node만 가능하기에 여러개의 노드를 한번에 등록할 수 가 없습니다. 이때 사용하는것이 가상의 컨테이너인 DocumentFragment입니다. 이 노드는 여러가지 node를 한번에 보관하고 하나의 Node취급을 하지만 어딘가에 등록이 될때에는 동시에 전달을 해줄 수가 있죠. 그래서 한번에 많은 Node를 다뤄야 할때 성능을 위해서 사용합니다.

도움이 되었기를 바랍니다.

수고하셨습니다. 2주차도 화이팅입니다! :)