UNPKG

@exlinep/router

Version:
630 lines (573 loc) 23.3 kB
import { Page } from './Page'; import { History, UpdateEventType } from './History'; import { MODAL_KEY, POPUP_KEY, Route as MyRoute } from './Route'; import { isDesktopSafari, preventBlinkingBySettingScrollRestoration } from '../tools'; import { State, stateFromLocation } from './State'; import { EventEmitter } from 'tsee'; import { PAGE_MAIN, PANEL_MAIN, ROOT_MAIN, VIEW_MAIN } from '../const'; import { RouterConfig } from './RouterConfig'; import { Location } from './Location'; import { HistoryUpdateType, PageParams } from './Types'; import { Fixer, USE_ALL_FIXES, USE_DESKTOP_SAFARI_BACK_BUG } from './HotFixers'; export declare type RouteList = { [key: string]: Page }; export declare type ReplaceUnknownRouteFn = (newRoute: MyRoute, oldRoute?: MyRoute) => MyRoute; /** * @ignore */ export declare type UpdateEventFn = (newRoute: MyRoute, oldRoute: MyRoute | undefined, isNewRoute: boolean, type: HistoryUpdateType) => void; /** * @ignore */ export declare type EnterEventFn = (newRoute: MyRoute, oldRoute?: MyRoute) => void; /** * @ignore */ export declare type LeaveEventFn = (newRoute: MyRoute, oldRoute: MyRoute, isNewRoute: boolean, type: HistoryUpdateType) => void; export declare type RouterMiddleware = (route: MyRoute, hash: string) => MyRoute; export class Router extends EventEmitter<{ update: UpdateEventFn; enter: EnterEventFn; }> { routes: RouteList = {}; history: History; enableLogging = false; defaultPage: string = PAGE_MAIN; defaultView: string = VIEW_MAIN; defaultRoot: string = ROOT_MAIN; defaultPanel: string = PANEL_MAIN; alwaysStartWithSlash = true; blankMiddleware: RouterMiddleware[] = []; preventSameLocationChange = false; hotFixes: Set<Fixer>; /** * Значение window.location.hash которое было на момент старта роутера */ startHash = ''; private deferOnGoBack: (() => void) | null = null; private startHistoryOffset = 0; private started = false; private readonly infinityPanelCacheInstance: Map<string, string[]> = new Map<string, string[]>(); private readonly performBlankMiddleware = (route: MyRoute, hash: string) => { return this.blankMiddleware.reduce((route, middleware) => { return middleware(route, hash); }, route); }; /** * * ```javascript * export const PAGE_MAIN = '/'; * export const PAGE_PERSIK = '/persik'; * export const PANEL_MAIN = 'panel_main'; * export const PANEL_PERSIK = 'panel_persik'; * export const VIEW_MAIN = 'view_main'; * const routes = { * [PAGE_MAIN]: new Page(PANEL_MAIN, VIEW_MAIN), * [PAGE_PERSIK]: new Page(PANEL_PERSIK, VIEW_MAIN), * }; * export const router = new Router(routes); * router.start(); * ``` * @param routes * @param routerConfig */ constructor(routes: RouteList, routerConfig: RouterConfig | null = null) { super(); this.routes = routes; this.history = new History(); this.hotFixes = new Set<Fixer>(); if (routerConfig) { if (routerConfig.enableLogging !== undefined) { this.enableLogging = routerConfig.enableLogging; } if (routerConfig.defaultPage !== undefined) { this.defaultPage = routerConfig.defaultPage; } if (routerConfig.defaultView !== undefined) { this.defaultView = routerConfig.defaultView; } if (routerConfig.defaultPanel !== undefined) { this.defaultPanel = routerConfig.defaultPanel; } if (routerConfig.noSlash !== undefined) { this.alwaysStartWithSlash = routerConfig.noSlash; } if (routerConfig.blankMiddleware !== undefined) { this.blankMiddleware = routerConfig.blankMiddleware; } if (routerConfig.preventSameLocationChange !== undefined) { this.preventSameLocationChange = routerConfig.preventSameLocationChange; } if (routerConfig.hotFixes) { routerConfig.hotFixes.forEach((f) => this.hotFixes.add(f)); } } } private static back() { window.history.back(); } private static backTo(x: number) { window.history.go(x); } replacerUnknownRoute: ReplaceUnknownRouteFn = (r) => r; start() { if (this.started) { throw new Error('start method call twice! this is not allowed'); } this.started = true; this.startHash = window.location.hash; let enterEvent: [MyRoute, MyRoute | undefined] | null = null; this.startHistoryOffset = window.history.length; let nextRoute = this.createRouteFromLocationWithReplace(window.location.hash); const state = stateFromLocation(this.history.getCurrentIndex()); state.first = 1; if (state.blank === 1) { nextRoute = this.performBlankMiddleware(nextRoute, window.location.hash); enterEvent = [nextRoute, this.history.getCurrentRoute()]; state.history = [nextRoute.getPanelId()]; } this.replace(state, nextRoute); if (this.hasFixer(USE_DESKTOP_SAFARI_BACK_BUG) && isDesktopSafari()) { window.history.pushState( { ...state, 'USE_DESKTOP_SAFARI_BACK_BUG': '1' }, `page=${state.index}`, `#${nextRoute.getLocation()}`, ); } window.removeEventListener('popstate', this.onPopState); window.addEventListener('popstate', this.onPopState); if (enterEvent) { this.emit('enter', ...enterEvent); } } stop() { this.started = false; window.removeEventListener('popstate', this.onPopState); } getCurrentRouteOrDef(): MyRoute { const r = this.history.getCurrentRoute(); if (r) { return r; } return this.createRouteFromLocation(this.defaultPage); } getCurrentStateOrDef(): State { const s = this.history.getCurrentState(); if (s) { return { ...s }; } return stateFromLocation(this.history.getCurrentIndex()); } log(...args: any) { if (!this.enableLogging) { return; } console.log.apply(this, args); } /** * Добавляет новую страницу в историю * @param pageId страница указанная в конструкторе {@link Router.constructor} * @param params можно получить из {@link Location.getParams} */ pushPage(pageId: string, params: PageParams = {}) { this.log(`pushPage ${pageId}`, params); Router.checkParams(params); let currentRoute = this.getCurrentRouteOrDef(); let nextRoute = MyRoute.fromPageId(this.routes, pageId, params); const s = { ...this.getCurrentStateOrDef() }; if (currentRoute.getViewId() === nextRoute.getViewId()) { s.history = s.history.concat([nextRoute.getPanelId()]); } else { s.history = [nextRoute.getPanelId()]; } this.push(s, nextRoute); } /** * Заменяет текущую страницу на переданную * @param pageId страница указанная в конструкторе {@link Router.constructor} * @param params можно получить из {@link Location.getParams} */ replacePage(pageId: string, params: PageParams = {}) { this.log(`replacePage ${pageId}`, params); let currentRoute = this.getCurrentRouteOrDef(); let nextRoute = MyRoute.fromPageId(this.routes, pageId, params); const s = { ...this.getCurrentStateOrDef() }; if (currentRoute.getViewId() === nextRoute.getViewId()) { s.history = s.history.concat([]); s.history.pop(); s.history.push(nextRoute.getPanelId()); } else { s.history = [nextRoute.getPanelId()]; } this.replace(s, nextRoute); } pushPageAfterPreviews(prevPageId: string, pageId: string, params: PageParams = {}) { this.log('pushPageAfterPreviews', [prevPageId, pageId, params]); const offset = this.history.getPageOffset(prevPageId); if (this.history.canJumpIntoOffset(offset)) { return this.popPageToAndPush(offset, pageId, params); } else { return this.popPageToAndPush(0, pageId, params); } } /** * Переход по истории назад */ popPage() { this.log('popPage'); Router.back(); } /** * Если x - число, то осуществляется переход на указанное количество шагов * Если x - строка, то в истории будет найдена страница с указанным pageId и осуществлен переход до нее * @param {string|number} x */ popPageTo(x: number | string) { this.log('popPageTo', x); if (typeof x === 'number') { Router.backTo(x); } else { const offset = this.history.getPageOffset(x); if (this.history.canJumpIntoOffset(offset)) { Router.backTo(offset); } else { throw new Error(`Unexpected offset ${offset} then try jump to page ${x}`); } } } popPageToAndPush(x: number, pageId: string, params: PageParams = {}) { this.log('popPageToAndPush', x, pageId, params); if (x !== 0) { this.deferOnGoBack = () => { this.pushPage(pageId, params); }; Router.backTo(x); } else { this.pushPage(pageId, params); } } popPageToAndReplace(x: number, pageId: string, params: PageParams = {}) { this.log('popPageToAndReplace', x, pageId, params); if (x !== 0) { this.deferOnGoBack = () => { this.replacePage(pageId, params); }; Router.backTo(x); } else { this.replacePage(pageId, params); } } /** * История ломается когда открывается VKPay или пост из колокольчика */ isHistoryBroken(): boolean { return window.history.length !== this.history.getLength() + this.startHistoryOffset; } /** * Способ починить историю браузера когда ее сломали снаружи из фрейма * например перейдя по колокольчику или открыв вкпей * проблема: миниап запущен фо фрейме и у него обшая исторя страниц с родительской страницей * все происходит хорошо когда только миниап пушит в историю страницы * [X1, X2, X3] * когда приходит родительская страница и пуши что-то в историю * [X1, X2, X3, Y1, Y1] * то случается беда, в истории перремешаны страницы, следующий popPage приведет в неожиданное место * в даннмо слусе ожидалось что popPage перейдет с X3 на X2, но фактически придут на Y1 * идея решения -- запушить снова все "нужные страницы поверх истории" * [X1, X2, X3, Y1, Y1, X1, X2, X3] */ fixBrokenHistory() { this.history.getHistoryFromStartToCurrent().forEach(([r, s]) => { window.history.pushState(s, `page=${s.index}`, `#${r.getLocation()}`); }); this.startHistoryOffset = window.history.length - this.history.getLength(); } /** * @param modalId * @param params Будьте аккуратны с параметрами, не допускайте чтобы ваши параметры пересекались с параметрами страницы */ pushModal(modalId: string, params: PageParams = {}) { Router.checkParams(params); this.log(`pushModal ${modalId}`, params); let currentRoute = this.getCurrentRouteOrDef(); const nextRoute = currentRoute.clone().setModalId(modalId).setParams(params); this.push(this.getCurrentStateOrDef(), nextRoute); } /** * @param popupId * @param params Будьте аккуратны с параметрами, не допускайте чтобы ваши параметры пересекались с параметрами страницы */ pushPopup(popupId: string, params: PageParams = {}) { Router.checkParams(params); this.log(`pushPopup ${popupId}`, params); let currentRoute = this.getCurrentRouteOrDef(); const nextRoute = currentRoute.clone().setPopupId(popupId).setParams(params); this.push(this.getCurrentStateOrDef(), nextRoute); } /** * @param modalId * @param params Будьте аккуратны с параметрами, не допускайте чтобы ваши параметры пересекались с параметрами страницы */ replaceModal(modalId: string, params: PageParams = {}) { this.log(`replaceModal ${modalId}`, params); let currentRoute = this.getCurrentRouteOrDef(); const nextRoute = currentRoute.clone().setModalId(modalId).setParams(params); this.replace(this.getCurrentStateOrDef(), nextRoute); } /** * @param popupId * @param params Будьте аккуратны с параметрами, не допускайте чтобы ваши параметры пересекались с параметрами страницы */ replacePopup(popupId: string, params: PageParams = {}) { this.log(`replacePopup ${popupId}`, params); let currentRoute = this.getCurrentRouteOrDef(); const nextRoute = currentRoute.clone().setPopupId(popupId).setParams(params); this.replace(this.getCurrentStateOrDef(), nextRoute); } popPageIfModal() { let currentRoute = this.getCurrentRouteOrDef(); if (currentRoute.isModal()) { this.log('popPageIfModal'); Router.back(); } } popPageIfPopup() { let currentRoute = this.getCurrentRouteOrDef(); if (currentRoute.isPopup()) { this.log('popPageIfPopup'); Router.back(); } } /** * @deprecated use popPageIfHasOverlay */ popPageIfModalOrPopup() { let currentRoute = this.getCurrentRouteOrDef(); if (currentRoute.isPopup() || currentRoute.isModal()) { this.log('popPageIfModalOrPopup'); Router.back(); } } popPageIfHasOverlay() { let currentRoute = this.getCurrentRouteOrDef(); if (currentRoute.hasOverlay()) { this.log('popPageIfHasOverlay'); Router.back(); } } /** * @param pageId * @param fn * @return unsubscribe function */ onEnterPage(pageId: string, fn: UpdateEventFn): () => void { const _fn = (newRoute: MyRoute, oldRoute: MyRoute | undefined, isNewRoute: boolean, type: HistoryUpdateType) => { if (newRoute.pageId === pageId) { if (!newRoute.hasOverlay()) { fn(newRoute, oldRoute, isNewRoute, type); } } }; this.on('update', _fn); return () => { this.off('update', _fn); }; } /** * @param pageId * @param fn * @return unsubscribe function */ onLeavePage(pageId: string, fn: LeaveEventFn): () => void { const _fn = (newRoute: MyRoute, oldRoute: MyRoute | undefined, isNewRoute: boolean, type: HistoryUpdateType) => { if (oldRoute && oldRoute.pageId === pageId) { if (!oldRoute.hasOverlay()) { fn(newRoute, oldRoute, isNewRoute, type); } } }; this.on('update', _fn); return () => { this.off('update', _fn); }; } getCurrentLocation(): Location { return new Location(this.getCurrentRouteOrDef(), this.getCurrentStateOrDef()); } getPreviousLocation(): Location | undefined { const history = this.history.getHistoryItem(-1); if (history) { const [route, state] = history; return new Location(route, { ...state }); } return undefined; } /** * @param safety - true будет ждать события не дольше 700мс, если вы уверены что надо ждать дольше передайте false */ afterUpdate(safety = true): Promise<void> { return new Promise((resolve) => { let t = 0; const fn = () => { clearTimeout(t); this.off('update', fn); resolve(); }; this.on('update', fn); if (safety) { // На случай когда метод ошибочно используется не после popPage // чтобы не завис навечно t = setTimeout(fn, 700) as any as number; } }); } private static checkParams(params: PageParams) { if (params.hasOwnProperty(POPUP_KEY)) { if (Router.isErrorThrowingEnabled()) { throw new Error(`pushPage with key [${POPUP_KEY}]:${params[POPUP_KEY]} is not allowed use another key`); } } if (params.hasOwnProperty(MODAL_KEY)) { if (Router.isErrorThrowingEnabled()) { throw new Error(`pushPage with key [${MODAL_KEY}]:${params[MODAL_KEY]} is not allowed use another key`); } } } private getDefaultRoute(location: string, params: PageParams) { try { return MyRoute.fromLocation(this.routes, '/', this.alwaysStartWithSlash); } catch (e) { if (e && e.message === 'ROUTE_NOT_FOUND') { return new MyRoute( new Page(this.defaultPanel, this.defaultView, this.defaultRoot), this.defaultPage, params, ); } throw e; } } private readonly onPopState = () => { let nextRoute = this.createRouteFromLocationWithReplace(window.location.hash); const state = stateFromLocation(this.history.getCurrentIndex()); let enterEvent: [MyRoute, MyRoute | undefined] | null = null; let updateEvent: UpdateEventType | null = null; if (state.blank === 1) { // Пустое состояние бывает когда приложение восстанавливают из кеша с другим хешом // такое состояние помечаем как первая страница nextRoute = this.performBlankMiddleware(nextRoute, window.location.hash); state.first = 1; state.index = this.history.getCurrentIndex(); state.history = [nextRoute.getPanelId()]; enterEvent = [nextRoute, this.history.getCurrentRoute()]; updateEvent = this.history.push(nextRoute, state); window.history.replaceState(state, `page=${state.index}`, `#${nextRoute.getLocation()}`); } else { updateEvent = this.history.setCurrentIndex(state.index); } if (this.deferOnGoBack) { this.log('onPopStateInDefer'); this.deferOnGoBack(); this.deferOnGoBack = null; return; } this.log('onPopState', { to: updateEvent[0], from: updateEvent[1], is_new_route: updateEvent[2], move_type: updateEvent[3], }); if (enterEvent) { this.emit('enter', ...enterEvent); } if (updateEvent) { this.emit('update', ...updateEvent); } }; private replace(state: State, nextRoute: MyRoute) { if (!state.blank && this.needPreventSameLocationChange(nextRoute)) { return; } state.length = window.history.length; state.index = this.history.getCurrentIndex(); state.blank = 0; const updateEvent = this.history.replace(nextRoute, state); window.history.replaceState(state, `page=${state.index}`, `#${nextRoute.getLocation()}`); preventBlinkingBySettingScrollRestoration(); this.emit('update', ...updateEvent); } private push(state: State, nextRoute: MyRoute) { if (this.needPreventSameLocationChange(nextRoute)) { return; } state.length = window.history.length; state.blank = 0; state.first = 0; let updateEvent = this.history.push(nextRoute, state); state.index = this.history.getCurrentIndex(); window.history.pushState(state, `page=${state.index}`, `#${nextRoute.getLocation()}`); preventBlinkingBySettingScrollRestoration(); this.emit('update', ...updateEvent); } /** * @param location значение window.location.hash * @private */ public createRouteFromLocationWithReplace(location: string): MyRoute { try { return MyRoute.fromLocation(this.routes, location, this.alwaysStartWithSlash); } catch (e) { if (e && e.message === 'ROUTE_NOT_FOUND') { const def = this.getDefaultRoute(location, MyRoute.getParamsFromPath(location)); return this.replacerUnknownRoute(def, this.history.getCurrentRoute()); } throw e; } } private createRouteFromLocation(location: string): MyRoute { try { return MyRoute.fromLocation(this.routes, location, this.alwaysStartWithSlash); } catch (e) { if (e && e.message === 'ROUTE_NOT_FOUND') { return this.getDefaultRoute(location, MyRoute.getParamsFromPath(location)); } throw e; } } private static isErrorThrowingEnabled() { return process.env.NODE_ENV !== 'production'; } /** * Чтобы отрендерить бесконечне панели надо знать их id * этот метод возвращает все id панелей которые хоть раз были отрендерены * это не эффективно, однако сейчас точно нельзя сказать когда панель нужна а когда нет * это обусловленно тем что панели надо убирать из дерева только после того как пройдет анимация vkui * кроме того панели могут убираться из середины, благодоря useThrottlingLocation.ts * * текущее решение -- рендерить все панели всегда * * @param viewId */ public getInfinityPanelList(viewId: string): string[] { const list = this.getCurrentLocation().getViewHistoryWithLastPanel(viewId); const oldList = this.infinityPanelCacheInstance.get(viewId) || []; const mergedList = Array.from(new Set(list.concat(oldList))); mergedList.sort((a, b) => { const [, xa] = a.split('..'); const [, xb] = b.split('..'); return Number(xa) - Number(xb); }); this.infinityPanelCacheInstance.set(viewId, mergedList); return mergedList; } private static isSameLocation(currentRoute: MyRoute, nextRoute: MyRoute) { return currentRoute.getLocation() === nextRoute.getLocation(); } private needPreventSameLocationChange(nextRoute: MyRoute) { return this.preventSameLocationChange && Router.isSameLocation(this.getCurrentRouteOrDef(), nextRoute); } public onVKWebAppChangeFragment(location: string) { window.location.hash = location.startsWith('#') ? location : `#${location}`; } private hasFixer(fixer: Fixer): boolean { return this.hotFixes.has(USE_ALL_FIXES) || this.hotFixes.has(fixer); } }