만들면서 이해하는 SPA - JSX, createElement

minzip·2024년 12월 24일
3
post-thumbnail

리액트와 같은 SPA 프레임워크에서 지원하는 기능들을 비슷하게 구현해보며 학습하는 것을 목표로 합니다 📚

JSX란

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 구문은 컴포넌트의 역할에 따라 관심사를 분리시킬 수 잇는 새로운 방식임을 인정받으며 현재까지 자리를 잡을 수 있었다.

특징 정리

  • 엔진(V8, Deno 등)이나 브라우저(크롬, 웨일 등)에 의해 실행되거나 표현되지 않는다.
  • ECMAScript에 포함되는 표준이 아니다.
  • 전처리기(Transpiler)를 통해 JSX를 표준 ECMAScript로 변환하는 것에 초점을 둔다.

JSX의 구성

JSX는 아래의 4가지 컴포넌트를 기반으로 구성된다.
자세한 내용은 모던 리액트 Deep Dive나 JSX 공식문서를 참고하고, 이번 글에서는 예시 코드로 대체한다.

JSXElement

JSX를 구성하는 가장 기본 요소로, HTML의 element와 비슷한 역할을 한다.
아래는 JSXElement가 될 수 있는 형태들의 예시이다.

<> {/* JSXFragment */}
  <div>
    <h1>Welcome to JSX!</h1> {/* JSXOpeningElement & JSXClosingElement */}
    <img src="image.jpg" alt="Sample" /> {/* JSXSelfClosingElement */}
  </div>
  <br /> {/* JSXSelfClosingElement */}
</>

사용자가 만든 컴포넌트의 경우에는 HTML 태그명과 구분짓기 위해 반드시 대문자로 시작해야한다!

JSXElementName

추가적으로 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" />

JSXAttributes

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}
/>

JSXChildren

JSXElement의 자식값을 나타낸다. JSXChildren의 기본 단위는 JSXChild로, JSXChildren는 JSXChild를 0개 이상 가질 수 있다.
아래는 JSXChild의 주요 유형들이다.

<div>
  Text Content {/* JSXText - {, <, >, }를 제외한 문자열 */}
  <span>Nested Element</span> {/* JSXElement */}
  <>hi</> {/* JSXFragment */}
  {(() => 'foo')()} {/* {JSXChildExpression (optional)} */}
</div>

JSXStrings

JSXStrings(JSXAttributeValue와 JSXText)에서는 HTML 문자 참조(예: &, <)를 허용하여 HTML과 JSX 간의 복사-붙여넣기를 쉽게 할 수 있도록 설계되어있다.
이때 설계로 인해 JS에서 특수문자를 처리할 때 사용되는 \ 이스케이프 시퀀스는 지원되지 않는다.

 <button>/</button> // ok
 let escape1 = "\" // Uncaught SyntaxError : Invalid or unexpected token
 let escape2 = "\\" // ok

JSX는 어떻게 변환될까?

기업에서 독자적으로 만든 구문인만큼 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라는 메서드를 반환함을 확인할 수 있다.

  1. packages/react/src/jsx/ReactJSXElement.js
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.createElementjsx / jsxs
도입 버전React 초기 버전React 17+
용도모든 JSX 표현을 변환JSX 표현을 최적화하여 변환
사용 시점항상 사용jsx: 단일 자식 요소
jsxs: 다중 자식 요소
필요한 importReact 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의 구성을 많이 참고하며 구현하고자 하였다.

구현된 JSX 팩토리 함수

// 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: [], // 텍스트 노드는 자식이 없으므로 빈 배열 설정
    },
  };
};

  • type: 생성할 요소의 유형. 태그 이름 (예: div, span) 또는 사용자 정의 컴포넌트(함수)일 수 있다.
  • props: 요소의 속성을 나타내는 객체로, 기본값은 빈 객체 {}이다.
  • ...children: 요소의 자식으로 전달된 모든 값. 배열로 수집된다.

또한 React 객체를 만들어 객체를 통해 핵심기능인 메서드들을 사용할 수 있도록 세팅해주었다.

import { createElement } from "./createElement";

const React = {
  createElement,
  render,
};

export default React;

JSX 변환 결과

// 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 트랜스파일링 설정

위와 같이 JSX 코드가 내가 만든 변환 메서드를 통해 객체로 파싱될 수 있도록 하려면 추가적인 설정이 필요하다.
babel, esbuild 등을 활용해 가능하며, 나의 경우에는 vite에 내장되어 있는 esbuild의 기능을 이용해 설정하였다.

vite.config.js

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';를 파일 상단에 명시적으로 작성을 해줘야한다.
    하지만 jsxInject 옵션을 사용하면 JSX 파일을 트랜스파일할 때 자동으로 import React from '@core/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

profile
내일은 더 성장하기

1개의 댓글

comment-user-thumbnail
2024년 12월 24일

JSX 이해에 큰 도움이 되었습니다!

답글 달기

관련 채용 정보