Vanilla TypeScript로 JSX 구현해 보기

김가희·2024년 7월 18일

이 글의 목적은 JSX와 유사한 구문을 직접 구현하여 React의 컴포넌트 렌더링 방식과 유사한 방식으로 UI를 구성해 보고, 프레임워크에 의존하지 않고도 선언적 UI를 구현하는 방법을 이해하는 것이다.


JSX

JSX는 JavaScript XML의 약자로, React 개발에 주로 사용되는 JavaScript를 확장한 문법이다. React에서 JavaScript 파일을 HTML과 비슷하게 마크업을 작성할 수 있도록 해 준다. UI 구성요소를 보다 직관적이고 읽기 쉽게 만들어, 개발자가 UI 로직을 효과적으로 표현할 수 있게 해 준다.

원래의 코드는 첫 번째 블록과 같고, 우리는 JSX 구현 후 이를 적용시켜 두 번째 블록처럼 조금 더 React스러워 보이게 만들 것이다.

// JSX 적용 전 코드
export default class MainPage extends Component {
  constructor(props: ComponentProps) {
    super(props);
    this.state = { count: 1 };
  }

  componentDidMount() {
    document.getElementById('root')?.addEventListener('click', (event) => {
      const target = event.target as HTMLElement;
      if (target.id === 'go-to-login') {
        const router = Router.getInstance();
        router.navigate('/login');
      } else if (target.id === 'increment') {
        this.setState({ count: this.state.count + 1 });
      }
    });
  }

  render() {
    const element = document.getElementById('root');
    if (element) {
      element.innerHTML = `
        <div>
          <button id="go-to-login">Go to Login</button>
          <div>
            <p>Count: ${this.state.count}</p>
            <button id="increment">Click me!</button>
          </div>
        </div>
      `;
    }
  }
}
// JSX 적용 후 코드
export default class MainPage extends Component {
  private router: Router;

  constructor(props: ComponentProps) {
    super(props);
    this.state = { count: 1 };
    this.router = Router.getInstance();
  }

  goToLogin() {
    this.router.navigate('/login');
  }

  incrementCount() {
    this.setState({ count: this.state.count + 1 });
  }

  render() {
    const contents = jsx`
      <div>
        <buttontoken interpolation">${() => this.goToLogin()}">Go to Login</button>
        <div>
          <p>Count: ${this.state.count}</p>
          <buttontoken interpolation">${() => this.incrementCount()}">
            Click me!
          </button>
        </div>
      </div>
    `;

    const element = document.getElementById('root');
    if (element) {
      element.innerHTML = '';
      element.appendChild(contents);
    }
  }
}

구현 방법

참고한 글: https://www.jeong-min.com/29-vanilla-spa-6/
내 전체 코드: https://github.com/soprue/vanilla-reminder/blob/develop/src/core/JSX.ts

jsx 함수 정의

템플릿 리터럴과 삽입된 값을 인자로 받아 DOM 요소를 생성하는 함수를 만든다.

strings 배열과 args 배열을 결합하여 템플릿을 만든다. 각 args 항목 앞에는 일련번호를 붙여 특정 인덱스를 나타내는 텍스트(tempindex:x:)를 삽입한다. 이 텍스트는 나중에 실제 값으로 교체될 템플릿 변수 역할을 한다.

  let template = document.createElement('div');

  template.innerHTML = strings
    .map((str, i) => `${str}${i < args.length ? `${TEMP.PREFIX}${i}:` : ''}`)
    .join('');

생성된 DOM 트리를 순회하면서 텍스트 노드와 요소 노드를 처리한다. 텍스트 노드는 processTextNode 함수를 사용하여 처리하고, 요소 노드는 bindEventHandler 함수를 사용하여 처리한다.

  let walker = document.createNodeIterator(template, NodeFilter.SHOW_ALL);
  let node: Node | null;

  while ((node = walker.nextNode())) {
    if (node.nodeType === Node.TEXT_NODE) {
      processTextNode(node, args);
    } else if (node.nodeType === Node.ELEMENT_NODE) {
      let element = node as Element;
      Array.from(element.attributes).forEach(({ name, value }) => {
        if (value.includes(TEMP.PREFIX)) {
          const match = TEMP.REGEX.exec(value);
          if (match)
            bindEventHandler(name, args[parseInt(match[1], 10)], element);
        }
      });
    }
  }

텍스트 노드 처리

텍스트 노드 내의 템플릿 변수를 실제 값으로 대체하는 함수를 만든다.

텍스트 노드의 값을 TEMP.SEPARATOR_REGEX_G 정규식으로 분할하여, 템플릿 변수와 일반 텍스트를 구분하고, 분할된 각 텍스트 조각에 대해 템플릿 변수를 실제 값으로 교체한다. TEMP.REGEX를 사용하여 인덱스를 추출하고, 해당 인덱스에 따라 args 배열에서 값을 가져와 convertJsxArgToNode 함수를 통해 DOM 노드로 변환한다.
실제 DOM을 직접 수정하는 대신, DocumentFragment를 사용하여 메모리에서 DOM 조작을 먼저 수행하고, 최종 결과만을 실제 DOM에 반영한다. 이 방법은 불필요한 DOM 접근을 줄이고, 페이지의 렌더링 성능을 향상시킨다.

function processTextNode(node: Node, args: JsxArg[]): void {
  if (
    node.nodeType !== Node.TEXT_NODE ||
    !node.nodeValue?.includes(TEMP.PREFIX)
  )
    return;

  const texts = node.nodeValue.split(TEMP.SEPARATOR_REGEX_G);
  const fragment = document.createDocumentFragment();

  texts.forEach((text) => {
    const tempindex = TEMP.REGEX.exec(text)?.[1];
    if (!tempindex) {
      fragment.appendChild(document.createTextNode(text));
    } else {
      fragment.appendChild(convertJsxArgToNode(args[Number(tempindex)]));
    }
  });

  node.parentNode?.replaceChild(fragment, node);
}

JsxArg 타입의 인자를 받아 DOM 노드로 변환하고, 이를 DocumentFragment에 추가하는 함수이다. DocumentFragment는 DOM에 직접적으로 추가되기 전에 노드를 효율적으로 조립할 수 있게 해주는 경량의 DOM 트리이다.

인자 arg의 타입에 따라 다르게 처리해 준다.

  • Node 인스턴스일 경우: 직접 프래그먼트에 추가한다.
  • 배열일 경우: 각 요소를 재귀적으로 처리하여 프래그먼트에 추가한다. 문자열은 div를 생성하여 innerHTML로 설정한 후, 생성된 노드들을 프래그먼트에 이동시킨다.
  • null이나 false가 아닐 경우: 문자열로 변환하여 텍스트 노드를 생성하고 프래그먼트에 추가한다. (args를 평가할 때 null이나 false 값을 무시하여 조건에 따라 특정 요소를 렌더링하거나 렌더링하지 않는 기능을 구현했다.)
type JsxArg = Node | string | null | boolean | JsxArg[];

function createTextFragment(str?: string): DocumentFragment {
  const fragment = document.createDocumentFragment();
  if (str) fragment.appendChild(document.createTextNode(str));
  return fragment;
}

function convertJsxArgToNode(arg: JsxArg): DocumentFragment {
  const fragment = document.createDocumentFragment();

  if (arg instanceof Node) {
    fragment.appendChild(arg);
  } else if (Array.isArray(arg)) {
    arg.forEach((item) => {
      if (item instanceof Node) {
        fragment.appendChild(item);
      } else {
        let container = document.createElement('div');
        container.innerHTML = item as string;
        while (container.firstChild) {
          fragment.appendChild(container.firstChild);
        }
      }
    });
  } else if (arg !== null && arg !== false) {
    fragment.appendChild(createTextFragment(String(arg)));
  }

  return fragment;
}

이벤트 핸들러 바인딩

요소의 속성 이름을 검사하여 on으로 시작하는 이벤트 핸들러가 있다면 해당 이벤트를 요소에 바인딩한다. on 뒤에 오는 문자열을 이벤트 타입으로 변환하고, 속성 값으로 제공된 함수를 해당 이벤트 리스너로 설정한다.

function bindEventHandler(name: string, value: any, element: Element) {
  if (typeof value === 'function') {
    element.addEventListener(name.replace('on', '').toLowerCase(), value);
    element.removeAttribute(name);
  }
}

실행 화면

조건부 렌더링과 리스트 렌더링까지 잘 구현된 모습이다.


배운 점

  • 프레임워크 내부에서 일어나는 복잡한 처리 과정을 더 깊이 이해할 수 있었다.
  • 직접적인 DOM 조작 방법에 대한 이해도 또한 향상되었으며, 이러한 지식은 다른 프레임워크나 라이브러리를 사용할 때에도 매우 유용할 것이다.

0개의 댓글