프레임워크 없이 SPA 만들기 - Part 2 11일차

anvel·2025년 4월 1일

항해 플러스

목록 보기
7/39
post-thumbnail

과제 정리

이번 과제는 '문제에 답이 있다' 와 같은 적절한 가이드를 통해 답을 유도하고 있어서, 1주차 보다는 그래도 빠르게 해결했습니다.
그래서 오늘은 이 깔끔한 테스트 코드를 기반으로 어떤 로직을 이해해야 할 지를 정리해보려고합니다.

1. createVNode

#가상 DOM, #JSX 변환, #props, #children, #평탄화

  • JSX 문법의 파일을 createVNode 로 트랜스 파일링 하여 실제로는 객체 {type, props, children} 형태로 변환합니다.
  • 이 객체는 DOM이 아닌, DOM을 생성하기 위한 선언형 객체 덩어리입니다.
  • JSX → createVNode() 로 변환되는 방식은 빌드 설정으로 인해 동작합니다.
// createVNode.js
export function createVNode(type, props, ...children) {
  // 자식은 평탄화 해야한다.
  children = children.flat(Infinity);
  // 랜더링 하지 않아야 할 것들을 예외한다.
  children = children.filter(
    (c) => c !== false && c !== null && c !== undefined && c !== true,
  );
  return { type, props, children };
}

// any.tsx
/** @jsx createVNode */
import { createVNode } from "@/lib/createVNode";

const Any = () => {
  return (
    <div></div>
  )
};
export default Any;

// vite.config.js
import { defineConfig } from "vite";

export default defineConfig({
  esbuild: { 
    jsxFactory: "createVNode" 
  },
  optimizeDeps: {
    esbuildOptions: {
      jsx: "transform",
      jsxFactory: "createVNode",
    },
  }
  /* ... */
});

2. normalizeVNode

#정규화, #컴포넌트 처리, #재귀 호출, #스칼라 값 처리

  • 가상 DOM의 형태가 일관되지 않은 경우를 통일된 구조로 정리하는 함수입니다.
    • null,undefined,boolean 은 빈 문자열로 처리
    • 문자열/숫자는 텍스트로 변환
    • 함수형 컴포넌트는 재귀적으로 호출하여 정규화
    • children 배열도 재귀적으로 정규화
export function nomalizeVNode(vNode) {
  // vNode로 예측되는 값
  // null, undefined, true, false → 정규화 제외, 빈 문자열로 전달
  // "string", 1 → 랜더링 스칼라 String 
  // function () {}, () => {} → 함수형 실행 후 props 전달
  if (vNode === null || vNode === undefined || typeof vNode === "boolean") return "";
  else if (typeof vNode === "number" || typeof vNode === "string") return String(vNode);
  else if (typeof vNode.type === "function") {
    const { type, props = {}, children = []} = vNode;
    return nomalizeVNode(type({ ...props, children }));
  }
  // 일관된 형태로 정규화
  const { type, props = null, children = []} = vNode;
  return { type, props, children: children.map(nomalizeVNode).filter(Boolean) };   
}

3. createElement

#DOM 생성, #DocumentFragment, #props 처리, #이벤트 바인딩

  • 정규화된 vNode를 바탕으로 실제 DOM을 생성합니다.
    • 타입이 string이면 document.createElement(type) 로 실제 DOM을 생성합니다.
    • 자식이 배열이면 DocumentFragment로 처리합니다.
    • props 중 className, data-*, boolean 속성, 이벤트 핸들러 등을 해석하고 반영합니다.
import { updateAttributes } from "../utils";

export function createElement(vNode) {
  // null | undefined | Boolean → "" 빈 문자열 DOM
  // string | number → String() 문자열 DOM
  // Array → DocumentFragment 를 활용하여 하위 노드를 재귀적으로 생성
  // { type, props, children } → HTML 시멘틱 태그 type으로 Element를 생성
  // props로 속성을 업데이트 하되, on 이벤트 함수는 매니저에 할당
}

4. eventManager

#이벤트 위임, #등록 및 해제, #버블링 처리(?), #map 구조

  • 이벤트를 개별 DOM에 직접 등록하지 않고, container 하나에 등록하고 이벤트를 위임(delegate)하는 구조로 처리합니다.
    • addEvent / removeEvent로 이벤트 핸들러 관리합니다.
    • setupEventListeners를 통해 이벤트 종류별로 container에 한 번만 등록합니다.
    • 타겟을 순회하며 버블링된 이벤트를 해당 element에 매핑된 handler로 실행합니다.
    • 테스트 중 익명함수로 인해 이벤트가 중복되어 적용하는 문제가 발생하였습니다.
      - root 또는 container에 이벤트를 연결할 때는 선언된 함수를 사용하여 중복을 피해야합니다.
const eventTypes = [];
const elementMap = new Map();

export function setupEventListeners($root) {
  eventTypes.forEach((eventType) => {
    $root.addEventListener(eventType, handleEvent);
  });
}

const handleEvent = (e) => {
  const handlerMap = elementMap.get(e.target);
  const handler = handlerMap?.get(e.type);
  if (handler) handler.call(e.target, e);
};

// TODO 이벤트 버블링 커스텀 구현하기 >> onClick, onClickCapture

export function addEvent(element, eventType, handler) {
  if (!eventTypes.includes(eventType)) eventTypes.push(eventType);
  const handlerMap = elementMap.get(element) || new Map();
  if (handlerMap.get(eventType) === handler) return;
  handlerMap.set(eventType, handler);
  elementMap.set(element, handlerMap);
}

export function removeEvent(element, eventType, handler) {
  const handlerMap = elementMap.get(element);
  if (!handlerMap) return;

  if (handlerMap.get(eventType) === handler) handlerMap.delete(eventType);

  if (handlerMap.size === 0) elementMap.delete(element);
  else elementMap.set(element, handlerMap);
}

5. renderElement

#렌더링, #재렌더링, #이벤트 재등록

  • 최상단 컨테이너에 JSX 기반 가상 DOM을 넘기면 DOM으로 변환하고 이벤트까지 연결하는 함수입니다.
    • 최초에는 createElement로 생성 후 append 합니다.
    • 이후 호출은 updateElement 또는 교체합니다.
    • 렌더 후 setupEventListeners를 통해 이벤트 재등록합니다.
import { createElement } from "./createElement";
import { setupEventListeners } from "./eventManager";
import { normalizeVNode } from "./normalizeVNode";
import { updateElement } from "./updateElement";

let oldNode = null;
export function renderElement(vNode, $container) {
  // 최초 렌더링시에는 createElement로 DOM을 생성하고
  // 이후에는 updateElement로 기존 DOM을 업데이트한다.
  // 렌더링이 완료되면 container에 이벤트를 등록한다.
  if (!vNode) throw new Error();

  const newNode = normalizeVNode(vNode);
  if (!$container.firstChild) {
    $container.appendChild(createElement(newNode));
  } else {
    updateElement($container, newNode, oldNode);
  }
  oldNode = newNode;
  setupEventListeners($container);
}

6. JSX 트랜스파일링

(/** @jsx createVNode */)
#pragma, #Babel 설정, #esbuild, #JSX 팩토리

  • JSX는 기본적으로 React.createElement()로 변환되지만, 이를 바꾸기 위해 Vite나 Babel 설정에서 @jsx createVNode 주석을 선언하고, jsxFactory: createVNode로 설정해 JSXcreateVNode(...)로 트랜스파일되게 만들 수 있습니다.

7. 조건부 렌더링, 배열 렌더링 처리

falsy 값 필터링, 배열 map, children 평탄화

  • JSX의 {false && <span>...>} 등의 조건부 표현은 falsechildren으로 들어갈 수 있으므로 createVNode에서 필터링이 필요합니다.
  • 배열(children) 중첩은 .flat()으로 평탄화합니다.
  • normalizeVNode에서 불필요한 값은 제거합니다.

8. 마치며

아직 완벽한 배포에 성공한 것은 아니지만, 그래도 저번주보단 더 나은 과제 진행이었습니다. 다음주도 어려울 것 같던데, 이번주 만큼은 하면 좋을 것 같습니다. 😊

0개의 댓글