vanila JS로 react 구현하기 (part 1)

개발 log·2022년 7월 17일
post-thumbnail

코드만 바로 확인하고 싶으시다면 Soact 라이브러리 Github <- 해당 링크로 바로 이동해주시면 됩니다.

계기

이번에 react를 직접 구현하고자 하게 된 계기는 동기들과 진행 중인 FEonTheBlock 스터디에서 Woowacourse에 있는 미션 중 자판기를 JavaScript로 구현하는 미션을 진행하게 되었는데, 자판기 미션의 Volumn이 생각보다 작지 않아 어려움을 느꼈기 때문이다.

이에 나는 자판기 미션에서 요구되는 사항을 정리하고 이를 library처럼 사용할 수 있도록 구현하고자 했다.

자판기 미션에서 필요했던 기능은 아래와 같다.

  • 페이지 변경
  • 상태에 따라 DOM 변경
  • localStorage를 이용하여 데이터 상태 유지

나는 이런 요구사항을 아래와 같은 라이브러리를 구현해서 사용하면 되겠다고 생각했다.

  • 페이지 변경: router
  • 상태에 따라 DOM 변경: react & stateHook
  • localStorage를 이용하여 데이터 상태 유지: react-query

사실 localStorage를 이용하기 때문에 비동기적으로 데이터를 관리할 필요는 없었지만 이왕 라이브러리를 구현해보는 김에 비동기 상태도 다뤄보기로 했다.

react의 동작 방식

react는 virtual DOM을 구성하고 변경점이 있을때마다 기존 virtual DOM과 새로운 virtual DOM을 비교해서 변경된 부분만 변경하며 리렌더링 과정을 최적화 시켰다.

그렇다. react가 효율적인 이유는 virtual DOM을 사용하기 때문이라는 추상적인 말보다는 virtual DOM을 사용해서 변경할 부분만 알아내어 해당 부분에만 실제 DOM을 조작하며 브라우저 렌더링 과정 중 리렌더링을 최적화할 수 있기 때문에 효율적인 것이다.

react를 구현하기 위해 필요한 메서드

우선 JSX 없이 사용하는 React를 보면 알겠지만 우리가 간단하게 react를 사용하기 위해서는 React.createElement, ReactDOM.render만 알면 쉽게 사용할 수 있다.

그럼 각각 이 두 메서드는 어떤 역할을 할까?

여기서부터는 react 공식문서에 기술된 내용이 아닌 내가 직접 코드를 분석해보고 나의 주관적인 생각이 반영된 지식이다.(아마 이렇게 동작할 것이다?라는 내용이라고 보면 된다.)

React.createElement

createElement가 하는 역할은 단순히 virtual DOM을 구성하는 것이다.

아마 react를 구현해보는 많은 글에서는 아래와 같이 메서드를 정의했을 것이다.

function createVirtualDOM(type, props, ...children) {  
  return { type, props, children };
}

간단하게 구현하자면 위와 같이 구성해도 된다.

하지만 비교 알고리즘을 수행하기 위해 위와 같은 메서드는 2% 부족하다.

비교 알고리즘을 수행할 때 기존 virtual DOM과 새로운 virtual DOM을 비교하는데 이때 위와 같이 반환된 virtual DOM에서는 무엇을 비교해야하는지 알수 없기 때문이다.

그래서 나는 아래와 같이 메서드를 정의했다.

const createElement = (
  el: keyof HTMLElementTagNameMap | Component,
  props: SoactProps = null,
  ...children: Children
): VDOM | TextVDOM => {
  const vDOMChildren: VDOMChildren = children.flat().map((child) => {
    switch (typeof child) {
      case 'string':
      case 'number':
      case 'boolean':
      case 'undefined':
        const value =
          typeof child === 'string' || typeof child === 'number'
            ? `${child}`
            : '';
        return { value, current: undefined };
      default:
        return child;
    }
  });

  if (typeof el === 'function') {
    const Component = el;
    return Component({ ...props, children: vDOMChildren });
  } else {
    return { el, props, children: vDOMChildren, current: undefined };
  }
};

좀 많이 복잡해졌다. (하하...😅)

내가 이렇게 메서드를 정의한 이유는 아래와 같다.

  • current 프로퍼티에 실제 DOM을 생성하고 바인딩한다.
    - 이렇게 하면 비교 알고리즘을 수행할 때 어떤 DOM을 비교대상으로 삼을지 알수 있다.

  • childrenstring이 바인딩 되면 textNode를 생성하고 TextVDOM이라는 객체로 치환한다.
    - 이렇게 해야 textNode에서도 비교 알고리즘을 수행할 수 있다.

실제로 이렇게 구현하기 전에 많은 시도를 했었는데 특히 children에 문자열이 전달되어 온 경우 위 처럼 textNode를 구성하지 않았을 때 비교 알고리즘이 제대로 동작하지 않아 많은 난항을 겪었었다.

또, 내가 이렇게 구현하게 된 계기는 실제 React도 비슷하게 동작할 것이라고 추론했기 때문이다.

리액트에서 아래 코드를 동작시켜보면 어떻게 DOM이 구성되어 있을까?

function Test() {
  return <div>{0}{1}</div>
}
<div>"0""1"</div>

이렇게 구성된다.
여기서 01textNode이다.
이렇게 실제 react에서도 textNodetext를 생성하기 때문에 위와 같이 textTextVDOM형태로 구성한 것이다.

import { createElement } from '../utils/Soact/v2';

function Test() {
  return createElement('div', null, 
		   createElement('h1', null, 'Test'),
           createElement('ul', null, 
             createElement('li', null, '1'),
             createElement('li', null, '2')
           )
         );
}

나중에 babel을 적용한 코드를 사용할 것이지만 우선 지금은 createElement가 어떻게 동작하는지 알기 위해 JSX 없이 코드를 작성했다.

이 코드가 실행되면 아래와 같은 VDOM이 생성된다.

const VDOM = {
  el: 'div',
  props: null,
  children: [
    {
      el: 'h1',
      props: null,
      children: [{ value: 'Test', current: {} }],
      current: {},
    },
    {
      el: 'ul',
      props: null,
      children: [
        {
          el: 'li',
          props: null,
          children: [{ value: '1', current: {} }],
          current: {},
        },
        {
          el: 'li',
          props: null,
          children: [{ value: '2', current: {} }],
          current: {},
        },
      ],
      current: {},
    },
  ],
  current: {},
};

다른 부분은 보지말고 stringTextVDOM형태로 { value: 'Test', current: {} } 이렇게 치환된 부분만 보면된다.

이렇게 변환되면 추후 내부 메서드인 createDOM을 통해 이 정보를 기반으로 DOM을 생성할 것이다.

지금까지 createElement가 어떻게 동작하는지, 실행되면 어떤 형태의 VDOM이 생성되는지, JSX없이 코드를 작성하면 얼마나 가독성이 떨어지고 사용하기 불편한지 알아보았다.

그렇다면 이렇게 생성된 VDOM으로 무엇을 할 수 있을까?

이제 ReactDOM.render를 사용해서 실제 DOM에 렌더링할 차례이다.

ReactDOM.render

ReactDOM의 render 메서드는 총 두개의 파라미터를 전달받는다.

  1. virtual DOM을 생성하는 콜백함수
  2. 위의 콜백함수로 생성된 VDOM을 기반으로 실제 DOM을 만든 뒤 appendChild$root 노드

파라미터로 전달받는 값들만 봐도 감이 온다.

render 메서드는 첫번째 파라미터로 전달받은 콜백함수로 VDOM을 생성하고 이를 기반으로 DOM을 구성한 뒤 $root노드에 appendChild만 해주면 끝이다.

하지만 난 여기서 다른 라이브러리에도 이 두 파라미터를 활용하기 위해 전역으로 등록하는 코드를 추가했다.

const render = (createVDOM: () => VDOM, $root: HTMLElement | null) => {
  if (!$root) {
    throw new Error('rootElement를 찾을 수 없습니다.');
  }
  // $root 노드를 전역으로 등록
  setRoot($root);
  // VDOM을 생성하는 함수를 전역으로 등록
  setCreateVDOM(createVDOM);
  // 비교 알고리즘을 수행하는 함수 (part2에서 다룰 예정)
  updateDOM();
};

여기서 updateDOM이 내가 구현한 Soact(react)라이브러리의 핵심이다.
이 로직만 바로 확인하고 싶다면 react 구현하기 (part 2)로 바로 이동하길 바란다.

이렇게 createElementrender메서드를 분석하고 구현해보았다.

다음 part에서는 비교 알고리즘을 수행하는 updateDOM에 대해 자세히 다룰 것이다.

사실 이 두 메서드만 알아도 react를 사용하는데에 있어서는 큰 무리가 없다.
그러니 정말 react를 깊게 공부하고 싶은 분들만 다음 글을 읽기 바란다.

profile
프론트엔드 개발자

0개의 댓글