最近主导设计实现了一个容器可视化管理的项目,考虑到其中部分模块的可以泛化至其他项目的可能,我将其中大部分模块都设计为了相对独立但可以通过相互之间的API、机制、组件调用致使不同功能Compose的能力。本文将对在该项目中前端实现中部分组件功能设计思路及实现tricks进行复盘记录。下文所使用前端项目依赖将在文末给出。
由于对话框(Modal模态框、弹出框)是类后台应用中较常用的一种信息展示机制,但未经封装的原生Modal框在我使用的过程中发现有以下缺点:
- 无法一次注册多处调用
- 若想使用API进行调用,则无法将通用对话框进行合理封装
- 对话框展开时,无法与调用方进行合理的进行通信
- 对话框无法以独立浏览器窗体打开(多窗体特性)
由于我写对话框组件时正在编写一个云桌面应用(所有组件均模块化实现),该应用由纯前端(Vue3+TSX)实现,因此其中的应用窗体Widget的设计部分设计和机制也参考了云桌面项目Widget窗体的一些机制。
对话框插件
首先,我将每一个对话框抽象为了一个独立的插件实体,该实体中包括了对话框所有参数,下方代码为通用文件管理窗体插件定义,该插件定义了类似Windows下打开文件是的文件选择弹出框,即上文中GIF所展示效果,该窗体设置为固定大小,无法最大化和全屏化,同时通过提供存储会话ID、初始文件访问路径将服务器文件系统打开(此处为MinIO存储系统)。
import { GenericWidgetPlugin } from '@/core/dialog/Dialog/definition'; import GenericFileSelectorDialogVue from './GenericFileSelectorDialog.vue'; import { FileItem } from '@/components/FileList/definition'; export interface GenericFileSelectorData { path: string; // 初始文件路径(可能无权限,若无权限则跳转至用户Home文件夹) selected: FileItem[]; // 用户选中的文件 ext: string[]; // 文件拓展名过滤列表 session?: string; // 会话(该字段于存储机制相关,由于后端存储模块为存储Adapter,因此存储会话) } export const GenerircFileSelectorWidgetPlugin: GenericWidgetPlugin<GenericFileSelectorData> = { title: '请选择文件', // 窗体title,该部分可自己设计component并替换原生窗体header type: 'exclusion', // 窗体类型(当前只有排他型) icon: '', // 窗体图标(WIP) metaName: 'generic-file-selector', // 窗体元名称,当需要调用该窗体时需通过该名称使用useDialog创建api句柄 multiple: false,// 是否可以打开多个 resizable: false, // 是否可以调整窗体大小 maximizable: false, // 是否可最大化 collapsable: false, // 是否可最小化(WIP) fullscreen: false, // 是否可全屏 position: 'center', // 打开初始位置 instantiate: () => { // 初始化窗体数据(类似于Vue组件的data,该方法存在目的为为每一窗体实例提供独立的状态数据) return { name: '请选择文件', data: { // 窗体自定义数据 path: '~', selected: [], ext: [] }, maximized: false,// 初始是否最大化 fullscreen: false, // 初始是否全屏 position: { x: 200, y: 200 }, // 初始位置信息 size: { width: 800, height: 600 }, // 初始窗体大小 collapsed: false // 初始是否最小化 }; }, content: GenericFileSelectorDialogVue // 窗体组件 };
对话框调用机制(调用方)
当前DialogAPI由于参考操作系统窗体设计,因此其中部分逻辑和常规桌面窗体一致,拥有以下特性:
- 调用方通过message方法向已开启窗体进行数据传递
- 窗体具备最大化能力
- 窗体具备全屏展示能力
- 窗体具备最小化能力
- 窗体具备弹出展示能力(浏览器窗口)[WIP]
上文中定义的对话框在通过在组件(或其他代码中)中调用`useDialog`API并传入对话框MetaName元名称、对话框实体数据返回该对话框API句柄,该方法返回值内包含对话框打开、最大化、关闭、事件注册等多种API,下方代码段中将详细解释。
<template> <div> <a-button type="primary" @click="openFileWidget">打开</a-button> </div> </template> <script setup lang="ts"> import { DEFAULT_DIALOGS } from '@/core/dialog/Dialog'; // 系统内置窗体枚举列表 import { useDialog } from '@/core/dialog/Dialog/store'; // 窗体调用Hook import { GenericFileSelectorData } from '@/core/storage/components/GenericFileSelector'; // 通用文件选择窗体实体数据类型 // 构建Dialog句柄API,设置默认窗体数据 const dialog = useDialog<GenericFileSelectorData>(DEFAULT_DIALOGS.FileSelector, { path: '~', selected: [], ext: [] }); const openFileWidget = () => { // 通过Dialog句柄API调用窗体打开方法open // 该方法将打开上方GIF途中文件管理窗体,并限制扩展名过滤器为`md`类型 dialog.open({ path: '~', selected: [], ext: ['md'] }); // 监听窗体Confirm事件,并将选中的文件通过存储中心stream方法下载下来(该设计将在另一存储模块设计部分讲解) dialog.on('onconfirm', async (data: WidgetEvent<GenericFileSelectorData, any>) => { file.value = data.data.selected[0]; const s = await storageStore.stream(session.value, [data.data.selected[0].path]); console.log(s); }); }; </script>
下图为useDialog函数内部定义,DailogAPI内部使用Pinia定义的WidgetStore对窗体数据进行管理,同时通过下方useDialog方法向调用方合理暴露窗体操作API,窗体调用方同时可以使用message方法向已开启窗体进行消息传递。
//================================== // useDialog 对话框Hook // metaName:string 对话框元名称 // data?: RefLike<Data> 对话框数据,该数据可为reactive数据 // options: useDialogOption 对话框参数 export const useDialog = <Data = any>(metaName: string, data?: RefLike<Data>, options?: useDialogOption): useDialogReturnType => { const store = useGenericDialogStore(); // 获取Pinia 通用对话框Store let wrapper: GenericWidgetInstanceStateWrapper = null as any; // 闭包对象构建 const triggerFn = (event: WidgetEventType, data: any = {}, source?: any) => { // 事件触发函数 const evtype = `on${event}` as WidgetEventListenerType; // 事件名构建 wrapper.listeners[evtype]?.({ type: event, data, source }); // 事件监听器调用 }; return { open: (openData: Data) => { // 对话框打开方法, 传入对话框实体数据 // 调用store中窗体创建方法 wrapper = store.createWidget(metaName, openData ? openData : data, Object.assign({}, options, { defaultOpen: false })); // 设置默认窗体为非最小化(坍缩)状态 wrapper.instance.collapsed = false; }, close: () => { // 触发窗体关闭事件,并传递窗体数据,该函数为调用方触发方式 triggerFn('close', wrapper.instance.data); // 从Store中移除窗体 store.removeWidget(wrapper.instance.id); }, maximize: () => { // 触发最大化窗体事件 triggerFn('maximize', wrapper.instance.data); wrapper.instance.maximized = true; }, fullscreen: () => { // 触发全屏窗体事件 triggerFn('fullscreen', wrapper.instance.data); wrapper.instance.fullscreen = true; }, collapse: (collapse: boolean) => { // 触发最小化窗体事件 triggerFn('collapse', wrapper.instance.data); wrapper.instance.collapsed = collapse; }, message: (msg: any, source?: any) => { // 窗体调用方像窗体内传递数据 if (wrapper.listeners['onmessage'] != undefined) _(wrapper.listeners['onmessage']({ type: 'message', data, source })); }, on: (event: WidgetEventListenerType, cb: WdigetEventListener) => { // 窗体调用方注册窗体回调监听器 wrapper.listeners[event] = cb; }, // 事件触发器(调用方触发,特殊使用方法,可自定义事件) trigger: triggerFn } as unknown as useDialogReturnType; };
对话框构建机制(窗体设计方)
下方代码为上文GIF中窗体内容组件GenericFileSelectorDialogVue,该组件调用FileAccess组件实现文件列表展示,本节将对该部分代码进行注释讲解。
DialogAPI中将窗体内容组件进行了一次封装,内容组件将从属性注入该组件所属的GenericWidgetPlugin对象,同时设计提供了一套通过`useWidgetInstance` Hook获取的窗体内部使用的API。通过该API可对窗体进行关闭、最大最小化等,为上节中useDialog返回API的窗体内调用方式。
<template> <div class="generic-file-selector"> <FileAccess :ext="instance.data.ext" :session="instance.data.session" v-model:checked="instance.data.selected" :start-path="instance.data.path" /> </div> </template> <script setup lang="ts"> import { GenericWidgetPlugin } from '@/core/dialog/Dialog/definition'; import { useWidgetInstance } from '@/core/dialog/Dialog/store'; import { PropType, ref } from 'vue'; import FileAccess from '@/views/storage/ftp/FileAccess.vue'; import { GenericFileSelectorData } from '.'; import { FileItem } from '@/components/FileList/definition'; // 接收窗体插件属性 const props = defineProps({ meta: { type: Object as PropType<GenericWidgetPlugin>, required: true } }); // 通过useWidgetInstance() 获取从上层窗体上下文中注入的窗体API对象 const api = useWidgetInstance(); // 获取窗体实体对象并传递至子组件中 const instance = api.instance<GenericFileSelectorData>(); </script> <style lang="less"> .generic-file-selector { width: 100%; height: 100%; position: relative; overflow: scroll; user-select: none; } </style>
下面我们将对useWidgetInstance方法进行代码详解:
type WidgetState = 'maximize' | 'collapse' | 'fullscreen'; type WidgetEventType = 'message' | 'confirm' | 'close' | `${'un'}${WidgetState}` | WidgetState; // 事件监听类型 type WidgetEventListenerType = `on${WidgetEventType}`; // 参数provideid为Hook调用方提供一个直接注入窗体ID的方法,供特殊情况使用 export const useWidgetInstance = (provideid?: string) => { // 通过上下文获取到当前窗体ID const { id } = useWidgetInstanceContext(provideid); // 获取DialogStore const store = useGenericDialogStore(); // 从DialogStore中通过ID获取窗体封装对象 const wrapper = store.getDialogById(id); return { // 获取窗体数据对象 instance: () => { return wrapper.instance; }, open: () => { // 打开窗体(由于该窗体已创建,因此该方法将仅变更窗体最小化状态) wrapper.instance.collapsed = false; }, close: () => { // 关闭窗体 并触发关闭事件 wrapper.listeners["onclose"]?.({ type: event, wrapper.instance, wrapper }); store.removeWidget(wrapper.instance.id); }, maximize: () => { // 最大化窗体 并触发事件 wrapper.listeners["onmaximize"]?.({ type: event, wrapper.instance, wrapper }); wrapper.instance.maximized = true; }, fullscreen: () => { // 全屏窗体 并触发事件 wrapper.listeners["onfullscreen"]?.({ type: event, wrapper.instance, wrapper }); wrapper.instance.fullscreen = true; }, collapse: (collapse: boolean) => { // 最小化窗体窗体 并触发事件 wrapper.listeners["oncollapse"]?.({ type: event, wrapper.instance, wrapper }); wrapper.instance.collapsed = collapse; }, message: (msg: any, source?: any) => { // 消息传递机制 (wrapper.listeners['onmessage']?.({ type: 'message', data: msg, source }); }, // 事件监听方法, on: (event: WidgetEventListenerType, cb: WdigetEventListener) => { wrapper.listeners[event] = cb; }, trigger: (event: WidgetEventType, data: any = {}, source?: any) => { const evtype = `on${event}` as WidgetEventListenerType; wrapper.listeners[evtype]?.({ type: event, data, source }); } } as unknown as useDialogReturnType; };
对话框注册中心 GenericDialogCenter
为解决对话框一次注册多处调用的问题,DialogAPI提供了对话框注册中心,通过将上节中定义的通用对话框插件`GenericWidgetPlugin`注册至系统中。GenericWidgetPlugin为单例实体,用户通过对话框插件定义中`metaName`调用对话框时将于GenericDialogCenter中搜索注册的对话框,并生成对应的API句柄。下图为该通用窗体中心类定义,该类设计较为简单,仅具备基本registry功能。
export class GenericWidgetCenter { private static _instance = new GenericWidgetCenter(); public plugins: Record<string, GenericWidgetPlugin> = {}; public static getInstance() { return GenericWidgetCenter._instance; } public register(plugin: GenericWidgetPlugin) { this.plugins[plugin.metaName] = plugin; } public get(metaName: string) { return this.plugins[metaName]; } public instantiate(metaName: string, data: any, options = {}) { const instance = Object.assign({}, this.get(metaName).instantiate(), options); instance.data = { ...instance.data, ...data }; return instance; } } export const generateDialogInstance = (id: string, metaName: string, data: any, options = {}): GenericWidgetInstance => { const instance = GenericWidgetCenter.getInstance().instantiate(metaName, data) as GenericWidgetInstance; instance.id = id; return instance; }; export const getWidgetPluginByMetaName = (metaName: string) => { return GenericWidgetCenter.getInstance().get(metaName); };