React - Virtual DOM 만들어보기
가상 돔을 만들어보자
리액트를 공부해보면서 가상돔에 대해서 많이 언급하게 된다. 리액트의 핵심적인 기술이기 때문이다. 하지만 핵심치고는 내가 너무 모른다는 생각이 들어 리액트 딥다이브를 통해 조금 가까워지도록 노력하고 있다. 그래서 기왕 시작해본 김에 가상 돔을 직접 구현해보면 어떨까 싶다.
가상 돔을 내가 만들수 있을까 싶지만 결국 리액트도 자바스크립트를 통해 만들어진 기술이다. 물론 내가 100%동일한 동작을 하도록 만들지는 못하겠지만 흉내낼 수는 있다. 나도 내 머리가 비상해서 가상돔의 구조와 원리를 파악하고 척척 구현하면 좋겠지만 그렇지 않기에 다른 블로그들을 참고해서 원리를 이해해보고 익혀나가 보겠다. 언젠가 나도 보는 시야가 넓어지지 않을까 싶다.
프로젝트 세팅
우리는 순수 자바스크립트로만 개발하는 바닐라 자바스크립트를 사용해서 Virtual DOM을 구현해보겠다. 우선 초기 세팅은 vite를 사용해서 바닐라 자바스크립트로 설정해주겠다.
npm create vite@latest my-vanilla-ts-app --template vanilla-ts
그리고 추가적으로 해줘야하는 작업이 있다. react의 경우는 babel을 통해 jsx를 변환시킨다. 하지만 vite를 사용하면 babel을 사용하지 않고 esbuild를 통해 jsx를 변환할 수 있다.
// vite.config.ts import { defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; export default defineConfig({ plugins: [tsconfigPaths()], esbuild: { jsx: 'transform', // (1) jsxInject: `import { h } from '@/lib/jsx/jsx-runtime'`, // (2) jsxFactory: 'h', // (3) }, });
이 파일은 vite에서 esbuild 설정을 할수 있는 파일이다. 1번은 jsx를 트랜스파일할때 사용하는 옵션이다. jsx를 트랜스파일할때 별도의 처리를 할때 transform으로 설정하면 된다. 그리고 별도 처리는 3번 jsxFactory에 지정한 함수를 통해서 하게 된다. 그래서 매번 트렌스파일할때마다 변환 함수인 h가 사용되기 때문에 import구문을 자동으로 삽입해주는 2번 옵션을 추가해줬다.
//tsconfig.json ... "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "@/lib/jsx", ...
그리고 추가적으로 나는 타입스크립트를 사용하고 있기 때문에 tsconfig.ts옵션을 설정해줘야 한다. jsxImportSource 옵션은 jsxFactory를 실행할때 해당 함수가 있는 위치이다.
// global.d.ts declare namespace JSX { interface IntrinsicElements { [elemName: string]: any; } }
그리고 tsx파일에서 jsx의 타입 추론을 위해 global 타입을 지정해줬다. 이제 준비는 끝났다. 가장 먼저 jsx변환 함수를 알아보겠다.
JSXFactory 함수 만들기
지금까지 프로젝트 세팅을 하면서 설정해준 것이 JSX를 변환할때 우리가 만든 함수를 이용해서 변환하도록 만든것이다. 우선 이 함수가 어떻게 적용되는지 간단하게 console을 생성해서 확인해 보겠다.
// main.tsx console.log(<div>Hello, World</div>);
이렇게 main.tsx에 jsx를 감싼 console을 만든후 빌드해보면
console.log(c('div', null, 'Hello, World'));
이렇게 vite가 트랜스파일링을 한다. 태그, 속성, children으로 구분하고 c라는 함수로 감싸져있는 것을 볼수가 있다. 여기에서 c함수가 JSXFactory이 되는 것이다.
// @/src/jsx/jsx-runtime.ts export type VNode = string | number | VDOM | null | undefined; export type VDOM = { tag: 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(), }; };
JSXFactory함수인 h함수를 만들었다. 가상돔은 전에도 알아봤듯이 DOM을 객체로 만들어 저장하는 것이다. 그래서 함수의 리턴값은 객체로 이뤄져있다. 그리고 트랜스파일된 jsx의 태그이름, 속성인 props, 내용인 children을 가지고 객체를 만드는 것이다.
{ children : ['Hello, World'], props : null, tag : "div", }
그래서 위에서 main.tsx에 만든 태그를 이런 형태의 객체로 변환된다. 그렇다면 만약 컴포넌트 내부에 컴포넌트가 있는 경우는 어떻게 될까?
{ tag: "div", props: null, children: [{ tag: ({className}) => h("div", { className }, "Hello World"); props: {className: 'myClass'}, children: [] }] }
이런 식으로 컴포넌트 내부에 있는 컴포넌트도 JSXFactory함수를 실행하기 때문에 function이 tag로 들어간다. 그래서 컴포넌트 내부 태그는 별도의 처리를 해줘야한다.
if (typeof component === 'function') { return component({ ...props, children }); }
이렇게 조건을 추가해줘서 다시 JSXFactory함수가 제대로 동작하도록 만들어준다.
{ tag: "div", props: null, children: [ { tag: "div", props: { className: "myClass" }, children: [ "Hello, World" ] } ] }
그러면 이렇게 모든 태그가 정상적으로 변환되는 것을 볼수가 있다. 이제 가상돔은 만들었고 가상돔을 실제 적용시켜보겠다.
가상돔을 DOM에 등록하기
리액트에서는 React.createElement를 사용해서 가상돔을 DOM에 등록한다. 하지만 직접 만들어보겠다.
import { VNode } from '@/lib/jsx/jsx-runtime'; const createElement = (node: VNode) => { // (1) if (node === null || node === undefined) { return document.createDocumentFragment(); // null이나 undefined의 경우 fragment 생성 } if (typeof node === 'string' || typeof node === 'number') { return document.createTextNode(String(node)); // 기본형 타입의 경우 text노드를 생성 } const isFragment = node.tag === 'fragment'; if (isFragment) { return document.createDocumentFragment(); // fragment인 경우 fragment 생성 } // (2) const element = document.createElement(node.tag); // node.tag을 기반으로 실제 dom에 element생성 // (3) Object.entries(node.props || {}).forEach(([attr, value]) => { // 태그 내부 속성을 등록 if (attr.startsWith('data-')) { element.dataset[attr.slice(5)] = value; } else { (element as any)[attr] = value; } }); // (4) node.children.forEach((child) => element.appendChild(createElement(child))); // children 노드 처리 return element; }; export { createElement };
되게 복잡해보이지만 사실상 엄청 복잡한 구조는 아니다. 해당 함수로 받은 가상돔 객체를 변환해주는 과정이다.
- Virtual DOM에 대한 타입 체킹을 하면서 null,undefined이거나 기본형 타입일 경우 그에 맞는 node를 생성후 반환
- Virtual DOM의 type에 맞는 실제 돔을 생성
- Virtual DOM의 props를 실제 돔에 반영
- Virtual DOM의 children을 재귀적으로 순회하면서 부모요소에 appendChild를 이용하여 부착
총 4가지 과정을 거친다. 이 과정을 거치면 우리가 생성한 컴포넌트가 가상돔으로 변환되고, 변환된 가상돔이 실제 브라우저 DOM에 적용되는 것이다.
렌더링해보기
이렇게 만든 함수를 실제로 사용해보자. 우선 App컴포넌트를 만들고 jsx를 작성해보자.
const MyComponent = ({ className }: { className: string }) => { return <div className={className}>Hello World!!!</div>; }; const App = () => { return ( <div> <div>안녕?</div> <MyComponent className='111' /> </div> ); }; export default App;
간단하게 app컴포넌트를 작성하고 태그 내부에 또 다른 컴포넌트를 넣은 상태이다. 우리가 만든 JSXFactory함수를 기반으로 문제없이 가상돔으로 변환될 것이다.
import App from './App'; import { createElement } from './lib/dom/client'; const app = document.getElementById('app') as HTMLElement; app.appendChild(createElement(<App />));
이제 main.tsx에서 직접 만든 createElement를 사용해주면 된다. App.tsx컴포넌트를 가상돔으로 변환하고, id가 app인 태그 내부에 넣어주는 방식이다.
브라우저에서 확인해보면 우리가 작성한 className과 같은 속성도 잘 삽입이 되어 있고 태그 내부내용도 잘 랜더링되었다.
마무리
아직 리액트에서 사용하는 state나 router는 구현하기 전이다. 하지만 이제 가상돔도 만들어졌으니 변경이 생긴 부분만 감지해 변경해주면 된다. 그래서 리액트가 어떤 원리로 움직이는지 직접 보고 싶다.
위에서도 짧게 얘기했지만 내가 이 모든 것을 구상하고 만들수 있는 실력은 아니다. 하지만 이렇게 클론 코딩도 해보고 핵심 원리를 파해치다보면 언젠가는 깨닫는 날이 오지 않을까? 가만히 있는 것 보다는 의미있는 성과가 있을 것이다. 빨리 리액트 훅도 만들어보고 route도 다뤄봐야겠다.
개의 댓글
1
가상 돔을 만들어보자
프로젝트 세팅
JSXFactory 함수 만들기
가상돔을 DOM에 등록하기
렌더링해보기
마무리