본문으로 건너뛰기

06 라우팅

6장의 목적은 클라이언트 측 라우팅 시스템 구축 방법을 알아본다.

라우팅 시스템의 핵심요소

  • 애플리케이션의 경로 목록을 수집하는 레지스트리. 가장 간단한 형태의 경로는 URL을 DOM 구성 요소에 매칭하는 객체다.
  • 현재 URL의 리스너(listener). URL이 변경되면 라우터는 본문(또는 메인 컨테이너)의 내용을 현재 URL과 일치하는 경로에 바인딩된 구성 요소로 교체한다.

코드 예제

프래그먼트 식별자

  • 모든 URL은 프래그먼트 식별자라고 불리는 해시(#)로 시작하는 선택적 부분을 포함할 수 있다.
  • 웹 페이지의 특정 섹션을 식별하기 위함.
  • www.domain.org/foo.html#bar에서 bar가 프래그먼트 식별자다.
  • 브라우저는 프래그먼트로 식별된 요소가 뷰포트의 맨 위에 오도록 페이지를 스크롤한다.
index.html
<a href="#/">Go To Index</a>
<a href="#/list">Go To List</a>
<a href="#/dummy">Dummy Page</a>
pages.js
export default (container) => {
const home = () => {
container.textContent = 'This is Home page';
};

const list = () => {
container.textContent = 'This is List Page';
};

const notFound = () => {
container.textContent = 'Page Not Found!';
};

return {
home,
list,
notFound,
};
};
  • Dom 요소의 컨텐츠를 업데이트 하는 일반함수
router.js
export default () => {
const routes = [];
let notFound = () => {};

const router = {};

// 라우터의 핵심 메서드
const checkRoutes = () => {
const currentRoute = routes.find((route) => {
return route.fragment === window.location.hash;
// 프래그먼트 식별자는 location 객체의 hash 속성에 저장된다.
});

if (!currentRoute) {
notFound();
return;
}

currentRoute.component();
};

router.addRoute = (fragment, component) => {
routes.push({
fragment,
component,
});

return router;
};

router.setNotFound = (cb) => {
notFound = cb;
return router;
};

router.start = () => {
window.addEventListener('hashchange', checkRoutes);
// 현재 프래그먼트가 변경될 때마다 알림을 받는데 사용할 수 있는 hashchange 이벤트가 있다.
if (!window.location.hash) {
window.location.hash = '#/';
}

checkRoutes();
};

return router;
};
  • 라우터는 세 가지 공개 메서드를 가진다.
    • addRoute 메서드는 새 라우터와 프래그먼트로 구성된 구성 객체, component를 정의한다.
    • setNotFound 메서드는 레지스트리에 없는 모든 프래그먼트에 대한 component를 설정한다.
    • start 메서드는 라우터를 초기화하고 URL 변경을 listen한다.
  • checkRoutes 메서드: 이 메서드는 현재 프래그먼트와 일치하는 경로를 찾는다. 경로가 발견되면 해당 component 함수가 메인 컨테이너에 있는 contents를 대체한다. 발견되지 않으면 일반 notFound 함수가 호출된다. 메서드는 라우터가 시작될 때와 haschange 이벤트가 발생할 때마다 호출된다.
index.js
import createRouter from './router.js';
import createPages from './pages.js';

const container = document.querySelector('main');

const pages = createPages(container);

const router = createRouter();

router
.addRoute('#/', pages.home)
.addRoute('#/list', pages.list)
.setNotFound(pages.notFound)
.start();
  • index.js에서 pages 함수를 다음처럼 사용.
  • 구성 요소를 올바른 프래그먼트에 연결
  • 이 방식은 앵커를 클릭하는 고전적인 방식으로 탐색이 되어있다.

프로그래밍 방식으로 탐색

  • 로그인에 성공한 사용자를 개인 페이지로 이동시키는 등 리디렉션이 필요한 프로그래밍 방식이 필요할 수도 있다.
index.html
<!-- <a href="#/">Go To Index</a> -->
<!-- <a href="#/list">Go To List</a> -->
<!-- <a href="#/dummy">Dummy Page</a> -->

<button data-navigate="/">Go To Index</button>
<button data-navigate="/list">Go To List</button>
<button data-navigate="/dummy">Dummy Page</button>
router.js
export default () => {
const routes = [];
let notFound = () => {};

const router = {};

const checkRoutes = () => {
const currentRoute = routes.find((route) => {
return route.fragment === window.location.hash;
});

if (!currentRoute) {
notFound();
return;
}

currentRoute.component();
};

router.addRoute = (fragment, component) => {
routes.push({
fragment,
component,
});

return router;
};

router.setNotFound = (cb) => {
notFound = cb;
return router;
};

router.navigate = (fragment) => {
window.location.hash = fragment;
};

router.start = () => {
window.addEventListener('hashchange', checkRoutes);
if (!window.location.hash) {
window.location.hash = '#/';
}

checkRoutes();
};

return router;
};
index.js
import createRouter from './router.js';
import createPages from './pages.js';

const container = document.querySelector('main');

const pages = createPages(container);

const router = createRouter();

router
.addRoute('#/', pages.home)
.addRoute('#/list', pages.list)
.setNotFound(pages.notFound)
.start();

const NAV_BTN_SELECTOR = 'button[data-navigate]';

document.body.addEventListener('click', (e) => {
const { target } = e;
if (target.matches(NAV_BTN_SELECTOR)) {
const { navigate } = target.dataset;
router.navigate(navigate);
}
});
  • 프로그래밍 방식으로 다른 뷰로 이동하도록 라우터에 새로운 공개메서드public method를 생성했다.
  • 이 메서드는 새 프래그먼트를 가져와 location 객체에서 대체한다.

경로 매개변수

  • 일반적으로 URL에 매개변수가 포함돼 있다.
  • 인수를 허용하도록 구성 요소를 일부 수정한다.
index.html
<button data-navigate="/">Go To Index</button>
<button data-navigate="/list">Go To List</button>
<button data-navigate="/list/1">Go To Detail With Id 1</button>
<button data-navigate="/list/2">Go To Detail With Id 2</button>
<button data-navigate="/list/1/2">Go To Another Detail</button>
<button data-navigate="/dummy">Dummy Page</button>
pages.js
export default (container) => {
const detail = (params) => {
const { id } = params;
container.textContent = `This is Detail Page with Id ${id}`;
};

const anotherDetail = (params) => {
const { id, anotherId } = params;
container.textContent = `
This is Detail Page with Id ${id}
and AnotherId ${anotherId}
`;
};

// ...

return {
home,
list,
detail,
anotherDetail,
notFound,
};
};
index.js
router
.addRoute('#/', pages.home)
.addRoute('#/list', pages.list)
.addRoute('#/list/:id', pages.detail)
.addRoute('#/list/:id/:anotherId', pages.anotherDetail)
.setNotFound(pages.notFound)
.start();
  • 경로 매개변수 관리를 위해 라우터 구현을 정규 표현식을 기반으로 추가한다.
router.js
const ROUTE_PARAMETER_REGEXP = /:(\w+)/g;
const URL_FRAGMENT_REGEXP = '([^\\/]+)';

const extractUrlParams = (route, windowHash) => {
const params = {};

if (route.params.length === 0) {
return params;
}

const matches = windowHash.match(route.testRegExp);

matches.shift();

matches.forEach((paramValue, index) => {
const paramName = route.params[index];
params[paramName] = paramValue;
});

return params;
};

export default () => {
const routes = [];
let notFound = () => {};

const router = {};

const checkRoutes = () => {
const { hash } = window.location;

const currentRoute = routes.find((route) => {
const { testRegExp } = route;
return testRegExp.test(hash);
});

if (!currentRoute) {
notFound();
return;
}

const urlParams = extractUrlParams(currentRoute, window.location.hash);

currentRoute.component(urlParams);
};

router.addRoute = (fragment, component) => {
const params = [];

const parsedFragment = fragment
.replace(ROUTE_PARAMETER_REGEXP, (match, paramName) => {
// 이 정규식은 `replace` 메서드와 함께 사용한다. 정규식이 대상과 매칭될 때마다 호출된다.
params.push(paramName);
return URL_FRAGMENT_REGEXP;
// '([^\\/]+)' 으로 매칭이 바뀐다.
})
.replace(/\//g, '\\/');

console.log(`^${parsedFragment}$`);

routes.push({
testRegExp: new RegExp(`^${parsedFragment}$`),
component,
params,
});

return router;
};

return router;
};

정규식

  • 프래그먼트에서 매개변수를 추출하려면 정규식 /:(\w+)/g를 사용한다.

  • 이 정규식은 :id, :anotherId와 매칭된다.

  • :(\w+)

    • :은 정확하게 한 문자와 매칭한다.
    • ()는 캡처 그룹의 시작을 나타낸다.
    • \w[a-zA-Z0-9_]의 단축키로 모든 표준 문자와 매칭한다.
    • +는 하나 이상의 표준 문자를 허용한다.
  • #/list/:id/:anotherId 프래그먼트를 addRoute 메서드에 인수로 전달하면 testRegExp 값이 ^#\/list\/([^\\/\+)\/([^\\/]+)$가 되고, 이 경로가 location 객체의 현재 프래그먼트와 매칭되는지 확인하는 데 사용.

  • ^#\/list\/([^\\/\+)\/([^\\/]+)$

    • ^은 문자열의 시작
    • #\/list\/는 정확한 문자열과 매칭
    • ()는 첫 번째 캡쳐 그룹의 시작
    • [^\\/]는 / 나 \를 제외한 모든 문자와 매칭
    • +는 하나 이상의 이전 매칭 항목을 수락
    • ()는 두 번째 캡쳐 그룹의 시작
    • [^\\/]는 / 나 \를 제외한 모든 문자와 매칭
    • +는 하나 이상의 이전 매칭 항목을 수락
    • $는 문자열의 끝을 나타냄
  • 프래그먼트를 파싱했으므로 생성된 정규 표현식을 사용해 현재 프래그먼트의 올바른 경로를 선택하고 실제 매개변수를 추출.

testRegExp

  • testRegExp를 사용해 현재 프래그먼트가 레지스트리의 경로 중 하나와 매칭되는지 확인한 후 동일한 정규식을 사용해 component 함수의 인수로 params를 추출.

extractUrlParams

  • extractUrlParams에서 String.matches 메서드는 일치하는 전체 문자열을 첫 번째 요소로 포함하는 Array를 반환한 다음 괄호 안에 캡처된 결과가 온다.
  • 일치하는 것이 없으면 null 반환.
  • shift를 사용해 배열에서 첫 번째 요소를 삭제.

요약

  1. #/list/:id/:anotherId 프래그먼트가 addRoute 메서드로 전달된다.
  2. addRoute 메서드는 두 개의 매개변수 이름(id와 anotherId)을 추출하고 정규식 ^\/list\/([^\\/\+)\/([^\\/\+)$에서 프래그먼트를 변환한다.
  3. 사용자가 #/list/1/2 같은 프래그먼트를 탐색할 때 checkRoutes 메서드는 정규식을 사용해 올바른 경로를 선택한다.
  4. extractUrlParams 메서드는 이 객체의 현재 프래그먼트에서 실제 매개변수를 추출한다. { id: 1, anotherId: 2 }
  5. 객체가 DOM을 업데이트하는 compenent로 전달된다.

History API

  • History API를 통해 개발자는 사용자 탐색 히스토리를 조작할 수 있다.
  • 라우팅을 위해 History API를 사용하는 경우 프래그먼트 식별자를 기반으로 경로를 지정할 필요가 없다.
  • 실제 URL을 활용하자.
methodDescription
back()history에서 이전 페이지로 이동한다.
forward()history에서 다음 페이지로 이동한다.
go(index)history에서 특정 페이지로 이동한다.
pushState(state, title, URL)history stack의 데이터를 푸시하고 제공된 URL로 이동한다.
relaceState(state, title, URL)history stack에서 가장 최근 데이터를 바꾸고 제공된 URL로 이동한다.
router.js
router.navigate = (path) => {
window.history.pushState(null, null, path);
};

router.start = () => {
checkRoutes();
window.setInterval(checkRoutes, TICKTIME);
};
index.js
router
.addRoute('/', pages.home)
.addRoute('/list', pages.list)
.addRoute('/list/:id', pages.detail)
.addRoute('/list/:id/:anotherId', pages.anotherDetail)
.setNotFound(pages.notFound)
.start();
  • pushState 메서드는 새 URL로 이동하는 역할을 한다.
  • 이전 방법과 가장 큰 차이점은 URL이 변경될 때 알림을 받을 수 있는 DOM 이벤트가 없다는 것이다.
  • 비슷한 결과를 얻고자 setInterval을 사용해 경로가 변경되었는지 정기적으로 확인한다.
  • path에서 해시를 제거한다.

링크 사용

History API로 전환하려면 템플릿에 있는 링크를 업데이트해야 한다.

index.html
<header>
<a data-navigation href="/">Go To Index</a>
<a data-navigation href="/list">Go To List</a>
<a data-navigation href="/list/1">Go To Detail With Id 1</a>
<a data-navigation href="/list/2">Go To Detail With Id 2</a>
<a data-navigation href="/list/1/2">Go To Another Detail</a>
<a data-navigation href="/dummy">Dummy Page</a>
</header>
  • 라우터 navigate method를 사용한다.
router.js
const NAV_A_SELECTOR = 'a[data-navigation]';
router.start = () => {
checkRoutes();
window.setInterval(checkRoutes, TICKTIME);
document.body.addEventListener('click', (e) => {
if (target.matches(NAV_A_SELECTOR)) {
e.preventDefault();
router.navigate(target.href);
}
});
return router;
};
  • Event 객체의 preventDefault 메서드를 사용해 모든 DOM 요소의 표준 핸들러를 비활성화할 수 있다.