ohayolibs
Version:
Ohayo is a set of essential modules for ohayojp.
564 lines (506 loc) • 16.6 kB
text/typescript
import { Injectable, Injector, OnDestroy } from '@angular/core';
import {
ActivatedRoute,
ActivatedRouteSnapshot,
ExtraOptions,
NavigationEnd,
NavigationStart,
Router,
ROUTER_CONFIGURATION,
} from '@angular/router';
import { Menu, MenuService, ScrollService } from '@ohayo/theme';
import { NzSafeAny } from 'ng-zorro-antd/core/types';
import { BehaviorSubject, Observable, Unsubscribable } from 'rxjs';
import {
ReuseComponentRef,
ReuseHookOnReuseInitType,
ReuseHookTypes,
ReuseTcomponentsached,
ReuseTabMatchMode,
ReuseTabNotify,
ReuseTabRouteParamMatchMode,
ReuseTitle,
} from './reuse-tab.interfaces';
/**
* 路由复用类,提供复用所需要一些基本接口
*
* **注:** 所有缓存数据来源于路由离开后才会产生
*/
export class ReuseTabService implements OnDestroy {
private _inited = false;
private _max = 10;
private _keepingScroll = false;
private _cachedChange = new BehaviorSubject<ReuseTabNotify | null>(null);
private _cached: ReuseTcomponentsached[] = [];
private _titleCached: { [url: string]: ReuseTitle } = {};
private _closableCached: { [url: string]: boolean } = {};
private _router$: Unsubscribable;
private removeUrlBuffer: string | null;
private positionBuffer: { [url: string]: [number, number] } = {};
componentRef: ReuseComponentRef;
debug = false;
routeParamMatchMode: ReuseTabRouteParamMatchMode = 'strict';
mode = ReuseTabMatchMode.Menu;
/** 排除规则,限 `mode=URL` */
excludes: RegExp[] = [];
private get snapshot(): ActivatedRouteSnapshot {
return this.injector.get(ActivatedRoute).snapshot;
}
// #region public
get inited(): boolean {
return this._inited;
}
/** 当前路由地址 */
get curUrl(): string {
return this.getUrl(this.snapshot);
}
/** 允许最多复用多少个页面,取值范围 `2-100`,值发生变更时会强制关闭且忽略可关闭条件 */
set max(value: number) {
this._max = Math.min(Math.max(value, 2), 100);
for (let i = this._cached.length; i > this._max; i--) {
this._cached.pop();
}
}
set keepingScroll(value: boolean) {
this._keepingScroll = value;
this.initScroll();
}
get keepingScroll(): boolean {
return this._keepingScroll;
}
keepingScrollContainer: Element;
/** 获取已缓存的路由 */
get items(): ReuseTcomponentsached[] {
return this._cached;
}
/** 获取当前缓存的路由总数 */
get count(): number {
return this._cached.length;
}
/** 订阅缓存变更通知 */
get change(): Observable<ReuseTabNotify | null> {
return this._cachedChange.asObservable(); // .pipe(filter(w => w !== null));
}
/** 自定义当前标题 */
set title(value: string | ReuseTitle) {
const url = this.curUrl;
if (typeof value === 'string') value = { text: value };
this._titleCached[url] = value;
this.di('update current tag title: ', value);
this._cachedChange.next({
active: 'title',
url,
title: value,
list: this._cached,
});
}
/** 获取指定路径缓存所在位置,`-1` 表示无缓存 */
index(url: string): number {
return this._cached.findIndex(w => w.url === url);
}
/** 获取指定路径缓存是否存在 */
exists(url: string): boolean {
return this.index(url) !== -1;
}
/** 获取指定路径缓存 */
get(url?: string): ReuseTcomponentsached | null {
return url ? this._cached.find(w => w.url === url) || null : null;
}
private remove(url: string | number, includeNonCloseable: boolean): boolean {
const idx = typeof url === 'string' ? this.index(url) : url;
const item = idx !== -1 ? this._cached[idx] : null;
if (!item || (!includeNonCloseable && !item.closable)) return false;
this.destroy(item._handle);
this._cached.splice(idx, 1);
delete this._titleCached[url];
return true;
}
/**
* 根据URL移除标签
*
* @param [includeNonCloseable=false] 是否强制包含不可关闭
*/
close(url: string, includeNonCloseable: boolean = false): boolean {
this.removeUrlBuffer = url;
this.remove(url, includeNonCloseable);
this._cachedChange.next({ active: 'close', url, list: this._cached });
this.di('close tag', url);
return true;
}
/**
* 清除右边
*
* @param [includeNonCloseable=false] 是否强制包含不可关闭
*/
closeRight(url: string, includeNonCloseable: boolean = false): boolean {
const start = this.index(url);
for (let i = this.count - 1; i > start; i--) {
this.remove(i, includeNonCloseable);
}
this.removeUrlBuffer = null;
this._cachedChange.next({ active: 'closeRight', url, list: this._cached });
this.di('close right tages', url);
return true;
}
/**
* 清除所有缓存
*
* @param [includeNonCloseable=false] 是否强制包含不可关闭
*/
clear(includeNonCloseable: boolean = false): void {
this._cached.forEach(w => {
if (!includeNonCloseable && w.closable) this.destroy(w._handle);
});
this._cached = this._cached.filter(w => !includeNonCloseable && !w.closable);
this.removeUrlBuffer = null;
this._cachedChange.next({ active: 'clear', list: this._cached });
this.di('clear all catch');
}
/**
* 移动缓存数据
* @param url 要移动的URL地址
* @param position 新位置,下标从 `0` 开始
*
* @example
* ```
* // source
* [ '/a/1', '/a/2', '/a/3', '/a/4', '/a/5' ]
* move('/a/1', 2);
* // output
* [ '/a/2', '/a/3', '/a/1', '/a/4', '/a/5' ]
* move('/a/1', -1);
* // output
* [ '/a/2', '/a/3', '/a/4', '/a/5', '/a/1' ]
* ```
*/
move(url: string, position: number): void {
const start = this._cached.findIndex(w => w.url === url);
if (start === -1) return;
const data = this._cached.slice();
data.splice(position < 0 ? data.length + position : position, 0, data.splice(start, 1)[0]);
this._cached = data;
this._cachedChange.next({
active: 'move',
url,
position,
list: this._cached,
});
}
/**
* 强制关闭当前路由(包含不可关闭状态),并重新导航至 `newUrl` 路由
*/
replace(newUrl: string): void {
const url = this.curUrl;
if (this.exists(url)) {
this.close(url, true);
} else {
this.removeUrlBuffer = url;
}
this.injector.get<Router>(Router).navigateByUrl(newUrl);
}
/**
* 获取标题,顺序如下:
*
* 1. 组件内使用 `ReuseTabService.title = 'new title'` 重新指定文本
* 2. 路由配置中 data 属性中包含 titleI18n > title
* 3. 菜单数据中 text 属性
*
* @param url 指定URL
* @param route 指定路由快照
*/
getTitle(url: string, route?: ActivatedRouteSnapshot): ReuseTitle {
if (this._titleCached[url]) {
return this._titleCached[url];
}
if (route && route.data && (route.data.titleI18n || route.data.title)) {
return {
text: route.data.title,
i18n: route.data.titleI18n,
} as ReuseTitle;
}
const menu = this.getMenu(url);
return menu ? { text: menu.text, i18n: menu.i18n } : { text: url };
}
/**
* 清除标题缓存
*/
clearTitleCached(): void {
this._titleCached = {};
}
/** 自定义当前 `closable` 状态 */
set closable(value: boolean) {
const url = this.curUrl;
this._closableCached[url] = value;
this.di('update current tag closable: ', value);
this._cachedChange.next({
active: 'closable',
closable: value,
list: this._cached,
});
}
/**
* 获取 `closable` 状态,顺序如下:
*
* 1. 组件内使用 `ReuseTabService.closable = true` 重新指定 `closable` 状态
* 2. 路由配置中 data 属性中包含 `reuseClosable`
* 3. 菜单数据中 `reuseClosable` 属性
*
* @param url 指定URL
* @param route 指定路由快照
*/
getClosable(url: string, route?: ActivatedRouteSnapshot): boolean {
if (typeof this._closableCached[url] !== 'undefined') return this._closableCached[url];
if (route && route.data && typeof route.data.reuseClosable === 'boolean') return route.data.reuseClosable;
const menu = this.mode !== ReuseTabMatchMode.URL ? this.getMenu(url) : null;
if (menu && typeof menu.reuseClosable === 'boolean') return menu.reuseClosable;
return true;
}
/**
* 清空 `closable` 缓存
*/
clearClosableCached(): void {
this._closableCached = {};
}
getTruthRoute(route: ActivatedRouteSnapshot): ActivatedRouteSnapshot {
let next = route;
while (next.firstChild) next = next.firstChild;
return next;
}
/**
* 根据快照获取URL地址
*/
getUrl(route: ActivatedRouteSnapshot): string {
let next = this.getTruthRoute(route);
const segments: string[] = [];
while (next) {
segments.push(next.url.join('/'));
next = next.parent!;
}
const url =
'/' +
segments
.filter(i => i)
.reverse()
.join('/');
return url;
}
/**
* 检查快照是否允许被复用
*/
can(route: ActivatedRouteSnapshot): boolean {
const url = this.getUrl(route);
if (url === this.removeUrlBuffer) return false;
if (route.data && typeof route.data.reuse === 'boolean') return route.data.reuse;
if (this.mode !== ReuseTabMatchMode.URL) {
const menu = this.getMenu(url);
if (!menu) return false;
if (this.mode === ReuseTabMatchMode.Menu) {
if (menu.reuse === false) return false;
} else {
if (!menu.reuse || menu.reuse !== true) return false;
}
return true;
}
return !this.isExclude(url);
}
isExclude(url: string): boolean {
return this.excludes.findIndex(r => r.test(url)) !== -1;
}
/**
* 刷新,触发一个 refresh 类型事件
*/
refresh(data?: any): void {
this._cachedChange.next({ active: 'refresh', data });
}
// #endregion
// #region privates
private destroy(_handle: any): void {
if (_handle && _handle.componentRef && _handle.componentRef.destroy) _handle.componentRef.destroy();
}
private di(...args: NzSafeAny[]): void {
if (!this.debug) return;
// tslint:disable-next-line:no-console
console.warn(...args);
}
// #endregion
constructor(private injector: Injector, private menuService: MenuService) { }
init(): void {
this.initScroll();
this._inited = true;
}
private getMenu(url: string): Menu | null | undefined {
const menus = this.menuService.getPathByUrl(url);
if (!menus || menus.length === 0) return null;
return menus.pop();
}
runHook(method: ReuseHookTypes, comp: ReuseComponentRef | number, type: ReuseHookOnReuseInitType = 'init'): void {
if (typeof comp === 'number') {
const item = this._cached[comp];
comp = item._handle.componentRef;
}
const compThis = comp.instance;
if (comp == null || !compThis) {
return;
}
const fn = compThis[method];
if (typeof fn !== 'function') {
return;
}
if (method === '_onReuseInit') {
fn.call(compThis, type);
} else {
(fn as () => void).call(compThis);
}
}
private hasInValidRoute(route: ActivatedRouteSnapshot): boolean {
return !route.routeConfig || !!route.routeConfig.loadChildren || !!route.routeConfig.children;
}
/**
* 决定是否允许路由复用,若 `true` 会触发 `store`
*/
shouldDetach(route: ActivatedRouteSnapshot): boolean {
if (this.hasInValidRoute(route)) return false;
this.di('#shouldDetach', this.can(route), this.getUrl(route));
return this.can(route);
}
/**
* 存储
*/
store(_snapshot: ActivatedRouteSnapshot, _handle: any): void {
const url = this.getUrl(_snapshot);
const idx = this.index(url);
const isAdd = idx === -1;
const item: ReuseTcomponentsached = {
title: this.getTitle(url, _snapshot),
closable: this.getClosable(url, _snapshot),
position: this.getKeepingScroll(url, _snapshot) ? this.positionBuffer[url] : null,
url,
_snapshot,
_handle,
};
if (isAdd) {
if (this.count >= this._max) {
// Get the oldest closable location
const closeIdx = this._cached.findIndex(w => w.closable!);
if (closeIdx !== -1) this.remove(closeIdx, false);
}
this._cached.push(item);
} else {
this._cached[idx] = item;
}
this.removeUrlBuffer = null;
this.di('#store', isAdd ? '[new]' : '[override]', url);
if (_handle && _handle.componentRef) {
this.runHook('_onReuseDestroy', _handle.componentRef);
}
if (!isAdd) {
this._cachedChange.next({ active: 'override', item, list: this._cached });
}
}
/**
* 决定是否允许应用缓存数据
*/
shouldAttach(route: ActivatedRouteSnapshot): boolean {
if (this.hasInValidRoute(route)) return false;
const url = this.getUrl(route);
const data = this.get(url);
const ret = !!(data && data._handle);
this.di('#shouldAttach', ret, url);
if (ret) {
const compRef = data!._handle.componentRef;
if (compRef) {
this.componentRef = compRef;
this.runHook('_onReuseInit', compRef);
}
} else {
this._cachedChange.next({ active: 'add', url, list: this._cached });
}
return ret;
}
/**
* 提取复用数据
*/
retrieve(route: ActivatedRouteSnapshot): {} | null {
if (this.hasInValidRoute(route)) return null;
const url = this.getUrl(route);
const data = this.get(url);
const ret = (data && data._handle) || null;
this.di('#retrieve', url, ret);
return ret;
}
/**
* 决定是否应该进行复用路由处理
*/
shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
let ret = future.routeConfig === curr.routeConfig;
if (!ret) return false;
const path = ((future.routeConfig && future.routeConfig.path) || '') as string;
if (path.length > 0 && ~path.indexOf(':')) {
if (this.routeParamMatchMode === 'strict') {
ret = this.getUrl(future) === this.getUrl(curr);
} else {
ret = path === ((curr.routeConfig && curr.routeConfig.path) || '');
}
}
this.di('=====================');
this.di('#shouldReuseRoute', ret, `${this.getUrl(curr)}=>${this.getUrl(future)}`, future, curr);
return ret;
}
// #region scroll
/**
* 获取 `keepingScroll` 状态,顺序如下:
*
* 1. 路由配置中 data 属性中包含 `keepingScroll`
* 2. 菜单数据中 `keepingScroll` 属性
* 3. 组件 `keepingScroll` 值
*/
getKeepingScroll(url: string, route?: ActivatedRouteSnapshot): boolean {
if (route && route.data && typeof route.data.keepingScroll === 'boolean') return route.data.keepingScroll;
const menu = this.mode !== ReuseTabMatchMode.URL ? this.getMenu(url) : null;
if (menu && typeof menu.keepingScroll === 'boolean') return menu.keepingScroll;
return this.keepingScroll;
}
private get isDisabledInRouter(): boolean {
const routerConfig = this.injector.get<ExtraOptions>(ROUTER_CONFIGURATION, {} as any);
return routerConfig.scrollPositionRestoration === 'disabled';
}
private get ss(): ScrollService {
return this.injector.get(ScrollService);
}
private initScroll(): void {
if (this._router$) {
this._router$.unsubscribe();
}
this._router$ = this.injector.get<Router>(Router).events.subscribe(e => {
if (e instanceof NavigationStart) {
const url = this.curUrl;
if (this.getKeepingScroll(url, this.getTruthRoute(this.snapshot))) {
this.positionBuffer[url] = this.ss.getScrollPosition(this.keepingScrollContainer);
} else {
delete this.positionBuffer[url];
}
} else if (e instanceof NavigationEnd) {
const url = this.curUrl;
const item = this.get(url);
if (item && item.position && this.getKeepingScroll(url, this.getTruthRoute(this.snapshot))) {
if (this.isDisabledInRouter) {
this.ss.scrollToPosition(this.keepingScrollContainer, item.position);
} else {
setTimeout(() => this.ss.scrollToPosition(this.keepingScrollContainer, item.position!), 1);
}
}
}
});
}
// #endregion
ngOnDestroy(): void {
const { _cachedChange, _router$ } = this;
this.clear();
this._cached = [];
_cachedChange.complete();
if (_router$) {
_router$.unsubscribe();
}
}
}