@relax.dev/router
Version:
Router for VKUI
681 lines (616 loc) • 24.1 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, InfinityPanelIdTitle, PageParams, RouterGenericBase } from './Types';
import { Fixer, USE_ALL_FIXES, USE_DESKTOP_SAFARI_BACK_BUG } from './HotFixers';
export declare type RouteList<T extends RouterGenericBase = RouterGenericBase> = Partial<Record<T['pageId'], Page<T>>>;
export declare type ReplaceUnknownRouteFn<T extends RouterGenericBase = RouterGenericBase> = (
newRoute: MyRoute<T>,
oldRoute?: MyRoute<T>
) => MyRoute<T>;
/**
* @ignore
*/
export declare type UpdateEventFn<T extends RouterGenericBase = RouterGenericBase> = (
newRoute: MyRoute<T>,
oldRoute: MyRoute<T> | undefined,
isNewRoute: boolean,
type: HistoryUpdateType
) => void;
/**
* @ignore
*/
export declare type EnterEventFn<T extends RouterGenericBase = RouterGenericBase> = (
newRoute: MyRoute<T>,
oldRoute?: MyRoute<T>
) => void;
/**
* @ignore
*/
export declare type LeaveEventFn<T extends RouterGenericBase = RouterGenericBase> = (
newRoute: MyRoute<T>,
oldRoute: MyRoute<T>,
isNewRoute: boolean,
type: HistoryUpdateType
) => void;
export declare type RouterMiddleware<T extends RouterGenericBase = RouterGenericBase> = (
route: MyRoute<T>,
hash: string
) => MyRoute<T>;
export class Router<T extends RouterGenericBase = RouterGenericBase> extends EventEmitter<{
update: UpdateEventFn<T>;
enter: EnterEventFn<T>;
}> {
routes: RouteList<T>;
history: History<T>;
enableLogging = false;
defaultPage = PAGE_MAIN;
defaultView = VIEW_MAIN;
defaultRoot = ROOT_MAIN;
defaultPanel = PANEL_MAIN;
alwaysStartWithSlash = true;
blankMiddleware: Array<RouterMiddleware<T>> = [];
preventSameLocationChange = false;
hotFixes: Set<Fixer>;
hashParamName = '';
/**
* Значение window.location.hash которое было на момент старта роутера
*/
startHash = '';
private deferOnGoBack: (() => void) | null = null;
private startHistoryOffset = 0;
private started = false;
private readonly infinityPanelCacheInstance = new Map<T['viewId'], Array<InfinityPanelIdTitle<T['panelId']>>>();
private readonly performBlankMiddleware = (route: MyRoute<T>, 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<T>, routerConfig: RouterConfig<T> | 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<T> = (r) => r;
private extractHashFromParam(hashParamName?: string) {
const baseHash = window.location.hash;
if (!hashParamName) return baseHash;
const url = new URL(window.location.href);
const hash = url.searchParams.get(hashParamName);
if (hash) {
try {
return `#${atob(hash)}`;
} catch (_) {
return baseHash;
}
}
return baseHash;
}
private get currentHash() {
return this.extractHashFromParam(this.hashParamName);
}
start(hashParamName = '') {
if (this.started) {
throw new Error('start method call twice! this is not allowed');
}
this.started = true;
this.hashParamName = hashParamName;
this.startHash = this.currentHash;
let enterEvent: [MyRoute<T>, MyRoute<T> | undefined] | null = null;
this.startHistoryOffset = window.history.length;
let nextRoute = this.createRouteFromLocationWithReplace(this.startHash);
const state = stateFromLocation<T>(this.history.getCurrentIndex());
state.first = 1;
if (state.blank === 1) {
nextRoute = this.performBlankMiddleware(nextRoute, this.startHash);
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.location
);
}
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<T> {
const r = this.history.getCurrentRoute();
if (r) {
return r;
}
return this.createRouteFromLocation(this.defaultPage);
}
getCurrentStateOrDef(): State<T> {
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: T['pageId'], params: PageParams = {}) {
this.log(`pushPage ${pageId}`, params);
Router.checkParams(params);
const currentRoute = this.getCurrentRouteOrDef();
const nextRoute = MyRoute.fromPageId(this.routes, pageId, params, currentRoute.getViewId());
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: T['pageId'], params: PageParams = {}) {
this.log(`replacePage ${pageId}`, params);
const currentRoute = this.getCurrentRouteOrDef();
const nextRoute = MyRoute.fromPageId(this.routes, pageId, params, currentRoute.getViewId());
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: T['pageId'], pageId: T['pageId'], 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 | T['pageId']) {
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: T['pageId'], 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: T['pageId'], 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.location);
});
this.startHistoryOffset = window.history.length - this.history.getLength();
}
/**
* @param modalId
* @param params Будьте аккуратны с параметрами, не допускайте чтобы ваши параметры пересекались с параметрами страницы
*/
pushModal(modalId: T['modalId'], 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: T['popupId'], 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: T['modalId'], 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: T['pageId'], 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<T> {
return new Location(this.getCurrentRouteOrDef(), this.getCurrentStateOrDef());
}
getPreviousLocation(): Location<T> | 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: ReturnType<typeof setTimeout> | null = null;
const fn = () => {
if (t !== null) {
clearTimeout(t);
}
this.off('update', fn);
resolve();
};
this.on('update', fn);
if (safety) {
// На случай когда метод ошибочно используется не после popPage
// чтобы не завис навечно
t = setTimeout(fn, 700);
}
});
}
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(params: PageParams): MyRoute<T> {
try {
return MyRoute.fromLocation(this.routes, this.defaultPage, this.alwaysStartWithSlash);
} catch (e) {
if (e && e.message === 'ROUTE_NOT_FOUND') {
return new MyRoute<T>(
new Page<T>(this.defaultPanel, this.defaultView, this.defaultRoot),
this.defaultPage,
params
);
}
throw e;
}
}
private readonly onPopState = () => {
let nextRoute = this.createRouteFromLocationWithReplace(this.currentHash);
const state = stateFromLocation<T>(this.history.getCurrentIndex());
let enterEvent: [MyRoute<T>, MyRoute<T> | undefined] | null = null;
let updateEvent: UpdateEventType<T> | null = null;
if (state.blank === 1) {
// Пустое состояние бывает когда приложение восстанавливают из кеша с другим хешом
// такое состояние помечаем как первая страница
nextRoute = this.performBlankMiddleware(nextRoute, this.currentHash);
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.location);
} 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<T>, nextRoute: MyRoute<T>) {
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.location);
preventBlinkingBySettingScrollRestoration();
this.emit('update', ...updateEvent);
}
private push(state: State<T>, nextRoute: MyRoute<T>) {
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.location);
preventBlinkingBySettingScrollRestoration();
this.emit('update', ...updateEvent);
}
/**
* @param location значение window.location.hash
* @private
*/
public createRouteFromLocationWithReplace(location: T['pageId']): MyRoute<T> {
try {
return MyRoute.fromLocation<T>(this.routes, location, this.alwaysStartWithSlash);
} catch (e) {
if (e && e.message === 'ROUTE_NOT_FOUND') {
const def = this.getDefaultRoute(MyRoute.getParamsFromPath(location));
return this.replacerUnknownRoute(def, this.history.getCurrentRoute());
}
throw e;
}
}
private createRouteFromLocation(location: T['pageId']): MyRoute<T> {
try {
return MyRoute.fromLocation(this.routes, location, this.alwaysStartWithSlash);
} catch (e) {
if (e && e.message === 'ROUTE_NOT_FOUND') {
return this.getDefaultRoute(MyRoute.getParamsFromPath(location));
}
throw e;
}
}
private static isErrorThrowingEnabled() {
return process.env.NODE_ENV !== 'production';
}
/**
* Чтобы отрендерить бесконечне панели надо знать их id
* этот метод возвращает все id панелей которые хоть раз были отрендерены
* это не эффективно, однако сейчас точно нельзя сказать когда панель нужна а когда нет
* это обусловленно тем что панели надо убирать из дерева только после того как пройдет анимация vkui
* кроме того панели могут убираться из середины, благодоря useThrottlingLocation.ts
*
* текущее решение -- рендерить все панели всегда
*
* @param viewId
*/
public getInfinityPanelList(viewId: T['viewId']): Array<InfinityPanelIdTitle<T['panelId']>> {
const list = this.getCurrentLocation().getViewHistoryWithLastPanel(viewId);
const oldList: Array<InfinityPanelIdTitle<T['panelId']>> = this.infinityPanelCacheInstance.get(viewId) || [];
// TODO looks like it in list we can have all types of panels (not only infinity).
// TODO sooo wtf is going on here??
const mergedList = Array.from(new Set(list.concat(oldList))) as Array<InfinityPanelIdTitle<T['panelId']>>;
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.location === nextRoute.location;
}
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);
}
}