// 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 = '
'; } /** 뷰가 던졌을 때. 화면을 비워 두지 않고 다시 시도할 길을 준다. */ function renderViewError(outlet, err) { const message = (err && err.message) || '알 수 없는 오류가 발생했습니다.'; outlet.innerHTML = ''; 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, '''); } /** * 현재 해시에 맞는 뷰를 그린다. * @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;