这是我参与「第四届青训营 」笔记创作活动的的第7天
1. 需求
要求开发一个 Web 页面编辑器,支持通过拖拽方式编辑页面内容并生成最终可视页面,核心功能:
-
支持
button、图片、视频、文本、链接等基础组件 -
支持自定义组件动作(单击、双击等)逻辑
2. 技术栈
vue3、ts、 pinia、vite
3. 布局
分为三栏:
-
组件库:存放基础组件,如
button、图片、视频、文本、链接等 -
预览区域(画布):进行页面的组装
-
操作台:进行组件的配置
4. 操作流程
5. 技术实现点
5.1、维护json树
预览(画布)区域主要是承载组件和组件的拖拽,我们需要一个顶级对象,来存储全局配置信息,以及组件元数据及样式信息。
let tree = {
config: {
name: '', //页面名称
size: {
}, //页面尺寸
background: {
}, //页面背景信息
},
elements: [ //存放该页面所放入的组件
{ //元素
code: "MyInput", //组件名 Component
name: "输入框",
desc: "输入框的描述",
icon: "input",
props: {
name: "字段名称",
label: "label名称",
labelCol: "",
wrapperCol: "",
required: false,
},
}
],
}
我们大部分操作都基于这个 tree
5.2、组件拖拽
实现如图的效果:自由在画布区域拖拽,标尺、显示参考线
实现标尺:
- 标尺插件-
vue-sketch-ruler
支持功能:
- 该插件支持通过点击标尺,添加参考线
- 该插件支持通过鼠标悬浮参考线顶部或左部,显示移除按钮,或直接拖出边界外部
- 该插件支持显示或隐藏参考线
- 该插件支持标尺响应视图的缩放
- 该插件支持显示或隐藏线标尺
实现拖拽:
- 使用
vuedraggable插件进行拖拽实现
<template>
<a-row style="padding: 20px">
<a-col span="10">
<h3>列表区域</h3>
<draggable
class="dragArea list-group"
:list="list1"
:sort="false"
:group="{ name: 'people', pull: 'clone', put: false }"
>
<div class="list-group-item" v-for="element in list1" :key="element.name" >
{{ element.name }}
</div>
</draggable>
</a-col>
<a-col span="10" offset="4">
<h3>目标区域</h3>
<draggable
class="dragArea list-group"
:list="list2"
group="people"
>
<div class="list-group-item" v-for="element in list2" :key="element.name" >
{{ element.name }}
</div>
</draggable>
</a-col>
</a-row>
</template>
<script>
import draggable from "vuedraggable";
export default {
components: {
draggable
},
data() {
return {
list1: [
{ name: "组件1", id: 1 },
{ name: "组件2", id: 2 },
{ name: "组件3", id: 3 },
{ name: "组件4", id: 4 }
],
list2: [
{ name: "画布组件1", id: 5 },
{ name: "画布组件2", id: 6 },
{ name: "画布组件3", id: 7 }
]
};
}
};
</script>
<style scoped>
.list-group-item {
height: 30px;
border: 1px solid #888888;
}
</style>
上面的demo定义了两个区域,列表区域和目标区域,并定义了两个数组,list1和list2。列表区域和目标区域分别使用list1数组和list2数组进行遍历渲染。当我们将列表区域的组件3拖动到目标区域时,我们打印list2变量的数据,就会 发现组件3被复制到了list2中,即复制到了目标区域。细心的小伙伴已经发现,唉!这不就是我们页面设计器组件拖动到画布中的实现方式嘛!是的,设计器中的拖动原理就是这样简单。
支持拖动的区域需要使用<draggable>组件进行包裹,<draggable>组可以添加onAdd、onStart、onEnd及move事件回调函数,我们可以在这些事件中添加一些我们需要的逻辑。例如,我们可以在onAdd函数中对我们添加到list2数组列表中的对象动态的添加一个唯一值id,用于我们区分同一个页面拖入两个相同组件的情况。
注意:这个地方没有显示
josn树,只显示组件的拖拽,维护在操作台处讲解
组件的设计:
组件库列表,列表中的每个组件,我们都需要使用一段json来进行描述,这段json我们将它称之为 元数据,元数据中描述了当前组件的中文名称,在列表中显示的图标及描述,和组件可进行配置的一些动态属性。我们以输入框组件为例,它的元数据大致可以定义为如下的样子:
{
code: "MyInput",
name: "输入框",
desc: "输入框的描述",
icon: "input",
props: {
name: "字段名称",
label: "label名称",
labelCol: "",
wrapperCol: "",
required: false,
}
}
组件拖拽后如何渲染到预览区:
首先,把画布区域抽象为一个列表(存放组件以及布局 json树),组件就是获得拖拽组件的元数据对象插入到列表中即可
<template>
<div v-for="item in list2" :key="item.id">
<my-input v-if="item.code === 'MyInput'" :data="item"/>
<my-select v-if="item.code === 'MySelect'" :data="item"/>
...
</div>
</template>
组件的拖拽、复制、删除:
- 组件的拖拽,改变组件列表中的组件的顺序即可
-
复制:
- 我们已经知道,画布中的组件是通过
list2遍历渲染出的。当点击复制操作时,只需要将当前被点击复制按钮的组件所对应的元数据添加到list2中就可以了。这里需要注意,在复制元数据的时候,我们需要将id属性值进行累加计算,这样才能区分被复制的组件和复制生成的组件。
- 我们已经知道,画布中的组件是通过
- 删除组件列表的组件即可
组件嵌套:
- 实现组件嵌套我们需要在组件元数据中添加一个属性
children,如下所示:
[{
code: "MyCard",
name: "卡片",
props: {
...卡片组件相关配置属性
},
children: [{
code: "MyContainer",
name: "容器",
props: {
...容器组件相关配置属性
},
children: [{
....
}]
}]
}]
为了满足嵌套组件的需求,我们需要做两个方面的调整。
- 布局类的组件能够继续拖入组件
我们在容器类组件内部再次引用<draggable>组件,组件的list参数值为容器组件元数据的children数组,然后在draggable组件内部使用插槽将children进行渲染。容器组件的模板大致是下面的样子
<template>
<div>
<draggable class="dragArea list-group" :list="data.children" handle=".drag-icon"
@add="handleAdd" @start="handleStart" @end="handleEnd">
<slot>
</slot>
</draggable>
</div>
</template>
- 画布能够按照嵌套组件进行显示
对于无嵌套组件的页面,渲染时我们只需要对list2数组进行遍历渲染就可以了。但是具有嵌套组件的页面这种简单的for循环就无法满足了,我们需要对组件进行深层循环遍历。例如下面的实例代码。
export default {
props: {
data: Array,
},
methods: {
renderTree (list, id) {
return list.map((item) => {
return (
<content-item data={item} id={id} >
{item.children && item.children.length ? this.renderTree(item.children, item.id) : null}
</content-item>
);
});
}
},
render: function (h) {
return (
<div>
{this.renderTree(this.data)}
</div>
);
}
};
这样我们就扫清了拖动的一切障碍。下面,让我们把目光聚焦到页面设计器右侧的属性配置区域。
5.3、操作台
对画布中的组件添加点击事件,当点击某个组件时,我们能够获取到当前点击组件的组件类型,例如输入框、下拉选择等等,针对每一种组件,我们已经提前在元数据中的props属性定义了这个组件能够进行动态控制的参数,我们只需要将这些参数以合适的表单形式展示在右侧的属性配置区域就可以了,例如,按钮组件的props中有一个text属性,用来控制按钮的显示文案,那么我们就在右侧属性控制区域用一个输入框来做为控制这个属性的表单形式,当修改数据时,我们找到list2中该组件所在的元数据对象,然后将该对象中props属性中text属性值修改为输入框中的内容。每个组件都会接受这个组件对应的元数据props参数,然后根据参数值进行渲染。例如按钮组件,现在按钮的文案时,我们可以使用props.text进行显示。
操作栏区域:
从上面的文章中可以看出,一个页面实际就是用一段带有层级结构的json 树来进行描述的。
预览:
在上面讲解画布区域时,我们已讲到组件如何通过json进行渲染。预览以及真实的页面渲染实际和画布中组件的展示实现原理完全一致。其中的区别有两点:(1)画布中的组件不支持交互操作,这里,我们需要屏蔽画布中组件的交互操作。我们可以通过css中的after伪类,设置content为""来实现。(2)画布中的组件需要包裹一个div,这个div包含了复制、删除等功能。
5.4、自定义组件动作(单击、双击等)
通过对画布中的组件添加点击事件,当点击某个组件时,获取组件配置在操作台展示。
我们可以创建一系列可选的自定义事件,添加到组件上即可。
5.5、撤销重做
- 利用堆栈,记录每一次的操作记录,放入到一个数组中,通过出栈、入栈实现
5.6、页面预览与发布
页面保存
两种方式:保存 json树
- 保存到本地,通过
localStroage
- 保存到数据库,建立后台
预览
- 拿到页面
json树,进行遍历渲染
6、参考
文章
github项目