[VanillaJS] 2021 Dev-Matching(하반기)

jun5e00·2022년 9월 3일
0

VanillaJS

목록 보기
2/2
post-thumbnail

0. 들어가며

지난번 과제는 MVC 패턴으로 작성하고자 노력했었다. Controller를 나누지 않아서 너무 비대해진다고 생각해서 이번에는 컴포넌트 단위로 쪼개서 구현하였다.

1. 설계

1-1. 컴포넌트 단위 설계

App이라는 가장 상위 컴포넌트를 두고, Router를 그 아래에 두어 history 관리를 해주었다.
Router를 통해 url의 변경에 따라 페이지가 렌더링 되게 하였다.

1-2. Router

hash를 사용하면 hashchange라는 이벤트를 사용하면 된다.
하지만 hash를 사용하지 않기 때문에 custom event를 만들어야 한다.

1-3. Page

Page는 하위 컴포넌트들에 필요한 data를 불러와서 전달하는 역할을 한다.

init

data를 불러오는 부분이다. async/await을 활용해서 data 로딩이 성공적이면 setState를 해서 하위 컴포넌트에 전달한다.

render

Page의 가장 상위 Element를 생성하고, 하위에 필요한 컴포넌트들을 호출하는 부분이다.

setState

state를 업데이트하고, render한다.

1-4. Component

Page로부터 초기 state 값을 받는다.

render

data를 아직 불러오지 못한 경우에는 loading 처리를 하고, data를 불러온 이후에 화면을 렌더링한다.

setEvent

해당 컴포넌트에서 필요한 event를 등록한다.

2. 구현

2-1. Router

class Router {
  routes = {};

  constructor({ $app, routes }) {
    this.$app = $app;

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

    this.setEvent();
  }

  setEvent() {
    // custom event를 등록한다.
    
    /**
    * 뒤로 가기를 한 경우에 url만 바뀌기 때문에 url에 맞는 페이지를 렌더링하기 위해서
    * popstate 이벤트를 등록해준다.
    */
  }

  onChangeRouteHandler(e) {
    // custom event로 전달 받은 path를 확인하고, pushState로 이동시킨다.
    // pushState는 단순히 url만 변경하기 때문에 path에 맞는 page를 다시 렌더링해야 한다.
  }

  renderPage(path) {
      // routes의 path에 맞는 page를 렌더링한다.
  }

  push(path) {
    // url 변경 event를 dispatch 한다.
    customEventDispatcher(ROUTE_CHANGE_EVENT, {
      ...history.state,
      path,
    });
  }
}

renderPage

renderPage(path) {
  let route;

  const routePath = path.replace('/web/', '/');

  const regex = /[/product]\d{1,}$/;

  if (this.hasRoute(path)) { // --- 1
      route = this.getRoute(path);
  } else if (regex.test(routePath)) { // --- 2
      route = this.getRoute('/web/product');
  }

  this.$app.innerHTML = '';

  new route(this.$app);
}
hasRoute(path) {
  return this.routes[path] !== undefined;
}

getRoute(path) {
  return this.routes[path];
}

과제에서 주어진 url이 "/", "/product/{productId}", "/cart" 이다.
"/"와 "/cart"의 경우 뒤에 추가적으로 붙는 url이 없기 때문에 1번에서 if문에 걸릴 것이고, "product/{productId}"의 경우 2번 if문에 걸릴 것이다. 이외의 url에 대한 fallback 처리는 아래의 onChangeRouteHandler에서 해줬다.

onChangeRouteHandler

onChangeRouteHandler(e) {
  const path = e.detail?.path ?? '/web/';

  history.pushState(e.detail, "", path);

  this.renderPage(path);
}

path 값을 설정할 때, 기본 값을 /web/으로 설정해주었다. 이러면 없는 url 주소로 들어오면 /web/에 해당하는 페이지를 보여주게 된다.

pushState를 활용해 url만 변경하였다. 이후에 위에서 구현한 renderPage를 호출하면 url에 해당하는 page가 화면에 그려진다.

setEvent

setEvent() {
  window.addEventListener(
      ROUTE_CHANGE_EVENT,
      this.onChangeRouteHandler.bind(this)
  );

  window.addEventListener('popstate', this.onChangeRouteHandler.bind(this));
}

ROUTE_CHANGE_EVENT라는 customEvent를 연결해준다.

const ROUTE_CHANGE_EVENT = "ROUTE_CHANGE";

url 뒤로 가기가 발생하면 url만 변경 되고, 페이지는 이전 페이지를 유지하는 문제가 발생한다. 따라서 popstate event를 연결했다.

push

push(path) {
  customEventDispatcher(ROUTE_CHANGE_EVENT, {
    ...history.state,
    path,
  });
}

ROUTE_CHANGE_EVENT를 발생시키고, 전달 받은 path(=url)을 전달한다.
React의 Router에서 push 하는 역할을 담당한다.

router, initRouter

export let router = {};

export const initRouter = ({ $app, routes }) => {
  const routerObj = new Router({ $app, routes });

  router = {
    push: (path) => routerObj.push(path),
  };

  customEventDispatcher(
    ROUTE_CHANGE_EVENT,
    history.state ?? {
      path: "/web/",
    }
  );
};

가장 상위에서 initRouter를 통해서 route 주소와 가장 상위 DOM Element를 전달하면 경로를 설정한다.
url 변경이 필요한 부분에서 router의 push를 통해서 url을 변경한다.

customEventDispatcher

url 변경을 감지하는 event를 만들기 위한 custom event trigger 이다.

const customEventDispatcher = (eventType, detail) => {
  window.dispatchEvent(
    new CustomEvent(eventType, {
      detail,
    }),
  );
};

export default customEventDispatcher;
import { ROUTE_CHANGE_EVENT } from "../lib/constants.js";

import customEventDispatcher from "../lib/utils/customEventDispatcher.js";

class Router {
  routes = {};

  constructor({ $app, routes }) {
    this.$app = $app;

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

    this.setEvent();
  }

  setEvent() {
    window.addEventListener(
        ROUTE_CHANGE_EVENT,
        this.onChangeRouteHandler.bind(this)
    );

    window.addEventListener('popstate', this.onChangeRouteHandler.bind(this));
  }

  onChangeRouteHandler(e) {
    const path = e.detail?.path ?? '/web/';

    history.pushState(e.detail, "", path);

    this.renderPage(path);
  }

  renderPage(path) {
      let route;

      const routePath = path.replace('/web/', '/');

      const regex = /[/product]\d{1,}$/;

      if (this.hasRoute(path)) {
          route = this.getRoute(path);
      } else if (regex.test(routePath)) {
          route = this.getRoute('/web/product');
      }

      this.$app.innerHTML = '';

      new route(this.$app);
  }

  hasRoute(path) {
      return this.routes[path] !== undefined;
  }

  getRoute(path) {
      return this.routes[path];
  }

  push(path) {
    customEventDispatcher(ROUTE_CHANGE_EVENT, {
      ...history.state,
      path,
    });
  }
}

export let router = {};

export const initRouter = ({ $app, routes }) => {
  const routerObj = new Router({ $app, routes });

  router = {
    push: (path) => routerObj.push(path),
  };

  customEventDispatcher(
    ROUTE_CHANGE_EVENT,
    history.state ?? {
      path: "/web/",
    }
  );
};

2-2. Page

init

// ProductListPage의 예시이다.
init() {
  (async () => {
    const productList = await getProductList();

    this.setState({
      isLoading: false,
      productList,
    });
  })();
}

즉시 실행 함수를 통해 data를 불러오고 setState를 해주었다.

render

// ProductListPage의 예시이다.
render() {
  this.$app.innerHTML = `
          <div class="ProductListPage">
              <h1>상품목록</h1>
          </div>
      `;

  new ProductList({
    $app: this.$app,
    initialState: this.state,
  });
}

가장 뼈대가 되는 상위 Element만 $app에 렌더링한다. 이후에 하위 컴포넌트인 ProductList를 호출한다.

setState

setState(nextState) {
  this.state = {
    ...this.state,
    ...nextState,
  };
  this.render();
}

state가 바뀌면 새로운 state로 화면을 그려주기 위해서 render를 호출했다.

2-3. Component

render

// ProductList의 예시이다.
render() {
  const { isLoading, productList } = this.state;

  const $h1 = $("h1");

  if (isLoading) {
    $h1.insertAdjacentHTML(
      "afterend",
      `
              <div>loading...</div>
          `
    );
    return;
  }

  $h1.insertAdjacentHTML(
    "afterend",
    `
          <ul>
              ${productList
                .map(
                  ({ id, imageUrl, name, price }) => `
                  <li class="Product" data-product-id="${id}">
                      <img src="${imageUrl}" >
                      <div class="Product__info">
                          <div>${name}</div>
                          <div>${price}~</div>
                      </div>
                  </li>
              `
                )
                .join("")}
          </ul>
      `
  );
  this.setEvent();
}

innerHTML을 사용하면 HTML 전체를 비우고 새로 만든다.
따라서 insertAdjacentHTML을 사용하여 원하는 위치에 새로운 Element를 연결했다.

setEvent

// ProductList의 예시이다.
setEvent() {
  $("ul").addEventListener("click", (e) => {
    const $li = e.target.closest("li");

    if (!$li) return;

    const { productId } = $li.dataset;

    router.push(`/web/product/${productId}`);
  });
}

해당 컴포넌트에서 필요한 event를 설정해주었다.
모든 li 요소를 선택해서 이벤트를 하나씩 생성하는 방법도 있다.
하지만 이벤트 위임으로 처리한다면 불필요한 이벤트 핸들러를 추가하지 않아도 된다.

3. 마무리

위의 틀에 따라서 상세 구현을 마무리했다. 바닐라 자바스크립트로 완벽한 SPA는 아니지만 비슷하게 구현해보는 경험을 할 수 있었다. 해당 과제를 통해서 좀 더 React처럼 작성하기 위해 노력했다.

현재는 각각의 Page, Component를 틀만 정해두고 작성했다. 이렇게 되면 코드를 작성하는 내가 실수를 할 수 있다. 다음에는 이를 추상화하여 Component Class를 만들어서 정리해야겠다.

Component Class를 만들어서 추상화 하면 얻을 것으로 예상 되는 장점

  1. 코드 순서를 내가 전부 기억하지 않아도 된다. 필요한 순간에 추상화된 Component Class를 보면 된다.
  2. 코드 내부의 실행 순서를 잘못 작성하는 실수, 에러가 없을 것이다.
profile
공부 일기장

0개의 댓글