지난주 바닐라 JS를 SPA로 만드는 것 이후, 이제는 리액트 만들기에 들어왔다.
흐름을 따라가며 이해해보려 노력한 일
처음에는 어떤 식으로 시작해야할 지 감이 안왔다.
코치님이 '테스트 하나하나 통과하는 느낌으로 진행'해보라는 말이 있었다.
발제 노트에서 가상돔 구현하기를 작성해 주신 내용이 있어서 참고하며 읽는데 뭐야 그냥 저대로 쓰면 되는거 아니야? 꿀이다! 하고 사용하려했다.
근데 사용 후 테스트코드 돌렸더니 자꾸 터지는거.
역시 그대로 작성하는 게 아니었다 ㅎㅎ;
아래에 이해하려 정리한 내용들은 길어서 토글로 만들어두었다.
처음 막혔던 부분은 children이 flat하게 그려져야 하는 부분.
export function createVNode(type, props, ...children) {
return { type, props, children: children.flat() };
}
flat() 은 아무 내용 없이 쓰면 그냥 1단계만 평탄화 시켜준다.
근데 flat(Infinity) 써주면 전부 평탄화시켜줌
그래서 끝난줄 알았는데 2번째 터진 부분은 예외처리 부분이다.
describe("JSX로 표현한 결과가 createVNode 함수 호출과 동일해야 한다", () => {
const TestComponent = ({ message }) => <div>{message}</div>;
const ComplexComponent = ({ items, onClick }) => (
<div className="container">
{items.map((item) => (
<span key={item.id}>{item.text}</span>
))}
<button onClick={onClick}>Click me</button>
</div>
);
it.each([
{
name: "조건부 렌더링",
vNode: (
<div>
{true && <span>Shown</span>}
{false && <span>Hidden</span>}
</div>
),
expected: {
type: "div",
props: null,
children: [{ type: "span", props: null, children: ["Shown"] }],
},
},
..
..
..
)]
flat(Intinity) 만으로는 해결이 안됨.export function createVNode(type, props, ...children) {
const flatFilterChildren = children
.flat(Infinity)
.filter(
(child) => child !== null && child !== undefined && child !== false
);
return { type, props, children: flatFilterChildren };
}
필터로 null, undefined, false 를 솎아냈다!
이렇게 createVNode를 지나갈 수 있었다.
// 컴포넌트를 정규화한다
const UnorderedList = ({ children, ...props }) => (
<ul {...props}>{children}</ul>
);
const ListItem = ({ children, className, ...props }) => (
<li {...props} className={`list-item ${className ?? ''}`}>
- {children}
</li>
);
const TestComponent = () => (
<UnorderedList>
<ListItem id="item-1">Item 1</ListItem>
<ListItem id="item-2">Item 2</ListItem>
<ListItem id="item-3" className="last-item">
Item 3
</ListItem>
</UnorderedList>
);
JSX → createVNode 함수 호출
<TestComponent />
{
type: TestComponent, // 함수
props: null,
children: []
}
normalizeVNode에 전달
normalizeVNode(<TestComponent />)
// normalizeVNode({ type: TestComponent, props: null, children: [] })
normalizeVNode 내부 처리 - 컴포넌트 함수 실행
// 함수면, 재귀로 돌려버림
if (typeof vNode.type === 'function') {
return normalizeVNode(
vNode.type({ ...vNode.props, children: vNode.children }) // --> TestComponent({}) 가 실행됨!
);
}
TestComponent 실행되면 아래처럼 나오는데,
<UnorderedList>
<ListItem id="item-1">Item 1</ListItem>
<ListItem id="item-2">Item 2</ListItem>
<ListItem id="item-3" className="last-item">Item 3</ListItem>
</UnorderedList>
이게 JSX이니까 createVNode 호출들이 연쇄적으로 일어남
{
type: UnorderedList, // 함수
props: {},
children: [
{ type: ListItem, props: { id: 'item-1' }, children: ['Item 1'] },
{ type: ListItem, props: { id: 'item-2' }, children: ['Item 2'] },
{ type: ListItem, props: { id: 'item-3', className: 'last-item' }, children: ['Item 3'] }
]
}
UnorderedList 컴포넌트 함수 UnorderedList({ children:[3개 ListItem 있는 배열]}) 실행
// 함수면, 재귀로 돌려버림
if (typeof vNode.type === 'function') {
return normalizeVNode(
vNode.type({ ...vNode.props, children: vNode.children })
// --> UnorderedList({ children:[3개 ListItem 있는 배열]}) 가 실행됨!
);
}
다시 normalizeVNode가 호출되면, UnorderedList도 함수니까 실행되고:
<ul {...{}}>{children}</ul>
JSX를 반환
{
type: 'ul', // ← 문자열! (이제 DOM 엘리먼트)
props: {},
children: [
{ type: ListItem, props: { id: 'item-1' }, children: ['Item 1'] },
{ type: ListItem, props: { id: 'item-2' }, children: ['Item 2'] },
{ type: ListItem, props: { id: 'item-3', className: 'last-item' }, children: ['Item 3'] }
]
}
type이 문자열이면 children 정규화
// nomalizeVNode 조건 중
if (typeof vNode.type === 'string') {
// children 배열을 정규화하고 빈 문자열 제거
const normalizedChildren = vNode.children
.map((child) => normalizeVNode(child)) // --> ListItem 정규화 됨.
.filter((child) => child !== '');
return {
...vNode,
children: normalizedChildren
};
}
각 ListItem이 정규화. ListItem도 함수
ListItem 컴포넌트 함수 실행
ListItem({ id: 'item-1', children: ['Item 1'] })
반환
<li {...{ id: 'item-1' }} className={`list-item ${null ?? ''}`}>
- {children}
</li>
JSX 결과
{
type: 'li', // ← 문자열
props: { id: 'item-1', className: 'list-item ' },
children: [
'- ',
'Item 1'
]
}
li의 children 재귀 정규화
// nomalizeVNode 조건 중
if (typeof vNode.type === 'string') {
// children 배열을 정규화하고 빈 문자열 제거
const normalizedChildren = vNode.children // --> children: ['- ', 'Item 1']
.map((child) => normalizeVNode(child))
.filter((child) => child !== '');
return {
...vNode,
children: normalizedChildren
};
}
// 문자열과 숫자는 문자열로 변환
if (typeof vNode === 'string' || typeof vNode === 'number') {
return String(vNode); // --> 그대로 문자열 반환 '- ', 'Item 1'
}
최종 li
{
type: 'li',
props: { id: 'item-1', className: 'list-item ' },
children: ['- ', 'Item 1'] // 두 개의 문자열
}
최종 결과
{
type: 'ul',
props: {},
children: [
{ type: 'li', props: { id: 'item-1', className: 'list-item ' }, children: ['- ', 'Item 1'] },
{ type: 'li', props: { id: 'item-2', className: 'list-item ' }, children: ['- ', 'Item 2'] },
{ type: 'li', props: { id: 'item-3', className: 'list-item last-item' }, children: ['- ', 'Item 3'] }
]
}
최상위 <ul> 처리
createElement({
type: 'ul',
props: {},
children: [
{ type: 'li', props: { id: 'item-1', className: 'list-item ' }, children: ['- ', 'Item 1'] },
{ type: 'li', props: { id: 'item-2', className: 'list-item ' }, children: ['- ', 'Item 2'] },
{ type: 'li', props: { id: 'item-3', className: 'list-item last-item' }, children: ['- ', 'Item 3'] }
]
})
// createElement
if (
typeof vNode === 'object' && // ✅ true (객체)
vNode.type && // ✅ true ('ul')
typeof vNode.type === 'string' // ✅ true (문자열)
) {
const $element = document.createElement(vNode.type); // <ul></ul> DOM 생성
<ul>의 props 설정
// createElement
if (vNode.props) {
Object.entries(vNode.props).forEach(([key, value]) => {
const attrName = key === 'className' ? 'class' : key;
if (attrName.startsWith('on')) {
const eventType = attrName.slice(2).toLowerCase();
addEvent($element, eventType, value);
} else {
setElementProperty($element, key, value);
}
});
}
<ul>의 자식 처리 (재귀 시작)
if (vNode.children) { // ✅ 자식이 3개 있음
vNode.children.forEach((child) => {
$element.appendChild(createElement(child));
// 각 <li> vNode를 createElement로 호출
});
}
첫 번째 자식 <li id="item-1">:
createElement({
type: 'li',
props: { id: 'item-1', className: 'list-item ' },
children: ['- ', 'Item 1']
})
// createElement
if (
typeof vNode === 'object' &&
vNode.type &&
typeof vNode.type === 'string'
) {
const $element = document.createElement(vNode.type); // --> <li></li> 만듦
if (vNode.props) {
Object.entries(vNode.props).forEach(([key, value]) => {
const attrName = key === 'className' ? 'class' : key;
if (attrName.startsWith('on')) {
const eventType = attrName.slice(2).toLowerCase();
addEvent($element, eventType, value);
} else {
setElementProperty($element, key, value);
}
});
}
id: 'item-1', className: 'list-item '이는 아래처럼 동작하게된다.
// createElement 83-94줄
if (vNode.props) { // { id: 'item-1', className: 'list-item ' }
Object.entries(vNode.props).forEach(([key, value]) => {
// 첫 번째 반복: key = 'id', value = 'item-1'
const attrName = key === 'className' ? 'class' : key; // attrName = 'id'
if (attrName.startsWith('on')) { // ✅ false ('id'는 'on'으로 시작 안 함)
// 이벤트 핸들링 안 함
} else {
setElementProperty($element, 'id', 'item-1'); // 13-39줄 실행
// → $element.setAttribute('id', 'item-1')
// → <li id="item-1"></li>
}
});
// 두 번째 반복: key = 'className', value = 'list-item '
Object.entries(vNode.props).forEach(([key, value]) => {
const attrName = key === 'className' ? 'class' : key; // attrName = 'class'
if (attrName.startsWith('on')) { // ✅ false
// 이벤트 핸들링 안 함
} else {
setElementProperty($element, 'className', 'list-item ');
// 13-14줄: className → class로 변환
// → $element.setAttribute('class', 'list-item ')
// → <li id="item-1" class="list-item "></li>
}
});
}
→ <li id="item-1" class="list-item "></li> 완성
<li>의 자식 처리
// 97-101줄
if (vNode.children) { // ['- ', 'Item 1']
vNode.children.forEach((child) => {
$element.appendChild(createElement(child));
});
}
첫 번째 자식 '- ' (문자열):
createElement('- ')
// createElement 52 ~ 54
if (typeof vNode === 'string' || typeof vNode === 'number') { // ✅ true
return document.createTextNode('- '); // 텍스트 노드 반환
}
→ 텍스트 노드 '- ' 생성 후 <li>에 append
$element.appendChild(document.createTextNode('- '));
// <li id="item-1" class="list-item ">- </li>
두 번째 자식 'Item 1' (문자열):
createElement('Item 1')
// createElement 52 ~ 54
if (typeof vNode === 'string' || typeof vNode === 'number') { // ✅ true
return document.createTextNode('Item 1'); // 텍스트 노드 반환
}
→ 텍스트 노드 'Item 1' 생성 후 <li>에 append
$element.appendChild(document.createTextNode('Item 1'));
// <li id="item-1" class="list-item ">- Item 1</li>
모든 <li> 동일하게 처리
같은 방식으로 두 번째, 세 번째 <li> 생성:
// 두 번째 <li>
<li id="item-2" class="list-item ">- Item 2</li>
// 세 번째 <li>
<li id="item-3" class="list-item last-item">- Item 3</li>
최종 결과
// 1단계에서 생성한 <ul>에 3개의 <li> append됨
<ul>
<li id="item-1" class="list-item ">- Item 1</li>
<li id="item-2" class="list-item ">- Item 2</li>
<li id="item-3" class="list-item last-item">- Item 3</li>
</ul>
지금까지의 흐름을 정리해보자면 아래와 같다.
normalizeVNode 결과 ↓ createElement 호출 ↓ type = 'ul' (문자열) → <ul> DOM 엘리먼트 생성 ↓ props 적용 (이 경우 빈 객체) ↓ children 반복 (3개 <li>) ↓ 각 <li>마다 createElement 재귀 호출 ↓ <li>마다 props 적용 (id, className) ↓ <li>마다 자식 처리 (텍스트 노드들) ↓ 최종 DOM 트리 완성
이후에도 이를 활용한 renderElement, updateElement 부분이 있는데, 아직 흐름을 이해하는 중이라 적지 않았다. 추후 공부해서 더 작성해놔야겠다.
전체 흐름을 다 이해하지 못한점
createVNode, normalizeVNode, createElement 까지는 흐름을 익혔는데, 그 뒤 내용을 제대로 이해하지 못한 채 사용하게 됐다.
- AI를 활용한 부분에 대해 정확히 이해하려 노력하기
/** @jsx createVNode */ 가 적혀있었다. 이게 무슨 의미인가 싶어 찾아보았다.JSX Pragma(pragmacomment)라고 불리며, 트랜스파일러(Babel 등)에게 JSX를 어떤 함수로 변환할지 알려주는 지시문
한줄 설명: JSX 문법 = createVNode 함수 호출로 자동 변환되는 것
예시:
Footer.jsx의 이 코드:
<footer className="bg-white shadow-sm sticky top-0 z-40">
<div>내용</div>
</footer>
실제로는 이렇게 변환:
createVNode("footer", { className: "bg-white shadow-sm sticky top-0 z-40" }, [
createVNode("div", {}, ["내용"])
])
/** @jsx createVNode */
import { createVNode } from "../lib"; // ← 직접 호출 안 하는데 왜 import?
export function Footer() {
return (
<footer>...</footer> // ← JSX 문법 (실제로는 createVNode로 변환됨)
);
}
이유:
1️⃣ 원본 코드 (소스):
<footer className="test"></footer>
2️⃣ Babel 트랜스파일러가 pragma 주석 읽음:
/** @jsx createVNode */
3️⃣ JSX를 createVNode 함수 호출로 변환:
createVNode("footer", { className: "test" }, [])
4️⃣ 변환된 코드 실행할 때 createVNode 필요:
✅ import { createVNode } from "../lib"
우와 닭갈비 집 어딘가요🤤