리액트와 같은 SPA 프레임워크에서 지원하는 기능들을 비슷하게 구현해보며 학습하는 것을 목표로 합니다 📚
JSX is an XML-like syntax extension to ECMAScript without any defined semantics.
- JSX 공식문서 -
JSX란 JavaScript에 XML/HTML을 추가한 JS 확장 문법이다.
리액트를 페이스북(현 메타)에서 소개하며 함께 등장한 새로운 구문이지만 리액트에 종속적이지만은 않은 독자적 문법이다. 또한 ECMAScript(JS 표준)의 일부도 아니다.
당시 JSX 구문이 공개되었을 때에는 JS 코드 내에 HTML을 추가한다는 점에서 관심사 분리의 원칙이 제대로 지켜지지 않았다고 보여졌기 때문에 부정적인 반응이 많았다고 한다. 하지만 이후 JSX 구문은 컴포넌트의 역할에 따라 관심사를 분리시킬 수 잇는 새로운 방식임을 인정받으며 현재까지 자리를 잡을 수 있었다.
특징 정리
JSX는 아래의 4가지 컴포넌트를 기반으로 구성된다.
자세한 내용은 모던 리액트 Deep Dive나 JSX 공식문서를 참고하고, 이번 글에서는 예시 코드로 대체한다.
JSX를 구성하는 가장 기본 요소로, HTML의 element와 비슷한 역할을 한다.
아래는 JSXElement가 될 수 있는 형태들의 예시이다.
<> {/* JSXFragment */}
<div>
<h1>Welcome to JSX!</h1> {/* JSXOpeningElement & JSXClosingElement */}
<img src="image.jpg" alt="Sample" /> {/* JSXSelfClosingElement */}
</div>
<br /> {/* JSXSelfClosingElement */}
</>
사용자가 만든 컴포넌트의 경우에는 HTML 태그명과 구분짓기 위해 반드시 대문자로 시작해야한다!
추가적으로 JSXElement의 요소 이름으로 쓸 수 있는 것은 아래와 같다.
{/* JSXIdentifier - JSX 내부에서 사용할 수 있는 식별자*/}
<Header title="Welcome" />
{/* JSXNamespacedName - JSXIdentifier의 조합, : 한개만 사용 가능 */}
<svg:circle cx="50" cy="50" r="40" fill="blue" />
{/* JSXMemberExpression JSXIdentifier의 조합, . 여러 개 사용 가능*/}
<UI.Button label="Click Me" />
JSXElement에 부여할 수 있는 속성을 의미한다.
아래와 같이 다양한 방식으로 속성값이 들어갈 수 있다.
const props = { disabled: true, label: "Submit" };
<Button
id="mainButton"
type="submit"
disabled={false}
aria-label={'Accessible Button'}
foo:bar="baz"
attribute=<div>hello</div>
{...props}
/>
JSXElement의 자식값을 나타낸다. JSXChildren의 기본 단위는 JSXChild로, JSXChildren는 JSXChild를 0개 이상 가질 수 있다.
아래는 JSXChild의 주요 유형들이다.
<div>
Text Content {/* JSXText - {, <, >, }를 제외한 문자열 */}
<span>Nested Element</span> {/* JSXElement */}
<>hi</> {/* JSXFragment */}
{(() => 'foo')()} {/* {JSXChildExpression (optional)} */}
</div>
JSXStrings(JSXAttributeValue와 JSXText)에서는 HTML 문자 참조(예: &, <)를 허용하여 HTML과 JSX 간의 복사-붙여넣기를 쉽게 할 수 있도록 설계되어있다.
이때 설계로 인해 JS에서 특수문자를 처리할 때 사용되는 \ 이스케이프 시퀀스는 지원되지 않는다.
<button>/</button> // ok
let escape1 = "\" // Uncaught SyntaxError : Invalid or unexpected token
let escape2 = "\\" // ok
기업에서 독자적으로 만든 구문인만큼 JSX 코드를 아무런 처리 없이 실행을 하게 된다면 SyntaxError가 발생하며, 반드시 트랜스파일러를 거쳐 JS 런타임이 이해할 수 있는 JS 코드로 변환시켜줘야 한다.
다음으로는 어떻게 JSX 구문을 변환시키는지 알아보자!
우선 JSX 컴포넌트를 콘솔로 찍어보았을 때 어떤 형식으로 나오는지 확인해보았다.
참고로 babeljs에서도 확인이 가능하다.
콘솔을 보았을때 컴포넌트는 jsxs와 jsx라는 메서드로 변환되는 것을 확인할 수 있었다.
참고로 해당 변환 결과는 React 17 이후의 방식이며 React 17 이전의 방식은 JSX를 React.createElement라는 메서드를 이용해 변환한다.
// JSX
function App() {
return <h1>Hello World</h1>;
}
// 변환 결과 (17 이전)
import React from 'react';
function App() {
return React.createElement('h1', null, 'Hello world');
}
// 변환 결과 (17 이후)
import {jsx as _jsx} from 'react/jsx-runtime';
function App() {
return _jsx('h1', { children: 'Hello world' });
}
우선 jsx, jsxs 메서드의 코드를 살펴보자
1. packages/react/src/jsx/ReactJSX.js
import {
jsxProd,
jsxProdSignatureRunningInDevWithDynamicChildren,
jsxProdSignatureRunningInDevWithStaticChildren,
jsxDEV as _jsxDEV,
} from './ReactJSXElement';
const jsx: any = __DEV__
? jsxProdSignatureRunningInDevWithDynamicChildren
: jsxProd;
// we may want to special case jsxs internally to take advantage of static children.
// for now we can ship identical prod functions
const jsxs: any = __DEV__
? jsxProdSignatureRunningInDevWithStaticChildren
: jsxProd;
jsxProd라는 메서드를 반환함을 확인할 수 있다.
export function jsxProd(type, config, maybeKey) {
...
return ReactElement(
type,
key,
undefined,
undefined,
getOwner(),
props,
undefined,
undefined,
);
}
그리고 jsxProd는 ReactElement를 반환한다.
function ReactElement(
type,
key,
self,
source,
owner,
props,
...
) {
...
let element;
if (__DEV__) {
...
} else {
element = {
$$typeof: REACT_ELEMENT_TYPE,
type,
key,
ref,
props,
};
}
return element;
}
ReactElement는 React 컴포넌트나 DOM 노드, 속성(props) 등을 정의하는 JS 객체를 생성하는 역할로, React가 컴포넌트 트리를 분석하고 렌더링하는 데 사용된다.
결론적으로 JSX 파일을 트랜스파일링하면 리액트의 버전에 따라 jsxs
& jsx
메서드 혹은 React.createElement
로 변환되어, React가 컴포넌트 트리를 분석하고 실제 DOM에 반영할 수 있는 가상 DOM 구조를 생성하게 된다.
특징 | React.createElement | jsx / jsxs |
---|---|---|
도입 버전 | React 초기 버전 | React 17+ |
용도 | 모든 JSX 표현을 변환 | JSX 표현을 최적화하여 변환 |
사용 시점 | 항상 사용 | jsx : 단일 자식 요소jsxs : 다중 자식 요소 |
필요한 import | React import 필요 | react/jsx-runtime 사용 (React import 불필요) |
번들 크기 | 더 크고 비효율적일 수 있음 | 더 작고 최적화된 결과 생성 |
런타임 성능 | 상대적으로 느림 | 빠르고 최적화된 성능 |
트랜스파일링 결과 | React.createElement(type, props, ...children) | jsx(type, props) 또는 jsxs(type, props) |
호환성 | 이전 버전과의 호환성 필요 | 최신 React 버전에 최적화 |
위에서 알아본 ReactElement를 참고하며 JSX를 기본적인 형태의 element로 변환하는 메서드를 만들어보고자한다.
React.createElement("h1", { className: "title" }, "Hello!");
또한 위와 같은 React.createElement의 구성을 많이 참고하며 구현하고자 하였다.
// createElement 함수: JSX로 표현된 요소를 분석해 가상 DOM 객체를 생성
export const createElement = (type, props = {}, ...children) => {
// 자식 요소를 평탄화하여 중첩 배열 구조를 단일 배열로 변환
const flatChildren = children.flat();
// 자식 요소를 필터링 및 변환
const filteredChildren = flatChildren
// null, undefined, false 값은 제거
.filter((child) => child !== null && child !== undefined && child !== false)
// 객체인 경우 그대로 유지, 문자열/숫자는 텍스트 노드로 변환
.map((child) =>
typeof child === "object" ? child : createTextElement(child)
);
// Fragment 처리: type이 undefined이거나 "FRAGMENT"인 경우
if (type === undefined || type === "FRAGMENT") {
return {
type: "FRAGMENT", // Fragment 타입으로 설정
props: {
children: filteredChildren, // 자식 요소를 그대로 추가
},
};
}
// 컴포넌트 함수 처리: type이 함수인 경우
if (typeof type === "function") {
return type({
...props, // 속성을 전달
children: filteredChildren, // 정리된 자식 요소 전달
});
}
// 일반 DOM 요소 처리: type이 문자열인 경우
return {
type, // 태그 이름
props: {
...props, // 전달받은 속성 복사
children: filteredChildren, // 정리된 자식 요소 추가
},
};
};
// 텍스트 요소를 생성하는 함수
const createTextElement = (text) => {
return {
type: "TEXT_ELEMENT", // 텍스트 노드임을 명시
props: {
nodeValue: text, // 텍스트 값을 저장
children: [], // 텍스트 노드는 자식이 없으므로 빈 배열 설정
},
};
};
또한 React 객체를 만들어 객체를 통해 핵심기능인 메서드들을 사용할 수 있도록 세팅해주었다.
import { createElement } from "./createElement";
const React = {
createElement,
render,
};
export default React;
// App.jsx
import React from "../core/React";
const Child = () => {
return (
<div>Child</div>
)
}
const App = () => {
return (
<div>
<Child />
<span id="first">hi</span>
<p className="second">hihi</p>
</div>
);
};
export default App;
// index.js
import App from "./components/App";
console.log(App());
정상적으로 위와 같은 객체 구조로 변환됨을 확인할 수 있었다. (실제 리액트보다 추상적이고 간소화된 형태이다.)
위와 같이 JSX 코드가 내가 만든 변환 메서드를 통해 객체로 파싱될 수 있도록 하려면 추가적인 설정이 필요하다.
babel, esbuild 등을 활용해 가능하며, 나의 경우에는 vite에 내장되어 있는 esbuild의 기능을 이용해 설정하였다.
import { defineConfig } from "vite";
import path from "path";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
"@core": path.resolve(__dirname, "src/core")
},
},
esbuild: {
jsxInject: `import React from '@core/React'`,
jsxFactory: "React.createElement",
},
});
jsxFactory
: JSX가 어떤 함수로 변환될지 결정하는 옵션이다. 직접 만든 createElement 함수의 경로를 넣어준다.jsxInject ⭐️
: JSX 코드에서 React 객체를 사용하려면 보통 import React from 'react';를 파일 상단에 명시적으로 작성을 해줘야한다.참고로 React 17에서 React import문이 사라진 이유 역시 createElement
에서 react/jsx-runtime의 jsx함수
로 변경되면서 빌드시점에 babel이 해당 구문을 inject하는 방식으로 개선되었기 때문이다.
JSX의 주 기능과 구성에 대해 이해하고, JSX를 변환하는 JSX 팩토리 함수를 직접 만들어 볼 수 있었다.
이 과정을 통해 JSX가 어떻게 자바스크립트 코드로 변환되는지, 그리고 이를 처리하기 위한 트랜스파일링 과정에서 사용되는 설정들에 대해 알게 되었다. 또한, React 17에서 JSX 트랜스파일링 방식이 개선되면서 React 임포트문이 사라지고, jsx-runtime을 사용한 자동 임포트 방식이 도입된 점에 대해서도 이해할 수 있었다 😊
다음 글에서는 변환된 element를 실제 DOM에 렌더링해보고, 가상 DOM과의 비교를 통한 DOM 업데이트를 진행해보자!
모던 리액트 Deep Dive
https://ccomccomhan.tistory.com/242?category=649977
https://so-so.dev/react/import-react-from-react
JSX 이해에 큰 도움이 되었습니다!