Vsee架构——如何设计Portal

推送通
• 阅读 618

前言

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

Vsee架构——如何设计Portal

但有的客户希望这个首页能够尽可能的定制化,因此需要我们开发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,用于加载组件(PortletComponent)和配置器(ConfigContainer),并将组件props暴露出去;
  2. ConfigContainer,用于配置组件的容器,包含两个部分

    1. PropForm,由开发人员自定义配置的组件属性编辑器,接受PortletLoader暴露的props数据
    2. StyleForm,内置的UI样式属性编辑器

他们的结构层次应该如下:

PortletLoader
    AsyncComponent
    ConfigContainer
        PropsForm<slot>
        StyleForm

可操配属性有:

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

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

Vsee架构——如何设计Portal

初期设想

无倾入式开发

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

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构成由属性编辑和样式编辑构成的页面:

Vsee架构——如何设计Portal

代码如下:

<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打开了。

Vsee架构——如何设计Portal

现在我们遇到一个问题,样式编辑的数据如何同步到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))

回顾流程

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

Vsee架构——如何设计Portal

页面渲染流程

  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事件形式同步给父组件,由开发者决定合并的逻辑。

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

Vsee架构——如何设计Portal

最后

我们可以根据自己的需要设计添加portlet的页面,拖拽、勾选等方式都是可行的。这是我所实现的方式,请看vcr:
https://www.bilibili.com/video/BV1Yb4y1g7at/?aid=620891786&ci...

点赞
收藏
评论区
推荐文章
blmius blmius
4年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Oracle 分组与拼接字符串同时使用
SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(
Wesley13 Wesley13
4年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
Peter20 Peter20
4年前
mysql中like用法
like的通配符有两种%(百分号):代表零个、一个或者多个字符。\(下划线):代表一个数字或者字符。1\.name以"李"开头wherenamelike'李%'2\.name中包含"云",“云”可以在任何位置wherenamelike'%云%'3\.第二个和第三个字符是0的值wheresalarylike'\00%'4\
Stella981 Stella981
4年前
Python+Selenium自动化篇
本篇文字主要学习selenium定位页面元素的集中方法,以百度首页为例子。0.元素定位方法主要有:id定位:find\_element\_by\_id('')name定位:find\_element\_by\_name('')class定位:find\_element\_by\_class\_name(''
Wesley13 Wesley13
4年前
4cast
4castpackageloadcsv.KumarAwanish发布:2020122117:43:04.501348作者:KumarAwanish作者邮箱:awanish00@gmail.com首页:
Wesley13 Wesley13
4年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
4年前
Java日期时间API系列36
  十二时辰,古代劳动人民把一昼夜划分成十二个时段,每一个时段叫一个时辰。二十四小时和十二时辰对照表:时辰时间24时制子时深夜11:00凌晨01:0023:0001:00丑时上午01:00上午03:0001:0003:00寅时上午03:00上午0
Stella981 Stella981
4年前
Docker 部署SpringBoot项目不香吗?
  公众号改版后文章乱序推荐,希望你可以点击上方“Java进阶架构师”,点击右上角,将我们设为★“星标”!这样才不会错过每日进阶架构文章呀。  !(http://dingyue.ws.126.net/2020/0920/b00fbfc7j00qgy5xy002kd200qo00hsg00it00cj.jpg)  2
为什么mysql不推荐使用雪花ID作为主键
作者:毛辰飞背景在mysql中设计表的时候,mysql官方推荐不要使用uuid或者不连续不重复的雪花id(long形且唯一),而是推荐连续自增的主键id,官方的推荐是auto_increment,那么为什么不建议采用uuid,使用uuid究