티스토리 뷰

728x90
SMALL

배경

사실 이전에도 글을 올렸었다. 그런데 많이 부족한 내용이였고 버그도 많이 있었다. 이번에 기회가 있어서 새롭게 간단한 프로젝트를 만들었는데 저번보다 훨씬 괜찮게 만들었다고 생각해서 다시한번 글을 올리게 되었다. 그리고 황준일님의 블로그를 참조했다.

리액트 동작 살펴보기

먼저 spa 라이브러리 중 가장 유명한 리액트를 생각해보자. 훅스는 비교적 만들기가 더 어렵다. 기본 useState, useEffect의 경우는 클로저를 이용해 간단하게 만들수 있지만(쉽지는 않다) 프로젝트 전체에 적용하는데는 더 많은 시간과 비용이 필요하다. 그래서 일단은 추상 클래스를 만들것이다. 리액트에서는 setState로 state를 변경하고 render, componentDidMount, componentDidUpdate 메서드가 존재한다. 이게 기본이고 먼저 기본을 만들고 그 이후에 필요한 기능들을 추가해보자

간단한 컴포넌트 만들어보기

정말 간단하게만 만들어보면 다음과 같이 만들수 있다. 먼저 constructor부터 살펴보면 target과 props가 들어간다. target이 들어가는 이유는 리액트를 사용하지 않고 클래스를 jsx에 바로 넣어줄 수 없기 때문이다. 조금 불필요한 엘리먼트가 생기긴하지만 일단은 이렇게 구현해보자.

먼저 state를 정의해줘야 한다. 리액트에서는 constructor에서 super를 호출하고 this.state를 호출하는데 리액트와 동일하게 구현하면 state가 undefined가 된상태에서 render가 일어나고 오류가 난다. 그래서 setup에서 this.state를 정의해준다. state가 정의되고 markup에 있는 내용을 바탕으로 target에 innerhtml을 해준다. markup은 string으로 만들면 된다.

그리고 componentDidmount를 호출한다. 또한 setState를 할때마다 componentDidUpdate 함수를 호출하고 state를 바꾸고 render 함수를 호출한다. 이때 두번째 파라미터로 콜백함수를 넣어주는 경우 render된 후에 함수가 호출된다. 이것도 리액트와 동일하다.

type TState = Record<string, any>;

abstract class Component {
  target: HTMLElement;
  props: TState;
  state!: TState;

  constructor(target: HTMLElement, props = {}) {
    this.target = target;
    this.props = props;
    this.setup();
    this.render();
    this.componentDidMount();
  }

  public setup() {}

  private render() {
    this.target.innerHTML = this.markup();
  }

  public markup() {
    return '';
  }

  public componentDidMount() {}

  public setState(changeState: TState, cb?: Function) {
    this.componentDidUpdate({ ...this.state }, { ...this.state, ...changeState });
    this.state = { ...this.state, ...changeState };
    this.render();
    cb?.();
  }

  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  public componentDidUpdate(state: TState, nextState: TState) {}
}

export default Component;

diff 알고리즘 적용

저번 글에서는 attribute만 비교해서 업데이트 했다. 이번에는 재귀적으로 전체를 비교해서 수정하려고 한다. 그전에 아까 markup을 string으로 작성했는데 나는 jsx로 만들었다.

render

render 함수를 조금 수정해야 한다.
먼저 cloneNode를 이용해서 타겟을 복사한다. 그리고 markup 값으로 element를 만들고 복사한 타겟에 값을 덮어씌운다.(children 값을 수정)
그리고 target으로부터 childNodes를 불러오고 방금 생성한 newNode로부터 childNodes를 불러온다. 그리고 각각 비교하면서 update를 해주면 된다.

  private render() {
    const { target } = this;

    const newNode = target.cloneNode(true) as HTMLElement;
    const newElements = this.createElement(this.markup());
    newNode.replaceChildren(newElements);

    const oldChildNodes = [...target.childNodes];
    const newChildNodes = [...newNode.childNodes];
    const max = Math.max(oldChildNodes.length, newChildNodes.length);
    for (let i = 0; i < max; i++) {
      this.updateElement(target, newChildNodes[i], oldChildNodes[i]);
    }
  }

createElement

위에서 이용한 createElement 코드를 살펴보자. 참고로 IJsx는 이전 글을 보면 알 수 있다. jsx 함수의 리턴값이다.

먼저 node가 string이면 text node를 만들어주면 끝이다.

이제 node가 jsx인 경우를 보자. 먼저 type을 기반으로 엘리먼트를 만든다. svg나 circle인 경우에는 createElementNS로 태그를 만들고 frag인 경우에는 fragment를 나머지 경우에는 createElement를 해준다. svg인 경우에는 createElement로 생성했을 때 정상적으로 동작하지 않는다. 그래서 예외 처리를 해줘야 한다. 다른 태그도 있는데 일단은 프로젝트에 두개의 태그만 사용해서 두개만 넣어줬다. 라이브러리로 배포할 때는 조금 더 찾아봐서 추가해줄 것이다. fragment도 필요한 경우가 있다. jsx에서는 감싸는 태그가 없으면 에러를 뱉는다. 그래서 <>표시로 fragment를 만들어준다. 저렇게 표시하면 더 좋을 것 같기는한데 일단은 방법을 못찾아서 frag라는 태그를 만나면 fragment를 생성하게 해줬다.

그리고 fragment가 아니고 props가 존재하는 경우 방금 생성한 엘리먼트에 attribute를 넣어준다.

또한 children의 요소가 jsx가 아니고 string이 아닌 경우가 있다. 이 때 String으로 변환시켜줬다. 사실 createTextNode에 boolean이나 number를 넣어도 정상적으로 동작한다. 그런데 그렇게되면 node의 타입이 너무 많아진다. map을 한번 실행함으로써 생기는 비용을 생각하면 이게 옳은 방법인가?? 하는 생각도 들기는 한다. 하지만 자바스크립트 엔진은 그렇게 느리지 않다. 그렇기 때문에 이부분은 일단 이렇게 하고 넘어가겠다.

그리고 이제 children의 모든 요소에 createElemtn를 해준다. 그리고 만든 엘리먼트를 위에서 만들어둔 element에 append 해주면 된다.

  private createElement(node: IJsx | string) {
    // string인 경우 텍스트 노드를 리턴
    if (typeof node === 'string') {
      return document.createTextNode(node);
    }

    const { type, props, children } = node;

    // element를 만듦
    const element = ['svg', 'circle'].includes(type)
      ? document.createElementNS('http://www.w3.org/2000/svg', type)
      : type === 'frag'
      ? document.createDocumentFragment()
      : document.createElement(type);

    // props가 있는 경우에 attribute를 추가
    const isFrag = element instanceof DocumentFragment;
    if (props && !isFrag) {
      Object.entries(props).forEach(([name, value]) => {
        element.setAttribute(name, value);
      });
    }

    // node가 jsx가 아닌 경우 string으로 변환
    const parseChildren = children.map((child) => {
      if (typeof child !== 'object') return String(child);
      return child;
    });

    // 재귀적으로 함수를 실행하고 현재 element에 append
    const createElementArr = parseChildren.map((jsx) => this.createElement(jsx));
    createElementArr.forEach((createElement) => {
      element.appendChild(createElement);
    });

    return element;
  }

updateElement

이 코드는 솔직히 특별히 설명할게 없다. 주석의 설명을 읽으면 충분히 이해할 수 있다. 이전노드와 새로운 노드를 비교하면서 텍스트 또는 attribute를 변경시킨다고 보면 된다. 조금 특이한? 부분은 html tag가 바뀔경우 전체를 replace한다. 사실 저번에 컴포넌트를 만들때는 태그이름을 교체해줬는데 리액트 공식문서 설명을 읽고 생각을 조금 바꿨다. 조금 다른 이야기지만 각자 사용하는 spa 라이브러리 혹은 프레임워크 공식 문서를 한번 읽어보는 것을 추천한다. 만드는데도 많은 도움이 된다.

마지막 재귀적으로 updateElemnt를 호출하는 것은 render와 유사하기 때문에 설명을 생략한다.

https://ko.reactjs.org/docs/reconciliation.html#elements-of-different-types

  private updateElement(parent: HTMLElement, newNode: ChildNode, oldNode: ChildNode) {
    // oldNode만 존재하면 remove, newNode만 존재하면 append
    if (!newNode && oldNode) return parent.removeChild(oldNode);
    if (newNode && !oldNode) return parent.appendChild(newNode);

    // oldNode와 newNode가 텍스트 노드인경우 교체
    if (newNode instanceof Text && oldNode instanceof Text) {
      if (oldNode.nodeValue === newNode.nodeValue) return;
      oldNode.nodeValue = newNode.nodeValue;
      return;
    }

    // html tag가 바뀔경우 전체를 replace
    if (newNode.nodeName !== oldNode.nodeName) {
      parent.replaceChild(newNode, oldNode);
      return;
    }

    if (!(newNode instanceof HTMLElement && oldNode instanceof HTMLElement)) throw new Error('update error');

    this.changeAttributes(oldNode, newNode);

    const newChildNodes = [...newNode.childNodes];
    const oldChildNodes = [...oldNode.childNodes];
    const max = Math.max(newChildNodes.length, oldChildNodes.length);
    for (let i = 0; i < max; i++) {
      this.updateElement(oldNode, newChildNodes[i], oldChildNodes[i]);
    }
  }

changeAttributes

attribute를 바꾸는 메서드다. 코드를 보기전에 먼저 생각을 해보자.

  1. oldNode에 존재하고 newNode에 존재하지 않는다면 attribute를 remove한다.
  2. oldNode와 newNode의 공통된 attribute name이 존재하고 value가 같다면 attribute를 변경하지 않는다.
  3. oldNode에 attribute가 존재하지 않고 newNode에 새로운 attribute가 있는 경우 attribute 추가
  4. oldNode와 newNode의 공통된 attribute name이 존재하고 value가 다르면 attribute 변경

이를 바탕으로 코드를 짜면 다음과 같다.

  private changeAttributes(oldNode: HTMLElement, newNode: HTMLElement) {
    const oldAttributes = [...oldNode.attributes];
    const newAttributes = [...newNode.attributes];
    oldAttributes.forEach(({ name }) => {
      if (newNode.getAttribute(name) === null) oldNode.removeAttribute(name);
    });
    newAttributes.forEach(({ name, value: newValue }) => {
      const oldValue = oldNode.getAttribute(name) || '';
      if (newValue !== oldValue) oldNode.setAttribute(name, newValue);
    });
  }

추가해야 될 것들

글이 너무 길어져서 기본이 되는 내용만 적었다. 다음 글을 읽어보기 전에 어떤 기능을 추가해야 할지 생각해보자. 또한 위의 코드로 만들었을 때 문제점도 있을 것이다. 다음글에서....

728x90
LIST
댓글
공지사항