mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-16 18:17:31 +08:00
250 lines
7.3 KiB
TypeScript
250 lines
7.3 KiB
TypeScript
import { Component, Element, Event, EventEmitter, Listen, Method, Prop, QueueApi } from '@stencil/core';
|
|
|
|
import { Config, RouteChain, RouterDirection, RouterEventDetail } from '../../interface';
|
|
import { debounce } from '../../utils/helpers';
|
|
|
|
import { RouterIntent } from './utils/constants';
|
|
import { printRedirects, printRoutes } from './utils/debug';
|
|
import { readNavState, waitUntilNavNode, writeNavState } from './utils/dom';
|
|
import { routeRedirect, routerIDsToChain, routerPathToChain } from './utils/matching';
|
|
import { readRedirects, readRoutes } from './utils/parser';
|
|
import { chainToPath, generatePath, parsePath, readPath, writePath } from './utils/path';
|
|
|
|
@Component({
|
|
tag: 'ion-router'
|
|
})
|
|
export class Router {
|
|
|
|
private previousPath: string | null = null;
|
|
private busy = false;
|
|
private state = 0;
|
|
private lastState = 0;
|
|
|
|
@Element() el!: HTMLElement;
|
|
|
|
@Prop({ context: 'config' }) config!: Config;
|
|
@Prop({ context: 'queue' }) queue!: QueueApi;
|
|
@Prop({ context: 'window' }) win!: Window;
|
|
|
|
/**
|
|
* By default `ion-router` will match the routes at the root path ("/").
|
|
* That can be changed when
|
|
*
|
|
*/
|
|
@Prop() root = '/';
|
|
|
|
/**
|
|
* The router can work in two "modes":
|
|
* - With hash: `/index.html#/path/to/page`
|
|
* - Without hash: `/path/to/page`
|
|
*
|
|
* Using one or another might depend in the requirements of your app and/or where it's deployed.
|
|
*
|
|
* Usually "hash-less" navigation works better for SEO and it's more user friendly too, but it might
|
|
* requires aditional server-side configuration in order to properly work.
|
|
*
|
|
* On the otherside hash-navigation is much easier to deploy, it even works over the file protocol.
|
|
*
|
|
* By default, this property is `true`, change to `false` to allow hash-less URLs.
|
|
*/
|
|
@Prop() useHash = true;
|
|
|
|
/**
|
|
* Event emitted when the route is about to change
|
|
*/
|
|
@Event() ionRouteWillChange!: EventEmitter<RouterEventDetail>;
|
|
|
|
/**
|
|
* Emitted when the route had changed
|
|
*/
|
|
@Event() ionRouteDidChange!: EventEmitter<RouterEventDetail>;
|
|
|
|
async componentWillLoad() {
|
|
console.debug('[ion-router] router will load');
|
|
await waitUntilNavNode(this.win);
|
|
console.debug('[ion-router] found nav');
|
|
|
|
await this.onRoutesChanged();
|
|
|
|
this.win.addEventListener('ionRouteRedirectChanged', debounce(this.onRedirectChanged.bind(this), 10));
|
|
this.win.addEventListener('ionRouteDataChanged', debounce(this.onRoutesChanged.bind(this), 100));
|
|
this.onRedirectChanged();
|
|
}
|
|
|
|
@Listen('window:popstate')
|
|
protected onPopState() {
|
|
const direction = this.historyDirection();
|
|
const path = this.getPath();
|
|
console.debug('[ion-router] URL changed -> update nav', path, direction);
|
|
return this.writeNavStateRoot(path, direction);
|
|
}
|
|
|
|
/** Navigate to the specified URL */
|
|
@Method()
|
|
push(url: string, direction: RouterDirection = 'forward') {
|
|
const path = parsePath(url);
|
|
const intent = DIRECTION_TO_INTENT[direction];
|
|
console.debug('[ion-router] URL pushed -> updating nav', url, direction);
|
|
|
|
this.setPath(path, intent);
|
|
return this.writeNavStateRoot(path, intent);
|
|
}
|
|
|
|
/** @hidden */
|
|
@Method()
|
|
printDebug() {
|
|
console.debug('CURRENT PATH', this.getPath());
|
|
console.debug('PREVIOUS PATH', this.previousPath);
|
|
printRoutes(readRoutes(this.el));
|
|
printRedirects(readRedirects(this.el));
|
|
}
|
|
|
|
/** @hidden */
|
|
@Method()
|
|
async navChanged(intent: number): Promise<boolean> {
|
|
if (this.busy) {
|
|
return false;
|
|
}
|
|
const { ids, outlet } = readNavState(this.win.document.body);
|
|
const routes = readRoutes(this.el);
|
|
const chain = routerIDsToChain(ids, routes);
|
|
if (!chain) {
|
|
console.warn('[ion-router] no matching URL for ', ids.map(i => i.id));
|
|
return false;
|
|
}
|
|
|
|
const path = chainToPath(chain);
|
|
if (!path) {
|
|
console.warn('[ion-router] router could not match path because some required param is missing');
|
|
return false;
|
|
}
|
|
|
|
console.debug('[ion-router] nav changed -> update URL', ids, path);
|
|
this.setPath(path, intent);
|
|
|
|
await this.writeNavState(outlet, chain, RouterIntent.None, path, null, ids.length);
|
|
return true;
|
|
}
|
|
|
|
private onRedirectChanged() {
|
|
const path = this.getPath();
|
|
if (path && routeRedirect(path, readRedirects(this.el))) {
|
|
this.writeNavStateRoot(path, RouterIntent.None);
|
|
}
|
|
}
|
|
|
|
private onRoutesChanged() {
|
|
return this.writeNavStateRoot(this.getPath(), RouterIntent.None);
|
|
}
|
|
|
|
private historyDirection() {
|
|
if (this.win.history.state === null) {
|
|
this.state++;
|
|
this.win.history.replaceState(this.state, this.win.document.title, this.win.document.location.href);
|
|
}
|
|
|
|
const state = this.win.history.state;
|
|
const lastState = this.lastState;
|
|
this.lastState = state;
|
|
|
|
if (state > lastState) {
|
|
return RouterIntent.Forward;
|
|
} else if (state < lastState) {
|
|
return RouterIntent.Back;
|
|
} else {
|
|
return RouterIntent.None;
|
|
}
|
|
}
|
|
|
|
private async writeNavStateRoot(path: string[] | null, intent: RouterIntent): Promise<boolean> {
|
|
if (this.busy) {
|
|
return false;
|
|
}
|
|
if (!path) {
|
|
console.error('[ion-router] URL is not part of the routing set');
|
|
return false;
|
|
}
|
|
|
|
// lookup redirect rule
|
|
const redirects = readRedirects(this.el);
|
|
const redirect = routeRedirect(path, redirects);
|
|
let redirectFrom: string[] | null = null;
|
|
if (redirect) {
|
|
this.setPath(redirect.to!, intent);
|
|
redirectFrom = redirect.from;
|
|
path = redirect.to!;
|
|
}
|
|
|
|
// lookup route chain
|
|
const routes = readRoutes(this.el);
|
|
const chain = routerPathToChain(path, routes);
|
|
if (!chain) {
|
|
console.error('[ion-router] the path does not match any route');
|
|
return false;
|
|
}
|
|
|
|
// write DOM give
|
|
return this.writeNavState(this.win.document.body, chain, intent, path, redirectFrom);
|
|
}
|
|
|
|
private async writeNavState(
|
|
node: HTMLElement | undefined, chain: RouteChain, intent: RouterIntent,
|
|
path: string[], redirectFrom: string[] | null,
|
|
index = 0
|
|
): Promise<boolean> {
|
|
if (this.busy) {
|
|
return false;
|
|
}
|
|
this.busy = true;
|
|
|
|
// generate route event and emit will change
|
|
const routeEvent = this.routeChangeEvent(path, redirectFrom);
|
|
if (routeEvent) {
|
|
this.ionRouteWillChange.emit(routeEvent);
|
|
}
|
|
|
|
const changed = await writeNavState(node, chain, intent, index);
|
|
this.busy = false;
|
|
|
|
if (changed) {
|
|
console.debug('[ion-router] route changed', path);
|
|
}
|
|
|
|
// emit did change
|
|
if (routeEvent) {
|
|
this.ionRouteDidChange.emit(routeEvent);
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
private setPath(path: string[], intent: RouterIntent) {
|
|
this.state++;
|
|
writePath(this.win.history, this.root, this.useHash, path, intent, this.state);
|
|
}
|
|
|
|
private getPath(): string[] | null {
|
|
return readPath(this.win.location, this.root, this.useHash);
|
|
}
|
|
|
|
private routeChangeEvent(path: string[], redirectFromPath: string[] | null): RouterEventDetail | null {
|
|
const from = this.previousPath;
|
|
const to = generatePath(path);
|
|
this.previousPath = to;
|
|
if (to === from) {
|
|
return null;
|
|
}
|
|
const redirectedFrom = redirectFromPath ? generatePath(redirectFromPath) : null;
|
|
return {
|
|
from,
|
|
redirectedFrom,
|
|
to,
|
|
};
|
|
}
|
|
}
|
|
|
|
const DIRECTION_TO_INTENT = {
|
|
'back': RouterIntent.Back,
|
|
'root': RouterIntent.None,
|
|
'forward': RouterIntent.Forward
|
|
};
|