본문 바로가기

개인회고록/선우(Seonup)

[Seonup] UI 문법을 제공하기 위한 parse 메서드 구현하기

들어가며

oh-so 팀은 JSX와 유사한 문법적 설탕(이하 UI 문법)을 고안하고, 코어 컴포넌트의 parse 메서드를 통해 이 UI 문법을 사용자에게 제공할 수 있도록 설계했습니다. 본 글에서는 parse 메서드의 구현 과정을 다룹니다.

UI 문법 특징

oh-so 팀에서 고안한 UI 문법은 태그 형태의 문자열을 템플릿으로 작성하며, 다음과 같은 특징을 가지고 있습니다.

1. 컴포넌트를 HTML 요소처럼 작성할 수 있습니다.

2. 컴포넌트에 props를 전달할 때 HTML 요소의 attribute처럼 작성할 수 있습니다.

3. HTML 요소에 이벤트를 등록할 때 attribute처럼 작성할 수 있습니다.

4. state 값 변수 등 JavaScript 표현식을 삽입할 수 있습니다.

 

UI 문법을 작성하는 방식은 parse 메서드가 구현되는 과정에 따라 점차 간단해졌습니다. 구현 과정을 따라가며 변화된 UI 문법 작성 방식을 함께 살펴보겠습니다.

parse 메서드란?

parse 메서드는 Babel과 같은 트랜스파일러의 역할을 수행합니다. Babel이 JSX를 브라우저나 node 환경에서 실행될 수 있는 JavaScript로 변환하기 위해 파싱 단계, 변환 단계, 코드 생성 단계를 거치듯이, parse 메서드 또한 UI 템플릿을 파싱하고 DOM을 생성하도록 만들었습니다.

 

현재 구현된 parse는 임시 모델입니다. 외부 트랜스파일러는 작성된 UI 템플릿을 트리 자료구조로 만들어 구문 분석 후 함수 호출문으로 변환하는 로직을 취하는 반면, parse는 템플릿을 수정하는 단계를 거쳐 임시 DOM으로 만든 뒤, 임시 DOM을 분석하여 최종적으로 출력될 DOM으로 변환합니다.

 

외부 트랜스파일러처럼 구현하는 것이 성능적으로 우수할 것으로 예상되지만, 학습의 과정으로 여기고 추후 비교를 통해 parse 메서드를 보완할 예정입니다.

초기 구현

UI 문법 작성 방식

초기 모델에서 UI 문법을 작성하는 방식은 아래와 같습니다.

사용자 컴포넌트 및 props 전달

<component @ComponentName propKey="PropValue" />

 

사용자 컴포넌트는 태그명으로 component를 작성하고, @ 심볼에 사용자 컴포넌트의 이름을 작성하여 전달할 수 있습니다. props는 좌항에 prop의 키를, 우항에 prop의 값을 작성하여 전달할 수 있습니다.

 

HTML 표준에서는 태그명은 모두 소문자로 작성하도록 규정하고 있습니다. 따라서, 태그명을 대문자로 작성해도 브라우저는 이를 소문자로 변환하여 DOM을 생성합니다. 우리의 parse 메서드는 UI 템플릿을 임시 DOM으로 만들기 때문에, JSX를 작성하듯 <Header>로 작성된 사용자 컴포넌트를 <header>로 변환합니다. 즉, HTMLHeaderElement가 되어 사용자 컴포넌트라는 것을 알 수 없게 됩니다. 이러한 특성을 고려하여, 커스텀 컴포넌트와 Element를 구분하기 위해 component라는 임의의 태그명을 사용하게 되었습니다.

이벤트 주입

@evetType="handlerName"

 

이벤트 주입 방식은 Vue의 이벤트 주입 방식을 모방하여, 좌항에는 @ 심볼을 붙인 이벤트 타입을, 우항에는 이벤트 핸들러의 이름을 작성하여 Element에 attribute를 추가하듯 작성합니다. 이벤트 주입과 등록에 대한 내용은 추후 관련글에서 자세히 다루도록 하겠습니다.

표현식 삽입

삼항연산자 사용 등 연산이 필요한 식은 템플릿리터럴 방식으로 작성합니다. 변수를 삽입하는 방식은 Vue의 double curly braces({{}})를 모방했습니다.

작성 예시

// 템플릿 작성 예시
`<div class="app" @click=”handleClick”>
  <component @Header />
  <component @Content count="{{count}}" />
  {{count}}
</div>`

// 사용자 컴포넌트 적용 예시
class App extends Component {
  template() {
    return `
      <div class="app" @click=”handleClick”>
        <component @Header />
        <component @Content count="{{count}}" />
        {{count}}
      </div>
    `;
  }
}

구현 과정

초기 parse 메서드는 사용자 컴포넌트에서 작성한 템플릿을 임시 DOM에 주입하고, 노드별로 분석 및 변환할 수 있도록 임시 DOM의 자식 노드를 순회하며 createElement를 호출합니다.

 

class Component {
  // 생략

  template(): string {
    throw new Error('템플릿을 작성해주세요.');
  }

  render() {
    this.parse();
  }

  private parse() {
    const $temp = document.createElement('div');
    $temp.innerHTML = this.template(); // template으로 임시 DOM 생성

    [...$temp.childNodes].forEach(($child) => {
      createElement(this, $child);
    });
  }
}

 

createElement 함수는 컴포넌트 인스턴스(component)와 분석할 노드($target)를 인수로 받고, 아래와 같은 일을 수행합니다.

 

  1. $target이 text node일 경우, state가 주입된 text node인지 확인하고 state와 관련된 처리를 합니다.
  2. $target이 user custom component일 경우, $target이 사용자 컴포넌트일 경우, createComponent 함수를 호출하여 컴포넌트 인스턴스를 생성합니다. 생성된 컴포넌트 인스턴스의 render 메서드를 호출하여 해당 컴포넌트에서의 parse 메서드를 실행합니다.
  3. $target이 일반 HTMLElement일 경우, attribute에 event가 주입됐는지 판별하고 event와 관련된 처리를 합니다.
  4. $target의 자식 노드를 순회하며 1~3을 반복합니다.
function createElement(component: Component, $target: Node) {
  // 1. target이 text node일 때
  if ($target.nodeName === '#text') {
    // state가 주입된 text인지 판별
    const state = getStatesFromText($target.textContent!);
    console.log('[state]', state); // TODO: state 주입하기
    return;
  }

  // HTMLElement가 아니면 return; (원활한 타입 추론을 위한 타입 가드)
  if (!($target instanceof HTMLElement)) return;

  if (
    // 2. target이 user custom component일 때
    $target.nodeName === USER_CUSTOM_COMPONENT
  ) {
    const $component = createComponent(component, $target).render();
    $target.replaceWith($component);
  } else {
    // 3. target이 일반 element일 때
    // attribute에 event가 주입됐는지 판별하기
    [...$target.attributes].forEach(({ name, value }) => {
      if (VARIABLE_REGEX.test(name)) {
        const action = name.slice(1);
        console.log('[event]', action, value); // TODO: event 등록
      }
    });
  }

  // 4. 자식들 loop 돌면서 재귀.
  [...$target.childNodes].forEach(($child) => {
    createElement(component, $child);
  });
}

 

createComponent는 컴포넌트 인스턴스(component)와 분석할 노드($target)를 인수로 받고, 다음과 같은 일을 수행합니다.

 

  1. $target의 attributes를 순회하여 컴포넌트 생성에 필요한 정보를 componentObj에 저장합니다.
    1-1. attribute의 key가 @ 심볼로 시작될 경우, 심볼 뒤에 오는 텍스트로 선언해두었던 사용자 컴포넌트 클래스와 매칭합니다.
    1-2. 그 밖의 attribute는 props로 전달하되, prop의 값에 state 등 변수가 포함되어 있는지 판별합니다.
  2. 수집된 정보로 사용자 컴포넌트의 인스턴스를 생성하고 반환합니다.
function createComponent(component: Component, $target: HTMLElement) {
  // 1. target의 attributes 순회
  const componentObj = [...$target.attributes].reduce(
    (obj, { name, value }) => {
      // 1-1. attribute가 @componentClass일 경우
      if (VARIABLE_REGEX.test(name)) {
        const targetComponent =
          component.components?.[`${name[1].toUpperCase()}${name.slice(2)}`];

        if (!targetComponent) throw new Error('등록된 component가 없습니다.');

        obj['component'] = targetComponent;
      } else {
        // 1-2. 그 밖에 작성된 attribute는 props로 전달

        // value에 state가 포함됐는지 판별
        const state = getStatesFromText(value);
        console.log('[state]', state); // TODO: value에 state값 주입해서 전달하기

        obj.props = {
          ...obj.props,
          [name]: value,
        };
      }

      return obj;
    },
    {} as ComponentObj
  );

  // 2. 수집된 정보로 사용자 컴포넌트 클래스 호출
  const newComponent = new componentObj.component({
    ...componentObj.props,
    components: parentComponent.components,
  });

  return newComponent;
}

사용자 컴포넌트 전달 방식 변경

parse 메서드가 완성되었으나, 사용자 컴포넌트를 전달하는 방식이 타 프레임워크의 UI 문법에 비해 다소 복잡해 보였습니다. 따라서, 보다 선언적으로 UI 문법을 작성할 수 있게 parse 메서드를 수정하기로 결정하였습니다.

 

타 프레임워크처럼 컴포넌트명을 태그명으로 작성하면, parse 메서드 내부에서 이를 <component @ComponentName /> 형태로 변경하도록 했습니다. 이를 통해 기존의 사용자 컴포넌트를 인식하고 파싱하는 형태를 그대로 유지했습니다.

작성 예시

// 템플릿 작성 예시
`<div class="app" @click=”handleClick”>
  <Header /> 
  <Content count="{{count}}" />
  <footer>끝</footer>
</div>`

// 사용자 컴포넌트 적용 예시
class App extends Component {
  template() {
    return `
      <div class="app" @click=”handleClick”>
        <Header />
        <Content count="{{count}}" />
        <footer>끝</footer>
      </div>
    `;
  }
}

구현 과정

  1. UI 템플릿을 임시 DOM으로 변환하기 전에, 정규표현식을 이용하여 사용자 컴포넌트 태그를 <component @ComponentName> 형태로 교체합니다.
  2. 교체된 템플릿을 임시 DOM으로 변환하고 순회하며 실제 DOM을 생성합니다.
const COMPONENT_START_TAG_REGEX = /<([A-Z][a-z]*)(.*)>/g;
const COMPONENT_END_TAG_REGEX = /<\/([A-Z][a-z]*)>/g;

class Component {
  private parse() {
    // 1. 컴포넌트 태그 찾아서 <component @ComponentName> 형태로 교체
    const template = this.template()
      // 1-1. 열린 컴포넌트 태그 찾기
      .replace(
        COMPONENT_START_TAG_REGEX,
        (_, captureStartTag, captureAttributes) =>
          // 닫힘 플래그가 포함됐는지 체크
          captureAttributes.includes('/')
            ? `<component @${captureStartTag}${captureAttributes.replace('/', '')}></component>`
            : `<component @${captureStartTag}${captureAttributes}>`
      )
      // 1-2. 닫힘 컴포넌트 태그
      .replace(COMPONENT_END_TAG_REGEX, '</component>');

    // 2. 가상 element를 이용하여 template을 DOM으로 만든 후
    const $fragment = document.createElement('div');
    $fragment.innerHTML = template;

    [...$fragment.childNodes].forEach(($child) => {
      createElement(this, $child);
    });

    // $temp의 내용물을 반환한다.
    return [...$temp.childNodes];
  }
}

children prop 추가

JSX는 중첩 컴포넌트를 작성할 수 있도록 구현되어 있습니다. Dynup 팀에서도 중첩 컴포넌트를 제공하기 위해 자식을 prop으로 전달할 수 있도록 chilren prop을 추가했습니다.

작성 예시

class App extends Component {
  template() {
    return `
      <Header>
        <Nav />
      </Header>
    `;
  }
}

class Header extends Component {
  template() {
    return `
      <header class="header">
        <h1>헤더</h1>
        {{children}}
      </header>
    `;
  }
}

구현 과정

createComponent 함수에서 컴포넌트 인스턴스를 생성할 때 children prop를 전달하도록 추가합니다.

 

function createComponent($target: HTMLElement)
  // 생략

  const newComponent = new componentObj.component({
    ...componentObj.props,
    components: parentComponent.components,
    children: $target.innerHTML, // <---- children prop 전달
  });

  return newComponent;
}

 

컴포넌트가 호출되면 setChildren 메서드의 인수로 props.children 값을 전달합니다. setChildren 메서드는 임시 DOM을 생성하고, 자식 노드를 순회하여 분석 및 변환 과정을 거쳐 실제 DOM을 반환합니다. 반환된 DOM은 컴포넌트 인스턴스의 children 프로퍼티에 등록됩니다.

 

class Component {
  constructor(props) {
    // 생략
    this.children = this.setChildren(props?.children);
  }

  // 생략

  private setChildren(children: string) {
    const $temp = document.createElement('div');
    $temp.innerHTML = children;

    [...$temp.childNodes].forEach(($child) => {
      createElement(this, $child);
    });

    return [...$temp.childNodes];
  }
}

 

parse 메서드에서 UI 템플릿을 임시 DOM으로 변환하기 전, 정규표현식을 이용하여 {{ children }}<childrenProp></childrenProp>로 교체하는 로직을 추가합니다.

 

const CHILDREN_PROPS_REGEX = /{{children}}/g;

class Component {
  // 생략
  private parse() {
    const template = this.template()
      .replace(
        COMPONENT_START_TAG_REGEX,
        (_, captureStartTag, captureAttributes) =>
        captureAttributes.includes('/')
        ? `<component @${captureStartTag}${captureAttributes.replace(
          '/',
          ''
        )}></component>`
        : `<component @${captureStartTag}${captureAttributes}>`
      )
      .replace(COMPONENT_END_TAG_REGEX, '</component>');
      .replace(CHILDREN_PROPS_REGEX, '<childrenProp></childrenProp>'); // <---- 1-3. children 교체

    // 생략
  }
}

 

createElement 함수에서 $target이 children일 경우, 노드를 컴포넌트 인스턴스에 등록해두었던 children으로 교체합니다.

 

const CHILDREN_PROP = 'CHILDRENPROP';

function createElement(component: Component, $target: Node) {
  // 생략

  if (
    // 3. target이 children일 때
    $target.nodeName === CHILDREN_PROP
  ) {
    $target.replaceWith(...component.children);
    return $target;
  }

  // 생략
}

마치며

Babel과 같은 외부 트랜스파일러의 방식이 아닌 다른 방식으로 구현해보는 과정이 즐거웠습니다. 메모리에만 존재하는 DOM을 조작하는 것이었기 때문에 성능적으로 큰 차이가 없지 않을까 하는 마음에 시도해본 방법이었습니다. 글 초반에 언급했듯 추후 외부 트랜스파일러의 방식으로 구현하고 비교하는 과정을 거쳐 어떤 방식이 더 효율적인지 확인할 예정입니다.