vanila JS로 react 구현하기 (part 2)

개발 log·2022년 7월 19일
post-thumbnail

코드만 바로 확인하고 싶으시다면 Soact 라이브러리 Github <- 해당 링크로 바로 이동해주시면 됩니다.

이전 글에 이어 이번 글에서는 비교 알고리즘을 수행하는 updateDOM 함수에 대해 자세히 설명해보려 한다.

이 글을 읽기 전에 얄팍한 코딩사전의 DOM은 뭐고 가상 DOM은 뭔가요?을 보고 오면 이 글을 이해하는데에 더 도움이 될 것이다.

영상에서도 설명하고 있지만 react는 가상 DOM이라는 설계도를 기준으로 변경할 부분을 찾아서 실제 DOM을 변경시킨다.

그렇다면 비교작업을 수행하면서 변경작업까지 동시에 수행하려면 어떤 알고리즘이 가장 적합할까?

우선 DOM은 트리 형태의 자료구조이니 트리를 순회하는 알고리즘을 사용해야할텐데 나는 여기서 BFS와 DFS가 떠올랐다.

결론부터 말하자면 나는 DFS를 사용했다.

그 이유는 비교작업을 수행하면서 동시에 변경작업까지 수행한다고 했을 때 만약 상위노드를 먼저 변경해버리면 하위노드를 변경할때 상위노드를 잘못 참조하는 일이 발생하기 때문이다.

만약 BFS를 사용하면 상위 노드부터 비교하고 변경할 것이다.

하지만 DFS를 사용하면 한 부모의 최하단까지 이동하고 다음 부모로 이동하기 때문에 최하위노드부터 변경이 가능하므로 DFS를 선택했다.

자세한 로직은 아래쪽에 작성할 createDOMupdateDOM 코드를 보면 된다.

나는 우선 createDOMupdateDOM 코드를 설명하기 전에 이 메서드는 무슨 역할을 하고 왜 필요한지부터 설명하려 한다.

virtual DOM

앞서 작성한 게시글 vanila JS로 react 구현하기 (part 1)에서 내가 구현한 createElement메서드로 어떻게 virtualDOM을 구성하는지 설명했다.

재조명해보자면 아래와 같은 코드는

function Test() {
  return (
    <div>
      <h1>테스트</h1>
      <span>
        {0}
        {1}
      </span>
    </div>
  );
}

아래처럼 생긴 VDOM을 생성한다.

const VDOM =  {
  el: 'div',
  props: null,
  children: [
    {
      el: 'h1',
      props: null,
      children: [{ value: '테스트', current: {} }],
      current: {},
    },
    {
      el: 'span',
      props: null,
      children: [
        { value: '0', current: {} },
        { value: '1', current: {} },
      ],
      current: {},
    },
  ],
  current: {},
};

그럼 이제 VDOM은 준비되었으니 이 정보를 기반으로 실제 DOM을 생성할 일만 남았다.

여기서 실제 DOM을 생성하는 메서드가 createDOM이다.

createDOM

createDOM이 수행하는 역할은 코드의 주석으로 설명하겠다.

const createDOM = (vDOM?: VDOM | TextVDOM) => {
  if (isTextVDOM(vDOM)) {
    // 전달된 VDOM이 TextVDOM이면 value를 기준으로 $textNode를 생성한다.
    const $textNode = document.createTextNode(vDOM.value);
    const { current } = vDOM;

    // 혹시 전달된 VDOM에 current(Text | HTMLElement)가 있다면
    // value가 같은지 비교 작업을 수행하고 같으면 current를 반환하고
    // 같지 않다면 current를 교체한 후 새로 생성한 $textNode를 반환한다.
    if (
      typeof current?.data === 'string' &&
      Object.is(current?.data, $textNode.data)
    ) {
      return current;
    } else {
      vDOM.current = $textNode;
      return $textNode;
    }
  } else if (vDOM) {
    // 전달된 VDOM이 VDOM이면 `el`에 맞는 HTMLElement를 생성하고 current에 바인딩한다.
    // 그리고 `props`를 기준으로 $el에 attributes를 세팅한다.
    // 그리고 `children`을 순회하며 재귀적으로 `createDOM`을 수행한뒤 해당 배열에 담긴
    // $childEl을 부모가 될 $el에 appendChild하고 $el을 반환한다.
    const { el, props, children } = vDOM;
    const $el = document.createElement(el);
    vDOM.current = $el;
    setAttrs(props, $el);
    children?.map(createDOM).forEach(($childEl) => {
      if ($childEl) {
        $el.appendChild($childEl);
      }
    });
    return $el;
  }
};

주석으로는 이해하기 힘들 수 있으니 조금 나눠서 설명하자면 아래와 같다.

  1. 전달된 VDOM이 TextVDOM인지 VDOM인지 구분한다.
  2. TextVDOM이라면 현재 VDOM에 존재하는 current와 비교한다.
    a. 변경점이 없다면 current를 반환한다.
    b. 변경점이 있다면 current를 교체하고 새로 생성한 $textNode를 반환한다.
  3. VDOM이라면 el을 기준으로 새로운 $el을 생성한다.
    a. VDOM의 current를 교체한다.
    b. props를 기준으로 $el의 attributes를 세팅한다.
    c. children을 순회하며 자식 노드를 생성하고 이들이 담겨있는 배열을 구성한다.
    d. children.map을 통해 구성된 자식 노드들을 부모가 될 $el에 appendChild한다.
    e. $el을 반환한다.

적다보니 재귀적인 요소에서 이해하기 힘든 부분들이 있겠다고 느꼈다.
이런 부분들은 차근차근 여러번 읽어보면 이해할 수 있을 것이라 생각한다.
화이팅(...👍)

그렇다면 이 createDOM메서드는 언제 사용될까?
바로 updateDOM을 수행하며 VDOM의 비교작업을 진행할 때 새로 생성해야할 노드가 있는 경우에 사용하면 된다.

updateDOM

드디어 대망의 updateDOM을 설명할 시간이다.
필자인 나도 이 메서드를 작성하면서 함수를 클린하게 작성했는지 로직이 논리적인지 많은 고민을 하면서 구현했지만 아직 고쳐야될 부분이 많다는 것을 느꼈다.
하지만 조금 비효율적인 부분이 있을지언정 비교작업 자체에는 문제가 없으니 우선 소개하고 추후에 차근차근 로직을 더 개선해나가려 한다.

아래는 전체 로직이지만 updateDOMupdateElement를 구분해서 설명하려 한다.

// 이 메서드를 실행하고 나면 DOM이 업데이트되기 때문에 `updateDOM`이라고 명명했다.
const updateDOM = (
  $parent: HTMLElement = getRoot(),
  newVDOM: VDOM = getNewVDOM(),
  initVDOM: VDOM = getVDOM()
) => {
  // 이 메서드를 실행하고 나면 DOM tree의 Element 요소 하나씩 변경되기 때문에 `updateElement`라고 명명했다.
  const updateElement = (
    $parent: HTMLElement | Text,
    newVDOM: TextVDOM | VDOM | undefined,
    initVDOM?: TextVDOM | VDOM | undefined
  ) => {
    const $current = initVDOM?.current;
    if (!initVDOM || (isTextVDOM(initVDOM) && !initVDOM.value)) {
      const $next = createDOM(newVDOM);
      if ($next) {
        $parent.appendChild($next);
      }
    } else if (!newVDOM || (isTextVDOM(newVDOM) && !newVDOM.value)) {
      if ($current) {
        $parent.removeChild($current);
      }
    } else if (isChanged(initVDOM, newVDOM)) {
      const $next = createDOM(newVDOM);
      if ($current && $next) {
        $current.replaceWith($next);
        newVDOM.current = $next;
      }
    } else if (!isTextVDOM(initVDOM) && !isTextVDOM(newVDOM)) {
      const length = Math.max(
        initVDOM.children?.length || 0,
        newVDOM.children?.length || 0
      );
      newVDOM.current = $current;

      for (let i = 0; i < length; i++) {
        if ($current) {
          updateElement(
            $current,
            newVDOM.children?.[i],
            initVDOM.children?.[i]
          );
        }
      }
    } else {
      newVDOM.current = $current;
    }

    if (!isTextVDOM(newVDOM) && newVDOM) {
      setAttrs(newVDOM.props, $current);
    }
  };

  resetStateId();
  updateElement($parent, newVDOM, initVDOM);
  setVDOM(newVDOM);
};

updateDOM

updateDOM 자체만을 놓고 보자면 로직은 간단하다.

const updateDOM = (
  $parent: HTMLElement = getRoot(),
  newVDOM: VDOM = getNewVDOM(),
  initVDOM: VDOM = getVDOM()
) => {
  resetStateId();
  updateElement($parent, newVDOM, initVDOM);
  setVDOM(newVDOM);
};
  1. stateId를 reset한다.
    a. 이 부분은 stateHook인 useState를 사용하기 위해 선언된 함수이니 추후에 다룰 stateHook 구현하기에서 제대로 설명할 예정이다.
    b. 지금은 딱히 신경 안써도 된다.
  2. updateElement를 수행한다.
    a. 이 메서드는 각 Element가 부모에게 자신의 상황을 append해야할지 remove해야할지 replace해야할지 알려야하기 때문에 $parent파라미터를 받는다.
    b. 비교할 VDOM 두개를 전달한다.
  3. updateElement를 수행하고 나면 실제 DOM에 모든 변경사항은 적용된 후이다.
  4. 다음에 비교작업을 수행할 VDOM을 새로운 VDOM으로 교체해준다.

updateElement

const updateElement = (
    $parent: HTMLElement | Text,
    newVDOM: TextVDOM | VDOM | undefined,
    initVDOM?: TextVDOM | VDOM | undefined
  ) => {
    const $current = initVDOM?.current;
    if (!initVDOM || (isTextVDOM(initVDOM) && !initVDOM.value)) {
      const $next = createDOM(newVDOM);
      if ($next) {
        $parent.appendChild($next);
      }
    } else if (!newVDOM || (isTextVDOM(newVDOM) && !newVDOM.value)) {
      if ($current) {
        $parent.removeChild($current);
      }
    } else if (isChanged(initVDOM, newVDOM)) {
      const $next = createDOM(newVDOM);
      if ($current && $next) {
        $current.replaceWith($next);
        newVDOM.current = $next;
      }
    } else if (!isTextVDOM(initVDOM) && !isTextVDOM(newVDOM)) {
      const length = Math.max(
        initVDOM.children?.length || 0,
        newVDOM.children?.length || 0
      );
      newVDOM.current = $current;

      for (let i = 0; i < length; i++) {
        if ($current) {
          updateElement(
            $current,
            newVDOM.children?.[i],
            initVDOM.children?.[i]
          );
        }
      }
    } else {
      newVDOM.current = $current;
    }

    if (!isTextVDOM(newVDOM) && newVDOM) {
      setAttrs(newVDOM.props, $current);
    }
  };

비교작업을 수행하기 위해 알아야할 경우의 수는 총 4가지이다.

  1. 원래 VDOM이 없거나 원래 TextVDOM의 value가 없는지
  2. 원래 VDOM은 있지만 새로운 VDOM이 없거나 새로운 TextVDOM의 value가 없는지
  3. 둘 다 VDOM이고 존재한다면 변경점이 있는지
  4. 둘 다 VDOM인지(이 작업을 마지막에 한번 더 수행하는 이유는 마지막에 DFS를 호출해야 최하위 노드부터 변경사항을 적용하기 때문)

이 4가지 경우가 필요한 이유는 아래와 같다.

  1. 원래 VDOM이 없거나 원래 TextVDOM의 value가 없다면 아래와 같은 행동을 취하면 된다.
    a. newVDOM이 있다면 새로운 DOM을 생성해서 appendChild한다.
    b. newVDOM이 없다면 아무것도 하지 않는다.
  2. 원래 VDOM은 있지만 새로운 VDOM이 없거나 새로운 TextVDOM의 value가 없다면 아래와 같은 행동을 취하면 된다.
    a. 위 말을 직역하자면 새로운 VDOM에서는 기존 내용이 삭제되었다는 것이다.
    b. 때문에 기존 Element를 지워주면 된다.
  3. 둘 다 VDOM이고 존재한다면 변경점이 있는지 확인해서 변경사항을 적용한다.
    a. 변경사항은 아래의 isChanged 함수를 통해 알아낸다.
    b. 변경사항이 있다면 DOM을 새로 생성하고 기존 $current와 replace해준다.
  4. 마지막에 둘 다 VDOM일 경우를 한번 더 DFS를 통해 자식 노드들에도 동일한 작업을 해야하기 때문이다.
    a. initVDOMnewVDOM에 어떤 변경사항이 있었는지 알 수 없으니 두 VDOMchildren의 최대길이를 알아낸 후 그만큼 순회한다.
    b. 이렇게 되면 비교작업을 수행할때 VDOM | TextVDOM | undefined가 파라미터로 전달된다.

isChanged


const isChanged = (
  initVDOM: VDOM | TextVDOM | undefined,
  newVDOM: VDOM | TextVDOM | undefined
) => {
  const isTextInitVDOM = isTextVDOM(initVDOM);
  const isTextNewVDOM = isTextVDOM(newVDOM);

  // 두 타입이 다르거나
  // 두 타입이 TextVDOM일 때 두 값이 다르거나
  // 두 타입이 VDOM일 때 두 VDOM의 el이 다르면 VDOM에 변화가 생긴 것
  return (
    isTextInitVDOM !== isTextNewVDOM ||
    (isTextInitVDOM &&
      isTextNewVDOM &&
      !Object.is(initVDOM.value, newVDOM.value)) ||
    (!isTextInitVDOM && !isTextNewVDOM && !Object.is(initVDOM?.el, newVDOM?.el))
  );
};

결론 및 요약

updateDOM

  • 비교작업을 통해 DOM tree를 업데이트한다.
  • stateHook을 사용하기 위해 store의 id를 초기화한다.
  • 다음에 비교할 VDOM을 새로 생성한 VDOM으로 교체해준다.

updateElement

  • 4가지 분기점을 통해 $element를 추가하거나 삭제하거나 교체해준다.
  • 최하위 노드부터 변경사항을 적용하기 위해 DFS로 순회한다.

isChanged

실제 변경사항을 알아내는건 훨씬 복잡한 로직이겠지만 나는 이정도로만 비교했다.

  • 파라미터로 VDOM이나 TextVDOM이 전달되었을때 비교작업을 통해 boolean값을 반환한다.
  • VDOMTextVDOM처럼 타입이 다른지
  • 두 파라미터의 타입이 모두 TextVDOM이라면 value가 다른지
  • 두 파라미터의 타입이 모두 VDOM이라면 el이 다른지

결론

이렇게 비교 알고리즘을 Node를 비교해가며 변경하는 형식으로 구현해보니 상태가 변경되었을때 필요한 부분만 변경이 발생한다는 것을 알 수 있었다.

처음에 구현할때는 textNode가 아닌 string으로 관리했는데 이렇게 되니 상태가 변경되었을때 text가 삭제되지 않고 계속 추가된다거나 이렇게 삭제되지 않고 추가된 text로 인해 변경작업에 오류가 생기는 등 많은 시행착오가 있었다.

이를 통해 모든 요소를 Node로 관리하면 변경이 필요한 부분만 변경할 수 있다는 것을 알게되어 값진 시간이었다.

다음 글에서는 useState를 구현하기 위해 어떤 생각을 가지고 어떻게 구현했는지 다뤄볼 생각이며 이번 글에서 잠깐 등장했던 resetStateId에 대해서도 설명해볼 것이다.

profile
프론트엔드 개발자

0개의 댓글