Babel 설정을 통한 React의 JSX Transform
들어가며
리액트가 어떻게 JSX 코드를 브라우저가 이해할 수 있는 JavaScript로 변환하는지 알아보겠습니다.
이를 위해서 Babel의 @babel/plugin-transform-react-jsx 플러그인을 통해 트랜스파일 과정을 살펴보고, 이 과정에서 React.createElement와 jsx 함수가 어떤 역할을 하는지 분석해 보겠습니다.
JSX Transform
우선 아래와 같이 플러그인을 설정하고 컴파일을 진행해 보겠습니다.
{
"plugins": [
[
"@babel/plugin-transform-react-jsx",
{
"runtime": "classic",
}
]
]
}위 설정에서 runtime: "classic"은 React 17 이전 버전에서 사용하던 방식입니다.
17부터는 runtime: "automatic"을 사용합니다. 이와 관련해서는 추후 다시 살펴보겠습니다.
이제 간단한 React 컴포넌트를 살펴보겠습니다.
export default function App() {
return (
<div id="app">
<h1>hello</h1>
</div>
);
}이 JSX 코드가 Babel을 통해 트랜스파일되면 다음과 같이 변환됩니다.
export default function App() {
return /*#__PURE__*/ React.createElement(
"div",
{
id: "app",
},
/*#__PURE__*/ React.createElement("h1", null, "hello")
);
}리액트의 공식문서에 따르면 createElement(type, props, ...children) 함수는 다음과 같이 ReactElement 객체를 만들어 Virtual DOM을 구성합니다.
// Slightly simplified
{
type: "div",
props: {
id: 'app'
children: {
type: "h1",
props: {
children: "Hello"
}
}
},
key: null,
ref: null,
}그렇다면 runtime: "automatic" 옵션은 무엇일까요?
import { jsx as _jsx } from "react/jsx-runtime";
export default function App() {
return /*#__PURE__*/_jsx("div", {
id: "app",
children: /*#__PURE__*/_jsx("h1", {
children: "hello"
})
});
}놀랍게도 React.createElement 대신 react/jsx-runtime에서 jsx 함수를 불러옵니다.
그러면 jsx 함수는 반환값이 다를까요? 잠시 살펴보겠습니다.
export function jsxProd(type, config, maybeKey) {
let key = null;
/* ...생략....*/
let props;
if (!('key' in config)) {
// If key was not spread in, we can reuse the original props object. This
// only works for `jsx`, not `createElement`, because `jsx` is a compiler
// target and the compiler always passes a new object. For `createElement`,
// we can't assume a new object is passed every time because it can be
// called manually.
props = config;
}
/* ...생략....*/
return ReactElement(
type,
key,
undefined,
undefined,
getOwner(),
props,
undefined,
undefined,
);
}
반환값은 똑같이 ReactElement를 반환합니다. 그러면 createElement와 무슨 차이가 있을까요?
리액트에서는 이 차이점을 주석 부분에 친절히 설명해주었습니다.
번역하자면 키를 기준으로 jsx 함수는 컴파일러를 대상으로 하기 때문에 props가 항상 새로운 객체임을 보장하지만,
createElement는 수동으로 호출할 수 있기 때문에 새로운 객체임을 보장하지 않는다는 점입니다.
이를 통해 우리는 jsx 함수와 createElement가 나뉜 것은 새로운 객체를 보장하느냐 아니냐임을 알 수 있습니다.
그러면 새로운 객체를 보장하는 게 왜 중요할까요?
이는 리액트의 렌더링 최적화 및 함수형 컴포넌트의 동작 방식과 밀접한 관련이 있습니다.
함수형 컴포넌트는 클래스 컴포넌트와 달리 인스턴스를 생성하지 않고 매 렌더링마다 함수를 새로 호출하여 결과값을 반환합니다. 이때 함수 내부에서 선언된 모든 것들이 매번 새롭게 생성됩니다.
리액트는 컴포넌트의 리렌더링 여부를 결정할 때 props의 변화를 확인합니다.
새로운 객체가 매번 보장된다는 것은 리액트가 이전 props와 현재 props를 비교할 때 명확한 기준을 갖게 해줍니다.
반면, 참조가 일관되지 않으면 리액트의 비교 알고리즘이 불필요한 리렌더링을 발생시키거나, 반대로 필요한 업데이트를 놓칠 수 있습니다.
그러면 createElement는 새로운 객체를 어떻게 보장할까요?
export function createElement(type, config, children) {
// Reserved names are extracted
const props = {};
// Remaining properties are added to a new props object
for (propName in config) {
if (
hasOwnProperty.call(config, propName) &&
// Skip over reserved prop names
propName !== 'key' &&
// Even though we don't use these anymore in the runtime, we don't want
// them to appear as props, so in createElement we filter them out.
// We don't have to do this in the jsx() runtime because the jsx()
// transform never passed these as props; it used separate arguments.
propName !== '__self' &&
propName !== '__source'
) {
props[propName] = config[propName];
}
}
}두 함수 모두 결과적으로는 새로운 props 객체를 생성하지만, createElement의 경우 최적화와 관련된 몇 가지 문제를 안고 있었습니다.
(원문)
- We don’t know if the passed in props is a user created object that can be mutated so we must always clone it once.
- The transform uses React.createElement which is a dynamic property look up instead of a constant closed over module scope. This minimizes poorly and takes a little cost to run.
(번역)
- 전달된 props가 변경될 수 있는 사용자 생성 객체인지 알 수 없으므로 항상 한 번 복제해야 합니다.
- 이 변환은 모듈 스코프에서 클로즈된 상수 대신 동적 속성 조회인 React.createElement를 사용합니다. 이는 최소화가 잘 되지 않으며 실행하는 데 약간의 비용이 듭니다.
+) 더 많은 이슈는 이곳을 참고해주세요.
객체 복사는 위의 코드에서 차이점을 확인했습니다.
그러면 모듈 스코프에서 클로즈된 상수와 동적 속성 조회는 무엇을 의미할까요?
import
The transform uses React.createElement which is a dynamic property look up instead of a constant closed over module scope. This minimizes poorly and takes a little cost to run.
동적 속성 조회(dynamic property look up)
React.createElement와 같이 객체의 속성에 접근하는 방식입니다.
런타임에 React 객체를 찾고, 그 안에서 createElement 속성을 찾아야 합니다.
이는 자바스크립트 엔진이 매번 이 경로를 따라가서 함수를 찾아야 하므로 약간의 성능 비용이 발생합니다.
모듈 스코프에서 클로즈된 상수(constant closed over module scope)
모듈 스코프에서 직접 함수나 값을 가져와 사용하는 방식입니다.
import { jsx as _jsx } from "react/jsx-runtime";
export default function App() {
return /*#__PURE__*/_jsx("div", {
id: "app",
children: /*#__PURE__*/_jsx("h1", {
children: "hello"
})
});
}Babel이 트랜스파일하는 과정에서 import { jsx as _jsx } from "react/jsx-runtime"를 주입해줍니다.
덕분에 이런 정적 import는 트리 쉐이킹과 같은 최적화 기법을 더 효과적으로 적용할 수 있으며, 매번 React 객체를 통해 ‘createElement’를 찾는 과정이 없어 미세한 성능 최적화 효과를 누릴 수 있습니다.
나가며
JSX가 어떻게 트랜스파일 되는지 확인해보면서 리액트가 어떻게 최적화를 하는지 살짝 엿볼 수 있었습니다.
여기서 한 가지 중요한 부분은 매 렌더링 시 함수형 컴포넌트가 새로운 객체를 반환한다면 상태는 어떻게 유지가 될까요?
바로 클로저와 파이버인데요.
이에 대해 더 알아보면 좋을 거 같습니다.