312 lines
10 KiB
JavaScript
312 lines
10 KiB
JavaScript
// router.js — 해시 기반 클라이언트 라우터
|
|
// 라우트 표는 app.js 가 만들고, 각 뷰는 async (params, outlet) => cleanup 형태다.
|
|
//
|
|
// 규칙
|
|
// · 라우트는 배열 순서대로 매칭한다. 마지막에 '*' 폴백을 둔다.
|
|
// · params 는 쿼리값(#/?cat=game)과 경로 캡처(#/post/12)를 합친 객체다.
|
|
// · 이전 뷰의 cleanup 을 반드시 먼저 부르고 나서 다음 뷰를 그린다.
|
|
// · 비동기 뷰가 그리는 도중 해시가 또 바뀌면, 늦게 끝난 뷰는 화면에 손대지 않는다.
|
|
|
|
import { setBusy } from './ui.js';
|
|
|
|
/** 모듈 단위 라우터 상태 (앱당 라우터 하나) */
|
|
let routeTable = [];
|
|
let outletEl = null;
|
|
let started = false;
|
|
|
|
/** 지금 화면에 떠 있는 라우트 정보 */
|
|
let current = null;
|
|
/** 지금 화면에 떠 있는 뷰의 cleanup */
|
|
let currentCleanup = null;
|
|
/** 렌더 순번. 늦게 끝난 렌더를 버리는 데 쓴다. */
|
|
let renderToken = 0;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 해시 파싱
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** decodeURIComponent 가 깨진 이스케이프에서 던지지 않도록 감싼다. */
|
|
function safeDecode(text) {
|
|
try {
|
|
return decodeURIComponent(String(text).replace(/\+/g, ' '));
|
|
} catch {
|
|
return String(text);
|
|
}
|
|
}
|
|
|
|
/** 'a=1&b=2' → { a: '1', b: '2' } */
|
|
function parseQuery(text) {
|
|
const out = {};
|
|
if (!text) return out;
|
|
for (const chunk of String(text).split('&')) {
|
|
if (!chunk) continue;
|
|
const eq = chunk.indexOf('=');
|
|
const rawKey = eq >= 0 ? chunk.slice(0, eq) : chunk;
|
|
const rawVal = eq >= 0 ? chunk.slice(eq + 1) : '';
|
|
const key = safeDecode(rawKey);
|
|
if (!key) continue;
|
|
out[key] = safeDecode(rawVal);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 경로 문자열을 세그먼트 배열로. '/' → [], '/post/12' → ['post','12'] */
|
|
function toSegments(path) {
|
|
return String(path).split('/').filter((s) => s.length > 0);
|
|
}
|
|
|
|
/**
|
|
* location.hash 를 { path, query } 로 나눈다.
|
|
* '#/?cat=game' → { path: '/', query: { cat: 'game' } }
|
|
*/
|
|
function parseHash(raw) {
|
|
let text = typeof raw === 'string' ? raw : '';
|
|
if (text.charAt(0) === '#') text = text.slice(1);
|
|
|
|
const q = text.indexOf('?');
|
|
let path = q >= 0 ? text.slice(0, q) : text;
|
|
const queryText = q >= 0 ? text.slice(q + 1) : '';
|
|
|
|
if (!path) path = '/';
|
|
if (path.charAt(0) !== '/') path = '/' + path;
|
|
// 끝의 '/' 는 무시한다 ('#/about/' 도 '#/about' 로 취급).
|
|
if (path.length > 1 && path.charAt(path.length - 1) === '/') path = path.slice(0, -1);
|
|
|
|
return { path, query: parseQuery(queryText) };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 패턴 매칭
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** '#/post/:postNo' → { wildcard:false, segments:['post',':postNo'] } */
|
|
function compilePattern(pattern) {
|
|
const raw = String(pattern === null || pattern === undefined ? '' : pattern);
|
|
if (raw === '*') return { wildcard: true, segments: [] };
|
|
|
|
let text = raw.charAt(0) === '#' ? raw.slice(1) : raw;
|
|
const q = text.indexOf('?');
|
|
if (q >= 0) text = text.slice(0, q);
|
|
return { wildcard: false, segments: toSegments(text) };
|
|
}
|
|
|
|
/**
|
|
* 세그먼트 배열끼리 맞춰 본다.
|
|
* @returns {Object|null} 캡처값 객체 또는 null
|
|
*/
|
|
function matchSegments(patternSegments, pathSegments) {
|
|
if (patternSegments.length !== pathSegments.length) return null;
|
|
const captured = {};
|
|
for (let i = 0; i < patternSegments.length; i += 1) {
|
|
const p = patternSegments[i];
|
|
const v = pathSegments[i];
|
|
if (p.charAt(0) === ':') {
|
|
const name = p.slice(1);
|
|
if (!name) return null;
|
|
captured[name] = safeDecode(v);
|
|
} else if (p !== v) {
|
|
return null;
|
|
}
|
|
}
|
|
return captured;
|
|
}
|
|
|
|
/** 라우트 표를 순서대로 훑어 첫 일치 항목을 돌려준다. */
|
|
function matchRoute(path) {
|
|
const pathSegments = toSegments(path);
|
|
for (const entry of routeTable) {
|
|
if (entry.compiled.wildcard) return { entry, params: {} };
|
|
const captured = matchSegments(entry.compiled.segments, pathSegments);
|
|
if (captured) return { entry, params: captured };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 화면 그리기
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** 이전 뷰 정리. cleanup 이 던져도 라우팅은 계속되어야 한다. */
|
|
function runPreviousCleanup() {
|
|
const fn = currentCleanup;
|
|
currentCleanup = null;
|
|
if (typeof fn !== 'function') return;
|
|
try {
|
|
fn();
|
|
} catch (err) {
|
|
// 정리 중 오류는 다음 화면을 막을 이유가 되지 않는다.
|
|
console.error('[router] cleanup 오류', err);
|
|
}
|
|
}
|
|
|
|
/** 아무 라우트도 못 찾았을 때 (라우트 표에 '*' 가 없는 경우에만 보인다) */
|
|
function renderNotFound(outlet, path) {
|
|
outlet.innerHTML =
|
|
'<div class="empty">' +
|
|
'<div class="empty__icon" aria-hidden="true">✦</div>' +
|
|
'<p>페이지를 찾을 수 없습니다.</p>' +
|
|
'<p class="tagline">' + escapeAttr(path) + '</p>' +
|
|
'<a class="btn btn--ghost" href="#/">홈으로</a>' +
|
|
'</div>';
|
|
}
|
|
|
|
/** 뷰가 던졌을 때. 화면을 비워 두지 않고 다시 시도할 길을 준다. */
|
|
function renderViewError(outlet, err) {
|
|
const message = (err && err.message) || '알 수 없는 오류가 발생했습니다.';
|
|
outlet.innerHTML =
|
|
'<div class="empty">' +
|
|
'<div class="empty__icon" aria-hidden="true">⚠</div>' +
|
|
'<p>화면을 불러오지 못했습니다.</p>' +
|
|
'<p class="tagline">' + escapeAttr(message) + '</p>' +
|
|
'<a class="btn btn--ghost" href="#/">홈으로</a>' +
|
|
'</div>';
|
|
console.error('[router] 뷰 오류', err);
|
|
}
|
|
|
|
/** 이 모듈 안에서만 쓰는 최소 escape (ui.js 를 순환 참조하지 않기 위해 별도로 둔다) */
|
|
function escapeAttr(value) {
|
|
return String(value === null || value === undefined ? '' : value)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
/**
|
|
* 현재 해시에 맞는 뷰를 그린다.
|
|
* @param {boolean} isInitial 최초 렌더 여부 (스크롤을 건드리지 않는다)
|
|
*/
|
|
async function render(isInitial) {
|
|
if (!outletEl) return;
|
|
|
|
const token = (renderToken += 1);
|
|
const parsed = parseHash(location.hash);
|
|
const found = matchRoute(parsed.path);
|
|
|
|
// 이전 화면을 먼저 정리하고 비운다.
|
|
runPreviousCleanup();
|
|
outletEl.replaceChildren();
|
|
|
|
if (!isInitial) {
|
|
try {
|
|
window.scrollTo(0, 0);
|
|
} catch {
|
|
// 스크롤 실패는 무시한다.
|
|
}
|
|
}
|
|
|
|
if (!found) {
|
|
current = { pattern: null, path: parsed.path, params: parsed.query };
|
|
renderNotFound(outletEl, parsed.path);
|
|
return;
|
|
}
|
|
|
|
// 쿼리값 위에 경로 캡처를 덮어쓴다.
|
|
const params = Object.assign({}, parsed.query, found.params);
|
|
const routeInfo = { pattern: found.entry.pattern, path: parsed.path, params };
|
|
current = routeInfo;
|
|
|
|
if (found.entry.title) document.title = found.entry.title;
|
|
|
|
setBusy(true);
|
|
try {
|
|
const cleanup = await found.entry.view(params, outletEl);
|
|
|
|
// 그리는 사이에 해시가 또 바뀌었으면 이 결과는 버린다.
|
|
if (token !== renderToken) {
|
|
if (typeof cleanup === 'function') {
|
|
try {
|
|
cleanup();
|
|
} catch {
|
|
// 버려지는 뷰의 정리 오류는 삼킨다.
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
currentCleanup = typeof cleanup === 'function' ? cleanup : null;
|
|
|
|
window.dispatchEvent(new CustomEvent('route:change', {
|
|
detail: { pattern: routeInfo.pattern, path: routeInfo.path, params: routeInfo.params }
|
|
}));
|
|
} catch (err) {
|
|
if (token !== renderToken) return;
|
|
renderViewError(outletEl, err);
|
|
} finally {
|
|
if (token === renderToken) setBusy(false);
|
|
}
|
|
}
|
|
|
|
function onHashChange() {
|
|
render(false);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 공개 API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* 라우터를 만들고 첫 화면을 그린다.
|
|
* @param {Array<{pattern:string, view:Function, title?:string}>} routes
|
|
* @param {HTMLElement} outlet
|
|
* @returns {{destroy: Function, refresh: Function}}
|
|
*/
|
|
export function createRouter(routes, outlet) {
|
|
if (!outlet) throw new Error('createRouter: outlet 엘리먼트가 필요합니다.');
|
|
|
|
routeTable = (Array.isArray(routes) ? routes : [])
|
|
.filter((r) => r && typeof r.view === 'function')
|
|
.map((r) => ({ pattern: String(r.pattern), view: r.view, title: r.title, compiled: compilePattern(r.pattern) }));
|
|
|
|
outletEl = outlet;
|
|
|
|
if (!started) {
|
|
window.addEventListener('hashchange', onHashChange);
|
|
started = true;
|
|
}
|
|
|
|
// 해시가 비어 있으면 '#/' 로 본다 (주소창은 건드리지 않는다).
|
|
render(true);
|
|
|
|
return {
|
|
destroy() {
|
|
window.removeEventListener('hashchange', onHashChange);
|
|
started = false;
|
|
runPreviousCleanup();
|
|
routeTable = [];
|
|
outletEl = null;
|
|
current = null;
|
|
},
|
|
refresh() {
|
|
render(false);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 해시를 바꿔 화면을 이동한다. 이미 같은 해시면 강제로 다시 그린다.
|
|
* @param {string} hash '#/admin' 같은 값
|
|
*/
|
|
export function navigate(hash) {
|
|
let target = String(hash === null || hash === undefined ? '' : hash);
|
|
if (!target) target = '#/';
|
|
if (target.charAt(0) !== '#') target = '#' + (target.charAt(0) === '/' ? target : '/' + target);
|
|
|
|
if (location.hash === target || (!location.hash && target === '#/')) {
|
|
render(false);
|
|
return;
|
|
}
|
|
location.hash = target;
|
|
}
|
|
|
|
/**
|
|
* 지금 화면에 떠 있는 라우트 정보.
|
|
* @returns {{pattern:string|null, path:string, params:Object}|null}
|
|
*/
|
|
export function currentRoute() {
|
|
return current;
|
|
}
|
|
|
|
export default createRouter;
|