前言

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

但有的客户希望这个首页能够尽可能的定制化,因此需要我们开发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组件容器属性,定义每个容器的基本尺寸,由开发人员配置;

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

初期设想

无倾入式开发

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

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

代码如下:

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

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

回顾流程

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

页面渲染流程

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

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

最后

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


loong
234 声望35 粉丝

看到问题不代表解决问题,系统化才能挖掘问题的本真!