前言
通常,我们的站点首页或者根路径上,都会有整个应用的关注性导航或者视图。有时,我们会称之为“工作台”或者“首页”:
但有的客户希望这个首页能够尽可能的定制化,因此需要我们开发Portal来满足客户的需求。
Portal & Portlet
Portlet是可以提供基于 WEB 的内容、应用程序和其他资源访问的可重用组件。从用户角度上,首页上的一个个独立的块就是一个Portlet,而多个Portlet所组合的应用就叫做Portal。
了解了这样一点,我们就可以提出Portal组件的设计需求:
Portal组件和Portlet组件的开发,其中Portal是容器,Portlet是子容器;Portal用于装载子容器Portlet,为Portlet提供数据和样式属性;Portlet用于装载客制化组件Component,为Component提供数据,并暴露组件可配置化表单;
架构图
有个业务上的需求,我们再把架构上的设计理一理,经过整理发现,开发这套东西的底层,需要包含 2 个核心:
PortletLoader,用于加载组件(AsyncComponent)和配置器(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、PropsFormPortletLoader挂载PortletComponent和ConfigContainer;PortletComponent挂载时,将内部定义的$props同步给PortletLoaderConfigContainer挂载插槽和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