可视化拖拽页面编辑器 一

15,503 阅读3分钟

前端技术日益发展,组件化日益成熟,作为一个前端,每天的工作就是用组件堆砌页面,有没有一种方式可以像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来描述在画布上的每个组件

第三部分代码

完整代码 GitHub

下一节:左侧组件菜单、组件拖拽渲染和组件的选中与移动