Created
November 3, 2023 06:26
-
-
Save emayom/cc053ed6479228dfc8a85bb32039ea62 to your computer and use it in GitHub Desktop.
[F-Lab] History API를 활용하여 SPA Router 구현
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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