Skip to content

Instantly share code, notes, and snippets.

@emayom
Created November 3, 2023 06:26
Show Gist options
  • Save emayom/cc053ed6479228dfc8a85bb32039ea62 to your computer and use it in GitHub Desktop.
Save emayom/cc053ed6479228dfc8a85bb32039ea62 to your computer and use it in GitHub Desktop.
[F-Lab] History API를 활용하여 SPA Router 구현
export default (function(){
let instance = null;
/**
* Create a new Router.
* @class
* @param {HTMLElement} root
* @param {object} [options]
*/
function Router(root, options){
if(!(root instanceof HTMLElement)){
throw new Error(`arguments[0] requires a HTMLElement but got a ${toString.call(root)}`);
}
this.root = root;
this.routes = new Map();
this.base = options.base || '/';
}
/**
* @function
* @memberof Router
* @return {Router}
*/
Router.prototype.initialize = function(){
/** @type {number} */
this.intervalId = setInterval((()=> this.route(window.location.pathname)).bind(this), 200);
return this;
}
/**
* @function
* @memberof Router
* @param {string} path - request pathname
* @param {boolean} replace
* @void
*/
Router.prototype.setCurrentPath = function(path, replace){
// 현재와 같은 경로를 요청할 경우 경로를 재설정하지 않는다.
if(this.current === path) return;
const state = '';
(replace === true)
? window.history.replaceState(state, '', path)
: window.history.pushState(state, '', path);
}
/**
* 라우터에 저장 할 path를 정규표현식으로 컴파일하여 리턴한다.
* @param {string} path
* @returns {RegExp} matcher
* @example
* // returns /^\/posts\/([^\/]+)$/
* compilePath('/posts/:id');
*/
Router.prototype.pathToRegexp = function(path){
// :param 같은 path parameter 정보를 매칭하기 위한 정규 표현식
const PARAMETER_REGEXP = /:(\w+)/g;
// 매칭 된 path parameter를 문자열과 매칭될 수 있도록 대체하기 위한 정규 표현식
const URL_REGEXP = '([^\\/]+)';
return new RegExp(`^${path.replace(PARAMETER_REGEXP, URL_REGEXP)}$`);
}
/**
* 라우터에 저장 할 path parameters 정보를 배열로 리턴한다.
* @param {string} path
* @returns {array} params
* @example
* // returns [ 'getting-started', 'install' ]
* getUrlParams('/docs/getting-started/install');
*/
Router.prototype.getParameters = function(path){
// /post/:id 에서 /post와 같은 베이스 네임을 매칭하기 위한 정규 표현식
const BASENAME_REGEXP = /\/(\w+(-*\w*)*)/;
return path.replace(BASENAME_REGEXP, '').match(/(\w+(-*\w*)*)/g);
}
/**
* 요청한 url의 path parameters 정보와 라우터의 path parameters 정보를 매핑한다.
* @param {string} path
* @param {array} params
* @returns
*/
Router.prototype.setParameters = function(path, params){
// /post/:id 에서 /post와 같은 베이스 네임을 매칭하기 위한 정규 표현식
const BASENAME_REGEXP = /\/(\w+(-*\w*)*)/;
return path.replace(BASENAME_REGEXP, '')
.match(/(\w+(-*\w*)*)/g)
.reduce((acc, currentVal, currentIndex) => {
return { ...acc, [params[currentIndex]]: currentVal }
}, {});
}
/**
* @function
* @memberof Router
* @param {string} path
* @param {function} callback
* @void
*/
Router.prototype.addRoute = function(path, callback){
if(typeof callback !== 'function'){
throw new Error(`Router.addRoute() requires a callback function but got a ${toString.call(callback)}`);
}
this.routes.set( this.pathToRegexp(path), {
params: this.getParameters(path),
callback: callback
});
}
/**
* @function
* @memberof Router
* @void
*/
Router.prototype.route = function(path){
// 같은 경로를 요청할 경우 경로를 재설정하지 않는다.
if(this.current === path) return;
this.current = path;
let match = this.match(path);
if(match.callback){
match.callback({
root: this.root,
params: match.params
});
}else {
if(this.errorCallback){
this.errorCallback();
}else {
// 메인으로 이동
this.setCurrentPath('/', true);
}
}
}
/**
* @function
* @memberof Router
* @return {object}
*/
Router.prototype.match = function(path){
let match;
let routes = this.routes;
if(path != null){
match = routes.get(
[ ...routes.keys() ].find( regexp => regexp.test(path))
) || {};
if(match.params){
match.params = this.setParameters(path, match.params);
}
return match;
}
}
/**
* @function
* @memberof Router
* @param {function} callback
* @void
*/
Router.prototype.onRouteError = function(callback){
this.errorCallback = callback;
}
return {
/**
* Get the Router instance.
* @param {HTMLElement} root
* @param {object} options
* @returns {Router}
*
* @example
* ```js
* const htmlEl = document.getElementById('id');
* const router = createHashRouter(htmlEl, {
* base: '/'
* })
* ```
*/
createHistoryRouter: function(root, options){
if(!instance){
instance = new Router(root, options).initialize();
/**
* Listen to click event
* @type {window} - The target of the event.
* @listens document:click
*
* <a>아닌 element의 clickEvent로 라우팅이 실행되어야 할 경우
* 1. event.target -> dataset에 대상 attriubute 값이 존재하는지 확인 -> stopPropagation
* 2. 이벤트 실행 흐름 상에 존재하는 지 확인 -> stopPropagation
*/
document.addEventListener('click', (event)=>{
const dataset = event.target.dataset;
const path = dataset?.resourcePath
? dataset?.resourcePath
: event.composedPath().find(node => node.dataset?.resourcePath)?.dataset.resourcePath;
if(path){
instance.setCurrentPath(path);
event.preventDefault();
event.stopPropagation();
}
}, true);
/**
* Listen to popstate event.
* @type {window} - The target of the event.
* @listens window:popstate
*/
window.addEventListener('popstate', (event)=> instance.route(window.location.pathname));
}
return instance;
}
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment