import React from 'react' import { makeAutoObservable, observable, reaction, when } from 'mobx' import VBrowserContainer from './ui/index' import BrowserContext from './context/browser' import { CacheItem, VBrowserProps, VBrowserWindow } from './types' import { parse, stringify } from 'querystring' import { debounce, isEqual } from 'lodash' // @ts-ignore const req = require.context('@/pages', true, __AUTO_ROUTER_REG__, 'lazy') export const pages = req.keys().map((key) => { return { path: key.replace(/^\./, '').replace('/index.page.tsx', ''), loader: () => Promise.resolve(req(key)), } }) const STORAGE_KEY = 'vbrowser-cache' type EventName = 'error' | 'change' | 'close' | 'show' interface VBrowser { on(e: 'error', fn: (err: Error) => void): void on(e: 'change', fn: (to: VBrowserWindow, from: VBrowserWindow) => void): void on(e: 'close', fn: (w: VBrowserWindow) => void): void on(e: 'show', fn: (w: VBrowserWindow) => void): void } class VBrowser implements VBrowser { constructor(props: VBrowserProps) { this.props = props this.props.restore = this.props.restore ?? true this.ui = ( ) // this.open = debounce(this.open).bind(this) as typeof this.open this._loadStash().then(async () => { await when(() => this.mounted) this.props.onReady && this.props.onReady() return null }) makeAutoObservable(this, { ui: false, windows: observable.shallow }) } props!: VBrowserProps /** 子窗口列表 */ windows: VBrowserWindow[] = [] /** 选中窗口索引 */ activeIndex = -1 /** 选中窗口 */ get activeWindow() { return this.windows[this.activeIndex] as VBrowserWindow | undefined } private _histories: VBrowserWindow[] = [] /** 窗口打开/切换记录,最多10, 不包含当前 */ get histories() { return this._histories } /** 切换已打开窗口 */ switchWindow(w: number | VBrowserWindow) { const oldWindow = this.activeWindow const ignored = this.props.ignoredPath?.find((p) => typeof p === 'string' ? p === oldWindow?.path : p.test(oldWindow?.path!) ) if (ignored) { const i = this.windows.indexOf(oldWindow!) this.windows.splice(i, 1) delete this._cache[i] } if (typeof w === 'number') { this.activeIndex = w } else { const index = this.windows.findIndex((item) => item.path === w.path) if (index === -1) { throw new Error(`窗口不存在(${w.path} )`) } this.activeIndex = index } setTimeout(() => this._scrollToActiveTab(), 100) if (oldWindow) { this._histories = [...[...this._histories, oldWindow].slice(-10)] } this.props.onChange && this.props.onChange(oldWindow, this.windows[this.activeIndex], this.windows) this._stash() } /** 打开新子窗口, 子窗口已存在则判断query是否相等,相等则切换,不相等则隐性销毁重新加载;子窗口不存在则新建子窗口; * * target为'_blank'时,新开浏览器窗口 */ async open( w: VBrowserWindow | string, { target = '_self' }: { target: '_self' | '_blank' } = { target: '_self' } ) { await when(() => this.mounted) // #region 格式化 if (typeof w === 'string') { w = { path: w.split('?')[0], query: parse(w.split('?')[1]) as {} } } w.closeable = w.closeable ?? true w.title = w.title ?? (this.props.autoTitle && this.props.autoTitle(w.path)) ?? '-' // #endregion // #region 鉴权 let pass = true const auth = this.props.auth || (() => true) // @ts-ignore if (auth[Symbol.toStringTag] === 'AsyncFunction') { pass = (await auth(this.activeWindow, w as VBrowserWindow)) ?? false } else { pass = (auth(this.activeWindow, w as VBrowserWindow) as boolean) ?? false } if (!pass) return this._fire('error', new Error('鉴权失败')) if (!pages.find((p) => p.path === (w as VBrowserWindow).path)) { console.error(w.path, '页面不存在') } // #endregion // #region 打开新子窗口 if (target === '_self') { const index = this.windows.findIndex( (item) => item.path === (w as VBrowserWindow).path ) if (index === -1) { if (this.props.maxLength && this.windows.length >= this.props.maxLength) { this.props.onError && this.props.onError({ code: 0, message: '超过最大允许的窗口数量' }) return } this.windows.push(w) } else { /** * 打开已存在的相同路由的子窗口,此时情况比较复杂 * 当前浏览器窗口中不能开多个相同路由的子窗口,因为存在store共用冲突 * 如果参数相同,切换到已存在的窗口 * 如果不相同,销毁旧窗口重新新建,以重新触发其中的生命周期 */ const oldW = this.windows[index] if (!isEqual(oldW.query, w.query)) { // 删除缓存,使其需要重建 delete this._cache[w.path] } Object.assign(this.windows[index], w) } this.switchWindow(w as VBrowserWindow) } // #endregion // #region 打开新浏览器窗口 if (target === '_blank') { // 避免继承 const currentCache = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '{}') const newCache = { windows: [] as VBrowserWindow[], activeIndex: 0 } newCache.windows.push(...this.windows.filter((w) => !w.closeable)) newCache.windows.push(w) newCache.activeIndex = newCache.windows.findIndex( (item) => item.path === (w as VBrowserWindow).path ) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(newCache)) window.open(`#${w.path}?${stringify(w.query || {})}`, '_blank') sessionStorage.setItem(STORAGE_KEY, JSON.stringify(currentCache)) } // #endregion } /** 关闭子窗口, 不指定则关闭当前窗口 */ close(i?: number | VBrowserWindow) { if (i === undefined) { i = this.activeIndex } if (typeof i !== 'number') { i = this.windows.findIndex((item) => item.path === (i as VBrowserWindow).path) } const originWindows = this.windows.slice() const activeWindow = this.activeWindow this.windows.splice(i, 1) delete this._cache[originWindows[i].path] if (this.activeIndex === i) { const left = originWindows.slice(0, i).reverse()[0] const right = originWindows.slice(i + 1, originWindows.length)[0] const to = this.windows.findIndex( (w) => w.path === left?.path || w.path === right?.path ) this.switchWindow(to) } else { this.switchWindow(this.windows.indexOf(activeWindow!)) } } private _scrollToActiveTab() { const tabEl: HTMLDivElement | null = document.querySelector( `[data-tab-id="${this.activeWindow?.path}"]` ) if (!tabEl) return tabEl.scrollIntoView() } readonly ui!: JSX.Element /** ui是否已挂载 */ mounted = false /** 是否隐藏标签栏 */ private _hidingTabs = false private readonly _portal = document.createElement('div') /** 窗口组件的缓存 */ private _cache: { [key: string]: CacheItem } = {} /** 设置缓存 */ private _setCache(key: string, { vNode }: CacheItem) { // key被重新设置会使原有的子窗口被销毁 this._cache[key] = { vNode } } // 保存已开窗口 private async _stash() { const windows = this.windows.slice().map((item) => ({ ...item })) const string = JSON.stringify({ windows, activeIndex: this.activeIndex, }) sessionStorage.setItem(STORAGE_KEY, string) } // 恢复已开窗口 private async _loadStash() { await new Promise((resolve) => setTimeout(resolve, 100)) const { windows = [], activeIndex = 0 } = JSON.parse( sessionStorage.getItem(STORAGE_KEY) || '{}' ) if (windows.length === 0) return this.windows = windows this.open(windows[activeIndex]) } /** 清除后重新进入无法恢复已开窗口 */ deleteStash() { sessionStorage.removeItem(STORAGE_KEY) } /** 隐藏标签栏 */ hideTabs() { this._hidingTabs = true } /** 显示标签栏 */ showTabs() { this._hidingTabs = false } private _events: { [eventName: string]: Array<{ event: Function }> } = {} on(eventName: EventName, event: Function) { if (!this._events[eventName]) { this._events[eventName] = [] } this._events[eventName].push({ event }) return this } off(eventName: EventName, event: Function) { const events = this._events[eventName] || [] const index = events.findIndex((item) => item.event === event) events.splice(index, 1) } private _fire(eventName: EventName, ...args: any[]) { const events = this._events[eventName] || [] events.forEach((item) => item.event(...args)) } } export default VBrowser