Thief of Wealth
article thumbnail

https://github.com/woowacourse/javascript-own-ui-library/pull/20

 

[2단계 - Virtual DOM 만들기] 도비(김정혁) 미션 제출합니다. by zereight · Pull Request #20 · woowacourse/javas

https://musing-lamport-6e9960.netlify.app/ WebComponent 기반으로 동작합니다. Step1 심화 요구사항인 JSX 파서를 만들었습니다. (버그 존재) Step1의 Vdom 알고리즘을 개선했습니다. 이제 한번에 변경사항 모아서

github.com

 

지난 3일동안 우테코 미션을 했다.

나만의 ui 라이브러리를 만들어보는 것인데, 기한이 내일까지라 버그가 있음에도 제출을 했다.

 

버그는 대부분 JSX파서에 있다.

 

이틀전부터 손보던 JSX파서는 다음과 같이 되었다.

// tagged literal template의 인자를 대체할 유니크한 키워드
const TAGGED_TEMPLATE_LITERAL_PARAM_FLAG = "dobyParamArrIndex=";

// 이벤트를 붙여주는 함수
const attachEvent = (dom, eventType, cb) => {
  dom.addEventListener(eventType, cb);
};

/*  
  JSX 파서, 개선해야할 사항
  - 연속된 ${} param값 처리안됨 (`${}${}`이런거 파싱 못한다는 뜻)
  - <App/> 같은 컴포넌트 인식 못함
*/

export const html = (stringArr, ...paramArr) => {
  // tagged literal template 형식대로 받습니다.
  const htmlStr = stringArr.reduce((acc, curr, index) => {
    acc += curr;
    if (paramArr[index] !== undefined) {
      if (typeof paramArr[index] === "string") {
        acc += paramArr[index];
      } else {
      // 인자가 string이 아니면 일단, 유니크한 값 넣어줍니다.
        acc += `${TAGGED_TEMPLATE_LITERAL_PARAM_FLAG}${index}`;
      }
    }

    return acc;
  }, "");

  // 내가 새로 그려야할 dom객체를 반환합니다.
  // woowahan jsx에서처럼 document.createElement('frame').firstChild() 하셔도됩니다!
  const $domFromHtmlString = new DOMParser()
    .parseFromString(htmlStr, "text/html")
    .querySelector("body").firstChild;

  // Dom 트리 순회해주는겁니다. 개꿀
  const domIterator = document.createNodeIterator(
    $domFromHtmlString,
    NodeFilter.SHOW_ALL
  );

  // 속성 적용해주는것
  const applyAttribute = (dom, attributes) => {
    for (const attribute of attributes) {
      const attrName = attribute.name;
      let value = attribute.nodeValue;

      // 속성에 아까 유니크하게 넣어준값있으면 param으로 대체
      if (value.includes(TAGGED_TEMPLATE_LITERAL_PARAM_FLAG)) {
        const realValueIndex = value.split("=")[1];

        value = paramArr[realValueIndex];
      }

	// 이벤트면 이벤트 달아주기
      if (attrName.startsWith("on")) {
        attachEvent(dom, attrName.slice(2).toLowerCase(), value);
        dom.removeAttribute(attrName);
      } else {
        dom.setAttribute(attrName, value);
      }
    }
  };

// 순회합니다.
  while (true) {
    const node = domIterator.nextNode();
    if (!node) break;

	// 텍스트 노드면
    if (node.nodeType === Node.TEXT_NODE) {
      const text = node.textContent;

      if (text.includes(TAGGED_TEMPLATE_LITERAL_PARAM_FLAG)) {
        const realValueIndex = Number(text.split("=")[1].trim());

		// 텍스트 노드인데, 들어갈 값이 HTMLElement면 노드를 바꿔~
        if (paramArr[realValueIndex] instanceof HTMLElement) {
          node.replaceWith(paramArr[realValueIndex]);
        } else if (
        // falsy값은 버려~
          paramArr[realValueIndex] === false ||
          paramArr[realValueIndex] === null ||
          paramArr[realValueIndex] === undefined
        ) {
          node.remove();
        } else {
        // 너는 진짜 text구나 텍스트 값 바로 넣기
          node.textContent = paramArr[realValueIndex];
        }
      } else if (text.trim().length === 0) {
      // empty text 노드 제거 (이거 진짜 화난다..)
        node.remove();
      }
    }

    const attributes = Array.from(node.attributes ?? []);

    if (attributes.length > 0) applyAttribute(node, attributes);
  }

  $domFromHtmlString.vDom = $domFromHtmlString.cloneNode(true);

  return $domFromHtmlString;
};

 

버그가 있는 상태이지만, 내가 원하는 대로는 동작한다.

여기서 가장 중요한건, empty text node를 제거하는것이다.

저거 있으면 Dom tree를 순회할때, 어디에 있는지도 모르는 empty text node 때문에 순회 순서가 진짜 많이 꼬인다.

이걸로 몇시간을 디버깅했는지... ㅜㅜ

 

그리고 웹 컴포넌트 기반으로 UI를 만들 수 있도록 했다.

import { html } from "../utils/dom.js";

// 웹 컴포넌트 입니다.
class Component extends HTMLElement {
  constructor() {
    super();

    this.state = {};

    /* 
    프록시를 사용하려했는데 안되어서 남겨진 코드 (this.state 감지중인데 this.state.count = 0 했을때 프록시가 실행안되네요? ㅜ 클래스면 뭐 다른가?
      
    this.stateProxy = new Proxy(this.state, {
      get(target, prop) {
        console.log("proxy get");
        return target[prop];
      },
      set(target, prop, value) {
        target[prop] = value;
        console.log("proxy set"); // 왜 동작안하지?

        const newTemplate = this.getTemplate();
        this.diff(this.template, newTemplate);
      }
    });
  */
  }

  // 웹 컴포넌트에서 기본적으로 제공하는 메서드로, 엘리먼트가 생성될때 자동으로 실행됩니다.
  connectedCallback() {
    this.append(this.template);
  }

  // 템플릿을 반환하는 함수입니다.
  getTemplate() {
    return html`<div></div>`;
  }

  // 디바운스용입니다.
  timeId = null;

  // vDom의 변경사항을 실제 Dom에 반영하는 메서드 입니다.
  updateVDom2RealDom() {
    this.diff(this.template, this.template.vDom);
  }

  // 그리기 메서드입니다.
  render() {
    // 처음에 template없으면 여기서 할당해줍니다.
    if (!this.template) {
      this.template = this.getTemplate();
    }

    // 최신 상태값을 반영하여, vDom을 업데이트 합니다.
    this.diff(this.template.vDom, this.getTemplate());

    // 디바운스를 사용하여, 100ms이내에 변경사항이 있으면 추가로 vDom에 반영합니다.
    if (this.timeId) {
      clearTimeout(this.timeId);
    }

    // 100ms 지나도록 변경사항이 없으면, RealDom에 업데이트 합니다.
    this.timeId = setTimeout(this.updateVDom2RealDom.bind(this), 100);
  }

  // diff 메서드입니다.
  diff($oldDom, $newDom) {
    // oldDom과 newDom을 순회하는 이터레이터입니다.
    const oldDomIterator = document.createNodeIterator(
      $oldDom,
      NodeFilter.SHOW_ALL
    );
    const newDomIterator = document.createNodeIterator(
      $newDom,
      NodeFilter.SHOW_ALL
    );

    while (true) {
      let oldNode = oldDomIterator.nextNode();
      let newNode = newDomIterator.nextNode();

      if (!oldNode) {
        break;
      }

      if (!newNode) {
        break;
      }

      // 같은 태그인가?
      const isSameTagName = oldNode.localName === newNode.localName;

      // 같은 속성인가?
      const oldNodeAttrs = Array.from(oldNode.attributes || []).sort(
        (a, b) => a.nodeName < b.nodeName
      );
      const newNodeAttrs = Array.from(newNode.attributes || []).sort(
        (a, b) => a.nodeName < b.nodeName
      );

      const isSameAttributes =
        oldNodeAttrs.length === newNodeAttrs.length &&
        oldNodeAttrs.every((oldNodeAttr, index) => {
          const newNodeAttr = newNodeAttrs[index];

          return (
            oldNodeAttr.nodeName === newNodeAttr.nodeName &&
            oldNodeAttr.nodeValue === newNodeAttr.nodeValue
          );
        }) &&
        oldNodeAttrs.length === newNodeAttrs.length;

      // 같은 데이터인가?
      const isSameData = oldNode?.data === newNode?.data;

      // 같은 길이의 자식들인가?
      const isSameChildrenLength =
        oldNode.childNodes.length === newNode.childNodes.length;

      // 그래서 같은 Dom인가?
      const isSameDom =
        isSameTagName && isSameAttributes && isSameData && isSameChildrenLength;

      // 다른 Dom이라고 판단되었을때 diff를 적용합니다.
      if (!isSameDom) {
        // text노드면 text값만 바꿔줍니다.
        if (oldNode.nodeType === Node.TEXT_NODE) {
          oldNode.textContent = newNode.textContent;
        } else {
          // Dom 자체가 다르면 바꿔줍니다.
          oldNode.replaceWith(newNode);

          // 바꾸고나서 next로 한번 더 가야, 이터레이터 순서가 newNode와 같길래 한번더 next 해줬습니다.
          oldNode = oldDomIterator.nextNode();
        }
      }
    }

    // 업데이트된 oldDom의 vDom을 자기자신으로 업데이트 해줍니다.
    $oldDom.vDom = $oldDom;
  }

  setState(newState) {
    // 상태를 반영합니다. 여기서 Proxy가 동작하지 않았습니다 ㅜ
    Object.entries(newState).forEach(([key, value]) => {
      this.state[key] = value;
    });

    // 상태가 변경되었으니 render해줍니다.
    this.render();
  }
}

export default Component;

 

프록시 객체를 사용해보려했는데, setState에서 this.state의 프로퍼티를 감지하고 있는데로 proxy가 호출되지 않았다. ㅜㅜ

이유는 아직까지도 모르겠다.

 

diff알고리즘은 진짜 2개의 Dom을 비교하고 업데이트하는 역할을하고,

render는 vDom과 그려야할 Dom을 먼저 업데이트하고, 100ms이후에 모든 변경사항이 반영되어있는 vDom을 realDom에 그리는 역할을 한다.

 

이게 핵심이다.

 

하지만 여기까지 오는데 엄청난 시간과 노력이 들었다.

도중에 포기할까라는 생각도 수십번들었다. 계속 같은 파일 같은 디버깅 같은 알고리즘을 고민하고 있었으니... ㅎ

 

10시간동안 미션했다.

스트레스 많이 받았다. 근데 집중력이 끊기질 않았다. 무조건 해내고 싶었다.

 

살짝 버그가 있는 구현이지만, vDom 로직을 이론상 "변경사항을 한번에 모아 realDom에 렌더링하는것"을 충분히 만족했고,

jsx 파서를 구현해서 실제로 렌더링되는 UI를 만들었다는것에 엄청난 희열을 느끼고 있다!

 

이렇게 라이브러리 하나 내고싶다! 처음엔 이 미션을 살짝 원망했는데 어느정도 해내고 나니 이 미션을 출제해준 포코가 고맙게 느껴진다.

역시 라이브러리를 직접 만들어보는 과정에서 엄청난 배움이 있는 것 같다.

 

https://musing-lamport-6e9960.netlify.app/

 

Simple Counter

 

musing-lamport-6e9960.netlify.app

 

profile on loading

Loading...