vanilla js로 라우팅 구현하기 - 더 많은 pathname을 처리해보자!

GY·2023년 3월 11일
0

Vanilla JS Project

목록 보기
19/19
post-thumbnail

vanilla js로 라우팅 기능을 구현하면서 로직에 대해 고민했던 내용을 정리합니다.

모든 pathname에 대응할 수 없는 문제

라우터의 메서드 중 renderPage는 현재 경로에 대응하는 페이지를 루트 요소에 삽입해 렌더링하는 메서드입니다.

처음 구현했던 코드는 다음과 같았습니다.

renderPage() {
    const [_, pathname, id] = window.location.pathname.split("/");
    const page = this.#routes[`/${pathname}`];
    this.#$app.innerHTML = "";
    page.attachTo(this.#$app);
  }

오직 하나의 pathname에만 대응할 수 있는 문제

window.location.pathname을 split한 결과값에 맞게 동적 라우팅 변수인 id를 반환받고 있는데, 이럴 경우 라우팅 변수가 많아지거나 갯수는 동일하고 그 이름이 달라질 경우 의도대로 동작할 수 없습니다.

  • path parameter의 갯수가 많아질 경우 대응할 수 없음
  • 동적 라우팅 경로의 path parameter의 갯수가 많아질 경우 대응할 수 없음
  • path parameter의 이름 (pathname)이 달라지는 경우 대응할 수 없음
  • path paramter를 받아오는 로직을 컴포넌트에서 호출해 사용하기에 간편하고 직관적이지 않음

왜 이렇게 했나...

그 때도 그랬지만 지금 보니 더 부끄럽...네요.

진행하던 프로젝트는 /article/list, article/:id 두 개의 라우팅 경로만을 갖고 있었습니다.
그 중 /article/list는 앱이 켜졌을 때 가장 먼저 뜨는 메인 페이지 역할을 했기 때문에 article/:id 라우팅 경로 형태에만 대응해 구현한 코드였습니다.

한번 기능을 구현한 다음에는 리팩토링을 통해 가능한 많은 종류의 pathname 형태에 대응할 수 있도록 개선하고자 했습니다.


다양한 형태의 pathname에 대응할 수 없는 문제

위 문제를 해결하기 위해 정규표현식을 사용했습니다.
동적 라우팅 변수는 :로 시작하는 변수로 지정하도록 하고, 이 :로 시작하는 문자열에 대해서는 라우팅 변수로 취급할 수 있도록 정규표현식을 생성하는 함수를 작성했습니다.

#createPathRegex = (path) => new RegExp("^" + path
  .replace(/\//g, "\\/")
  .replace(/:\w+/g, "(.+)") + "$");

이 함수를 사용해서 다음과 같은 라우팅 경로에 대응하는 것은 성공했습니다:

  • 동적 라우팅 변수의 이름이 달라질 경우:/article/:id,article/:title
  • 동적 라우팅 변수의 갯수가 많아질 경우: /article/:title/:id

그러나 다양한 경우의 라우팅 경로에 대한 테스트 코드를 작성하면서 다음과 같은 경우에는 대응할 수 없다는 것을 깨달았습니다.

  • /article/:category/blog/:id 과 같은 형태의 동적 라우팅 변수가 연속적으로 존재하지 않는 경우
  • 주어진 라우팅 경로 중 현재 경로와 매치되는 것이 없을 경우 not found page가 반환되도록 처리


React-router의 소스코드를 뜯어보고 개선하기

이쯤에서 외부 모듈을 한번 참고해보기로 했습니다.
이미 많은 유저가 사용하고 있는 라이브러리의 소스코드는 많은 경우의 수에 대응하고 있을 것이기 때문에, 로직을 개선 하는데 좋은 힌트를 얻을 수 있을 것 같았습니다.

React-router의 소스코드를 살펴보던 중, matchPath 메서드를 참고해 createPathRegex()가 반환하는 정규표현식을 변경해 볼 수 있었습니다.


handleRenderPage()

일반 경로는 그대로 맞는 경로의 페이지를 반환하지만,
동적 라우팅 경로일 경우에는 맞는 해당 경로와 라우팅 변수가 대응되는 경우 해당 라우팅 페이지를 반환하도록 구현했습니다.

  #handleRenderPage = () => {
    const path = window.location.pathname;
    for (let route of this.#routes) {
      if (this.#checkDynamicRoutePath(route.path, path)) {
        const pathVariables = this.#getMatchedPathVariables(route.path, path);
        if (pathVariables) return route.page;
      } else {
        if (route.path === path) return route.page;
      }
    }
    return this.#routes.find((route) => route.path === "*")?.page;
  };

getPathVariables()

동적 라우팅 변수와 동적 라우팅 변수의 값을 각각 배열로 받은 뒤,
{id: 3}과 같이 key : value형태의 객체로 리턴합니다.

react-router의 useParams()처럼 간단히 구조분해할당으로 원하는 라우팅 변수의 값을 가져와 사용하도록 하기 위함이었습니다.

const {id} = getPathVariables()
console.log(id) //3
  getPathVariables = () => {
    const path = window.location.pathname;
    for (let route of this.#routes) {
      if (this.#checkDynamicRoutePath(route.path, path)) {
        const pathVariables = this.#getMatchedPathVariables(route.path, path);
        const dynamicRouteVariables = this.#getDynamicPathVariables(route.path);
        const pathParams = this.#createPathParams(dynamicRouteVariables, pathVariables);
        return pathParams;
      }
    }
    throw new Error("no matched path variables");
  };

해당 로직을 조금 더 구체적으로 살펴보면 다음과 같습니다.

동적 라우팅 경로가 있을 경우
1. 동적 라우팅 변수만 인식해 처리할 수 있도록 해당 경로에 대응하는 정규표현식 생성
2. 해당 정규표현식에 매치되는 현재 경로의 pathvariables와 미리 지정된 라우팅 경로의 변수를 각각 배열로 반환

  • :로 시작하는 변수 자리에 다른 문자열이 대응할 수 있도록 변경해 다음과 같은 형태로 정규표현식을 생성
    ex)/article/:category/12/:id => ^/article/([^\/]+)/12/([^\/]+)

  #getMatchedPathVariables = (routePath: string, path: string) => {
    const pathRegex = this.#createPathRegex(routePath);
    const pathVariables = path.match(pathRegex)?.slice(1);
    return pathVariables;
  };

  #getDynamicPathVariables = (path: string): RegExpMatchArray => {
    const dynamicRouteVarRegex = new RegExp(/(?<=:)\w+/g);
    const matchedPath = path.match(dynamicRouteVarRegex);
    if (!matchedPath) throw new Error("no matched path");
    return matchedPath;
  };

  #createPathRegex = (path: string) => {
    const paramNames = [];
    const regexpSource =
      "^" +
      path.replace(/^\/*/, "/").replace(/\/:(\w+)/g, (_, paramName) => {
        paramNames.push(paramName);
        return "/([^\\/]+)";
      });
    return new RegExp(regexpSource, "i");
  };
}

개선 결과 👏

다음과 같은 경우에 모두 대응할 수 있게 되었습니다.

  • 일반 경로: /article
  • 동적 라우팅 경로: /article/:id
  • 여러개의 동적라우팅 변수가 있는 경우:
    /article/:category/:id, /article/:category/:subject/:id
  • 동적 라우팅 변수가 비연속적으로 존재할 경우:/article-category/:title/12/:id
  • /로 경로가 시작하지 않을 경우에 대한 예외처리
  • 현재 경로에 대응하는 라우팅 경로가 없을 경우 Not found page 반환

여전히 라우터의 내부 로직에 개선할 점은 많겠지만, 적어도

  • 전과 비교해 더 많은 경우의 수에 대응할 수 있도록 꾸준히 리팩토링을 진행해 개선한 점
  • 외부 모듈을 참고해 문제를 해결해본 점
  • 더불어 같은 로직이더라도 더 좋은 코드로 개선해본 점
  • 생각보다 큰 틀에서의 처리하는 로직은 유사한 점이 많은 것을 확인했다는 점

에서 의미 있는 시간이었습니다.

profile
Why?에서 시작해 How를 찾는 과정을 좋아합니다. 그 고민과 성장의 과정을 꾸준히 기록하고자 합니다.

0개의 댓글