Files
Genesis_GameServer/GameServer/wwwroot/js/router.js
2026-09-03 19:18:46 +09:00

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 현재 해시에 맞는 뷰를 그린다.
* @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;