지난 포스트에서 리액트의 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
에 작성한 커스텀 함수 createElement
와 Fragment
로 구문 분석을 수행할 수 있다.
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
을 받아서, type
과 props
를 키로 갖는 중첩 객체를 만들어주며, props
의 내부에는 HTML 요소의 속성으로 들어온 모든 값과 함께 children
에 하위 요소들의 정보를 중첩해서 갖는 객체를 만들어주는 것이다. 이런저런 자료를 찾아보고, 또 직접 시행착오를 겪으면서 알게 된 객체 생성 시 규칙은 아래와 같다.
- JSX 구문에서 HTML 태그, 또는 컴포넌트 함수와 만날 때면 JSX 구문 분석 함수가 바로 호출된다. 따라서, 하나의 요소 하위에 여러 요소가 중첩돼 있더라도 실행 순서에 따라 요소를 만날 때마다 순차적으로
createElement
함수가 재귀적으로 호출된다. createElemnt
의type
에는 HTML 태그나 함수가 올 수 있다.HTML
태그면 해당 층위에서props
에 대한 정보를 확인하는 단계로 넘어가지만, 함수가 올 경우 하위 요소가 컴포넌트인 것이기 때문에 재귀적으로 컴포넌트의return
에 정의된 JSX 구문들을 분석해줘야 한다.props
는 값을 객체로 받으며, Virtual DOM 객체의props
프로퍼티에 속성-값이 나열된다.children
은props
프로퍼티의 값으로 저장되는 객체의 프로퍼티로 등록된다.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
이 문자열이라면 인자로 전달된 값을 차례대로 type
과 props
에 할당한 객체를 반환한다. 이때 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)
로 호출되기 때문에, 값으로 동일하게 props
와 children
을 받도록 처리해 줬다.
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
함수가 재귀적으로 호출되며, 호출된 결과는props
의children
프로퍼티에 값이 저장된다. - Fragment 태그도 결국엔 컴포넌트다.
createElement
를 통해type
이 Fragment인 컴포넌트가 확인되면 미리 정의해 둔Fragment
함수를 실행해type
이fragment
인 노드로 변환된다. - 내가 별도로 신경을 쓰진 않았었지만, Vite 같은 빌더로 리액트 프로젝트를 열면 이러한 JSX 구문 분석을 알아서 트랜스파일링-Virtual DOM 변환 처리해 주는 함수들이 전부 내장되어 자동으로 작업이 처리된다. 해당 사실을 유념하면서 개발을 해야겠다.
구현한 코드에서 개선이 필요한 부분도 많이 있다.
- 타입 정의에 대한 부분은 너무 아쉽다. 타입스크립트를 잘 못 쓰다 보니 어디서 어떻게 타입 정의를 해야 하는지 막막한데, 피드백을 받고 좀 더 개선해 봐야겠다.
- 리액트의 규칙에 맞게 다양한 예외 처리를 하면 조금 더 안정적인 함수가 될 수 있다. 그런 모든 경우의 수를 다 체크해서 구현해내진 못했지만, 기회가 된다면 좀 더 탄탄한 함수를 만들어봐야겠다.
이외에도, 내가 인지하지 못한 여러 느슨한 부분들이 있을 텐데, 계속 더 깊게 공부하면서 지식을 넓혀가봐야겠다. 끝.
'React' 카테고리의 다른 글
React useState 직접 구현해보기 (2) | 2024.12.25 |
---|---|
React render 함수 구현해보기 (2) | 2024.12.19 |
React createElement 직접 구현하기: 환경 설정 (1) | 2024.12.14 |
useState 상태 업데이트와 useEffect 실행 시점 (1) | 2024.12.06 |
BrowserRouter와 createBrowserRouter (1) | 2024.12.03 |