前言
通常,我们的站点首页或者根路径上,都会有整个应用的关注性导航或者视图。有时,我们会称之为“工作台”或者“首页”:
但有的客户希望这个首页能够尽可能的定制化,因此需要我们开发Portal
来满足客户的需求。
Portal & Portlet
Portlet
是可以提供基于WEB的内容、应用程序和其他资源访问的可重用组件。从用户角度上,首页上的一个个独立的块就是一个Portlet
,而多个Portlet
所组合的应用就叫做Portal
。
了解了这样一点,我们就可以提出Portal
组件的设计需求:
Portal
组件和Portlet
组件的开发,其中Portal
是容器,Portlet
是子容器;Portal
用于装载子容器Portlet
,为Portlet
提供数据和样式属性;Portlet
用于装载客制化组件Component
,为Component
提供数据,并暴露组件可配置化表单;
架构图
有个业务上的需求,我们再把架构上的设计理一理,经过整理发现,开发这套东西的底层,需要包含2个核心:
PortletLoader
,用于加载组件(PortletComponent
)和配置器(ConfigContainer
),并将组件props
暴露出去;ConfigContainer
,用于配置组件的容器,包含两个部分PropForm
,由开发人员自定义配置的组件属性编辑器,接受PortletLoader暴露的props
数据StyleForm
,内置的UI样式属性编辑器
他们的结构层次应该如下:
PortletLoader
AsyncComponent
ConfigContainer
PropsForm<slot>
StyleForm
可操配属性有:
props
组件属性,例如标题、名称、接口地址之类的,是由开发者开发portlet
时暴露出来的,用户可自定义;style
组件外层容器样式,例如背景色、边框等,是应用内置好的常用配置并暴露出来,用户可自定义;option
组件容器属性,定义每个容器的基本尺寸,由开发人员配置;
先配上已经完成后的架构图,后面会讲解这个图的流程:
初期设想
无倾入式开发
我们的初期设想就是建立如下的文件路径:
src
portlet
card.vue
config
card.vue
在我们项目的目录下分别设有portlet
和config
两个文件夹,里面有同名文件,在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
应当包含两个主要部分:
- AsyncComponent——门户组件
- ConfigContainer——门户组件配置
而我们之前设想将这两个组件分别存放在portlet
和config
文件夹下,因此我们可以使用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)
}
PortletLoader
定义onStyleChange
函数,通过链条PortletLoader --> ConfigContainer --> StyleForm
传递到StyleForm
中;StyleForm
获取到更新函数,并将当前组件更新的样式,回传给祖先组件;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))
回顾流程
最后我们再来看看这张图,回顾一下流程:
页面渲染流程
- 使用
<portlet-loader />
组件,并传入所需要的style
、props
、path
属性; PortletLoader
根据构造器异步获取portlet
和config
文件夹下的文件,分别解析为PortletComponent
、PropsForm
PortletLoader
挂载PortletComponent
和ConfigContainer
;PortletComponent
挂载时,将内部定义的$props
同步给PortletLoader
ConfigContainer
挂载插槽和StyleForm
,插槽挂载PropsForm
数据更新流程
先来看绿色的style
数据更新流程:
<portlet-loader />
接受外部数据,获取style
数据;PortletLoader
渲染ConfigContainer
时,将style
数据同步到ConfigContainer
组件中;ConfigContainer
渲染StyleForm
时,将style
数据同步到StyleForm
组件中;StyleForm
对style
数据做响应式绑定,并在数据更新后,通过inject:updateStyle
将数据同步给PortletLoader
组件;PortletLoader
通过provide:updateStyle
接受StyleForm
更新后的style
,并通过emit
提交给父组件实现update:style
;
再来看橙色的props
数据更新流程:
<portlet-loader />
接受外部数据,获取props
数据;PortletLoader
将props
数据绑定到AsyncComponent
组件中;PortletLoader
异步加载PortletComponent
,在其onMounted
时,捕获原始定义的$props
数据,同步给当前props
;PortletLoader
通过provide:getProps
暴露props
原始数据给子组件;PortletLoader
加载的ConfigContainer
会加载空的插槽slot
,该插槽最终会插入开发者自定义的PropsForm
,并使用PortletConfig
组件;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...
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。