Vsee架构——如何设计Portal

99 阅读9分钟

前言

通常,我们的站点首页或者根路径上,都会有整个应用的关注性导航或者视图。有时,我们会称之为“工作台”或者“首页”:

WX20231115-100837@2x.png

但有的客户希望这个首页能够尽可能的定制化,因此需要我们开发Portal来满足客户的需求。

Portal & Portlet

Portlet是可以提供基于 WEB 的内容、应用程序和其他资源访问的可重用组件。从用户角度上,首页上的一个个独立的块就是一个Portlet,而多个Portlet所组合的应用就叫做Portal

了解了这样一点,我们就可以提出Portal组件的设计需求:

  1. Portal组件和Portlet组件的开发,其中Portal是容器,Portlet是子容器;
  2. Portal用于装载子容器Portlet,为Portlet提供数据和样式属性;
  3. Portlet用于装载客制化组件Component,为Component提供数据,并暴露组件可配置化表单;

架构图

有个业务上的需求,我们再把架构上的设计理一理,经过整理发现,开发这套东西的底层,需要包含 2 个核心:

  1. PortletLoader,用于加载组件(AsyncComponent)和配置器(ConfigContainer),并将组件props暴露出去;
  2. ConfigContainer,用于配置组件的容器,包含两个部分
    1. PropForm,由开发人员自定义配置的组件属性编辑器,接受PortletLoader暴露的props数据
    2. StyleForm,内置的 UI 样式属性编辑器
PortletLoader
  AsyncComponent
  ConfigContainer
    PropsForm<slot>
    StyleForm

可操配属性有:

  1. props组件属性,例如标题、名称、接口地址之类的,是由开发者开发portlet时暴露出来的,用户可自定义;
  2. style组件外层容器样式,例如背景色、边框等,是应用内置好的常用配置并暴露出来,用户可自定义;
  3. option组件容器属性,定义每个容器的基本尺寸,由开发人员配置;

先配上已经完成后的架构图,后面会讲解这个图的流程:

框架图.png

初期设想

无倾入式开发

我们的初期设想就是建立如下的文件路径:

src
  portlet
    card.vue
  config
    card.vue

在我们项目的目录下分别设有portletconfig两个文件夹,里面有同名文件,在portlet下负责定义组件、展示组件,在config下的负责配置组件属性、样式和容器属性。比如代码如下:

<!-- portlet/card.vue -->
<template>
  <div>hello {{ text }} </div>
</template>

<script>
export default {
  props: ["text"],
};
</script>

<!-- config/card.vue -->
<template>
  <portlet-config :option="{ maxW: 4, maxH: 4 }">
    <template #default="{ record }">
      <input v-model="record.text" />
    </template>
  </portlet-config>
</template>

选择这种方式的好处是,portlet/card.vue是一个可重用的组件,他本身就很干净,其配置项是独立在这个组件之外的。因此,这个组件除了可以作为portlet成员之一,还可以做其他的,以达到组件的复用性。现在PortletConfig就是我们要实现的组件之一。

异步组件加载

类似于<component is="xxxx">,我们需要一个异步加载的组件加载器。这个在我以前的文章中提到过,从而实现类似于:

<template>
  <portlet-loader
    v-model:prop="portlet.prop"
    v-model:style="portlet.style"
    path="/portlet/card.vue"
  />
</template>

我们能将组件的属性和样式都传递给他,然后由他来渲染组件,并能唤出配置页面进行配置。现在,我们又多个一个需要实现的PortletLoader组件。

ConfigContainer

PortletLoader主要就包含两个部分,一个用于展示页面的AsyncComponent,一个配置页面的ConfigContainer,首先我们要实现配置器的页面。我们预期配置器使用Drawer加上Tabs构成由属性编辑和样式编辑构成的页面:

WX20231115-105333@2x.png

代码如下:

<script>
// 构建样式编辑表单
const StyleForm
export default {
  setup (props, { attrs, slots }) {
    const activeKey = ref('prop')
    return () => (
      <Drawer { ...attrs }>
        <Tabs v-model:activeKey={ activeKey.value }>
          <TabPane key="prop" tab="属性">
            { slots?.default?.() }
          </TabPane>
          <TabPane key="style" tab="样式">
            <StyleForm style={ attrs.wrapStyle } />
          </TabPane>
        </Tabs>
      </Drawer>
    )
  }
}
</script>

注意:16 行代码是我们放进来的样式编辑器,主要就是编辑背景色、边框的样式。而 13 行代码,就是我们要嵌入进来的组件属性编辑器。到这里,就可以停一下了,后续我们会把PropsForm注入进来。

PortletLoader

门户组件加载器PortletLoader应当包含两个主要部分:

  1. AsyncComponent——门户组件
  2. ConfigContainer——门户组件配置

而我们之前设想将这两个组件分别存放在portletconfig文件夹下,因此我们可以使用defineAsyncComponent方式来构建组件异步加载的工厂函数:

//自行准备两个基本组件
const errorComponent;
const loadingComponent;

function portletFactory (path) {
  return defineAsyncComponent({
    loader: () => import(`/src/portlet/${path}`).catch(() => errorComponent),
    loadingComponent,
    errorComponent,
  })
}

function configFactory (path) {
  return defineAsyncComponent({
    loader: () => import(`/src/config/${path}`).catch(() => errorComponent),
    loadingComponent,
    errorComponent,
  })
}

现在,我们可以这样去构建我们的PortletLoader,并公开一个可以开启配置的方式:

// PortletLoader.js
// 需要准备一个编辑器容器
const ConfigContainer;
export default {
  props: ['path', 'prop', 'style', 'isEdit'],
  emits: ['update:prop', 'update:style', 'option'],
  setup (props, { slots, expose }) {
    const open = ref(false)
    const PortletRef = shallowRef(loadingComponent);
    const PropFormRef = shallowRef()
    const AsyncComponent = () => (
      <PortletRef
        {...props.prop}
        v-slots={ slots }
      />
    )

    watch(() => props.path, () => {
      PortletRef.value = portletFactory(props.path);
      props.isEdit && PropFormRef.value = configFactory(props.path)
    }, { immediate: true })
    expose({
      config () {
        open.value = true
      }
    })
    return () => (
      <>
        <AsyncComponent />
        {
          !props.isEidt ? null:
            <ConfigContainer v-model:open={open.value} style={props.style}>
              { PropFormRef.value ? <PropFormRef.value/> : null }
            </ConfigContainer>
        }
      </>
    )
  }
}

StyleForm

样式编辑的表单就比较简单,只要定义一个props是可编辑的样式对象即可:

<template>
  <a-form :model="style">
    <a-form-item label="背景色">
      <a-input v-model:value="style.background" />
    </a-form-item>
  </a-form>
</template>

<script>
export default {
  inheritAttrs: false,
  props: ["style"],
  setup(props) {
    const backup = props.style || {};
    const style = ref({ ...backup });
    function reset() {
      record.value = { ...backup };
    }
    function clear() {
      record.value = {};
    }
    function onFormChange() {
      // 当表单数据变化时触发
    }
    watch(record, onFormChange, { deep: true, immediate: true });
    return {
      style,
      reset,
      clear,
    };
  },
};
</script>

该表单可以编辑,也可以重置和清空表单。在 23 行,我们预留一个逻辑,这里是需要将表单更新的数据传递到上层的。至此,我们的基础组件就已经构建完成了。

Portal 容器

现在,我们需要一个可以提供Portlet进行展示的容器Portal,我们使用grid-layout组件进行布局:

<template>
  <grid-layout ref="gridRef" v-model:layout="layout">
    <grid-item
      v-for="item in layout"
      ref="itemRef"
      :key="item.i"
      v-bind="item"
      :i="item.i"
      :x="item.x"
      :y="item.y"
      :h="item.h"
      :w="item.w"
    >
      <portlet-loader
        v-if="item.component"
        ref="asyncRef"
        v-model:props="item.props"
        v-model:style="item.style"
        :path="item.component"
        is-edit
      />
    </grid-item>
  </grid-layout>
</template>

<script setup>
const gridRef = shallowRef();
const itemRef = shallowRef();
const asyncRef = shallowRef();
const layout = ref([]);
</script>

由于篇副问题,这里不会把全部代码展示出来,对门户进行编辑的方式有很多种,为了更好讲解这个例子,我们使用代码的方式进行讲解。

主数据对象layout是一个数组,数组的每一项结构如下:

{
  i: Number/String,     // 容器编号,确保唯一
  x: Number,            // 容器坐标
  y: Number,            // 容器坐标
  h: Number,            // 容器高度
  w: Number,            // 容器宽度
  maxH: Number,         // 容器最大高度
  maxW: Number,         // 容器最大宽度
  // ------------------
  component: String,    // 组件路径
  props: Object,        // 组件属性
  style: Object         // 组件样式
}

上半部分是grid-layout的原生属性,下半部分是我们拓展之后引入到PortletLoader里的属性。

现在我们公开layout到浏览器控制台,然后给他 push 一条数据:

{ component: '/card.vue', i: Date.now(), x: 4, y: 4 }

不出意外的话,页面上是可以看到卡片内容的。现在我们可以通过控制台找到 vue 开发者工具,然后找到该组件下的ConfigContainer 打开open变量,我们就能看到ConfigContainer打开了。

1700017625043.jpg

现在我们遇到一个问题,样式编辑的数据如何同步到PortletLoader上。

Style 同步

由于中间组件嵌套了很多层,所以,可以使用provide/inject来解决这个问题:

// PortletLoader.js
provide("onStyleChange", (style) => emit("update:style", style));
// StyleForm.vue
const onStyleChange = inject("onStyleChange", () => {});
function onFormChange() {
  const style = { ...record.value };
  onStyleChange(style);
}
  1. PortletLoader定义onStyleChange函数,通过链条PortletLoader --> ConfigContainer --> StyleForm传递到StyleForm中;
  2. StyleForm获取到更新函数,并将当前组件更新的样式,回传给祖先组件;
  3. PortletLoader将后代组件更新的样式同步给上层调用方的v-model:style

按照这条链路,我们的style编辑就生效了;

PortletConfig

我们编辑组件属性时,发现页面没有呈现,此时,我们需要实现PortletConfig,并跟一个步骤类似,将props能够传递给PortletLoader,我们可以如法炮制:

// PortletConfig.js
export default {
  props: ["option"],
  setup() {
    // 获取组件原先的属性
    const getProps = inject(PORTLET_GET_PROPS, () => reactive({}));
    // 更新组件容器属性的配置
    const updateOption = inject("updateOption", () => {});
    // 将组件原先的属性绑定到当前表单对象上
    const record = reactive(getProps());
    // 备份,用于还原
    const recordBackup = { ...toRaw(record) };
    // 还原表单
    function reset() {
      clean();
      Object.assign(record, recordBackup);
    }
    // 清空表单
    function clean() {
      const keys = Object.keys(record);
      for (const key of keys) {
        Reflect.deleteProperty(record, key);
      }
    }
    // 更新组件容器属性
    updateOption(props.option || {});
    return () => <>{slots?.default?.(record, reset, clean)}</>;
  },
};

现在,PortletConfig需要祖先组件PortletLoader定义这些函数。并且将渲染后组件的默认值(props定义的值)和当前传入的值做合并后,同步给Portal:

// PortletLoader.js
const portletRef = ref();
const AsyncComponent = () => (
  <PortletRef.value
    {...props.compProps}
    ref={portletRef}
    onVnodeMounted={onPortletMounted}
    v-slots={slots}
  />
);
function onPortletMounted() {
  if (![loadingComponent, errorComponent].includes(PortletRef.value)) {
    emit("update:pro", {
      ...portletRef.value.$props, // 组件原始props
      ...props.prop, // 当前loader传入的配置值
    });
  }
}
provide("getProps", () => props.compProps);
provide("updateStyle", (style) => emit(UPDATE_STYLE_EVENT, style));
provide("updateOption", (option) => emit(CONFIG_OPTION_EVENT, option));

回顾流程

最后我们再来看看这张图,回顾一下流程:

框架图.png

页面渲染流程

  1. 使用<portlet-loader />组件,并传入所需要的stylepropspath属性;
  2. PortletLoader根据构造器异步获取portletconfig文件夹下的文件,分别解析为PortletComponentPropsForm
  3. PortletLoader挂载PortletComponentConfigContainer;
  4. PortletComponent挂载时,将内部定义的$props同步给PortletLoader
  5. ConfigContainer挂载插槽和StyleForm,插槽挂载PropsForm

数据更新流程

先来看绿色的style数据更新流程:

  1. <portlet-loader />接受外部数据,获取style数据;
  2. PortletLoader渲染ConfigContainer时,将style数据同步到ConfigContainer组件中;
  3. ConfigContainer渲染StyleForm时,将style数据同步到StyleForm组件中;
  4. StyleFormstyle数据做响应式绑定,并在数据更新后,通过inject:updateStyle将数据同步给PortletLoader组件;
  5. PortletLoader通过provide:updateStyle接受StyleForm更新后的style,并通过emit提交给父组件实现update:style

再来看橙色的props数据更新流程:

  1. <portlet-loader />接受外部数据,获取props数据;
  2. PortletLoaderprops数据绑定到AsyncComponent组件中;
  3. PortletLoader异步加载PortletComponent,在其onMounted时,捕获原始定义的$props数据,同步给当前props;
  4. PortletLoader通过provide:getProps暴露props原始数据给子组件;
  5. PortletLoader加载的ConfigContainer会加载空的插槽slot,该插槽最终会插入开发者自定义的PropsForm,并使用PortletConfig组件;
  6. PortletConfig组件插槽通过inject:getProps捕获PortletLoader传递的props数据,交给PropsForm进行双向绑定编辑;

最后隐藏的option数据更新流程相对简单,配置<portlet-config />组件时,可以传入option参数:

{
  w: Number,        // 容器的宽度
  h: Number,        // 容器的高度
  maxW: Number,     // 容器的最大宽度
  maxH: Number,     // 容器的最大高度
}

然后这个参数会通过updateOption同步到PortletConfig,最终以@option事件形式同步给父组件,由开发者决定合并的逻辑。

现在,我们可以在项目中使用门户组件进行渲染了,在开发代码时,配置和组件分离:

iShot_2023-11-15_13.54.12.png

最后

我们可以根据自己的需要设计添加portlet的页面,拖拽、勾选等方式都是可行的。这是我所实现的方式,请看VCR

9_1699961434.gif