공부 및 일상기록

[Javascript] 바닐라 자바스크립트로 가상돔 구현하기 본문

개발/Javascript

[Javascript] 바닐라 자바스크립트로 가상돔 구현하기

낚시하고싶어요 2025. 7. 21. 14:20

지난번 바닐라 자바스크립트로 SPA 구현하기에 이어 이번에는 가상돔 구현 및 Diff 알고리즘 구현이 과제였다.

 

사실 지난번 과제에서 작은 상태 하나가 바뀌어도 전체 페이지가 재 렌더링 되어야하는 문제가 있어서 이를 어떻게 해결할 수 있을까? 하는 고민이 있었는데 이번 과제가 딱 그 문제를 해결하는 것이였다.

 

일단 가이드라인이 주어져있었고, 나는 그 가이드라인에 잘 따라가기만 하면 되서 큰 문제가 없을줄 알았지만 역시나 이번에도 많은 시행착오를 겪으며 과제를 완성했다.

 

가상돔 구현 및 렌더링 방식

일단 아주 간단하게 요약하면 다음과 같다.

JSX를 가상돔 객체로 변환 -> 1차적으로 변환된 가상돔을 쓰기 좋게 정규화 -> 이전 가상돔과 새 상태를 반영한 가상돔의 비교 및 업데이트 -> 이벤트 위임 설정

위 단계들을 통해 업데이트 되었다.

 

JSX를 가상돔 객체로 변환하기

export function createVNode(type, props, ...children) {
  // children의 중첩 배열을 모두 평탄화 하고 유효하지 않은 값들 (undefined, null, false)은 제거한다.
  // map 등으로 생성된 중첩된 JSX 배열을 처리하기 위함!
  const flatChildren = children
    .flat(Infinity)
    .filter((child) => child !== undefined && child !== null && child !== false);

  return {
    type,
    props: props ?? null,
    children: flatChildren,
  };
}

위 코드를 통해 하나의 가상돔 객체로 변환된다.

위 함수는은 vite.config에서 babel변환 과정에서 사용되는데 아래와 같이 설정하면 JSX에 적용시킬 수 있다.

esbuild: {
      jsx: "transform",
      jsxFactory: "createVNode",
    },
    optimizeDeps: {
      esbuildOptions: {
        jsx: "transform",
        jsxFactory: "createVNode",
      },
    },

 

createVNode함수를 보면 children을 flat하게 해주는 부분이 있는데, 그 이유는 중첩된 구조로 반환된 자식요소를 풀어 해치기 위해서이다.

예를들어 우리가 리액트에서 어떤 배열의 요소를 컴포넌트로 나열하면 arr.map(item=><Item item={item} />) 이런식으로 작성하게 될텐데, 이는 잘 생각해보면 배열을 리턴하는 구조이다. 그럼에도 우리는 배열이 아니라 저 아이템 컴포넌트가 나열된 것 처럼 보게 되는 것을 생각해보면 이해가 쉽다.

 

 

정규화 진행하기

createVNode로 JSX를 객체로 변환했다면 이제 normalizeVNode함수를 통해 쓰기 쉽게 정규화를 진행해야 한다.

/**
 * VNode를 정제하는 함수로 텍스트, 함수형컴포넌트, 일반 DOM태그 등을 정제하는 함수
 */

export function normalizeVNode(vNode) {
  // null, undefined, boolean 이면 "" 리턴
  // 조건부 표현에서 false, null, undefined를 제거하려는 의도
  if (vNode === null || vNode === undefined || typeof vNode === "boolean") {
    return "";
  }

  // 문자열 또는 숫자를 문자열로 변환
  if (typeof vNode === "string" || typeof vNode === "number") {
    return vNode.toString();
  }

  // 객체인 경우 분기 처리
  if (typeof vNode === "object") {
    // 타입이 함수형 컴포넌트면 함수를 실행하고 다시 normalizeVNode에 넣기
    if (typeof vNode.type === "function") {
      // props가 undefined인 경우를 대비하여 빈 객체로 대체
      // 스프레드연산자로 props를 모두 펼쳐서 컴포넌트 함수에 인자로 전달
      // children은 이미 정규화 되어있으므로 그대로 전달
      return normalizeVNode(vNode.type({ ...(vNode.props || {}), children: vNode.children }));
    }

    // 타입이 문자열 인것 처리 (일반적인 DOM 태그를 처리하기 위함)
    if (typeof vNode.type === "string") {
      let normalizedChildren = [];
      if (Array.isArray(vNode.children)) {
        // 자식 노드들도 똑같이 정규화를 진행하고 렌더링에 불필요한 falsy값 제거
        normalizedChildren = vNode.children.map(normalizeVNode).filter((child) => child !== "");
      }
      // 정규화 된 VNode 반환
      return {
        type: vNode.type,
        props: vNode.props || null,
        children: normalizedChildren,
      };
    }
  }

  // 예외 방지용..
  return "";
}

일단 조건부 렌더링 등에서 사용되는 null, undefined, boolean 값들을 없애주기 위해 빈문자열로 리턴해준다.

그리고 문자열과 숫자를 모두 문자열로 변경해서 리턴하는데, 이는 DOM 엘리먼트가 문자열만 받기 때문이다.

(문자열을 문자열로 변환하는 과정이 굳이 필요할까 싶지만 문자열로 리턴하는 부분을 명시적으로 표시해주려고 저렇게 작성했다. 맨 마지막 리턴에 그냥 리턴을 했다면 사실 이 조건이 없어도 제대로 동작하긴 했다.)

 

그리고 이제 Object인 경우를 본다. 사실 JSX가 object로 변환되었다는건 함수형 컴포넌트이거나 DOM element구조로 만들어진 JSX였다는 의미이다. 따라서 두 가지 경우를 나눠서 보면된다.

해당 객체의 타입이 함수라면 객체 내부에 함수형 컴포넌트가 있는 형태인데, 이는 제대로 된 dom element가 아니므로, 해당 함수를 다시 실행해서 DOM element로 이뤄진 구조를 찾아내야 한다. 따라서 normalizeVNode를 재귀적으로 실행되도록 실행시켜 준다.

그리고 타입이 string 이라면 드디어 제대로 된 element 객체가 온 것이다. 따라서 타입이 "div", "ul" 처럼 엘리먼트 이름으로 되어있을 것이다. 그리고 해당 엘리먼트의 자식에도 함수형컴포넌트가 포함되어있을 수 있으므로 해당 children도 정규화를 진행해준다.

 

render 하기

import { setupEventListeners } from "./eventManager";
import { createElement } from "./createElement";
import { normalizeVNode } from "./normalizeVNode";
import { updateElement } from "./updateElement";

let prevVNode = null;
export function renderElement(vNode, container) {
  // 1. vNode 정규화
  const normalized = normalizeVNode(vNode);

  // 2. 컨테이너에 루트 요소가 없으면 새로 생성하기
  if (!container._rootElement) {
    const element = createElement(normalized);
    container.innerHTML = "";
    if (element) {
      container.appendChild(element);
      container._rootElement = element;
    }
  }
  // 3. 루트 요소가 있는 경우 diff를 통해 변경하기
  else {
    updateElement(container, normalized, prevVNode, 0);
  }

  // 4. 이전 vNode 저장
  prevVNode = normalized;
  // 4. 이벤트 위임 리스너 등록
  setupEventListeners(container);
}

위 함수를 통해 렌더를 진행했다.

아직 설명하지 않은 createElement, updateElement, setupEventListners가 있는데 이는 차례대로 소개할 예정이다.

이 함수는 직관적이고 그저 렌더 과정에서 여러 함수를 호출할 뿐이니 설명은 넘어간다.

 

실제 DOM element 생성하기

import { addEvent } from "./eventManager";

// 브라우저 표준 이벤트 목록
const knownDomEvents = new Set([
  "click",
  "dblclick",
  "mousedown",
  "mouseup",
  "mouseover",
  "mouseout",
  "mousemove",
  "mouseenter",
  "mouseleave",
  "keydown",
  "keyup",
  "keypress",
  "focus",
  "blur",
  "change",
  "input",
  "submit",
  "contextmenu",
  "wheel",
  "scroll",
  "resize",
  "touchstart",
  "touchend",
  "touchmove",
  "touchcancel",
]);

/**
 * 가상DOM을 실제 브라우저 DOM으로 변환하는 함수
 */
export function createElement(vNode) {
  // 조건부 렌더링에서 나올 수 있는 false, null, undefined를 비워진 텍스트 노드로 처리
  if (vNode === undefined || vNode === null || typeof vNode === "boolean") {
    return document.createTextNode("");
  }
  // 문자열 또는 숫자를 텍스트 노드로 변환
  // TODO: 어차피 normalizeVNode에서 숫자도 문자열로 변환했는데 왜 필요하지?
  if (typeof vNode === "string" || typeof vNode === "number") {
    return document.createTextNode(vNode.toString());
  }

  // 배열인 경우 DocumentFragment로 묶어서 반환
  if (Array.isArray(vNode)) {
    const fragment = document.createDocumentFragment();
    vNode.forEach((node) => {
      const child = createElement(node);
      if (child instanceof Node) fragment.appendChild(child);
    });
    return fragment;
  }

  // 오브젝트인 경우 분기처리
  if (typeof vNode === "object") {
    // 함수형 컴포넌트는 엘리먼트로 생성 불가하므로 예외처리
    if (typeof vNode.type === "function") {
      throw new Error("컴포넌트로 엘리먼트를 생성할 수 없음");
    }

    // DOM 요소 생성
    const element = document.createElement(vNode.type);

    // props 처리
    if (vNode.props) {
      Object.entries(vNode.props).forEach(([key, value]) => {
        // className는 class로 변경
        if (key === "className") {
          element.setAttribute("class", value);
        }
        // 이벤트 핸들러는 이벤트 위임 방식으로 등록
        else if (key.startsWith("on") && typeof value === "function") {
          // onClick등을 click으로 변환
          const eventType = key.slice(2).toLowerCase();
          // 브라우저 표준 이벤트만 처리
          if (knownDomEvents.has(eventType)) {
            addEvent(element, eventType, value);
          }
        }
        // boolean 속성은 property로 직접 지정
        else if (typeof value === "boolean") {
          element[key] = value;
        } else {
          // 나머지는 그대로 설정
          element.setAttribute(key, value);
        }
      });
    }

    // children 처리
    vNode.children?.forEach((child) => {
      // 재귀적으로 돌아가도록 createElement를 자식에도 실행
      const childElement = createElement(child);
      if (childElement) element.appendChild(childElement);
    });

    return element;
  }
  return vNode;
}

이제 정규화된 가상돔객체를 가지고 실제 element를 만드는 과정이다.

이 또한 null, undefined, boolean 값 등을 비워진 텍스트 노드로 정제하고, 숫자도 문자열로 변경, 그리고 하나 추가된 것은 배열을 fragment로 만드는 것이다.

그리고 object는 해당 타입에 알맞는 element로 만들어 주고, props의 경우 여러가지 분기 처리를 해줬다.

해당 props가 className인 경우 실제 dom 구조에 맞기 class로 변경해주고, 이벤트 핸들러인 경우 addEvent함수를 통해 처리했다.

children또한 가상돔객체로 만들어져있으므로 createElement를 한번 더 실행시켜주는 재귀방식을 택했다.

 

이벤트 관리

const eventMap = new WeakMap();
const delegatedEvents = new Set();
let rootElement = null;

// 컨테이너에 이벤트 위임 리스너 등록
export function setupEventListeners(container) {
  rootElement = container;
  delegatedEvents.forEach((eventType) => {
    container.removeEventListener(eventType, handleEvent);
    container.addEventListener(eventType, handleEvent);
  });
}

// 이벤트 위임 핸들러
function handleEvent(event) {
  let target = event.target;
  while (target && target !== rootElement) {
    // stopPropagation이 호출되면 이벤트 탐색 중단
    if (event.cancelBubble) return;

    const elementEvents = eventMap.get(target);
    if (elementEvents) {
      const handlers = elementEvents.get(event.type);
      if (handlers) {
        handlers.forEach((handler) => handler(event));
        // 핸들러 실행 후 stopPropagation 체크
        if (event.cancelBubble) return;
      }
    }
    target = target.parentElement;
  }
}

// 엘리먼트에 이벤트 등록
export function addEvent(element, eventType, handler) {
  if (!eventMap.has(element)) eventMap.set(element, new Map());
  const elementEvents = eventMap.get(element);
  if (!elementEvents.has(eventType)) elementEvents.set(eventType, new Set());
  elementEvents.get(eventType).add(handler);

  if (!delegatedEvents.has(eventType)) {
    delegatedEvents.add(eventType);
    if (rootElement) {
      rootElement.removeEventListener(eventType, handleEvent);
      rootElement.addEventListener(eventType, handleEvent);
    }
  }
}

// 엘리먼트에서 이벤트 제거
export function removeEvent(element, eventType, handler) {
  const elementEvents = eventMap.get(element);
  if (!elementEvents) return;

  const handlers = elementEvents.get(eventType);
  if (!handlers) return;

  handlers.delete(handler);

  // 핸들러 모두 제거되었으면 eventType 제거
  if (handlers.size === 0) {
    elementEvents.delete(eventType);
  }

  // 해당 엘리먼트의 모든 이벤트가 제거되었으면 WeakMap에서 제거
  if (elementEvents.size === 0) {
    eventMap.delete(element);
  }
}

아까 createElement에서 addEvent를 통해 이벤트를 등록했는데 그 이유가 여기있다.

각각의 dom요소가 생성될 때, 이벤트 매니저에 해당 이벤트를 등록하고 해당 이벤트들은 루트에 일괄등록하는 것이다.

그렇게 하면 내가 어떤 요소를 클릭했을 때, 이벤트 버블링을통해 루트까지 올라올 것이고, 루트에 그 요소에 클릭 이벤트를 찾아 실행하게 되는 것이다. 

쉽게 설명해보면 약간 아래와 같은 구조라고 볼 수 있다.

eventMap (WeakMap)
├── button1 → Map
│   ├── "click" → Set[handleClick1, handleClick2]
│   └── "mouseover" → Set[handleMouseOver]
├── input1 → Map
│   └── "change" → Set[handleChange]
└── div1 → Map
    └── "click" → Set[handleDivClick]

 

이러한 구조를 선택하는 이유는

각 요소마다 이벤트 리스너를 달지 않아서 메모리 면에서 효율적이고, 나중에 추가되는 요소도 자동처리 되며, 모든 이벤트를 한 곳에서 다루기 때문에 편리하다. 또한 weakMap을 사용하여 제거된 키는 곧바로 가비지컬렉트 대상이 되어 곧바로 사라져 하나하나 신경써 줄 필요가 없다.

 

Diffing을 통한 updateElement

import { addEvent, removeEvent } from "./eventManager";
import { createElement } from "./createElement.js";

const BOOLEAN_PROPS = ["checked", "disabled", "selected", "readOnly"];

function updateAttributes(dom, newProps = {}, oldProps = {}) {
  // 새 props 추가 또는 변경
  for (const key in newProps) {
    const newVal = newProps[key];
    const oldVal = oldProps[key];

    if (newVal !== oldVal) {
      if (key.startsWith("on")) {
        const eventType = key.slice(2).toLowerCase();
        if (typeof oldVal === "function") removeEvent(dom, eventType, oldVal);
        if (typeof newVal === "function") addEvent(dom, eventType, newVal);
      } else if (key === "className") {
        dom.setAttribute("class", newVal);
      } else if (BOOLEAN_PROPS.includes(key)) {
        dom[key] = Boolean(newVal);
      } else if (key in dom && key !== "children") {
        // children은 getter-only
        dom[key] = newVal;
      } else {
        dom.setAttribute(key, newVal);
      }
    }
  }

  // 제거된 props 처리
  for (const key in oldProps) {
    if (!(key in newProps)) {
      if (key.startsWith("on") && typeof oldProps[key] === "function") {
        const eventType = key.slice(2).toLowerCase();
        removeEvent(dom, eventType, oldProps[key]);
      } else if (key === "className") {
        dom.removeAttribute("class");
      } else if (BOOLEAN_PROPS.includes(key)) {
        dom[key] = false;
      } else {
        dom.removeAttribute(key);
      }
    }
  }
}
export function updateElement(parent, newNode, oldNode, index = 0) {
  const dom = parent.childNodes[index];

  // 1. oldNode가 없으면 새로 추가
  if (!oldNode) {
    parent.appendChild(createElement(newNode));
    return;
  }

  // 2. newNode가 없으면 제거
  if (!newNode) {
    if (dom) parent.removeChild(dom);
    return;
  }

  // 3. 타입 또는 태그가 다르면 교체
  if (
    typeof newNode !== typeof oldNode ||
    (typeof newNode === "string" && newNode !== oldNode) ||
    (newNode.type && newNode.type !== oldNode.type)
  ) {
    parent.replaceChild(createElement(newNode), dom);
    return;
  }

  // 4. 텍스트 노드 업데이트
  if (typeof newNode === "string") {
    if (dom.textContent !== newNode) {
      dom.textContent = newNode;
    }
    return;
  }

  // 5. 속성 업데이트
  updateAttributes(dom, newNode.props || {}, oldNode.props || {});

  // 6. 자식 노드 비교
  const newChildren = newNode.children || [];
  const oldChildren = oldNode.children || [];

  // 7. 초과된 자식 제거
  while (dom.childNodes.length > newChildren.length) {
    dom.removeChild(dom.lastChild);
  }

  // 8. 나머지 재귀적 업데이트
  const max = Math.max(newChildren.length, oldChildren.length);
  for (let i = 0; i < max; i++) {
    updateElement(dom, newChildren[i], oldChildren[i], i);
  }
}

코드를 살펴보면 단순하다.

새로운 노드만 있는 경우엔 추가

이전 노드만 있는 경우엔 제거

타입이나 태그가 다르면 교체

텍스트가 다르면 텍스트만 교체

그리고 속성 업데이트를 해주는 것이다.

속성 업데이트 시 이벤트가 있다면 등록해주고, className은 class로, 그리고 기타 속성들을 등록해주는 방식이고, 만약 이전에 있던 속성인데 이제 사라졌다면 제거해주는 방식이다.

 

느낀점

사실 위 과정이 리액트가 구현한 가상돔과 100%일치하진 않는다. 여기에는 key를 통한 업데이트가 없고, 실제 diff알고리즘도 이렇게 단순하지 않을 것이다.

하지만 가상돔을 구현해보면서 말로만 알고있던 "리액트는 가상돔을 활용해 렌더링을 효율적으로 만들었다" 라는 개념을 이제는 머릿속으로 쉽게 그려볼 수 있게 되었다.

또한 리액트가 어떤 고민을 가지고 가상돔을 만들고, diff 알고리즘을 구현해내고, 이벤트를 왜 이렇게 관리하는지 몸소 체험해보니 더욱더 리액트의 이해도가 높아졌다.

모든 라이브러리가 그렇겠지만 아무 이유 없이 세상에 나오고 인기있는 라이브러리는 없다.

어떤 문제가 분명 존재하고, 해당 문제를 어떻게 해결하며, 그 해결로 인해 수많은 개발자들이 편하게 개발을 하게 되는데, 우리는 그 문제들이 무엇이고 무엇을 해결하려 한지 알게 되면 사용할때 더욱더 해당 라이브러리의 철학에 알맞게 개발할 수 있을 것이라고 생각한다.