前端技术日益发展,组件化日益成熟,作为一个前端,每天的工作就是用组件堆砌页面,有没有一种方式可以像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(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 () => (
自定义输入框
);
},
});
复制代码
三、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,
},
},
setup(props) {
const styles = computed(() => ({
top: `${props.block?.top}px`,
left: `${props.block?.left}px`,
}));
return () => (
这是一条block
);
},
});
复制代码
将定义的数据用v-model传入editor
App.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 },
],
},
};
},
});
复制代码
引入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,
},
},
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 () => (
menuheadoperator
{(dataModel.value?.blocks || []).map((block, index: number) => (
))}
);
},
});
复制代码
最终效果
画布会根据我们定义的editorData对象,来进行展示,container来描述画布的大小,block来描述在画布上的每个组件