React

React render 함수 구현해보기

GoJay 2024. 12. 19. 23:47

리액트 render 함수란?

리액트는 JSX 구문, 또는 React.createElement를 사용해 표현한 화면의 요소들을 가상의 자바스크립트 객체(Virtual DOM)로 변환한다.

Virtual DOM은 DOM에 대한 모든 정보를 가지고 있지만, 실제 DOM은 아니다. 메모리 상에만 존재하는 자료 구조이기 때문에, 분석된 Virtual DOM을 실제 DOM으로 업데이트해주는 과정이 필요하다. 해당 과정을 진행해 주는 함수가 render다.

Vite로 리액트-타입스크립트 개발 환경을 빌드해주면 main.tsx 파일에 아래와 같은 코드가 처음 세팅된다.

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

여기서 createRoot 함수의 실행 결과에 점 표기법으로 render 메서드가 체이닝 되었다. render는 리액트 돔의 진입점이 되는 div#root를 출발점으로 해서 리액트 컴포넌트를 통해 만들어진 Vritaul DOM을 루트 노드에 연결해 주는 역할을 한다. 결국, 컴포넌트 내부에서 다양한 상태가 관리되고, 상태 업데이트로 리렌더링의 트리거가 관리되지만, 실제로 화면에 변경된 사항을 반영해 주고 그려주는 건 render 함수가 처리해준다.

실제 render가 어떤 과정을 거쳐서 DOM에 정보를 반영해 주는지 알아보기 위해 해당 함수를 직접 커스텀해봤다. 지난 포스트에서 리액트의 createElement의 함수 동작을 구현해 봤고(createElement 동작 구현하기), 그 결과 JSX 구문을 분석해 type, props, props.children 키가 있는 객체 자료 구조를 만들어낼 수 있었는데, render 함수는 해당 Virtual DOM 객체를 인자로 전달받아 DOM에 Virtual DOM의 정보를 반영해 주도록 구현해 봤다.

프로젝트 기본 세팅

일단, 구현에 앞서 현재의 폴더 구조를 대략 표현해 보면 아래와 같다. tsconfigvite.config.ts의 ES Build 설정 등은 지난 createElement 구현을 위해 했던 세팅(환경 설정을 그대로 유지했다.

src
 ᄂ my_react
     ᄂ createElement.ts
     ᄂ render.ts
 ᄂ types
     ᄂ jsx.d.ts
 ᄂ App.tsx
 ᄂ main.tsx
index.html
package-lock.json
package.json
tsconfig.json
vite.config.ts

리액트의 render 함수의 역할을 해줄 커스텀 render 함수는 my_react/render.ts 경로에 정의해 줬고, 실제 리액트 프로젝트와 동일하게 App.tsx로 함수형 컴포넌트의 진입점을, main.tsx에 실제 DOM 노드의 진입점이 되는 div#root와 연결해 주는 render 함수 실행 구문을 작성했다.

/* main.tsx */
import App from './App';
import render from './my_react/render';

const $app = document.getElementById('app');

if ($app instanceof HTMLElement) {
  render($app, App()); // 커스텀한 render 함수는 root 요소와 함수형 구문의 반환값을 통해 생성된 Virtual DOM을 인자로 받는다.
}
/* App.tsx */
const App = () => {
  const arr = [1, 2, 3, 4, 5];

  const handleChange = (e: Event) => {
    const target = e.target as HTMLInputElement;
    console.log(target.value);
  };

  return (
    <div className='div' id='hello'>
      <h1>render 함수 구현</h1>
      <p></p>
      <input type='text' className='input-tag' onChange={handleChange} />
      <>
        <div styles={{ color: 'red', 'font-size': '32px' }}>div</div>
        <div>div</div>
      </>
      {arr.map((num) => (
        <div key={num}>{num}</div>
      ))}
      <div>temp</div>
      <ul className='ul'>
        <li>hi</li>
        <li>123</li>
      </ul>
    </div>
  );
};

export default App;

index.html 파일은 아래와 같이 설정해 body에는 진입점이 되는 div#app 태그만 위치시켰다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + TS</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

이제,

App.tsx에 있는 JSX 구문이 실제 화면에 HTML 요소로 표현되게 하는 것이 render 함수 구현의 목표다. 한 단계씩 구현 과정에 대해 살펴보자.

render 함수 구현하기

가장 먼저 render 함수를 만들어줬다.

const render = (root: HTMLElement, vDom: JSX.Element) => {
  if (!vDom.type) return; // vDom이 빈 객체면 바로 return
  renderDOM(root, vDom.type, vDom.props); // 빈 객체가 아니면 조건에 따라 요소들 추가
};

export default render;

실제 리액트의 render 함수는 createRoot 함수의 결과로 반환되는 객체의 메서드였다. createRoot의 인자로는 document.getElementById('root')를 넘겨주기 때문에, createRoot(document.getElementById('root'))에는 진입점이 되는 div#root에 대한 정보가 있을 거고, 결과에 체이닝 된 render 메서드는 this를 통해 루트 요소에 대한 정보를 참조할 것으로 예상된다.

하지만, 직접 구현한 render 함수는 div#app 요소에 바인드 해서 this로 참조하는 게 아니라, 실제 div#app 요소를 인자로 직접 전달해서 참조를 가질 수 있도록 설정했다.

그리고 커스텀한 createElement 함수로 생성한 Virtual DOM 객체를 두 번째 인자로 받아, Virtual DOM에 저장된 DOM 정보들을 바탕으로 DOM에 렌더링 할 요소 정보를 파악할 수 있게 했다.

const render = (root: HTMLElement, vDom: JSX.Element) => {
  // ...
}

참고로, vDom 파라미터로 받는 Virtual DOM 객체는 아래와 같은 모양을 하고 있다.

{
  "type": "div",
  "props": {
    "className": "div",
    "id": "hello",
    "children": [
      {
        "type": "h1",
        "props": {
          "children": {
            "type": "textNode",
            "props": {
              "children": "render 함수 구현"
            }
          }
        }
      },
      {
        "type": "p",
        "props": {}
      },
      // ... 중략
}

type은 필수 프로퍼티이기 때문에, 혹시라도 vDom.typeundefined라면 빈 객체를 의미하고, 해당 경우에는 DOM 생성을 종료하고 바로 undefined를 반환할 수 있게 처리해 두었다.

 if (!vDom.type) return;

Virtual DOM에 하나 이상의 노드에 대한 정보가 있을 경우엔 renderDOM이라는 함수를 통해 아래와 같은 코드를 실행한다.

const renderDOM = ($parent: HTMLElement, type: string, props: JSX.Props) => {
  switch (type) {
    case 'textNode':
      renderTextNode($parent, props.children);
      return;
    case 'fragment':
      renderFragment($parent, props);
      return;
    default: {
      // 위 경우가 아니면 HTML DOM 요소 추가해주는 함수 실행
      renderHTMLElement($parent, type, props);
      return;
    }
  }
};

Virtual DOM에 있는 하나의 객체는 type에 따라 세 종류로 구분된다.

textNode는 DOM의 최하단 리프 노드이면서, HTML 태그로 감싸져있지 않은 일반 텍스트를 의미한다. props를 가질 수 없고, children은 문자열 텍스트를 값으로 갖는다. fragment<Fragment>(<>) 태그로 감싸진 값을 의미하며, props로는 key만 가질 수 있고 children을 값으로 가질 수 있다. 이렇게 두 경우를 제외하면 모든 요소가 HTML 태그를 의미하며, HTML 태그들은 태그 특성에 따라 다양한 props를 가질 수 있다는 특징이 있다.

세 가지 경우에 따라 DOM에 요소를 표현하는 방식에 차이가 존재하기 때문에, switch 구문을 이용해 type별로 다른 함수가 실행되게 처리해 줬다. 먼저 typetextNode일 경우 실행되는 renderTextNode 함수이다.

const renderTextNode = ($parent: HTMLElement, textContent: JSX.TextNode) => {
  $parent.textContent = String(textContent);
};

renderTextNode 함수는 정말 간단하다. 매개 변수로 부모 요소와 children에 저장된 문자열 값을 받고, 부모 요소에 textContent 프로퍼티에 children에 있던 textContent 값을 저장해 준다. 이때 textContent의 타입은 string | number가 들어올 수 있기 때문에, 실제 DOM에는 String(textContent) 형태로 형 변환을 해서 반영해 줬다.

다음으로, type: 'fragment일 때 실행되는 renderFragment 함수이다

const renderFragment = ($parent: HTMLElement, props: JSX.Props) => {
  [...Object.keys(props)].forEach((prop) => {
    if (prop !== 'key' && props[prop]) addChildren($parent, props[prop]); // Fragment의 prop이 key면 무시하기
  });
};

코드를 살펴보자. 먼저,Object.keys(props)props 객체에 있는 프로퍼티들을 배열로 가져온다. 그리고, 해당 프로퍼티들을 forEach 배열 메서드로 순회하면서 key가 아닌 prop, 즉 childrenprop을 확인해 addChildren 함수를 다시 실행해 준다(인자로 props[prop]을 넘겨줬는데, 이는 props['children']을 넘긴 거고, fragment의 자식 요소들을 addChildren 함수에 넘겨준 게 된다).

fragmentpropskey 또는 children만을 프로퍼티로 받을 수 있다. 그리고, key는 리액트에서 배열 렌더링 시 참조를 만들기 위해 사용되는 값이며, 실제 DOM에 표현돼야 하는 prop이 아니기 때문에, 이를 고려해 실제 DOM에는 propkey가 아닐 경우, 즉 children일 때에 한해서만 필요한 로직을 처리해 주는 것으로 했다. 그리고, props[prop]undefined가 아닌 경우, 즉 children을 갖는 경우에만 addChildren 함수가 실행되도록 조건에 추가해 줬다.

다음으로, addChildren 함수를 살펴보자.

const addChildren = ($parent: HTMLElement, children: JSX.Element | JSX.Element[]) => {
  if (Array.isArray(children)) {
    // 배열이면 순회하면서 자식 요소 추가
    children.forEach((child: JSX.Element) => renderDOM($parent, child.type, child.props));
  } else {
    // 배열이 아니면 자식 요소 바로 추가
    renderDOM($parent, children.type, children.props);
  }
};

children은 총 두 가지 경우의 수를 갖는다. children 정보가 담겨있는 객체들이 여러 개 포함된 배열인 경우(자식 요소가 2개 이상인 경우), 또는 children 정보가 담겨있는 객체 하나만 값으로 갖는 경우(자식 요소가 1개인 경우)가 그것이다. 이 두 가지 경우를 분기 처리해서 로직을 처리해 줬으며, 배열일 경우는 forEach로 순회하면서 위에서 renderDOM을 재귀적으로 재실행하고, 객체일 경우엔 반복문 없이 바로 renderDOM을 재귀적으로 호출해 줬다.

renderDOM의 모습을 다시 살펴보면, DOM 요소 중 하위 요소의 부모 요소가 되는 값을 첫 번째 인자로, 요소의 type을 두 번째 인자로, props정보를 세 번째 인자로 받는다.

const renderDOM = ($parent: HTMLElement, type: string, props: JSX.Props) => {
  switch (type) {
    case 'textNode':
      renderTextNode($parent, props.children);
      return;
    case 'fragment':
      renderFragment($parent, props);
      return;
    default: {
      // 위 경우가 아니면 HTML DOM 요소 추가해주는 함수 실행
      renderHTMLElement($parent, type, props);
      return;
    }
  }
};

renderDOM을 재귀적으로 호출한다는 것은 Virtual DOM의 props.children에 nesting 된 객체로 표현된 DOM의 위계를 DOM에 실제로 표현하는 과정을 의미한다. 이 과정을 통해 DOM의 부모 요소와 자식 요소가 차례대로 연결되면서 DOM Tree가 만들어진다.

마지막으로, typetextNodefragment가 아닌 경우, 즉 일반 HTML 태그일 경우는 default 문에 있는 renderHTMLElement 함수가 호출된다. 해당 함수도 살펴보자.

const renderHTMLElement = ($parent: HTMLElement, type: string, props: JSX.Props) => {
  const $elem = document.createElement(type);
  $parent.appendChild($elem); // DOM 요소 생성해서 추가

  if (Object.keys(props).length === 0) return; // props가 빈 객체면 바로 리턴
  if (!props.children) {
    // children이 없으면 attribute만 추가
    Object.keys(props).forEach((prop) => {
      addAttribute($elem, props, prop);
    });
  } else {
    Object.keys(props).forEach((prop) => {
      // children이면 children 추가, 없으면 attribute 추가
      prop === 'children' ? addChildren($elem, props.children) : addAttribute($elem, props, prop);
    });
  }
};

renderHTMLElement는 부모 요소, type, props를 매개 변수로 받는다. 그리고, 함수 가장 상단에서 새로운 DOM 요소를 cocument.createElement(type)으로 생성해 부모 요소인 $parent에 연결해 준다. 참고로, 여기서 type에 들어오는 값들은 'div', 'ul', 'img'와 같은 HTML 태그 정보이다. 해당 값을 document.createElement 메서드의 첫 번째 인자로 넘겨주면 그 태그에 해당하는 HTML 요소를 생성할 수 있다.

이제 생성한 $elem 요소에 propschildren 정보를 추가해 주는 단계가 필요하다. propschildren 관련해서, 아래 세 가지 사항에 대한 대응이 필요하다.

  • props가 빈 객체다. 즉, 어떠한 속성과 자식 요소도 없이 <div></div> 처럼 태그만 존재한다.
  • props는 빈 객체가 아니지만, props.childrenundefined이다. 즉, <div className='container></div> 처럼 DOM에 표현해줘야 할 속성 정보는 있지만 자식 요소는 없는 경우이다.
  • props에 자식 요소가 존재한다. 자식 요소를 제외한 다른 속성이 있는 경우와 없는 경우로 또 분기를 만들 수도 있지만, 이는 삼항 연산자 조건으로 별도로 처리해 줬다.

해당 케이스들에 각각 대응되는 조건문 케이스로 분기 처리를 수행했다. 먼저, 아래 코드를 추가해 props가 빈 객체일 경우 함수를 종료시켰다.

if (Object.keys(props).length === 0) return;

다음으로, props가 빈 객체는 아니지만 children이 없는 경우에는 props 객체를 순회하면서 HTML 태그에 속성을 추가해 주는 addAttribute 함수를 실행해줬다(addAttribute 함수는 아래에서 따로 살펴볼 예정이다).

if (!props.children) {
  // children이 없으면 attribute만 추가
  Object.keys(props).forEach((prop) => {
    addAttribute($elem, props, prop);
  });
}

마지막으로, propschildren을 갖는 경우엔 props를 순회하면서 propchildren이면 위에서 살펴봤던 addChildren 함수를, children이 아니면 addAttribute 함수를 호출하도록 해줬다.

else {
  Object.keys(props).forEach((prop) => {
    // children이면 children 추가, 없으면 attribute 추가
    prop === 'children' ? addChildren($elem, props.children) : addAttribute($elem, props, prop);
  });
}

지금까진 Virtual DOM의 정보를 참고해서 DOM 요소를 document.createElement로 생성해 주고, 생성된 요소를 DOM Tree 위계에 맞게 appendChild로 연결해 주는 작업을 수행해 줬다. 이제 마지막으로, addAttribute 함수를 살펴보자. 해당 함수는 생성한 HTML 요소에 정의된 속성을 추가해 주는 역할을 수행한다.

const addAttribute = ($elem: HTMLElement, props: JSX.Props, prop: string) => {
  switch (prop) {
    case 'key':
      return;
    case 'children':
      return;
    case 'styles': {
      Object.keys(props[prop]).forEach((key) => {
        ($elem.style as any)[key] = props[prop][key];
      });
      return;
    }
    default: {
      prop.slice(0, 2) === 'on'
        ? $elem.addEventListener(prop.slice(2).toLowerCase(), props[prop])
        : $elem.setAttribute(prop, props[prop]);
      return;
    }
  }
};

children을 제외한 props는 종류도 다양하고, 받는 값에 따라 처리해줘야 할 로직도 제각각이다.

먼저, key 또는 childrenprop으로 전달되면 함수를 바로 종료시켜 준다. key는 DOM 요소에 표현해주지 않는 속성이고, childrenaddChildren 함수를 별도로 호출해 이미 대응해 줬기 때문이다.

다음 stylesprops로 오는 경우이다. JSX 구문에서 styles 속성은 인라인으로 HTML 요소에 스타일을 먹이기 위해 사용된다. 주로 <div styles={{ color: 'red' }}></div>와 같이 사용된다. props의 값으로 객체가 오며, 인라인 스타일은 setAttribute가 아닌, $elem.style의 속성으로 별도 등록해줘야 하기 때문에, 해당 속성에 대해 별도 예외 처리를 수행해 줬다.

먼저, Object.keysprops[prop](props['styles'])의 프로퍼티로 배열로 받아 forEach 배열 메서드로 순회해 준다. 그다음, 각 요소별로 $elem.stylekey 속성으로 props[prop][key] 값(e.g. props['styles']['color'])을 넣어주었다.

이렇게 직접 styles에 정의된 객체를 개별로 순회하면서 요소에 스타일을 인라인으로 정의해 줬다.

마지막으로, default 구문에선 두 가지 상황을 처리한다. 첫 번째로 prop의 첫 두 문자가 on으로 시작하는 경우이다. 해당 경우는 이벤트 핸들러가 등록되는 상황을 의미하며, 이벤트 핸들러는 $elem.addEventListener와 같이 이벤트 리스너에 등록을 해줘야 한다. 이벤트 리스너의 첫 번째 인자로는 prop.slice(2).toLowerCase()를 추가해 줘서 어떠한 이벤트인지를 나타내주고(예를 들어, onClick이면 click이 첫 번째 인자로 전달된다), 그다음 props[prop](props['onClick'])에 정의된 이벤트 핸들러 함수를 콜백 함수로 등록해 줬다. 이를 통해 HTML에 추가되는 이벤트 핸들러가 잘 등록된다.

해당 경우를 제외한 속성일 경우 단순하게 $elem.setAttribute 메서드를 통해 속성으로 추가해 준다. 해당 과정을 통해 HTML 요소에 따라 정의된 속성들을 attribute로 추가해 줄 수 있게 된다.

render.ts 파일에 최종 작성한 전체 코드는 아래와 같다.

const addAttribute = ($elem: HTMLElement, props: JSX.Props, prop: string) => {
  /* props의 종류에 따라 필요한 처리, 예외 케이스 더 확인되면 추가 필요 */
  switch (prop) {
    case 'styles': {
      Object.keys(props[prop]).forEach((key) => {
        ($elem.style as any)[key] = props[prop][key];
      });
      return;
    }
    case 'key':
      return;
    case 'children':
      return;
    default: {
      prop.slice(0, 2) === 'on'
        ? $elem.addEventListener(prop.slice(2).toLowerCase(), props[prop])
        : $elem.setAttribute(prop, props[prop]);
      return;
    }
  }
};

const addChildren = ($parent: HTMLElement, children: JSX.Element | JSX.Element[]) => {
  if (Array.isArray(children)) {
    // 배열이면 순회하면서 자식 요소 추가
    children.forEach((child: JSX.Element) => renderDOM($parent, child.type, child.props));
  } else {
    // 배열이 아니면 자식 요소 바로 추가
    renderDOM($parent, children.type, children.props);
  }
};

const renderHTMLElement = ($parent: HTMLElement, type: string, props: JSX.Props) => {
  const $elem = document.createElement(type);
  $parent.appendChild($elem); // DOM 요소 생성해서 추가

  if (Object.keys(props).length === 0) return; // props가 빈 객체면 바로 리턴
  if (!props.children) {
    // children이 없으면 attribute만 추가
    Object.keys(props).forEach((prop) => {
      addAttribute($elem, props, prop);
    });
  } else {
    Object.keys(props).forEach((prop) => {
      // children이면 children 추가, 없으면 attribute 추가
      prop === 'children' ? addChildren($elem, props.children) : addAttribute($elem, props, prop);
    });
  }
};

const renderFragment = ($parent: HTMLElement, props: JSX.Props) => {
  Object.keys(props).forEach((prop) => {
    if (prop !== 'key' && props[prop]) addChildren($parent, props[prop]); // Fragment의 prop이 key면 무시하기
  });
};

const renderTextNode = ($parent: HTMLElement, textContent: JSX.TextNode) => {
  $parent.textContent = String(textContent);
};

const renderDOM = ($parent: HTMLElement, type: string, props: JSX.Props) => {
  switch (type) {
    case 'textNode':
      renderTextNode($parent, props.children);
      return;
    case 'fragment':
      renderFragment($parent, props);
      return;
    default: {
      // 위 경우가 아니면 HTML DOM 요소 추가해주는 함수 실행
      renderHTMLElement($parent, type, props);
      return;
    }
  }
};

const render = (root: HTMLElement, vDom: JSX.Element) => {
  // vDom이 빈 객체면 바로 return
  if (!vDom.type) return;

  // 빈 객체가 아니면 조건에 따라 요소들 추가
  renderDOM(root, vDom.type, vDom.props);
};

export default render;

실행 결과, App 컴포넌트에 정의된 JSX 반환문이 화면에도 잘 그려지고, 요소로도 잘 들어오는 게 확인된다.

결론

props의 형태에 따른 예외 처리가 충분히 되지 않은 것 같아 아쉬운 부분이 있지만, 그래도 render 함수의 핵심적인 동작은 구현되었다.

실제 render 함수도 결국 document 객체를 사용해 DOM 요소들을 조작하는 코드들로 이루어져 있을 것이다. 결국, document 객체에 대한 이해와 어떤 태그-속성이 어떻게 사용되는지를 잘 이해하는 게 리액트의 동작을 깊게 이해하는 데 있어 중요한 부분이라는 걸 다시금 느낄 수 있었다.

그리고, Virtual DOM 객체가 순회되면서 DOM Tree를 구성한다는 큰 콘셉트에 대해 이해할 수 있었다. 여기에 Virtual DOM을 diffing하는 알고리즘까지 명확하게 이해하면 실제로 리액트가 어떻게 화면을 그려주는지에 대해 좀 더 깊은 이해가 생길 수 있을 것이다.

인식 없이 무조건적으로 사용만 하던 것들을 하나씩 뜯어보니 '이게 그래서 그랬구나' 하고 이해가 늘어나는 지점들이 하나둘씩 생긴다. 다음은 useState를 통해 상태를 정의하고, 상태가 업데이트될 시 화면을 리렌더 하는 과정을 코드로 구현해 볼 건데, 계속 깊게 깊게 파헤쳐 가면서 리액트에 대한 깊은 이해를 가질 수 있도록 더 노력해야겠다. 끝.