리액트 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의 정보를 반영해 주도록 구현해 봤다.
프로젝트 기본 세팅
일단, 구현에 앞서 현재의 폴더 구조를 대략 표현해 보면 아래와 같다. tsconfig
와 vite.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.type
이 undefined
라면 빈 객체를 의미하고, 해당 경우에는 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
별로 다른 함수가 실행되게 처리해 줬다. 먼저 type
이 textNode
일 경우 실행되는 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
, 즉 children
인 prop
을 확인해 addChildren
함수를 다시 실행해 준다(인자로 props[prop]
을 넘겨줬는데, 이는 props['children']
을 넘긴 거고, fragment
의 자식 요소들을 addChildren
함수에 넘겨준 게 된다).
fragment
는 props
에 key
또는 children
만을 프로퍼티로 받을 수 있다. 그리고, key
는 리액트에서 배열 렌더링 시 참조를 만들기 위해 사용되는 값이며, 실제 DOM에 표현돼야 하는 prop
이 아니기 때문에, 이를 고려해 실제 DOM에는 prop
이 key
가 아닐 경우, 즉 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가 만들어진다.
마지막으로, type
이 textNode
가 fragment
가 아닌 경우, 즉 일반 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
요소에 props
와 children
정보를 추가해 주는 단계가 필요하다. props
와 children
관련해서, 아래 세 가지 사항에 대한 대응이 필요하다.
props
가 빈 객체다. 즉, 어떠한 속성과 자식 요소도 없이<div></div>
처럼 태그만 존재한다.props
는 빈 객체가 아니지만,props.children
은undefined
이다. 즉,<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);
});
}
마지막으로, props
가 children
을 갖는 경우엔 props
를 순회하면서 prop
이 children
이면 위에서 살펴봤던 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
또는 children
이 prop
으로 전달되면 함수를 바로 종료시켜 준다. key
는 DOM 요소에 표현해주지 않는 속성이고, children
은 addChildren
함수를 별도로 호출해 이미 대응해 줬기 때문이다.
다음 styles
가 props
로 오는 경우이다. JSX 구문에서 styles
속성은 인라인으로 HTML 요소에 스타일을 먹이기 위해 사용된다. 주로 <div styles={{ color: 'red' }}></div>
와 같이 사용된다. props
의 값으로 객체가 오며, 인라인 스타일은 setAttribute
가 아닌, $elem.style
의 속성으로 별도 등록해줘야 하기 때문에, 해당 속성에 대해 별도 예외 처리를 수행해 줬다.
먼저, Object.keys
로 props[prop]
(props['styles']
)의 프로퍼티로 배열로 받아 forEach
배열 메서드로 순회해 준다. 그다음, 각 요소별로 $elem.style
의 key
속성으로 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
를 통해 상태를 정의하고, 상태가 업데이트될 시 화면을 리렌더 하는 과정을 코드로 구현해 볼 건데, 계속 깊게 깊게 파헤쳐 가면서 리액트에 대한 깊은 이해를 가질 수 있도록 더 노력해야겠다. 끝.
'React' 카테고리의 다른 글
React useState 직접 구현해보기 (2) | 2024.12.25 |
---|---|
React createElement 직접 구현하기: 동작 구현 (0) | 2024.12.16 |
React createElement 직접 구현하기: 환경 설정 (1) | 2024.12.14 |
useState 상태 업데이트와 useEffect 실행 시점 (2) | 2024.12.06 |
BrowserRouter와 createBrowserRouter (1) | 2024.12.03 |