React

React createElement 직접 구현하기: 동작 구현

GoJay 2024. 12. 16. 18:16

지난 포스트에서 리액트의 createElement 함수의 동작을 커스텀하게 만들어서 적용하기 위한 기본 환경 설정에 대해 살펴봤다. 간단하게 요약해 보면 다음과 같다.

  • Vite로 프로젝트를 빌드한다(바닐라 타입스크립트 프로젝트).
  • tsconfig.json"jsx": "preserve"로 설정해서 타입스크립트 트랜스파일러가 JSX 구문을 트랜스파일링하지 못하게 막는다.
  • "jsxFactory""jsxFragmentFactory" 속성에 이후에 JSX 구문 분석 시 사용될 함수를 정의해서 이후에 변환될 값을 기준으로 타입 검사를 수행할 수 있게 설정한다.
  • JSX 구문 분석 함수의 타입을 src/types/jsx.d.ts에 정의해두고, tsconfig.json 파일에 "types": ["./src/types/jsx.d.ts"]로 경로를 지정해준다.
  • vite.config.ts 파일을 최상단 디렉토리에 만들어두고, esbuild 속성으로 JSX 구문의 트랜스파일링 시 적용될 함수를 커스텀 함수로 지정해준다.
  • 모든 JSX 구문이 있는 파일에서 커스텀한 함수가 별도 import 없이 참조될 수 있도록 jsxInject 속성을 지정해준다.

이렇게 설정을 완료하면 JSX 구문 트랜스파일링 및 실행 시 src/my_react 폴더의 createElemnt.ts에 작성한 커스텀 함수 createElementFragment로 구문 분석을 수행할 수 있다.

createElement의 역할 톺아보기

createElement의 내부 동작을 구현해 보기에 앞서, 먼저 createElement라는 함수가 수행해 주는 역할에 대해 다시 한번 살펴보겠다(자세한 내용은 이 포스트 참고).

리액트의 JSX 구문은 자바스크립트 코드로 HTML 태그를 흉내 낸 방식인데, 자바스크립트의 기본 문법이 아니기 때문에 그 자체로는 자바스크립트 엔진이 코드를 해석하지 못한다. 그래서, JSX로 작성된 구문은 createElement(리액트 17 버전부턴 jsx jsxs 함수)로 변환되어 실행된다.

// JSX 코드
<div className='container'>
  <p>Hello JSX</p>
</div>

// createElement 변환 코드
React.createElement(
  'div', 
  { className: 'container' }, 
  React.createElement(
    'p',
    null,
    'Hello JSX'
  )
)

이렇게 변환된 createElement 함수는 결과론적으로 자바스크립트 객체를 생성해 준다. 위 예시의 코드는 아래와 같은 자바스크립트 객체를 생성해 메모리에 저장하며, 해당 객체를 DOM의 정보를 담고 있다고 해서 흔히 Virtual DOM이라고 부른다.

{
  type: 'div',
  props: {
    className: 'container',
    children: {
      type: 'p',
      props: {
        children: {
          type: 'TextNode',
          props: {
            children: 'Hello JSX'
          }
        }
      }
    }
  }
}

즉, createElement의 역할은 인자로 type, props, children을 받아서, typeprops를 키로 갖는 중첩 객체를 만들어주며, props의 내부에는 HTML 요소의 속성으로 들어온 모든 값과 함께 children에 하위 요소들의 정보를 중첩해서 갖는 객체를 만들어주는 것이다. 이런저런 자료를 찾아보고, 또 직접 시행착오를 겪으면서 알게 된 객체 생성 시 규칙은 아래와 같다.

  • JSX 구문에서 HTML 태그, 또는 컴포넌트 함수와 만날 때면 JSX 구문 분석 함수가 바로 호출된다. 따라서, 하나의 요소 하위에 여러 요소가 중첩돼 있더라도 실행 순서에 따라 요소를 만날 때마다 순차적으로 createElement 함수가 재귀적으로 호출된다.
  • createElemnttype에는 HTML 태그나 함수가 올 수 있다. HTML 태그면 해당 층위에서 props에 대한 정보를 확인하는 단계로 넘어가지만, 함수가 올 경우 하위 요소가 컴포넌트인 것이기 때문에 재귀적으로 컴포넌트의 return에 정의된 JSX 구문들을 분석해줘야 한다.
  • props는 값을 객체로 받으며, Virtual DOM 객체의 props 프로퍼티에 속성-값이 나열된다.
  • childrenprops 프로퍼티의 값으로 저장되는 객체의 프로퍼티로 등록된다.
  • children은 하위 요소가 하나일 땐 단일 객체로, 하위 요소가 두 개 이상 중첩되어 있을 경우엔 객체인 값을 담고 있는 배열로 값이 저장된다.
  • HTML 태그에 감싸진 텍스트가 있다면 children{ type: 'TextNode', children:${작성된 텍스트}}로 지정된 객체가 저장된다(트리 구조의 Virtual DOM에서 리프 노드가 된다).
  • Fragment가 사용될 경우, createElement가 아니라 Fragement라는 별도 함수가 호출된다.

함수 구현하기

위에 정리한 규칙들을 고려해서 아래와 같이 함수를 작성하였다(타입스크립트에 대한 이해가 부족해서, 타입 정의 부분 고려하지 못한 부분이 많을 수 있음을 미리 남겨둔다. 이후에 조금 더 엄격한 타입 정의를 적용할 수 있게 리팩토링 예정이다).

/*
* createElement.ts
*/
const createNode = (child: any): JSX.TextNode | JSX.Element => {
  if (typeof child === 'string' || typeof child === 'number') {
    return createTextNode(child);
  }
  return child;
};

const createTextNode = (child: string | number): JSX.TextNode => {
  return { type: 'textNode', children: child };
};

const createChildrenNode = (children: any[]): JSX.Children => {
  switch (children.length) {
    // children이 하나도 없을 시 children 값을 가지지 않도록 처리
    case 0:
      return;
    // children이 하나일 시 일반 객체로 값을 가지게 처리
    case 1:
      return createNode(children[0]);
    // 두 개 이상이면 객체를 값으로 가지는 배열로 반환
    default:
      return children.map((child) => createNode(child));
  }
};

export const createElement = (
  type: string | Function,
  props: JSX.Props,
  ...children: any[]
): JSX.Element => {
  // 함수형 컴포넌트면 props랑 전개한 children 내려줘서 재귀적으로 createElement 실행
  if (typeof type === 'function') {
    return type(props, ...children);
  }

  // HTML 표준 태그가 아닌데 태그로 사용된 경우 에러 발생시키기
  if (document.createElement(type) instanceof HTMLUnknownElement) {
    throw new Error('정확한 HTML 태그, 또는 함수형 컴포넌트를 사용해 주세요');
  }

  return {
    type,
    props: {
      ...props,
      children: createChildrenNode(children),
    },
  };
};

export const Fragment = (props: any, ...children: any[]): JSX.Fragment => {
  return {
    type: 'fragment',
    props: {
      ...props,
      children: createChildrenNode(children),
    },
  };
};

하나씩 살펴보자. 먼저, type에 들어온 값이 함수일 경우 이는 함수형 컴포넌트를 의미하는 것이고, 문자열일 경우는 HTML 태그를 나타낸다. 만약에 HTML 태그가 표준 HTML 태그가 아니라면 document.createElement(type)의 값은 HTMLUnknownElement에 체이닝 된다. 이를 이용해서 HTML 표준 태그가 아닌 값이 들어올 땐 에러를 발생시켜 주는 방어 코드를 추가했다.

if (document.createElement(type) instanceof HTMLUnknownElement) {
  throw new Error('정확한 HTML 태그, 또는 함수형 컴포넌트를 사용해 주세요');
}

그러면 함수형 컴포넌트의 반환값으로 있을 JSX 구문이 다시 순차적으로 실행되고, 해당 구문에서 HTML 요소를 만날 때마다 createElement 함수가 재귀적으로 재호출 된다.

만약에 type이 문자열이라면 인자로 전달된 값을 차례대로 typeprops에 할당한 객체를 반환한다. 이때 props 객체 안에는 createElement에서 인자로 받은 props 객체를 전개 연산자를 통해 그대로 값을 내려주고, 추가적으로 children을 반환값 객체의 props 내부 프로퍼티로 정의해 준다.

return {
  type,
  props: {
    ...props,
    children: createChildrenNode(children),
  },
};

children은 값이 하나일 땐 객체를 값으로 갖고, 두 개 이상일 땐 객체를 값으로 갖는 배열이 할당된다. 값이 하나도 없을 땐 undefined를 값으로 갖는다. 해당 로직을 반영해서 분기 처리해서 Node를 생성해 주는 함수를 아래와 같이 정의해 준다.

const createChildrenNode = (children: any[]): JSX.Children => {
  // children이 하나도 없을 시 children 값을 가지지 않도록 처리
  switch (children.length) {
    case 0:
      return;
    case 1:
      return createNode(children[0]);
    default:
      return children.map((child) => createNode(child));
  }
};

createNode 함수에선 자식 요소가 textNode인지 판별하여 참일 경우 createTextNode를 통해 type: textNode인 노드를 생성에 반환해 주도록 처리해 줬다.

const createNode = (child: any): JSX.TextNode | JSX.Element => {
  if (typeof child === 'string' || typeof child === 'number') {
    return createTextNode(child);
  }
  return child;
};

const createTextNode = (child: string | number): JSX.TextNode => {
  return { type: 'textNode', children: child };
};

마지막으로, Fragment 태그가 보일 경우에 호출되는 Fragment 함수도 아래와 같이 정의해 준다. Fragment는 JSX 구문에서 통용되는 태그이지만, 사실은 하나의 컴포넌트이다. 따라서, createElement(typeof type === 'function') 조건에 걸려서 type(props, ...children)로 호출되기 때문에, 값으로 동일하게 propschildren을 받도록 처리해 줬다.

export const Fragment = (props: any, ...children: any[]): JSX.Fragment => {
  return {
    type: 'fragment',
    props: {
      ...props,
      children: createChildrenNode(children),
    },
  };
};

이렇게 하면 Fragment 태그에 대해선 createElement 함수를 호출해 type === 'function' 분기문을 거쳐서 Fragment 함수가 호출되고, 결과적으로 type: 'fragment'인 객체가 반환된다.

타입 정의 부분에서 완벽하진 않아 수정이 많이 필요하지만, 일단 간단하게 createElement가 Virtual DOM을 만드는 과정을 코드로 직접 구현해 봤다. 이제 main.tsx 파일을 아래와 같이 작성하고 실행해 보면 브라우저의 콘솔 창에 Virtual DOM 형태의 객체가 잘 표현되는 게 확인된다.

import { createElement, Fragment } from './createElement';

const App = () => {
  let count = 0;

  const handleIncrease = () => {
    count += 1;
  };

  const handleDecrease = () => {
    count -= 1;
  };

  return (
    <>
      <div>
        <h1>Counter</h1>
        <Counter count={count} />
        <Button onIncrease={handleIncrease} onDecrease={handleDecrease} />
      </div>
    </>
  );
};

const Counter = ({ count }: { count: number }) => {
  return (
    <div className='count-container'>
      <p>{count}</p>
      <p>{count}</p>
    </div>
  );
};

const Button = ({ onIncrease, onDecrease }: { onIncrease: () => void; onDecrease: () => void }) => {
  return (
    <div className='button-container'>
      <button className='increase' onClick={onIncrease}>
        +
      </button>
      <button className='decrease' onClick={onDecrease}>
        -
      </button>
    </div>
  );
};

console.log(JSON.stringify(App(), null, 2));

참고로 추가해 준 onClick 함수는 JSON.stringify로 직렬화할 때 함수를 빼고 직렬화한다는 규칙 때문에 화면에 표시되진 않지만, 실제 객체를 까보면 onClick 함수도 잘 포함되어 있는 게 확인된다. 이렇게, 간단하지만 createElement를 직접 구현해 보고 Virtual DOM의 실체에 대해서도 직접 눈으로 확인해 봤다.

결론

해당 과정을 통해 정말 많은 걸 배울 수 있었다.

  • JSX 구문은 트랜스파일링을 통해 자바스크립트 코드로 변환된다. 그리고, 변환된 자바스크립트 코드가 실행되면 메모리 상에 DOM의 트리 구조를 나타내는 객체가 생성되며, 이를 흔히 Virtual DOM이라고 부른다.
  • Virtual DOM에는 HTML 태그(타입), Props에 대한 정보들, 하위의 자식 요소들에 대한 정보들이 포함된다. 그리고, 실제 JSX 구문 분석을 수행해 주는 함수는 해당 과정에 대한 훨씬 많은 예외 처리를 수행해주고 있을 것이다(리액트의 JSX 구문 사용 규칙에 맞게 예외 처리들이 돼있을 것이다).
  • JSX 구문을 분석하는 과정에서 하위 요소를 마주칠 때마다 createElement 함수가 재귀적으로 호출되며, 호출된 결과는 propschildren 프로퍼티에 값이 저장된다.
  • Fragment 태그도 결국엔 컴포넌트다. createElement를 통해 type이 Fragment인 컴포넌트가 확인되면 미리 정의해 둔 Fragment 함수를 실행해 typefragment인 노드로 변환된다.
  • 내가 별도로 신경을 쓰진 않았었지만, Vite 같은 빌더로 리액트 프로젝트를 열면 이러한 JSX 구문 분석을 알아서 트랜스파일링-Virtual DOM 변환 처리해 주는 함수들이 전부 내장되어 자동으로 작업이 처리된다. 해당 사실을 유념하면서 개발을 해야겠다.

구현한 코드에서 개선이 필요한 부분도 많이 있다.

  • 타입 정의에 대한 부분은 너무 아쉽다. 타입스크립트를 잘 못 쓰다 보니 어디서 어떻게 타입 정의를 해야 하는지 막막한데, 피드백을 받고 좀 더 개선해 봐야겠다.
  • 리액트의 규칙에 맞게 다양한 예외 처리를 하면 조금 더 안정적인 함수가 될 수 있다. 그런 모든 경우의 수를 다 체크해서 구현해내진 못했지만, 기회가 된다면 좀 더 탄탄한 함수를 만들어봐야겠다.

이외에도, 내가 인지하지 못한 여러 느슨한 부분들이 있을 텐데, 계속 더 깊게 공부하면서 지식을 넓혀가봐야겠다. 끝.