Composable 对话框特性

最近主导设计实现了一个容器可视化管理的项目,考虑到其中部分模块的可以泛化至其他项目的可能,我将其中大部分模块都设计为了相对独立但可以通过相互之间的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);
};

发表评论

您的电子邮箱地址不会被公开。 必填项已用 * 标注