Published on

바닐라 JS로 직접 만드는 React (with. vite)

최근 useState, Promise, 간단한 숫자 게임을 자바스크립트로 직접 구현하며, 동작 원리를 이해하고 바닐라로 구현하는 경험을 할 수 있었다. 바닐라를 이용해 개발을 진행할 때면, React 라이브러리가 매 순간 그리웠다.


React 였으면..


어렵긴 했지만, 그동안 학습한 내용들을 종합해 바닐라로 React를 직접 구현해 보며, Virtual DOM을 비롯해 React의 동작을 이해하는 경험을 가지고자 했다. 그리고 간단한 테스트를 위해 Hello, world처럼 기본이 되는 Todo 리스트를 만들어보게 되었다.


학습 목표

  • 바닐라 JS로 React 구현하기

  • 만든 React로 Todo 리스트 구현하기

개발 환경

  • vite, JavaScript, NPM


createElement 구현하기

실제 React에서 createElement는 Babel이 JSX를 JavaScript로 변환하는 과정에서 자동으로 호출되도록 설정되어 있기 때문에 개발자가 따로 신경 쓸 부분이 없다. 이 과정을 구현하기 위해서는 작성한 JSX를 형태를 변환해 DOM으로 변환하는 과정을 우선 구현할 필요가 있다.


vite 옵션 설정하기

export default defineConfig({
  plugins: [tsconfigPaths()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  esbuild: {
    jsx: 'transform',
    jsxDev: false,
    jsxInject: `import { createElement } from '@/libs/jsx/jsx-runtime'`,
    jsxFactory: 'createElement',
  },
})

JSX 문법은 브라우저가 인식할 수 없는 형태이기 때문에 jsx, jsxFactory, jsxInject 옵션을 통해 createElement 함수를 호출하는 과정을 구현했다.

JSX 문법 작성

const element = <div className="example">Hello, World!</div>

JSX -> createElement 변환

const element = createElement('div', { className: 'example' }, 'Hello, World!')
  • Vite로 자바스크립트로 변환한다.

createElement로 가상 DOM 생성

const element = {
  node: {
    tag: 'div', // 태그 이름
    props: { className: 'example' }, // 속성
    children: ['Hello, World!'], // 자식 노드
  },
}

위와 같은 과정을 거쳐 트리 구조를 이루고, 변경 사항을 비교해 실제 DOM에 반영할 수 있는 준비를 마치게 된다.


render, _render(rerender), useState

이제 setState로 변경 액션을 일으키고, 다시 화면에 변경사항을 반영하기 위한 작업이 필요하다. 해당 역할을 담당하는 render_render, useState는 밀접한 관련이 있기 때문에 React.ts 파일에 모아서 관리해 주었다.


render

  • 흔히 main.tsx 컴포넌트에서 볼 수 있는 render의 역할과 동일하며, 초기 한 번만 동작한다.

_render(rerender)

function _render() {
  values.stateIndex = 0
  renderInfo.futureVDOM = renderInfo.root?.({
    pageParams: renderInfo.pageParams,
  })

  updateDOM(renderInfo.$parent, renderInfo.currentVDOM, renderInfo.futureVDOM)

  renderInfo.currentVDOM = renderInfo.futureVDOM
}
  • setState 호출로 상태가 변경되면, 변경된 상태를 기반으로 컴포넌트를 다시 리렌더링하고, 변경된 결과를 현재 DOM에 반영하기 위한 로직을 실행한다.

useState

function useState<T>(initialState?: T) {
  const index = values.stateIndex;

  if (typeof values.states[index] === 'undefined') {
    values.states[index] = initialState;
  }

  const state = values.states[index] as T;

  function setState(newState: T) {
    if (shallowEqual(state, newState)) {
      return;
    }

    values.states[index] = newState;
    queueMicrotask(() => {
      _render();
    });
  }

  values.stateIndex += 1;

  return [state, setState] as [T, (newState: T | ((prevState: T) => T)) => void];
}
  • shallowEqual 함수를 통해 기존과 동일한 값일 경우 업데이트가 진행되는 것을 방지한다.
  • queueMicrotask는 여러 번의 setState 작업을 모아 한 사이클 내에서 배치 업데이트를 수행하도록 도와주는 역할로, 이를 통해 각 setState 호출마다 리렌더링이 발생하지 않고, 상태 변경이 최적화되어 단 한 번의 렌더링이 이루어진다.
    (비동기 작업 처리 메서드 중 하나로, 마이크로테스크 큐에 함수를 등록하여 현재 실행 중인 코드가 완료된 직후에 실행될 작업을 예약하는 역할 수행)

updateDOM

  • 변경 이전 Virtual DOM과 변경 후 Virtual DOM의 값을 비교하는 diff 알고리즘의 구조와 이 결과인 변경된 결과를 실제 DOM에 반영(patch) 하는 역할을 수행한다.


이전 Virtual DOM과 변경 후 Virtual DOM 비교(diff)

// updateDOM.ts

if (!newVDOM) {
  if (oldVDOM && $parent.childNodes[idx]) {
    $parent.removeChild($parent.childNodes[idx])
  }

  return true
}

/** 초기 화면 렌더링 때만 실행 */
if (!oldVDOM) {
  $parent.appendChild(createDOM(newVDOM))

  return false
}

if (!checkIsSameVDOM(oldVDOM, newVDOM)) {
  const targetNode = $parent.childNodes[idx]

  if (typeof newVDOM === 'string' || typeof newVDOM === 'number') {
    const textNode = document.createTextNode(String(newVDOM))

    if (targetNode instanceof Text) {
      targetNode.nodeValue = textNode.nodeValue
    } else {
      $parent.replaceChild(textNode, targetNode)
    }
  } else {
    $parent.replaceChild(createDOM(newVDOM), targetNode)
  }

  return false
}

TODO 리스트에서 일정을 추가하면, 기존(oldVDOM) 리스트의 첫 게시물과 새로(newVDOM) 추가된 게시물을 포함한 리스트를 두고 비교를 진행하게 된다.

비교 후에 새롭게 추가된(key: 1) 일정을 실제 DOM에 반영하는 작업을 통해 화면에 노출


이번 구현 과정에서 에러가 발생했을 때, 해결의 실마리를 찾기 위해 updateDOM 함수 내에 정말 많은 로그를 찍어봤던 것 같다. 생각보다 로그의 양도 많고, 데이터 뎁스도 깊어서 쉽지 않은 과정이었지만, 변경된 값의 구조를 이해하는 데 많은 도움이 되었다.


변경사항을 실제 DOM에 반영하기(patch)

if (
  typeof newVDOM === 'object' &&
  'node' in newVDOM &&
  typeof oldVDOM === 'object' &&
  'node' in oldVDOM
) {
  const oldNodeValue = oldVDOM.node;
  const newNodeValue = newVDOM.node;

  const targetNode = $parent.childNodes[idx] as Element;

  if (
    typeof oldNodeValue === 'object' &&
    typeof newNodeValue === 'object' &&
    !Array.isArray(oldNodeValue) &&
    !Array.isArray(newNodeValue)
  ) {
    updateAttributes(
      targetNode,
      newNodeValue.props ?? {},
      oldNodeValue.props ?? {},
    );
  }

  const oldChildren = extractChildren(oldNodeValue);
  const newChildren = extractChildren(newNodeValue);

  const maxLength = Math.max(oldChildren.length, newChildren.length);

  for (let i = 0; i < maxLength; i++) {
    const oldChild = oldChildren[i] ?? null;
    const newChild = newChildren[i] ?? null;

    updateDOM(targetNode, oldChild, newChild, i);
  }
}

return false;

targetNode 변수에 현재 DOM 트리에서 업데이트하려는 실제 노드를 저장하고, 마찬가지로 이전 속성과 새로운 속성을 비교해 속성 업데이트를 수행해 준다.

const maxLength = Math.max(oldChildren.length, newChildren.length)

for (let i = 0; i < maxLength; i++) {
  const oldChild = oldChildren[i] ?? null
  const newChild = newChildren[i] ?? null

  updateDOM(targetNode, oldChild, newChild, i)
}

그리고 마지막으로 자식 노드를 순회하며 oldChild와 newChild를 하나씩 비교하고, updateDOM을 재귀적으로 호출하여 자식 노드도 업데이트를 진행해 준다.

일련의 과정을 요약하면, 아래와 같다.

  1. oldVDOM과 newVDOM을 비교하여 달라진 부분만 실제 DOM에 적용
  2. 속성(props), 자식(children)을 업데이트
  3. 재귀적으로 DOM 트리를 탐색하여 필요한 업데이트만 수행

결과

이제 View 로직은 기존 React에서 만들던 것처럼, Todo를 추가하는 함수와 state 변수를 추가해 주면 간단한 테스트 진행이 가능하다.

const App = () => {
  const [id, setId] = useState<number>(0);
  const [todoList, setTodoList] = useState<Todo[]>([]);

  const addTodo = (e) => {
    e.preventDefault();

    const formData = new FormData(e.target);
    const todoData = Object.fromEntries(formData) as TodoForm;

    if (
      !todoData.title.trim() ||
      !todoData.content.trim() ||
      !todoData.author.trim()
    )
      return;

    const newTodo = {
      id,
      title: todoData.title,
      content: todoData.content,
      author: todoData.author,
    };
    setTodoList([...todoList, newTodo]);
    setId(id + 1);

    e.target.reset();
  };

  return (
    <main>
      <form onSubmit={addTodo}>
        <input type="text" name="title" placeholder="제목" />
        <input type="text" name="content" placeholder="내용" />
        <input type="text" name="author" placeholder="작성자" />
        <button type="submit">추가</button>
      </form>

      <ul>
        {todoList.map(({id, title, content, author}) => (
          <li key={id}>
            <span>{`제목: ${title}`}</span>
            <p>{`제목: ${content}`}</p>
            <span>{`작성자: ${author}`}</span>
          </li>
        ))}
      </ul>
    </main>
  );
};

export default App;

화면-기록-2024-12-22-오후-6 00 40


마치며

이전까지의 바닐라로 useState, Promise, router를 구현하는 과정은 단순히 한 기능을 개발하는 과정이었기 때문에 구현 과정에서 큰 어려움은 없었다. 하지만 이번 바닐라 React Todo 리스트 구현은 그동안 구현했던 기능을 종합해서 사용하고, 환경을 처음부터 구축해야 했기 때문에 순탄치만은 않았던 것 같다.

이것저것 구현해 보며, 그동안 당연하게만 생각했던 React의 소중함을 깨달을 수 있었다. 사실 diffing 알고리즘과 재조정 과정, 트랜스파일러의 역할은 기술면접 대비 단순 암기 느낌으로만 알고 있었는데, 직접 구현해 보며, 원리와 동작을 이해하는데 큰 도움이 되었다.

구현을 하며 가장 큰 느낀 점은 결국 근본이자 시작은 당연하게도 자바스크립트라는 것이다. 기술은 정말 빠르게 발전하고 있기 때문에 앞으로 어떤 라이브러리, 프레임워크가 등장하고 사라질지는 알 수 없고, 상황에 따라 숙련도가 낮은 Angular, Vue, Svelte를 사용해야 하는 상황이 올 수도 있다.

지금까지 프론트의 기반 언어는 자바스크립트였기에, 결국 기본을 충실히 하면 새로운 것을 배울 때에도 더 빠르고 원활하게 배울 수 있을 것이다. 하지만 기본은 쉬우면서도 동시에 자칫 쉽게 지킬 수도 없는 어려운 양날의 검이기 때문에 기본의 중요성을 잊지 말고, 뿌리가 탄탄한 개발자가 될 수 있도록 노력하자.