UNPKG

ohayolibs

Version:

Ohayo is a set of essential modules for ohayojp.

564 lines (506 loc) 16.6 kB
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'; /** * 路由复用类,提供复用所需要一些基本接口 * * **注:** 所有缓存数据来源于路由离开后才会产生 */ @Injectable({ providedIn: 'root' }) 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(); } } }