@exlinep/router
Version:
Router for VKUI
630 lines (573 loc) • 23.3 kB
text/typescript
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);
}
}