티스토리 뷰

728x90
SMALL

컴포넌트 글을 보지 않았다면 먼저 봐주세요~~
https://ms3864.tistory.com/380

개요

저번에 만든 컴포넌트와 연동할 라우터를 만들어야 한다. 참고로 리액트 라우터와 유사하게(애초에 리액트 라우터는 context를 사용하기 때문에 다르게 만들 수 밖에 없다.) 만들 것이다.

구현할 것

무엇을 구현해야 할 지 생각해보자. 컴포넌트의 html을 string으로 구현했기 때문에 일단 리액트처럼 Link를 사용할 수는 없다. 그러면 그냥 a태그에 이벤트를 거는 방법도 있을것이고 새로운 태그에 이벤트를 걸어도 괜찮다. 그리고 /로 경로를 나눠서 상태로 가지고 있으면 좋겠다. 왜냐하면 params가 있는경우가 있기 때문이다. 그리고 리액트의 훅같은 함수가 있으면 더 편할것같다. 대충 pushstate, popstate를 이용해서 라우팅을 하면 될 것 같다. 아 query도 생각해서 만들자.

기본 틀 잡기

class Router {
  constructor(target, routes, NotFoundPage) {
    this.target = target;
    this.routes = routes;
    this.NotFoundPage = NotFoundPage;
    this.set();
    this.route();
    this.addLinkChangeHandler();
    this.addBackChangeHandler();
  }

  set() {
      // 처음에 라우터 인스턴스가 생성될 때 해야할 세팅
  }

  route() {
    // 경로를 찾는 메서드
  }

  addLinkChangeHandler() {
      // a 태그의 이벤트를 바꾼다
    this.target.addEventListener('click', e => {
      const { target } = e;
      const closest = target.closest('a');
      if (!closest || closest.getAttribute('target')) return;
      e.preventDefault();
      const pathname = closest.getAttribute('href');
      this.push(pathname);
    });
  }

  addBackChangeHandler() {
    window.addEventListener('popstate', () => {
      this.route();
    });
  }

  push(url) {
    window.history.pushState(null, '', url);
    this.route();
  }
}

export default Router;

a태그의 이벤트를 바꿔주고 pushState 후에 라우트 실행. popState에도 라우트 실행.

route 메서드

route 메서드는 어떻게 구현해야 할까?? 위에서 말한대로 /로 나눠서 생각하면 될 것 같다. params가 있는 경우도 있기 때문에 그 경우는 for문에서 continue로 그냥 넘겨주면 될 것같다. 이때 상태값을 배열로 만들면 좋을 것 같다. 객체로 만들면 시간복잡도가 줄어들지만 parmas를 사용할 수 없다. 그리고 상태값은 꼭 router 클래스 안에 있을 필요가 없다. 분리하자. 그리고 pathname, query, params를 state로 정의하자. 이제 좀 개요가 잡힌다.

utils.js

const getPathname = () => {
  return window.location.pathname;
};

const getQuery = () => {
  const { search } = window.location;
  const queries = new URLSearchParams(search);
  const params = {};
  queries.forEach((value, key) => {
    params[key] = value;
  });
  return params;
};

const pathValidation = (currentPath, routePath) => {
  if (currentPath.length !== routePath.length) return false;
  const params = {};
  let index = 0;
  for (index = 0; index < currentPath.length; index++) {
    if (/^:/.test(routePath[index])) {
      params[routePath[index].slice(1)] = currentPath[index];
      continue;
    }
    if (currentPath[index] !== routePath[index]) {
      return false;
    }
  }
  return params;
};

export { getPathname, getQuery, pathValidation };

router-context.js

import { getPathname, getQuery } from './utils';

class RouterContext {
  constructor() {
    this.state = { pathname: getPathname(), query: getQuery(), params: {}, push: () => {}, goBack: () => {} };
  }

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

export default new RouterContext();

router.js

import routerContext from './router-context';

  route() {
    const currentPath = this.routerContext.state.pathname.slice(1).split('/');
    for (let i = 0; i < this.routes.length; i++) {
      const routePath = this.routes[i].path.slice(1).split('/');
      const params = pathValidation(currentPath, routePath);
      if (!params) continue;
      routerContext.setState({ params });
      const Page = this.routes[i].component;
      new Page(this.target);
      return;
    }
    new this.NotFoundPage(this.target);
  }

처음에 currentPath는 처음 /를 포함해서 split을 하면 '/user/123' => ['','user','123'] 이 되기 때문에 첫번째 글자를 자르는 것이다.
pathValidation 함수는 return을 false, 또는 params를 해주는데 경로가 다르다면 false를, 존재한다면 그 경로에서 찾은 parmas를 return해준다. 만약 params가 존재하지 않느다면 빈 객체를 반환한다. 실제 리액트 라우터에서는 조금 다르게 동작하는데 바닐라자바스크립트라서 그냥 간단하게만 구현했다. 그리고 Route의 두번째 인자인 routes는

    this.routes = [
      { path: '/', component: PostListPage },
      { path: '/login', component: LoginPage },
      { path: '/signup', component: SignupPage },
      { path: '/post/:id', component: PostPage },
    ];

이런식으로 넣어주면 된다. 그리고 만약 모든 path와 일치하지 않는다면 NotFoundPage를 렌더링해주는 것이다.

조금 더 나아가보자

routerContext에서 state를 이용해 훅스를 만들고 싶다. pathname, query, params는 이미있는데 push와 goBack이 비어있다.사실 routerContext에서 직접 정의해줄수도 있지만 router에서 해주는게 재사용도 되고 조금 더 괜찮을 것 같다. 그래서 route의 set 함수에서 routerContext의 상태를 바꿔주자.

class Router {
  constructor(target, routes, NotFoundPage) {
    this.target = target;
    this.routes = routes;
    this.NotFoundPage = NotFoundPage;
    this.routerContext = routerContext;
    this.push = this.push.bind(this);
    this.goBack = this.goBack.bind(this);
    this.set();
    this.route();
    this.addLinkChangeHandler();
    this.addBackChangeHandler();
  }

  set() {
    routerContext.setState({ push: url => this.push(url) });
    routerContext.setState({ goBack: () => this.goBack() });
  }

예시코드

router.js

import routerContext from './router-context';
import { getPathname, getQuery, pathValidation } from './utils';

class Router {
  constructor(target, routes, NotFoundPage) {
    this.target = target;
    this.routes = routes;
    this.NotFoundPage = NotFoundPage;
    this.routerContext = routerContext;
    this.push = this.push.bind(this);
    this.goBack = this.goBack.bind(this);
    this.set();
    this.route();
    this.addLinkChangeHandler();
    this.addBackChangeHandler();
  }

  set() {
    routerContext.setState({ push: url => this.push(url) });
    routerContext.setState({ goBack: () => this.goBack() });
  }

  route() {
    const currentPath = this.routerContext.state.pathname.slice(1).split('/');
    for (let i = 0; i < this.routes.length; i++) {
      const routePath = this.routes[i].path.slice(1).split('/');
      const params = pathValidation(currentPath, routePath);
      if (!params) continue;
      routerContext.setState({ params });
      const Page = this.routes[i].component;
      new Page(this.target);
      return;
    }
    new this.NotFoundPage(this.target);
  }

  addLinkChangeHandler() {
    this.target.addEventListener('click', e => {
      const { target } = e;
      const closest = target.closest('a');
      if (!closest || closest.getAttribute('target')) return;
      e.preventDefault();
      const pathname = closest.getAttribute('href');
      this.push(pathname);
    });
  }

  addBackChangeHandler() {
    window.addEventListener('popstate', () => {
      routerContext.setState({ pathname: getPathname(), query: getQuery() });
      this.route();
    });
  }

  push(url) {
    window.history.pushState(null, '', url);
    routerContext.setState({ pathname: url, query: getQuery() });
    this.route();
  }

  goBack() {
    window.history.back();
  }
}

export default Router;

router-context.js

import { getPathname, getQuery } from './utils';

class RouterContext {
  constructor() {
    this.state = { pathname: getPathname(), query: getQuery(), params: {}, push: () => {}, goBack: () => {} };
  }

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

export default new RouterContext();

routerHooks.js

import routerContext from './router-context';

const useHistory = () => routerContext.state;

const usePathname = () => routerContext.state.pathname;

const useQuery = () => routerContext.state.query;

const useParams = () => routerContext.state.params;

export { useHistory, useQuery, useParams, usePathname };

index.js

import App from './app';
import './styles/reset.css';
import './styles/index.css';

new App(document.getElementById('root'));

app.js

// import { Router } from 'ms-vanilla';
import Router from './lib/router';
import LoginPage from './pages/login-page';
import NotFoundPage from './pages/not-found-page';
import PostListPage from './pages/post-list-page';
import PostPage from './pages/post-page';
import SignupPage from './pages/signup-page';
import userStore from './store/user-store';

class App {
  constructor(target) {
    this.target = target;
    this.routes = [
      { path: '/', component: PostListPage },
      { path: '/login', component: LoginPage },
      { path: '/signup', component: SignupPage },
      { path: '/post/:id', component: PostPage },
    ];
    this.NotFoundPage = NotFoundPage;
    this.render();
    this.init();
  }

  render() {
    new Router(this.target, this.routes, this.NotFoundPage);
  }

  init() {
    if (localStorage.getItem('user')) userStore.autoLogin();
  }
}

export default App;
728x90
LIST
댓글
공지사항