vanilla js로 라우팅 기능을 구현하면서 로직에 대해 고민했던 내용을 정리합니다.
라우터의 메서드 중 renderPage는 현재 경로에 대응하는 페이지를 루트 요소에 삽입해 렌더링하는 메서드입니다.
처음 구현했던 코드는 다음과 같았습니다.
renderPage() {
const [_, pathname, id] = window.location.pathname.split("/");
const page = this.#routes[`/${pathname}`];
this.#$app.innerHTML = "";
page.attachTo(this.#$app);
}
window.location.pathname을 split한 결과값에 맞게 동적 라우팅 변수인 id를 반환받고 있는데, 이럴 경우 라우팅 변수가 많아지거나 갯수는 동일하고 그 이름이 달라질 경우 의도대로 동작할 수 없습니다.
그 때도 그랬지만 지금 보니 더 부끄럽...네요.
진행하던 프로젝트는 /article/list
, article/:id
두 개의 라우팅 경로만을 갖고 있었습니다.
그 중 /article/list
는 앱이 켜졌을 때 가장 먼저 뜨는 메인 페이지 역할을 했기 때문에 article/:id
라우팅 경로 형태에만 대응해 구현한 코드였습니다.
한번 기능을 구현한 다음에는 리팩토링을 통해 가능한 많은 종류의 pathname 형태에 대응할 수 있도록 개선하고자 했습니다.
위 문제를 해결하기 위해 정규표현식을 사용했습니다.
동적 라우팅 변수는 :
로 시작하는 변수로 지정하도록 하고, 이 :
로 시작하는 문자열에 대해서는 라우팅 변수로 취급할 수 있도록 정규표현식을 생성하는 함수를 작성했습니다.
#createPathRegex = (path) => new RegExp("^" + path
.replace(/\//g, "\\/")
.replace(/:\w+/g, "(.+)") + "$");
이 함수를 사용해서 다음과 같은 라우팅 경로에 대응하는 것은 성공했습니다:
/article/:id
,article/:title
/article/:title/:id
그러나 다양한 경우의 라우팅 경로에 대한 테스트 코드를 작성하면서 다음과 같은 경우에는 대응할 수 없다는 것을 깨달았습니다.
/article/:category/blog/:id
과 같은 형태의 동적 라우팅 변수가 연속적으로 존재하지 않는 경우이쯤에서 외부 모듈을 한번 참고해보기로 했습니다.
이미 많은 유저가 사용하고 있는 라이브러리의 소스코드는 많은 경우의 수에 대응하고 있을 것이기 때문에, 로직을 개선 하는데 좋은 힌트를 얻을 수 있을 것 같았습니다.
React-router의 소스코드를 살펴보던 중, matchPath 메서드를 참고해 createPathRegex()가 반환하는 정규표현식을 변경해 볼 수 있었습니다.
일반 경로는 그대로 맞는 경로의 페이지를 반환하지만,
동적 라우팅 경로일 경우에는 맞는 해당 경로와 라우팅 변수가 대응되는 경우 해당 라우팅 페이지를 반환하도록 구현했습니다.
#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;
};
동적 라우팅 변수와 동적 라우팅 변수의 값을 각각 배열로 받은 뒤,
{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와 미리 지정된 라우팅 경로의 변수를 각각 배열로 반환
:
로 시작하는 변수 자리에 다른 문자열이 대응할 수 있도록 변경해 다음과 같은 형태로 정규표현식을 생성/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
/
로 경로가 시작하지 않을 경우에 대한 예외처리여전히 라우터의 내부 로직에 개선할 점은 많겠지만, 적어도
에서 의미 있는 시간이었습니다.