바닐라 자바스크립트로 JSX와 Virtual DOM 구현하기

서준·2025년 2월 8일
0
post-thumbnail

최근 리액트 공식 문서를 다시 살펴보면서, 리액트의 원리를 깊이 이해하고 싶다는 생각이 들었습니다. 그래서 순수 자바스크립트로 리액트의 핵심 동작 기능들을 구현해보자는 아이디어가 떠올랐어요. 이를 위해 아래의 참조 글들을 참고하여 자바스크립트로 Virtual DOM을 구현해보는 과정을 기록해보려 합니다. 물론 실제 리액트의 내부 동작과는 차이가 있을 수 있지만, 이렇게 직접 구현해보는 과정이 리액트의 개념을 보다 깊게 이해하는 데 도움이 되었습니다.

🔎 글 개요

React를 사용할 때 가장 중요한 개념 중 하나는 JSXVirtual DOM입니다. 이번 글에서는 JSX를 활용해 Virtual DOM을 생성하고, 이를 실제 DOM에 반영하는 방법을 직접 구현해보겠습니다.

JSX는 JavaScript 코드에서 HTML과 유사한 문법을 사용할 수 있게 해주는 JavaScript 구문 확장입니다. 예를 들어, 다음과 같은 JSX 코드가 있다고 가정해볼까요?

const MyComponent = () => {
  return <div>Hello World!!!</div>;
};

이 JSX 코드는 Babel이나 TypeScript 트랜스파일링 과정을 거쳐 다음과 같은 JavaScript 코드로 변환됩니다

const MyComponent = () => {
  return React.createElement('div', null, 'Hello World!!!');
};

Virtual DOM이란?

Virtual DOM은 실제 DOM을 추상화한 가벼운 복사본으로, 변경 사항을 먼저 메모리에 적용한 뒤 이전 상태와 비교하여 필요한 부분만 실제 DOM에 반영하는 방식으로 동작하는 리액트의 핵심 기능입니다. 이 글에서는 Virtual DOM의 개념에 대해 따로 다루지는 않을게요

개발 환경 세팅

npm create vite@latest back-to-vanilla-ts-app --template vanilla-ts

프로젝트 내의 JSX 문법을 파싱하기 위해서는 vite.config.ts에서 esbuild 관련 설정을 해줘야 해요.

///vite.config.ts
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
  plugins: [tsconfigPaths()],
  esbuild: {
    jsx: 'transform',
    jsxInject: `import { h } from '@/lib/jsx/jsx-runtime'`,
    jsxFactory: 'h',
  },
});

각 속성에 대해 간단히 설명해볼게요

  • jsx: transform 은 JSX를 JavaScript로 변환할 때, JSX 문법을 h 함수 호출로 변환하도록 지시합니다. 이는 React에서 JSX를 가상 DOM을 생성하는 함수로 변환하는 과정이에요
  • jsxInject: import ~~~ : esbuild로 변환된 모든 파일에 대해 import 구문을 자동으로 삽입해줍니다
  • jsxFactory: h: 사용할 JSX 팩토리 함수(h(type, props, ...children) 형태의 함수)를 지정해줍니다

plugins의 tsconfigPaths는 TypeScript alias path를 위해 설정해줬어요

tsx 파일에서 타입 추론을 위해 tsconfig 설정도 해줘야 해요.
https://www.typescriptlang.org/tsconfig#jsxImportSource

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "./src/lib/jsx",
      //...
  }
}

https://www.typescriptlang.org/docs/handbook/jsx.html#type-checking

declare namespace JSX {
  interface IntrinsicElements {
    [elemName: string]: any;
  }
}

jsx 팩토리 함수 만들기

jsx 팩토리 함수는 src/lib/jsx 아래에 만들어줄게요.
jsx-runtime.ts에 함수를 작성해줘야 TypeScript 에러가 나지 않아요.

아래 코드를 살펴보기 전에 Virtual DOM으로 변환되는 시나리오를 살펴볼게요

<div>Hello World</div> 형태의 JSX 문법이 트랜스파일러를 통해 h('div', null, ['Hello World']) 형태로 변환되고, h 함수가 실행되면서 Virtual DOM이 만들어집니다.

// src/lib/jsx/jsx-runtime.ts

export type VNode = string | number | VDOM | null | undefined;
export type VDOM = {
  type: string;
  props: Record<string, any> | null;
  children: VNode[];
};

type Component = (props?: Record<string, any>) => VDOM;

export const h = (
  component: string | Component,
  props: Record<string, any> | null,
  ...children: VNode[]
) => {
  return {
    tag: component,
    props,
    children: children.flat(),
  };
};
  • component: HTML 태그명(string) 또는 사용자 정의 컴포넌트 함수를 받습니다.
  • props: 해당 컴포넌트나 엘리먼트의 속성들을 객체 형태로 받습니다.
  • children: 중첩된 자식 노드들을 받아서 flat()으로 1차원 배열로 정규화합니다.

이렇게 생성된 Virtual DOM 노드는 실제 DOM을 생성하는 데 필요한 정보를 제공합니다.

const MyComponent = ({ className }: { className: string }) => {
  return <div className={className}>Hello World!!!</div>;
};
console.log(MyComponent);
const App = () => {
  return (
    <div>
      <MyComponent className='myClass' />
    </div>
  );
};

export default App;

App 컴포넌트를 Virtual DOM으로 변환한 후 console.log를 통해 MyComponent를 출력하면 children의 type 부분이 function 형태로 되어 있는 걸 확인할 수 있습니다.

{
  type: "div",
  props: null,
  children: [{
    type: ({ className }) => h("div", { className }, "back to vanilla js"),
    props: { className: 'myClass' },
    children: []
  }]
}

이 경우 component 파라미터가 function일 때에 대한 분기문을 추가해주면 간단하게 예외를 방지할 수 있어요

// src/lib/jsx/jsx-runtime.ts
// ...
if (typeof component === 'function') {
  return component({ ...props, children });
}

변환된 Virtual DOM 모습

{
  type: "div",
  props: null,
  children: [
    {
      type: "div",
      props: {
        className: "myClass"
      },
      children: [
        "Hello World!!!"
      ]
    }
  ]
}

Virtual DOM을 DOM에 올리기

잘 만들어진 Virtual DOM을 실제 DOM에 올리기 위해서 createElement를 구현해줘야 해요.

Virtual DOM에 대한 타입 체크를 하고 그에 맞는 element를 생성하는 코드를 작성해볼게요.

//...
const createElement = (node: VNode) => {
  // 1. null이나 undefined의 경우 fragment 생성
  if (node === null || node === undefined) {
    return document.createDocumentFragment();
  }

createDocumentFragment라는 메서드가 특히 생소하여 추가 정리하자면, 해당 메서드는 createElement 함수가 null이나 undefined를 입력으로 받을 경우, 빈 DocumentFragment를 반환하여 오류를 방지하는 역할을 합니다, 해당 메서드를 통해 DOM 구조를 유지하면서 아무것도 추가하지 않으므로, DOM 조작 시 불필요한 오류를 피하고 리플로우 및 리페인트 작업을 줄여 성능을 최적화할 수 있습니다.

// src/lib/dom/client.ts
import { VNode } from '@/lib/jsx/jsx-runtime';

const createElement = (node: VNode) => {
  // 1. null이나 undefined의 경우 fragment 생성
  if (node === null || node === undefined) {
    return document.createDocumentFragment();
  }
  // 2. 기본형 타입의 경우 text 노드를 생성
  if (typeof node === 'string' || typeof node === 'number') {
    return document.createTextNode(String(node));
  }

  // 3. node.type을 기반으로 실제 DOM에 element 생성
  const element = document.createElement(node.type);

  // ...
};

export { createElement };

Virtual DOM의 props를 DOM에 반영시키는 과정도 거쳐줄게요

// src/lib/dom/client.ts
Object.entries(node.props || {}).forEach(([attr, value]) => {
  if (attr.startsWith('data-')) {
    element.dataset[attr.slice(5)] = value;
  } else {
    (element as any)[attr] = value;
  }
});

Virtual DOM의 type과 props에 대한 구현이 끝났으므로 children을 다루는 코드를 작성해볼게요. children의 경우엔 재귀적으로 createElement를 호출하면서 부모 요소인 element에 appendChild로 부착을 해줍니다

// src/lib/dom/client.ts
node.children.forEach((child) => element.appendChild(createElement(child)));

완성된 createElement 코드입니다. 요약하자면 4가지 과정을 거치게 됩니다.

  1. Virtual DOM에 대한 타입 체크를 하면서 null, undefined이거나 기본형 타입일 경우 그에 맞는 node를 생성 후 반환합니다.
  2. Virtual DOM의 type에 맞는 실제 DOM을 생성합니다.
  3. Virtual DOM의 props를 실제 DOM에 반영합니다.
  4. Virtual DOM의 children을 재귀적으로 순회하면서 부모 요소에 appendChild를 이용하여 부착합니다.

브라우저에서 APP 컴포넌트 확인

자, 이제 Virtual DOM에 대한 설정이 끝났으니 실제 APP 컴포넌트에서 잘 동작하는지 확인해볼까요?

// App.tsx
const MyComponent = ({ className }: { className: string }) => {
  return <div className={className}>Hello World!!!</div>;
};

const App = () => {
  return (
    <div>
      <div>근본은 바닐라 자바스크립트!</div>
      <MyComponent className='hello' />
    </div>
  );
};

export default App;
// main.tsx
import App from './app';
import { createElement } from './lib/dom/client';

const app = document.getElementById('app') as HTMLElement;
app.appendChild(createElement(<App />));

위와 같이 코드를 구성한 후 브라우저에서 확인해보면, 정상적으로 Virtual DOM이 DOM에 반영된 걸 확인할 수 있습니다.

profile
하나씩 쌓아가는 재미

0개의 댓글

관련 채용 정보