JSX와 React.createElement
JSX는 리액트에서 HTML과 유사한 형식으로 마크업을 작성할 수 있게 해주는 문법이다. 익숙한 HTML 문법을 그대로 활용해 DOM의 구조를 표현할 수 있기 때문에 직관적이고 편리하다.
JSX는 아래와 같은 형태로 사용한다.
const App = () => {
return (
<div>
<h1>Hello World!</h1>
</div>
)
}
JSX로 작성한 구문은 트랜스파일을 통해 아래와 같은 리액트 메서드로 변환되며,
// ~ React 16 버전
React.createElement("div", null, React.createElement("h1", null, "Hello, world!"));
// React 17 버전 ~
_jsx("div", null, _jsx("h1", null, "Hello, world!"));
해당 메서드는 메모리 상에 다시 아래와 같은 객체를 생성해 DOM의 정보로 관리된다. 흔히 얘기하는 Virtual DOM의 실체가 바로 이 객체이며, 실제론 좀 더 많은 정보가 프로퍼티로 나열돼 있다.
{
type: 'div',
props: {
children: [{
type: 'h1',
props: null,
children: 'Hello, World!'
}]
}
}
기회가 돼서 추상적으로만 이해하던 해당 내용을 직접 구현해 보게 됐다. 해당 과정에서 공부해 본 내용들을 기록해 둔다.
프로젝트 설정하기
Vite로 프로젝트 빌드하기
Vite는 클라이언트 개발 환경을 빠르게 구축할 수 있도록 도와주는 빌드 도구이다. Vite에 대한 포스팅이 아니기 때문에 자세한 설명은 중략하고(사실, Vite에 대해 아직 제대로 알진 못한다. 다음에 더 공부해 봐야겠다), 이번 프로젝트 설정에 활용한 정도로만 간단히 기록해 둔다.
먼저, Vite를 이용해 프로젝트를 개설해 준다.
npm create vite@latest
Vite는 React-Next.js-Vue.js와 같은 라이브러리-프레임워크, TypeScript-JavasScript 등 다양한 도구-언어를 지원한다. 나는 리액트의 핵심 기능을 바닐라로 직접 구현해 보는 프로젝트의 성격에 맞게 Vanila-TypeScript로 설정을 맞춰주고 프로젝트를 개설했다.
tsconfig.json
설정하기
타입스크립트는 JSX 구문을 분석하지 못한다. 그래서 별도 설정으로 "jsx": "react"
또는 "jsx": "react-jsx"
를 해서 리액트에서 사용되는 JSX 구문 분석기에 기반해 타입 검사를 실시한다.
하지만, 바닐라 타입스크립트로 JSX 구문 분석 함수를 만들 것이기 때문에, 타입스크립트에서는 JSX 구문이 나올 때 어떤 식으로 처리할지에 대해 별도 설정을 잡아주는 게 필요하다.
먼저, "jsx": "preserve"
로 설정해 준다. 해당 명령어는 JSX 구문을 만나게 되면 구문 분석 함수로 트랜스파일링 하지 말고 그냥 JSX 구문 자체를 남겨놓게 만드는 설정이다. 이렇게 하면 트랜스파일링 시 JSX 구문 분석에 대한 부분을 ES Build로 별도 설정할 커스텀 함수에게 위임할 수 있다.
JSX 구문의 트랜스파일링은 타입스크립트 트랜스파일러가 담당하지 않지만(타입스크립트 코드를 자바스크립트로 변환하는 작업만 수행해 준다), 그래도 JSX 구문의 타입 검사는 수행해야 한다. 따라서, JSX 구문의 타입 검사를 수행할 기준을 jsxFactory
와 jsxFragmentFactory
에 설정해줘야 한다.
/* tsconfig.json */
// ...
"jsx": "preserve,
"jsxFactory": "createElement" // 커스텀 createElement로 JSX 구문의 타입 검사 수행
"jsxFragementFactory": "Fragment" // 커스텀 Fragment로 JSX 구문 중 Fragment의 타입 검사 수행
// ...
참고로, createElement
와 Fragment
함수는 src
폴더에 있는 createElement.ts
파일에 위치한다. tsconfig
를 위와 같이 설정하면 타입스크립트는 '직접 JSX 구문을 분석하진 않지만, JSX 구문이 이후에 createElement
와 Fragment
함수를 통해 처리될 것을 인지하고 그에 맞게 타입 검사를 실시해주며, 이때 검사할 타입 검사의 기준을 src/types/jsx.d.ts
파일에 작성해준다.
// JSX 구문에 대한 타입 정의
declare namespace JSX {
interface IntrinsicElements {
div: Props;
h1: Props;
h2: Props;
h3: Props;
p: Props;
span: Props;
button: Props;
input: Props;
form: Props;
label: Props;
img: Props;
[element: string]: Props; // 모든 HTML 태그를 포괄하는 패턴
}
interface Props {
[prop: string]: any;
}
type Children = undefined | JSX.TextNode | JSX.Element | (JSX.TextNode | JSX.Element)[];
interface Element {
type: string;
props:
| {
[prop: string]: any;
children?: Children;
}
| { children?: Children };
}
interface Fragment extends Element {
type: 'fragment';
}
interface TextNode {
type: 'textNode';
children: string | number;
}
}
JSX 구문 분석 시 타입스크립트는 JSX
라고 정의된 네임스페이스의 InstrinsicElements
타입을 참조한다(JSX.InstrinsicElements
는 JSX 구문에서 사용 가능한 HTML 요소들의 타입 정의를 의미한다). 이를 고려하여 작성하였고, 마지막으로 타입스크립트의 타입 검사기가 createElement
와 Fragment
함수의 타입에 직접 참조할 수 있도록 타입 정의를 선언해 declare
로 전역에 등록하였다.
declare function createElement(
type: string | Function,
props: JSX.Props,
...children: any[]
): JSX.Element;
declare function Fragment(props: any, ...children: any[]): JSX.Fragment;
any
를 사용했기 때문에 완벽한 타입 방어는 아니지만, 당장의 목적을 달성하는 데에는 충분하다.
여기까지 설정을 마쳤으면, 마지막으로 tsconfig
의 types
속성을 아래와 같이 정의해서 타입 검사를 위한 경로까지 설정해준다.
{
"compilerOptions": {
// ...
"jsx": "preserve",
"jsxFactory": "createElement",
"jsxFragmentFactory": "Fragment",
"types": ["./src/types/jsx.d.ts"], // type 참조를 위한 경로 지정
// ...
},
}
트랜스파일러 설정하기
타입스크립트에서는 "jsx": "preserve"
로 JSX 구문 분석을 하지 않고 위임했기 때문에, 실제 JSX 구문에 대한 분석 함수 지정은 별도 트랜스파일러를 통해 해줘야한다.
프로젝트 빌드에 사용한 Vite에는 esbuild가 기본 트랜스파일러로 적용되어있다. 빌드 도구와의 호환을 고려해서 esbuild로 JSX 구문 트랜스파일링 설정을 진행해보자.
먼저, 프로젝트 디렉토리의 최상위에 vite.config.ts
파일을 만들어주고 아래 코드를 작성해준다.
import { defineConfig } from 'vite';
import path from 'path';
// vite.config.ts 파일의 경로를 확인
const __dirname = path.dirname(new URL(import.meta.url).pathname);
export default defineConfig({
resolve: {
alias: {
src: path.resolve(__dirname, 'src'),
},
},
esbuild: {
jsxFactory: 'createElement', // JSX를 변환할 때 사용할 함수명
jsxFragment: 'Fragment', // Fragment를 변환할 때 사용할 함수명
jsxInject: `import { createElement, Fragment } from 'src/my_react/createElement'`, // 자동으로 파일 상단에 삽입할 코드
},
});
하나씩 살펴보자. 먼저, defineConfig
는 Vite 프로젝트의 여러 설정값을 정의할 수 있도록 제공되는 객체이다. 해당 객체의 esbuild
속성으로 값을 주면 트랜스파일링 관련 설정을 해줄 수 있다.
먼저 jsxFactory
와 jsxFragment
는 각각 일반 JSX 태그(HTML 요소, 컴포넌트)와 Fragment 컴포넌트를 트랜스파일링 할 때 적용할 함수를 의미한다. 각각에 대해 직접 커스텀할 함수인 createElement
와 Fragment
를 지정해준다.
그 다음, jsxInject
옵션으로 JSX 구문 트랜스파일링이 필요한 모든 파일에 import { createElement, Fragment } from 'src/my_react/createElement'
라는 import
문이 자동으로 삽입되게 처리해준다. 이를 통해 개발할 때 개발자가 파일에 별도로 import
문을 사용하지 않아도 되기 때문에 좀 더 좋은 개발 경험을 만들 수 있다.
이때, createElement
와 Fragment
함수가 참조되는 파일의 경로 src/my_react/createElement
는 절대 경로로 지정되었는데, JSX 구문이 사용되는 파일의 위치가 매번 변경될 수 있기 때문이다. 만약에 상대 경로로 지정한다면 어떤 파일에선 ./../src/my_react/createElement
로 접근해야 할 것이고, 또 어떤 파일에선 ./../../src/my_react/createElement
로 접근해야 할 수 있다. 이렇게, 파일의 위치에 따라 상대적으로 참조 경로가 변경되면 일관된 동작을 유지하기 어렵기 때문에, JSX 구문 분석 함수의 경로는 절대 경로로 설정해둔다.
위에 resolve
속성에 지정한 값이 이를 위함이다. resolve
의 alias
속성을 이용하면 원하는 키워드로 절대 경로를 지정해둘 수 있다. 예시에선 src: path.resolve(__driname, 'src')
라고 설정했는데, 이는 "__dirname
위치의 'src'
라는 디렉토리의 경로를 src
라는 이름의 절대 경로로 지정한다'는 의미이다.
__dirname
은 const __dirname = path.dirname(new URL(import.meta.url).pathname)
에서 지정한 값이고, 이는 현재 vite.config.ts
파일이 있는 현재 디렉토리 경로를 의미한다. Node의 path
객체와 URL
객체의 pathname
등이 사용되었는데, 이에 대해선 조만간 '경로'에 대한 설정을 좀 더 자세히 공부해보고 다시 정리해보겠다.
결론
위와 같이 설정하면 'JSX 구문 분석은 타입스크립트가 아니라 esbuild가 수행해주고, JSX 구문을 만나면 절대 경로인 src/my_react/createElement
모듈에서 createElement
함수와 Fragment
함수를 사용해 구문 분석을 수행해주고, 해당 함수에 대한 타입 검사 기준은 src/types/jsx.d.ts
에서 참조해줘'라는 의미의 기본 설정이 완료된다.
다음 포스트에 본격적으로 createElement
구현 내용을 정리해 보기로 하고, 일단 이번 포스트에선 기본 환경 설정을 맞춘 내용만 정리해 봤다. 설정 부분은 언제 해도 모르겠고, 이게 최선인지에 대한 자신도 없다. 특히, 타입 정의 부분이 너무 느슨하고 불완전하게 된 것 같아 아쉬운데, 기회가 되면 공부 더 하면서 개선해보도록 하겠다. 끝
'React' 카테고리의 다른 글
React render 함수 구현해보기 (2) | 2024.12.19 |
---|---|
React createElement 직접 구현하기: 동작 구현 (0) | 2024.12.16 |
useState 상태 업데이트와 useEffect 실행 시점 (1) | 2024.12.06 |
BrowserRouter와 createBrowserRouter (1) | 2024.12.03 |
제어 컴포넌트와 비제어 컴포넌트 (1) | 2024.11.25 |