前端技术日益发展,组件化日益成熟,作为一个前端,每天的工作就是用组件堆砌页面,有没有一种方式可以像CocosCreator,通过组件+脚本绑定的方式来实现我们的页面和功能,今天我们就来实现一个提高生产力的工具
可视化拖拽页面编辑器
, 让产品和UI通过拖拽编辑页面,生产自己想要的页面。
技术框架采用Vue3 + Typescript + ElementPlus
每个章节下边都会贴出对应commit代码,方便大家对比学习
最终效果
实现功能:
- 主页面结构:左侧可选组件列表、中间容器画布、右侧编辑组件定义好的属性
- 从菜单拖拽组件到容器;
- 单选、多选;
- 容器内的组件可以拖拽移动位置;
- 组件拖拽调整宽高;
- 组件拖拽贴边,显示辅助线;
- 操作栏按钮与命令
- 撤销、重做;
- 导入、导出;
- 置顶、置底;
- 删除、清空;
- 组件绑定值;
- 根据组件标识,通过作用域插槽自定义某个组件的行为 预览地址
一、项目搭建与页面布局
通过vue-cli生成项目
vue create visual-editor-vue
- 选择手动配置 选择配置如下:
- 选择vue3.x版本
- 这一步选y,使用jsx写组件,需要添加对应的babel插件
接下来我们来实现基本的左中右布局
- 左侧菜单栏放置组件列表
- 中间是画布和工具栏,用来编辑预览页面
- 右侧是我们选中某个组件后,显示的该组件的属性
第一部分代码:基本布局
二、数据结构设计与双向绑定实现
数据结构设计
- 定义数据结构如下
- container 表示画布容器
- blocks 表示放置在容器中的组件
- 每个block表示一个组件,包含了组件的类型位置、宽高、选中状态等信息
- 画布采用绝对定位,里面的元素通过top、left来确定位置
{
"container": {
"height": 500,
"width": 800
},
"blocks": [
{
"componentKey": "button",
"top": 102,
"left": 136,
"adjustPosition": false,
"focus": false,
"zIndex": 0,
"width": 0,
"height": 0
},
{
"componentKey": "input",
"top": 148,
"left": 358,
"adjustPosition": false,
"focus": false,
"zIndex": 0,
"width": 244,
"height": 0
}
]
}
数据双向绑定实现
- 组件采用vue3中的jsx语法编写,需要实现数据双向绑定机制,useModel就是用来处理数据双向绑定的
import { computed, defineComponent, ref, watch } from "vue";
// 用jsx封装组件的时候,实现双向数据绑定
export function useModel<T>(getter: () => T, emitter: (val: T) => void) {
const state = ref(getter()) as { value: T };
watch(getter, (val) => {
if (val !== state.value) {
state.value = val;
}
});
return {
get value() {
return state.value;
},
set value(val: T) {
if (state.value !== val) {
state.value = val;
emitter(val);
}
},
};
}
useModel用法
// modelValue 外部可以用v-model绑定
export const TestUseModel = defineComponent({
props: {
modelValue: { type: String },
},
emits: {
"update:modelValue": (val?: string) => true,
},
setup(props, ctx) {
const model = useModel(
() => props.modelValue,
(val) => ctx.emit("update:modelValue", val)
);
return () => (
<div>
自定义输入框
<input type="text" v-model={model.value} />
</div>
);
},
});
第二部分代码
三、Block渲染
- 新建visual-editor-block的组件
- block来表示在画布显示的组件元素
- block先用文本来显示
import { computed, defineComponent, PropType } from "vue";
import { VisualEditorBlockData } from "./visual-editor.utils";
export const VisualEditorBlock = defineComponent({
props: {
block: {
type: Object as PropType<VisualEditorBlockData>,
},
},
setup(props) {
const styles = computed(() => ({
top: `${props.block?.top}px`,
left: `${props.block?.left}px`,
}));
return () => (
<div class="visual-editor-block" style={styles.value}>
这是一条block
</div>
);
},
});
- 将定义的数据用v-model传入editor App.vue文件
<template>
<div class="app">
<visual-editor v-model="editorData" />
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { VisualEditor } from "../src/packages/visual-editor";
export default defineComponent({
name: "App",
components: { VisualEditor },
data() {
return {
editorData: {
container: {
height: 500,
width: 800,
},
blocks: [
{ top: 100, left: 100 },
{ top: 200, left: 200 },
],
},
};
},
});
</script>
- 引入block组件,并进行渲染 visual-editor.tsx文件
import { computed, defineComponent, PropType } from "vue";
import { useModel } from "./utils/useModel";
import { VisualEditorBlock } from "./visual-editor-block";
import "./visual-editor.scss";
import { VisualEditorModelValue } from "./visual-editor.utils";
export const VisualEditor = defineComponent({
props: {
modelValue: {
type: Object as PropType<VisualEditorModelValue>,
},
},
emits: {
"update:modelValue": (val?: VisualEditorModelValue) => true,
},
setup(props, ctx) {
const dataModel = useModel(
() => props.modelValue,
(val) => ctx.emit("update:modelValue", val)
);
const containerStyles = computed(() => ({
width: `${props.modelValue?.container.width}px`,
height: `${props.modelValue?.container.height}px`,
}));
return () => (
<div class="visual-editor">
<div class="menu">menu</div>
<div class="head">head</div>
<div class="operator">operator</div>
<div class="body">
<div class="content">
<div class="container" style={containerStyles.value}>
{(dataModel.value?.blocks || []).map((block, index: number) => (
<VisualEditorBlock block={block} key={index} />
))}
</div>
</div>
</div>
</div>
);
},
});
-
最终效果
-
画布会根据我们定义的
editorData
对象,来进行展示,container来描述画布的大小,block来描述在画布上的每个组件