Activity/우아한테크캠프 4기

우아한테크캠프 3주차 회고

Hov 2021. 8. 8. 17:49

드디어 시작된 열정의 3주차..!

이번 프로젝트에서 우리는 Vanilla JS로 SPA 프레임워크를 만들어 보고자 했고, 큰 컨셉은 세가지로 이루어졌다.

Router, Virtual DOM, JSX Parser

 

01. Router 만들기

가만 생각해보니 이번에 우테캠 2차 과제를 할 때 Vanilla JS로 라우팅이 구현되어 있던 기억이 났다. 해당 라우팅의 핵심 구조는 다음과 같았다. 글 로만 설명이 될지는 잘 모르겠다...

1. 'hashchange' 이벤트가 일어나면 index.ts에서 현재 Path 확인
2. path를 key로 하는 page 엘리먼트 불러오기
3. 뒤로가기를 할 때 history.back(-1)을 불러주기

hash기반 라우팅은 url에 #가 붙어서 유저가 보기에 좋지 않다는 단점이 있었다. 주말에 hash기반이 아닌 history 기반의 router를 구현해보기로 했다.

더보기

router.ts

import Component from './core/Component';
import { updateRealDOM } from './index';
import { getRoot, parseURL } from './utils';
export type Route = {
  path: Path;
  page: any;
};

export enum Path {
  main = 'main',
  product = 'product',
  productEdit = 'product_edit',
  post = 'post',
  menu = 'menu',
  mypage = 'my_page',
  category = 'category',
  address = 'address',
  notFound = 'not_found',
  signIn = 'sign_in',
  signUp = 'sign_up',
  chatList = 'chat_list',
  chatDetail = 'chat_detail',
  index = '',
}

type Param = {
  [key: string]: string;
};

class Router {
  $app: HTMLElement;
  routes: {
    [key: string]: typeof Component;
  } = {};

  constructor($app: HTMLElement, routes: Route[]) {
    this.$app = $app;
    this.generateRoutes(routes);

    this.initEvent();
  }

  generateRoutes(routes: Route[]): void {
    this.routes = {};

    routes.forEach((route: Route) => {
      this.routes[route.path] = route.page;
    });
  }

  initEvent(): void {
    window.addEventListener('hashchange', this.hashChangeHandler.bind(this));
  }

  hasRouter(): boolean {
    return (this.routes as Object).hasOwnProperty(getRoot(parseURL().path));
  }

  getNotFoundRouter() {
    return this.routes[Path.notFound];
  }

  getRouter(path: Path | string) {
    return this.routes[path];
  }

  hashChangeHandler(): void {
    const { path, params } = parseURL();

    let route = this.getNotFoundRouter();

    if (this.hasRouter()) {
      route = this.getRouter(getRoot(path));
    }

    const page = new route({});
    if (this.$app.lastElementChild)
      this.$app.replaceChild(page.$dom, this.$app.lastElementChild);
    else this.$app.appendChild(page.$dom);
    updateRealDOM();
  }
}

export const initRouter = ($app: HTMLElement, routes: Route[]): void => {
  const router = new Router($app, routes);

  router.hashChangeHandler();
};

 

02.  Virtual DOM 만들기

 

브라우저에서 가장 큰 성능을 좌우 하는 부분이 바로 렌더링인데, 가상돔은 이 렌더링 부분에 있어서 최적화를 하기위한 기술이다. DOM의 변화가 일어나는 부분만을 체크해서 해당 element만 바꿔 줘서 페이지에 변화가 일어날 때 마다 DOM의 업데이트를 최소화 시키는 방법이다.

아래는 이번에 우리가 사용한 Diff 알고리즘이다. 로직은 간단하게 다음과 같다.

1. 두 노드의 attribute 갯수가 다른지 먼저 체크한다. (갯수가 다르면 다른 노드)
2. 두 노드의 attribute중 하나라도 다른 것이 있는지 체크한다. (하나라도 attribute가 다르면 다른 노드)
3. 두 노드 모두 child가 없을 때, textContent가 같은지 체크한다. (text가 다르면 다른 노드)
4. 두 노드 중 하나라도 child가 있다면 재귀적으로 체크한다.
   - origin에 Node가 있고 new에 Node가 없다면 (remove)
   - origin에 Node가 없고 new에 Node가 있다면 (appendChild)
   - origin에 Node가 있고 new에 Node가 있다면 diff check(위의 로직)
      - 두 node가 다르다면 (replaceChild)
      - 두 노드가 같다면 두 노드의 children으로 다시 재귀적으로 Diff 발동하기

대략적인 코드는 아래와 같다 (이것 보다 추후에 관련된 자세한 글을 작성해보겠다)

더보기

index.ts 

...

const $app = $('#app');

export const updateRealDOM = () => {
  window.requestAnimationFrame(() => {
    if ($app) applyDiff(document.body, $app, window.virtualDOM);
  });
};


if ($app) {
  window.virtualDOM = document.createElement('div');
  window.virtualDOM.id = 'app';
  initRouter(window.virtualDOM, routes);
}

...

 

diff.ts

import { cloneNodeWithEvent } from './utils';

export const isDiffrentNode = (node1: Element, node2: Element) => {
  const n1Attributes = node1.attributes;
  const n2Attributes = node2.attributes;

  if (n1Attributes.length !== n2Attributes.length) {
    return true;
  }

  const differentAttribute = Array.from(n1Attributes).find(attribute => {
    const { name } = attribute;
    const attribute1 = node1.getAttribute(name);
    const attribute2 = node2.getAttribute(name);

    return attribute1 !== attribute2;
  });

  if (differentAttribute) {
    return true;
  }

  if (
    node1.children.length === 0 &&
    node2.children.length === 0 &&
    node1.textContent !== node2.textContent
  ) {
    return true;
  }

  return false;
};

const applyDiff = (
  parentNode: Element,
  realNode: Element,
  virtualNode: Element,
) => {
  if (realNode && !virtualNode) {
    realNode.remove();
    return;
  }

  if (!realNode && virtualNode) {
    parentNode.appendChild(cloneNodeWithEvent(virtualNode));
    return;
  }

  if (isDiffrentNode(virtualNode, realNode)) {
    realNode.replaceWith(cloneNodeWithEvent(virtualNode));
    return;
  }

  const realChildren = Array.from(realNode.children);
  const virtualChildren = Array.from(virtualNode.children);

  const max = Math.max(realChildren.length, virtualChildren.length);
  for (let i = 0; i < max; i++) {
    applyDiff(realNode, realChildren[i], virtualChildren[i]);
  }
};

export default applyDiff;

 

이를 통해서 setState함수 같이 컴포넌트에 랜더링 업데이트가 일어난다면 모든 엘리먼트의 업데이트는 window.virtualDOM에 적용하고, 마지막에 applyDiff라는 함수를 통해 실제 dom과 virtual DOM의 비교 후 렌더링 업데이트를 발생시킨다.

 

03. JSX Parser

우리가 겪었던 큰 고민 중 하나는, 각각의 Component가 자식 Component를 렌더 함수에서 어떻게 표현을 해주느냐였다.
단순히 자식 컴포넌트의 template HTML string을 반환해도 되지만 그렇게 된다면, 최종적으로 만들어진 innerHTML을 가지고 다시 DOM을 생성하게 되는 문제가 있었다. 어차피 Component 별로 자신의 element를 갖고있다면 굳이 다시 html > DOM 의 과정을 거칠 필요가 있을까? 하는 생각에 Parser를 만들어 봤다.

Parser는 다음과 같이 동작한다.
1. 컴포넌트는 만들어 짐과 동시에 고유한 id값을 가진다.
2. 자식 컴포는 만들어짐과 동시에 id와 $dom으로 부모 컴포넌트의 $components 맵에 추가된다.

{
  'c-1': C1.$dom,
  'c-2': C2.$dom,
   ...
}


3. 부모 컴포넌트의 render 함수의 마크업에 각 자식의 id로 이루어진 태그로 구성한다.

render() => `
<div>
  <h1>Hello</h1>
  <c-1></c-1>
  <c-2></c-2>
</div>
`

4. Parser를 통해서 위의 html코드를 가지고 DOM을 형성한 뒤 c-1, c-2태그는 부모 컴포넌트의 $components값들을 참고해 $dom들을 가져와 replace 한 뒤 최종 $dom을 반환한다.

더보기

myJSX.ts

import Component from './Component';

const replaceComponent = (
  $parentNode: Element,
  $curr: Element,
  components: {
    [key: string]: Component;
  },
) => {
  const nodeName = $curr.nodeName;

  if (components[nodeName]) {
    const $new = components[nodeName].$dom;

    $parentNode.replaceChild($new, $curr);
  }
};

const searchTraverse = (
  $tar: Element,
  components: {
    [key: string]: Component;
  },
) => {
  const $children = Array.from($tar.children);

  if ($children.length === 0) {
    if ($tar.parentElement) {
      replaceComponent($tar.parentElement, $tar, components);
      return;
    }
  }

  $children.forEach($el => {
    searchTraverse($el, components);
  });
};

export const parseJSX = (
  html: string,
  components: {
    [key: string]: Component;
  },
): Element => {
  const $dom = document.createElement('div');
  $dom.innerHTML = html;

  searchTraverse($dom, components);

  return $dom.firstElementChild ?? $dom;
};

 

모든 기능들이 문제 없이 잘 돌아가고, 인규님은 우리 프레임워크가 정말 잘 만들어 진 것 같다고 하셨지만, 나는 왠지 자꾸만 개선할 점이 보이는 것 같았다. 그래서 3번째 프로젝트를 마친 주말에 따로 보일러 플레이트로 만들어 개선점을 보완하기로 했다.

아... 정말 기억을 떠올리는 목적으로 쓰다보니 정말 횡설수설이 따로없다. 나중에 배운 내용을 정리하는 글은 정말 잘 써봐야겠다 ...

 

그래서 이번 결과물은..!

채팅은 결국 스타일링과 마크업만 구현했다.

이번에 프레임워크를 만드는데 집중해서 글쓰기, 동네별 글 읽어오기, 회원가입까지만 기능구현을 완료했다.

채팅 기능을 만들어보지 못해 약간의 아쉬움은 있었지만, 프레임워크를 만들면서 많이 배우게 되어서 큰 후회는 없다!

[프로젝트 Git Repo]

 

GitHub - woowa-techcamp-2021/deal-4: 이호빈의 캐리버스 (승객 오인규)

이호빈의 캐리버스 (승객 오인규). Contribute to woowa-techcamp-2021/deal-4 development by creating an account on GitHub.

github.com

 

[아래는 내가 개선한 프레임워크]

 

GitHub - HobinLee/Woowact: 우테캠 하면서 배운 총지식 정리를 위한 우웩트... 프레임워크

우테캠 하면서 배운 총지식 정리를 위한 우웩트... 프레임워크. Contribute to HobinLee/Woowact development by creating an account on GitHub.

github.com

 

두 번째 프로젝트를 마치며

정말 많이 배운 프로젝트였다..! 덕분에 가상돔이나 라우터가 어떤식으로 작동되는지 알 수 있었고, TypeScript, SCSS도 처음 써 봤지만 많이 익숙해졌다 :) 다음 프로젝트에 기회가 된다면 이번에 만든 프레임워크를 사용해서 프로젝트에 완성도에 기여할 수 있었으면 좋겠다고 생각했다.

그리고 이번 프로젝트를 진행하는 동안 타회사의 최종 합격 소식이 있었다. 정말 좋은 면접 경험과 원하는 직무의 부서배치 등으로 그 회사로 가는게 거의 확정되다시피 했는데, 인규님이 나에게 자신감을 많이 불어 넣어 주셔서 캠프에 남기로 했다. (너무 너무 고맙다)

아무래도 최선을 다해 우아한 형제가 될 수 있도록 후회없이 남은 캠프기간을 지내보고싶다!