从零开始开发一个低代码表单设计器

21 阅读28分钟

写在前面

为啥会写这么个项目呢,倒不是觉得表单设计器在开发中多么的有用,能提高开发效率啥的,其实是因为面试被问到低代码相关的东西,答:不太了解。不太确定是不是因为这个被挂了,但是面了几家都问过低代码,于是决定自己写一个看看到底怎么个事儿。

项目仓库

演示地址

低代码表单设计器

低代码表单设计器(Low-Code Form Designer)是一种允许用户通过图形界面而非传统的手动编程方式设计、创建和定制表单的工具。这种工具通常用于简化应用程序的开发过程,特别是那些涉及大量表单和输入界面的应用。

低代码表单设计器的主要优势在于:

  1. 易于使用:对于非专业程序员来说,低代码表单设计器提供了一个直观、易用的图形界面,使得创建和修改表单变得简单明了。
  2. 提高效率:由于大量的重复性工作被自动化,开发者可以更快地创建和部署表单,从而提高了工作效率。
  3. 灵活性:低代码表单设计器通常提供了一系列预制的组件和模板,用户可以根据需要选择和组合这些组件,以创建出符合自己需求的表单。
  4. 降低成本:由于开发过程被大大简化,企业可以减少在表单开发上的投入,从而降低开发成本。

低代码表单设计器通常支持多种功能,如数据验证、条件逻辑、自定义样式等,使得用户能够创建出功能丰富、外观美观的表单。同时,这些工具通常也支持与其他系统的集成,如数据库、CRM系统等,使得表单数据能够方便地与其他系统进行交互。

虽然低代码表单设计器提供了许多便利,但也有一些限制。例如,对于特别复杂或特定的需求,可能仍需要手动编程来实现。此外,由于这些工具通常提供的是预设的组件和模板,因此在某些情况下,可能无法完全满足用户的个性化需求。

我要实现的就是通过拖拽生成表单,然后可以生成相关代码供前端开发者使用的一个东西(真的有人会用吗,笑),做不到直接生成一套网站流程的效果,那玩意我一个人属实写不来。 image.png

设计器组件结构

总的来说,主要是两个组件

  1. 表单设计器组件
  2. 表单渲染器组件

image.png

表单设计器组件

进入网站映入眼帘的就是设计器组件,该组件包含了以下部分

  1. 左侧物料展示区
  2. 上方工具栏
  3. 中间物料渲染区
  4. 右侧物料属性编辑区

代码结构如下,每块部分的代码各自对应一个文件夹 image.png

一、物料展示区

先说明一下物料,其实就是可供选择的表单组件。这块的功能就是展示所有物料,然后通过拖拽或者双击,将选择的物料渲染到中间的物料渲染区。

  1. 选择一款拖拽插件或者原生手写拖拽功能。我是选择的vue-draggable,这里有一份文档可供参考
  2. 根据业务确认要封装哪些物料,并且它存在哪些属性
  3. 定义好了一系列的物料JSON,把它显示到左侧物料展示区

1. 定义物料

// 定义物料属性
// 容器物料
export const containerFields = [
  {
    type: 'grid',
    displayName: '栅格',
    category: 'container',
    icon: 'Setting',
    cols: [],
    options: {
      propName: '',
      hidden: false,
      colHeight: '50', // 统一列高度
      optionItem: [],
      gutter: 0, //列间距
      justify: '',
      align: '',
    },
  },
  {
    type: 'grid-col',
    displayName: '栅格列',
    category: 'container',
    hidden: true, // 不显示容器元素的子元素
    widgetList: [],
    options: {
      propName: '',
      span: 12,
      offset: 0,
      push: 0,
      pull: 0,
    },
  },
]
// 基础物料
export const basicFields = [
  {
    type: 'input',
    displayName: '单行输入',
    icon: 'House',
    options: {
      propName: '', // 字段名称
      defaultValue: '', // 初始值
      label: 'input',
      labelAlign: 'left',
      labelWidth: '80',
      labelHidden: false,
      isRequired: false,
      requiredMessage: '请输入', // 必填校验提示
      rule: '', // 校验
      errorMessage: '', // 校验失败提示
      value: '', // 绑定值
      displayType: 'text',
      size: 'default',
      placeholder: '请输入',
      readonly: false,
      disabled: false,
      clearable: false,
      hidden: false,
      onBlur: '',
      onFocus: '',
      onChange: '',
      onInput: '',
      onClear: '',
    },
  }
]

我把大多数物料都删除掉了,只保留了容器物料和一个基础物料,咱们在这篇文章中尽量简化,只保证能走完全流程即可。

1.1 containerFields(容器物料)

为什么要有容器物料,其实我刚开始写该项目时并没有考虑到容器物料,只想写一些基础物料上去就完事,结果到后面才发现这玩意写出来是毫无用处,没有容器物料连基本的布局都做不到。

那什么是容器物料,看了上面的话想必也能理解,容器物料就是用来布局的物料,整个表单其实也是一个容器,如果没有容器物料,那就是所有基础物料都包含在表单这一个容器里,现在我们定义了栅格容器,就是为了在容器内完成布局。

1.2 basicFields(基础物料)

这里就是表单中需要的组件,比如单选框、复选框、输入框等。

2. 展示物料

定义好物料之后在页面上展示

 <draggable
  v-model="containerFields"
  :group="{ name: 'dragGroup', pull: 'clone', put: false }"
  :clone="handleFieldWidgetClone"
>
  <template #item="{ element }">
    <div
      v-if="!element.hidden"
      class="item move"
      @dblclick="addFieldByDbClick(element)"
    >
      {{ element.displayName }}
    </div>
  </template>
</draggable>

到这里,就可以展示出定义的物料的名称了,接下来就是拖拽生成物料,该功能是通过vue-draggable的分组拖拽来实现。看下面官网的示例代码可知,拖拽分组其实就是list属性的数据的变化。

<div class="group">
  <draggable
    :list="state.modules.arr1"
    ghost-class="ghost"
    handle=".move"
    filter=".forbid"
    :force-fallback="true"
    chosen-class="chosenClass"
    animation="300"
    @start="onStart"
    @end="onEnd"
    :group="state.groupA"
    :fallback-class="true"
    :fallback-on-body="true"
    :touch-start-threshold="50"
    :fallback-tolerance="50"
    :move="onMove"
    :sort="false"
  >
    <template #item="{ element }">
      <div class="item move">
        <label class="move">{{ element.name }}</label>
      </div>
    </template>
  </draggable>
</div>
<div class="group">
  <draggable
    :list="state.modules.arr2"
    ghost-class="ghost"
    handle=".move"
    filter=".forbid"
    :force-fallback="true"
    chosen-class="chosenClass"
    animation="300"
    @start="onStart"
    @end="onEnd"
    group="itxst"
    :fallback-class="true"
    :fallback-on-body="true"
    :touch-start-threshold="50"
    :fallback-tolerance="50"
    :move="onMove"
  >
    <template #item="{ element }">
      <div class="item move">
        <label class="move">{{ element.name }}</label>
      </div>
    </template>
  </draggable>
</div>

3. 数据控制

接下来我要创建一个对象,来控制整个表单设计器的数据的变动,也就是desinger对象,这是整个项目的核心部分

export class Designer {
  constructor(option) {
    this.initDesigner(option)
  }
  initDesigner(option) {
    this.widgetMap = setMapValueByJSON(new Map(), option.widgetList) // key:propName val: widget 用于快速查询物料
    this.command = new CommandManage(this) // 命令管理实例
    this.widgetList = option.widgetList // 物料集合
    this.cloneWidget = null // 拖拽时生成的临时物料
    this.multipleWidget = new Set() // 多选拖拽时生成的临时物料集合
    this.selectedWidget = null // 选中的物料
    this.parentContainer = this // 当前选中物料的父级容器,用于不同组物料移动时记录位置以便于回退
    this.formConfig = new FormConfig() // 表单配置
    this.selectedWidgetOption = null // 选中物料的属性配置
  }
}

先说明一下目前为止需要用到的属性

  • widgetList: 拖拽生成的所有物料数据
  • cloneWidget: 拖拽时生成的物料的深拷贝对象,共用一个对象的话会导致生成两个相同的物料时它们的属性共通
  • command: 命令管理实例,因为是设计器,肯定要有撤回、重做的功能,所以后面我将会用命令模式来开发工具栏的功能,拖拽生成物料也是一个命令。

4. 分组拖拽

先在物料渲染区中写一个跟物料展示区同组的draggable组件,为了方便书写,现在把物料展示区称之为A区,物料渲染区称之为B区。

<!-- A区代码 -->
<draggable
  class="draggable"
  v-model="basicFields"
  chosenClass="chosen"
  forceFallback="true"
  :group="{ name: 'dragGroup', pull: 'clone', put: false }"
  animation="1000"
  itemKey="type"
  :move="checkFieldMove"
  :clone="handleFieldWidgetClone"
  ghost-class="ghost"
  :sort="false"
>
  <template #item="{ element }">
    <div
      class="item move"
      @dblclick="addFieldByDbClick(element)"
    >
      <el-icon>
        <component :is="element.icon" />
      </el-icon>
      {{ element.displayName }}
    </div>
  </template>
</draggable>

<!-- B区代码 -->
<draggable
  class="draggable"
  :sort="true"
  :list="designer.widgetList"
  item-key="id"
  v-bind="{ group: 'dragGroup', ghostClass: 'ghost', animation: 300 }"
  @end="onDragEnd"
  @add="(e) => onDragAdd(e)"
  @update="onDragUpdate"
  handle=".active-drag"
  :move="checkMove"
>
  <template #item="{ element: widget, index }">
    <div class="move">
      <component
        v-model:options="widget.options"
        :is="getWidgetName(widget.type)"
        :widget="widget"
        :designer="designer"
        :key="widget.options.propName"
        :parent="designer"
        :index="index"
        @click.stop="selectWidget(widget, index)"
      ></component>
    </div>
  </template>
</draggable>

从A区拖拽到B区,触发的是A的clone事件和B的add事件,之前说过,要使用desinger对象来控制整个表单的数据以及行为。接下来把A,B的相应事件委托给desinger对象来执行(desinger对象在最外层的vue页面中实例化)

// A区代码
const designer = inject('designer')
const handleFieldWidgetClone = (e) => {
  designer.handleWidgetClone(e)
}

// B区代码
const designer = inject('designer')
const onDragAdd = (e) => {
  const { newIndex } = e
  designer.addNewWidgetToContainer()
  designer.selectedWidget.index = newIndex
}
// desinger类代码
handleWidgetClone(widget) {
  this.cloneWidget = new Widget(deepclone(widget), this.formConfig)
}
addNewWidgetToContainer(container = this) {
  // 如果添加的是容器物料
  if (this.cloneWidget.category === 'container') {
    this.command.execute(
      new AddContainerWidgetCommand(
        this.cloneWidget,
        container.widgetList,
        this.widgetMap,
      ),
   )
  } else {
    this.command.execute(
      new AddWidgetCommand(
        this.cloneWidget,
        container.widgetList,
        this.widgetMap,
      ),
    )
  }
}

说明一下上面用到的属性

  • cloneWidget:生成一个深拷贝对象,它就是你定义物料时的数据,这个Widget对象暂时不用管,我只是给Widget对象加了几个方法,更方便操作
  • widgetMap:记录当前表单中所有物料,用于根据唯一标识快速查询到物料
  • AddContainerWidgetCommand:添加容器物料命令
  • AddWidgetCommand:添加基础物料命令

可以看到上面出现了命令,因为添加物料这个操作是允许被撤回的,于是采用了命令模式来开发,接下来简单介绍下命令模式

5. 命令模式

命令模式是最简单和优雅的模式之一,命令模式中的命令(command)指的是一个执行某些特定事情的指令。

命令模式最常见的应用场景是:有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么。此时希望用一种松耦合的方式来设计程序,使得请求发送者和请求接收者能够消除彼此之间的耦合关系。

命令模式很关键的一点就是将发生变化的数据封装了起来,它保留了那个节点时改变前和改变后的数据,举个例子来说就是我向A容器中添加了物料a,并实例化了一个命令对象,这个对象保存了A容器和物料a,那么点击撤销时,就可以得知是什么容器添加了什么物料。

接下来贴代码

5.1 命令管理类
// 命令管理
export class CommandManage {
  constructor(designer) {
    this.undoList = []
    this.redoList = []
    this.designer = designer
  }
  // immediately存在的目的是:存在自执行的命令,譬如移动物料位置时,draggable已经移动过了,所以不需要再执行一次cmd,否则栈里存在两个移动指令
  // save存在的目的是:写物料事件属性时若不能保存被刷新掉了再次写很麻烦,但是此操作没有存入栈的意义
  execute(command, immediately = true, save = true) {
    save && this.undoList.push(command)
    if (immediately) command.execute()
    this.saveForm()
  }
  undo() {
    const cmd = this.undoList.pop()
    cmd.undo()
    this.redoList.push(cmd)
    this.saveForm()
  }
  redo() {
    const cmd = this.redoList.pop()
    cmd.execute()
    this.undoList.push(cmd)
    this.saveForm()
  }
  canUndo() {
    return this.undoList.length > 0
  }
  canRedo() {
    return this.redoList.length > 0
  }
  saveForm() {
    localStorage.setItem('widgetList', JSON.stringify(this.designer.widgetList))
    localStorage.setItem('formConfig', JSON.stringify(this.designer.formConfig))
  }
}

熟悉命令模式的小伙伴应该能看懂,这里代码是不怎么涉及设计器的业务的,与设计器有关的部分已经做了注释。不熟悉的请先阅读命令模式,这里不做过多介绍。

5.2 新增基础物料命令
// 增加基础物料
export class AddWidgetCommand {
  // widget: 需要添加的物料
  // widgetList: 父容器的属性,表明该容器下的所有物料
  // widgetMap: 表单的属性,记录表单中所有物料
  constructor(widget, widgetList, widgetMap) {
    this.widget = widget
    this.widgetList = widgetList
    this.widgetMap = widgetMap
  }
  execute() {
    // 向父容器中加入该物料
    this.widgetList.push(this.widget)
    // 在map中记录物料
    this.widgetMap.set(this.widget.options.propName, this.widget)
  }
  undo() {
    // 从父容器中移除该物料
    this.widgetList.pop()
    // 从map中移除该物料
    this.widgetMap.delete(this.widget.options.propName)
  }
}
5.3 新增栅格容器物料命令
import { deepclone } from "@/utils"
import { Widget } from "../widget"
import { containerFields } from "../widget-panel/widgetsConfig"
// 增加栅格容器
export class AddContainerWidgetCommand {
  constructor(widget, widgetList, widgetMap) {
    this.widget = widget
     // 初始化的时候,要生成两个栅格列
    const gridCol = containerFields.filter((item) => item.type === 'grid-col')[0]
    this.widget.cols.push(new Widget(deepclone(gridCol)), new Widget(deepclone(gridCol)))
    this.widgetList = widgetList
    this.widgetMap = widgetMap
  }
  execute() {
    this.widgetList.push(this.widget)
    this.widgetMap.set(this.widget.options.propName, this.widget)
  }
  undo() {
    this.widgetList.pop()
    this.widgetMap.delete(this.widget.options.propName)
  }
}

当拖拽完成时,就会实例化一个生成物料命令对象,命令对象由命令管理实例保管,并执行该对象的execute 方法,当点击撤回时,执行该对象的undo方法。

到目前为止,我们可以得到从A区拖拽物料到B区的物料数据,也就是designer对象的widgetList属性,接下来要做的是如何将物料数据渲染成组件。

二、物料渲染区

拖拽物料之后,需要渲染出实际的组件,先看下渲染器组件的代码

<draggable
  class="draggable"
  :sort="true"
  :list="designer.widgetList"
  item-key="id"
  v-bind="{ group: 'dragGroup', ghostClass: 'ghost', animation: 300 }"
  @end="onDragEnd"
  @add="(e) => onDragAdd(e)"
  @update="onDragUpdate"
  handle=".active-drag"
  :move="checkMove"
>
  <template #item="{ element: widget, index }">
    <div class="move">
      <component
        v-model:options="widget.options"
        :is="getWidgetName(widget.type)"
        :widget="widget"
        :designer="designer"
        :key="widget.options.propName"
        :parent="designer"
        :index="index"
        @click.stop="selectWidget(widget, index)"
      ></component>
    </div>
  </template>
</draggable>

属性说明

  • :list="designer.widgetList": 将物料数组传给draggable,该组件会遍历数组,template中的item就是每一项物料
  • :is="getWidgetName(widget.type)": 动态组件,getWidgetName方法就是给物料加个后缀名
  • :key="widget.options.propName": propName即字段名是物料的唯一标识,表单中每个组件都有其唯一对应的字段名,譬如一个表单中有两个input组件,分别是userNamepassword,这就是propName。
  • :parent="designer": 指明该物料的父容器,后续有其他功能需要用到该属性
  • :index="index": 指明该物料在父容器中的位置,后续有其他功能需要用到该属性

1.编写渲染组件

可以看到,我们是通过动态组件来渲染物料的,接下来要跟定义的物料一一对应来编写实际渲染的组件。

我们现在定义了两个容器物料和一个基础物料,看看该如何编写。

1.1 基础物料
<template>
  <form-item-wrapper :widget="widget" :is-design="isDesign" :parent="parent">
    <el-input
      v-model="state.optionsModel.value"
      :disabled="widget.options.disabled"
      :readonly="widget.options.readonly"
      :placeholder="widget.options.placeholder"
      :clearable="widget.options.clearable"
      :type="widget.options.displayType"
      :show-password="widget.options.displayType == 'password'"
      @focus="onFocusHandle"
      @blur="onBlurHandle"
      @input="onInputHandle"
      @change="onChangeHandle"
      @clear="onClearHandle"
    ></el-input>
  </form-item-wrapper>
</template>
<script setup>
import { computed, reactive } from 'vue'
import formItemWrapper from './form-item-wrapper.vue'
import registerEvents from '../registerEvents'
const emits = defineEmits(['update:options'])
defineOptions({
  name: 'input-widget',
  mixins: [registerEvents],
})
const props = defineProps({
  widget: {
    type: Object,
    default: () => {},
  },
  isDesign: {
    type: Boolean,
    default: true,
  },
  options: {
    type: Object,
    default: () => {},
  },
  parent: {
    type: Object,
    default: () => {},
  },
})
const state = reactive({
  optionsModel: computed({
    get() {
      return props.options
    },
    set(v) {
      emits('update:options', v)
    },
  }),
})
</script>

  • widget.options:之前定义物料时的属性,根据业务需求,想要哪些属性都可以加到el-input组件上,后续可以通过属性编辑功能来控制这些属性。
  • form-item-wrapper:把基础物料的共有行为抽离出来成公共组件
  • registerEvents: 混入组件,用于注册各个物料的事件

现在看一下form-item-wrapper的代码

<template>
  <!-- 隐藏属性激活时,只有预览时才会真正隐藏,表单设计时显示隐藏icon表示隐藏状态激活 -->
  <div v-if="!(widget.options.hidden && !isDesign)" class="form-item-wrapper">
    <el-form-item
      :class="[widget.options.labelAlign]"
      :label-width="widget.options.labelWidth + 'px'"
      :rules="state.rules"
      :prop="widget.options.propName"
      :size="widget.options.size"
    >
      <template #label>
        <div class="form-item-label" v-if="!widget.options.labelHidden">
          {{ widget.options.label }}
        </div>
      </template>
      <slot></slot>
    </el-form-item>
    <!-- 物料被选中时显示工具 -->
    <div class="active" v-if="designer && widget == designer.selectedWidget && isDesign">
      <div class="active-border"></div>
      <div class="active-drag">
        <el-icon><Rank /></el-icon>
        <span>{{ widget.displayName }}</span>
        <!-- 隐藏icon -->
        <el-icon v-if="widget.options.hidden"><Hide /></el-icon>
      </div>
      <div class="active-action">
        <el-icon @click.stop="selectParent"><Back /></el-icon>
        <el-icon @click.stop="selectPreWidget"><Top /></el-icon>
        <el-icon @click.stop="selectNextWidget"><Bottom /></el-icon>
        <el-icon @click.stop="copySelfToParent"><CopyDocument /></el-icon>
        <el-icon @click.stop="removeWidget"><DeleteFilled /></el-icon>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ElMessage } from 'element-plus'
import { computed, inject, reactive } from 'vue'
defineOptions({
  name: 'form-item-wrapper',
})

const designer = inject('designer')
const props = defineProps({
  widget: {
    type: Object,
    default: () => {},
  },
  isDesign: {
    type: Boolean,
    default: false,
  },
  parent: {
    type: Object,
    default: () => {},
  },
  // 父容器在爷爷容器中的位置
  parentIndex: {
    type: Number,
    default: 0,
  },
})
const state = reactive({
  rules: computed(() => {
    const rules = [
      {
        required: props.widget.options.isRequired,
        message: props.widget.options.requiredMessage,
        trigger: 'blur',
      },
    ]
    if (props.widget.options.rule) {
      rules.push({
        type: props.widget.options.rule,
        message: props.widget.options.errorMessage,
        trigger: 'blur',
      })
    }
    return rules
  }),
})
// 删除当前物料
const removeWidget = () => {
  designer.removeWidgetFromContainer(props.parent.widgetList)
}
// 选择父容器
const selectParent = () => {
  if (props.parent === designer) {
    ElMessage('当前已是最顶层')
    return
  }
  designer.selectWidget(props.parent, props.parentIndex)
}
// 选择上一个物料
const selectPreWidget = () => {
  designer.selectWidgetByWidgetListIndex(
    props.parent.widgetList,
    designer.selectedWidget.index - 1,
  )
}
// 选择下一个物料
const selectNextWidget = () => {
  designer.selectWidgetByWidgetListIndex(
    props.parent.widgetList,
    designer.selectedWidget.index + 1,
  )
}
// 复制当前物料
const copySelfToParent = () => {
  designer.copyWidgetToContainer(props.widget, props.parent.widgetList)
}
</script>

该组件主要是用于展示物料被选中时的样式以及工具条,有以下功能(具体实现等后面再说):

  • 拖拽物料变换位置
  • 选择当前物料的父容器
  • 选择上一个物料
  • 选择下一个物料
  • 删除当前物料
  • 复制当前物料到父容器
1.2 容器物料
<template>
  <containerWrapper :widget="widget" :is-design="isDesign">
    <div class="grid-widget">
      <el-row :gutter="widget.options.gutter">
        <gridColWidget
          :key="item.id"
          v-for="(item, idx) in widget.cols"
          :parent-option="options"
          :index="idx"
          :parent="widget"
          :parent-index="index"
          :widget="item"
          :is-design="isDesign"
        />
      </el-row>
    </div>
  </containerWrapper>
</template>
<script setup>
import containerWrapper from './container-wrapper.vue'
import gridColWidget from './grid-col-widget.vue'
defineOptions({
  name: 'grid-widget',
})
defineProps({
  widget: {
    type: Object,
    default: () => {},
  },
  isDesign: {
    type: Boolean,
    default: true,
  },
  options: {
    type: Object,
    default: () => {},
  },
  // 在父容器中的位置
  index: {
    type: Number,
    default: 0,
  },
})
</script>
<style lang="less" scoped></style>

这是一个栅格容器,同基础物料一样,该组件内部使用containerWrapper来抽离公共组件,这个公共组件代码就不贴了,和之前基本一样。

使用栅格列容器来配合完成布局,下面是栅格列的代码

<template>
  <el-col
    @click.stop="selectWidget(widget, index)"
    :span="widget.options.span"
    :offset="widget.options.offset"
    :push="widget.options.push"
    :pull="widget.options.pull"
    class="grid-col"
    :class="[widget == designer.selectedWidget ? 'active' : 'unactive']"
    :style="{
      minHeight: (widget.options.height ?? parentOption.colHeight) + 'px',
    }"
  >
    <draggable
      class="draggable"
      :sort="true"
      :list="widget.widgetList"
      item-key="id"
      v-bind="{ group: 'dragGroup', ghostClass: 'ghost', animation: 300 }"
      @start="onDragStart(widget)"
      @end="onDragEnd"
      @add="(e) => onDragAdd(e, widget)"
      @update="onDragUpdate"
      handle=".active-drag"
      :move="checkMove"
    >
      <template #item="{element: subWidget, index: idx}">
        <div class="move">
          <component
            v-model:options="subWidget.options"
            :is="getWidgetName(subWidget.type)"
            :widget="subWidget"
            :designer="designer"
            :parent="widget"
            :parent-index="index"
            :key="subWidget.options.propName"
            :isDesign="isDesign"
            @click.stop="selectWidget(subWidget, idx)"
          ></component>
        </div>
      </template>
    </draggable>
    <!-- 此处被选中时显示工具 -->
    <div class="active" v-if="widget == designer.selectedWidget && isDesign">
      <div class="active-name">
        <span>{{ widget.displayName }}</span>
      </div>
      <div class="active-action">
        <el-icon @click.stop="selectParent"><Back /></el-icon>
        <el-icon @click.stop="selectPreWidget"><Top /></el-icon>
        <el-icon @click.stop="selectNextWidget"><Bottom /></el-icon>
        <el-icon @click.stop="copySelfToParent"><CopyDocument /></el-icon>
        <el-icon @click.stop="removeWidget"><DeleteFilled /></el-icon>
      </div>
    </div>
  </el-col>
</template>
<script setup>
import { inject } from 'vue'
import { getWidgetName } from '@/utils/index.js'

import FieldComponents from '@/components/form-designer/form-widget/field-widget/index'
defineOptions({
  name: 'grid-col-widget',
  components: {
    ...FieldComponents,
  },
})
const props = defineProps({
  widget: {
    type: Object,
    default: () => {},
  },
  parentOption: {
    type: Object,
    default: () => {},
  },
  isDesign: {
    type: Boolean,
    default: true,
  },
  parent: {
    type: Object,
    default: () => {},
  },
  parentIndex: {
    type: Number,
    default: 0,
  },
  index: {
    type: Number,
    default: 0,
  },
})
const designer = inject('designer')
const checkMove = (e) => {
    designer.checkFieldMove(e)
}

const onDragStart = (widget) => {
    designer.parentContainer = widget
}
const onDragEnd = () => {
    designer.parentContainer = designer
}
// 向容器物料中添加
const onDragAdd = (e, widget) => {
    // 判断是不同组拖拽物料还是从物料栏新增物料
    if (designer.cloneWidget || designer.multipleWidget.size) {
        designer.addNewWidgetToContainer(widget)
    } else {
        // 触发移动物料
        designer.moveWidgetFromAToBContainer(e, widget)
    }
    // 记录位置
    designer.selectedWidget.index = e.newIndex
}
const onDragUpdate = (e) => {
    designer.dragUpdate(e)
}
const selectWidget = (widget, index) => {
    designer.selectWidget(widget, index)
}
const removeWidget = () => {
    designer.removeWidgetFromContainer(props.parent.cols)
}
// 新增一个grid容器,可能没有点击grid就直接点col,所以此时parent是没有index属性的,导致这时候无法选择兄弟物料,所以需要给parent绑定index
const selectParent = () => {
    designer.selectWidget(props.parent, props.parentIndex)
}
const selectPreWidget = () => {
    designer.selectWidgetByWidgetListIndex(
        props.parent.cols,
        designer.selectedWidget.index - 1,
    )
}
const selectNextWidget = () => {
    designer.selectWidgetByWidgetListIndex(
        props.parent.cols,
        designer.selectedWidget.index + 1,
    )
}
// 复制一份数据到父容器
const copySelfToParent = () => {
    designer.copyWidgetToContainer(props.widget, props.parent.cols)
}
</script>

需要注意的是容器的子物料会使用容器物料,在子物料内引入components会造成循环引用,所以需要将容器物料都注册为全局组件。

const modules = import.meta.globEager('./*.vue')

export default {
  install(app) {
    for (const path in modules) {
      let cname = modules[path].default.name
      app.component(cname, modules[path].default)
    }
  }
}

到这为止,已经可以做到拖拽物料并且渲染出实际组件。

三、指令区和工具条

渲染出实际组件后,需要实现工具条的功能,大致有如下功能:

  • 选中物料、选中当前物料的父容器/兄弟物料
  • 拖拽移动物料
  • 删除物料
  • 清空表单
  • 复制物料
  • 预览表单
  • 导入JSON生成表单
  • 导出表单JSON
  • 导出表单代码、生成SFC文件

1.选中物料

最基本的功能,得先选中物料才有后续其他的功能。选中物料主要有两种实现方式:

  • 鼠标点击直接选中,
  • 通过代码选中(为了实现选中当前物料的父物料/兄弟物料的功能)
1.1 点击直接选中

渲染区代码

<component
    v-model:options="widget.options"
    :is="getWidgetName(widget.type)"
    :widget="widget"
    :designer="designer"
    :key="widget.options.propName"
    :parent="designer"
    :index="index"
    @click.stop="selectWidget(widget, index)"
    >
</component>

desinger对象代码

// 当且仅当通过鼠标点击物料时,会得到draggable传来的index,表明它在父容器中的位置,
// 需要到用这个属性来控制选择兄弟物料,所以将index绑定到widget上
selectWidget(widget, index) {
    this.selectedWidget = widget
    index !== null && index !== undefined && (this.selectedWidget.index = index)
    window.dispatchEvent(new CustomEvent('select_widget'))
}
1.2 通过代码选中

desinger对象代码

 /**
* widgetList: 容器
* index: 下标
*/
// 根据下标,取得某容器的子物料
selectWidgetByWidgetListIndex(widgetList, index) {
    const target = widgetList[index]
    if (!target) {
      ElMessage(`已经移动到最${index < 0 ? '上面' : '下面'}`)
      return
    }
    this.selectedWidget = target
    this.selectedWidget.index = index
    window.dispatchEvent(new CustomEvent('select_widget'))
}
1.3 选择父容器/兄弟物料

工具条代码

有多个组件都存在工具条,所以就不标注是哪个组件了,这里通过props获取到当前物料的父容器,并且在已经得知当前物料的index的情况下,就可以做到选择兄弟物料。

这里需要注意的点是,props中的parent属性的传递需要仔细理一下,因为容器组件存在递归调用的情况。

const selectParent = () => {
  if (props.parent === designer) {
    ElMessage('当前已是最顶层')
    return
  }
  designer.selectWidget(props.parent, props.parentIndex)
}
const selectPreWidget = () => {
  designer.selectWidgetByWidgetListIndex(
    props.parent.widgetList,
    designer.selectedWidget.index - 1,
  )
}
const selectNextWidget = () => {
  designer.selectWidgetByWidgetListIndex(
    props.parent.widgetList,
    designer.selectedWidget.index + 1,
  )
}

通过以上的两种方法,designer.selectedWidget就指向当前选中的物料

2. 拖拽物料更换位置

变换位置也有两种情况:

  • 本容器内部的位置变换;
  • 不同容器之间的位置变换
2.1 本容器内部的位置变换

这种情况触发的是draggable组件的update事件

渲染区代码

<draggable
  class="draggable"
  :sort="true"
  :list="widget.widgetList"
  item-key="id"
  v-bind="{ group: 'dragGroup', ghostClass: 'ghost', animation: 300 }"
  @start="onDragStart(widget)"
  @end="onDragEnd"
  @add="(e) => onDragAdd(e, widget)"
  @update="onDragUpdate"
  handle=".active-drag"
  :move="checkMove"
>
...
</draggable>
<srcipt setup>
const onDragUpdate = (e) => {
  designer.dragUpdate(e)
}
</script>

desinger对象代码

更新位置以命令的形式来实现,command.execute的第二个参数false表明命令管理对象不要立即执行该命令。因为移动物料是一个特殊的操作,它是由draggable来实现的,拖拽时就已经完成了,不需要再执行一次。

// 拖拽更新位置
dragUpdate(e) {
    const { oldIndex, newIndex } = e
    this.command.execute(
        new MoveWidgetCommand(this.widgetList, oldIndex, newIndex),
        false,
    )
}

命令代码

接收了三个参数,分别代表容器的子物料列表、物料移动前位置、物料移动后位置。

撤销操作的时候,根据移动后位置找到目标物料,将它插入到移动前的位置。

重做操作的时候,根据移动前位置找到目标物料,将它插入到移动后的位置。

// 移动物料命令
export class MoveWidgetCommand {
  constructor(widgetList, oldIndex, newIndex) {
    this.widgetList = widgetList
    this.oldIndex = oldIndex
    this.newIndex = newIndex
  }
  execute() {
    // 要移动的物料
    const targetWidget = this.widgetList[this.oldIndex]
    // 先移除原先位置上的物料,将其插入移动后的位置
    this.widgetList.splice(this.oldIndex, 1)
    this.widgetList.splice(this.newIndex, 0, targetWidget)
  }
  undo() {
    // 要移动的物料
    const targetWidget = this.widgetList[this.newIndex]
    // 先移除移动后的物料,再将其插入原来的位置
    this.widgetList.splice(this.newIndex, 1)
    this.widgetList.splice(this.oldIndex, 0, targetWidget)
  }
}
2.2 不同容器之间的位置变换

这种情况触发的是容器的draggable组件的add事件

栅格列容器代码

当触发add事件的时候,有两种情况,需要判断下是新增物料事件还是物料位置变换。

<draggable
  class="draggable"
  :sort="true"
  :list="widget.widgetList"
  item-key="id"
  v-bind="{ group: 'dragGroup', ghostClass: 'ghost', animation: 300 }"
  @start="onDragStart(widget)"
  @end="onDragEnd"
  @add="(e) => onDragAdd(e, widget)"
  @update="onDragUpdate"
  handle=".active-drag"
  :move="checkMove"
>
...
</draggable>

<script setup>
const onDragStart = (widget) => {
  designer.parentContainer = widget
}
const onDragEnd = () => {
  designer.parentContainer = designer
}
// 向容器物料中添加
const onDragAdd = (e, widget) => {
  // 判断是不同组拖拽物料还是从物料栏新增物料
  if (designer.cloneWidget || designer.multipleWidget.size) {
    designer.addNewWidgetToContainer(widget)
  } else {
    // 触发移动物料
    designer.moveWidgetFromAToBContainer(e, widget)
  }
  // 记录位置
  designer.selectedWidget.index = e.newIndex
}
const onDragUpdate = (e) => {
  designer.dragUpdate(e)
}
</script>

desinger对象代码

parentContainer代表当前拖拽物料的父容器,所以在上面的栅格列容器代码中,需要在拖拽开始时,将parentContainer指向该栅格列,以便后面在命令代码中使用

// 从一个容器中移动物料去另一个容器
moveWidgetFromAToBContainer(e, BContainer) {
    const { oldIndex, newIndex } = e
    // 当前选中物料的父容器就是AContainer
    this.command.execute(
        new MoveWidgetFromAToBContainerCommand(
            oldIndex,
            newIndex,
            this.parentContainer,
            BContainer,
        ),
        false,
    )
}

命令代码

构造函数中的四个参数分别代表: 物料在原容器中的位置、在新容器中的位置、原容器、新容器。

有了这四个参数很容易就能将物料位置进行变换

export class MoveWidgetFromAToBContainerCommand {
  constructor(oldIdx, newIdx, AContainer, BContainer) {
    this.oldIdx = oldIdx
    this.newIdx = newIdx
    this.AContainer = AContainer
    this.BContainer = BContainer
  }
  execute () {
    // 获取A中的物料,移动到B
    const widget = this.AContainer.widgetList.splice(this.oldIdx, 1)[0]
    this.BContainer.widgetList.splice(this.newIdx, 0 , widget)
  }
  undo() {
    // 获取B中的物料,还给A
    const widget = this.BContainer.widgetList.splice(this.newIdx, 1)[0]
    this.AContainer.widgetList.splice(this.oldIdx, 0, widget)
  }
}

3. 删除物料

工具条代码

从父容器中移除当前物料

const removeWidget = () => {
  designer.removeWidgetFromContainer(props.parent.widgetList)
}

desinger对象代码

 // 从一个容器中移除当前选中物料
 // 不传值默认是从最外层表单中移除
  removeWidgetFromContainer(widgetList = this.widgetList) {
    this.command.execute(
      new RemoveWidgetCommand(
        this.selectedWidget,
        widgetList,
        this.selectWidget.bind(this),
        this.widgetMap,
      ),
    )
  }

命令代码

实现的效果是:删除物料后当前选中物料指向空,执行回退操作时重新指向该物料。需要注意的是selectWidget需要在传入时绑定this,否则无法重新指向该物料。

export class RemoveWidgetCommand {
  constructor(widget, widgetList, selectWidget, widgetMap) {
    // 要删除的物料
    this.widget = widget
    // 该物料所属的容器
    this.widgetList = widgetList
    // 选择物料方法
    this.selectWidget = selectWidget
    // 该物料所处的位置
    this.targetIdx = -1
    this.widgetMap = widgetMap
  }
  execute() {
    // 找到该物料所在位置
    this.targetIdx = this.widgetList.findIndex((item) => item.options.propName == this.widget.options.propName)
    // 移除该物料
    this.widgetList.splice(this.targetIdx, 1)
    // 当前选中物料指向空
    this.selectWidget(null)
    // 移除该物料的记录
    this.widgetMap.delete(this.widget.options.propName)
  }
  undo() {
    // 将被删除的物料插入回去
    this.widgetList.splice(this.targetIdx, 0, this.widget)
    // 当前选中物料指向该物料
    this.selectWidget(this.widget)
    // 添加记录
    this.widgetMap.set(this.widget.options.propName, this.widget)
  }
}

4. 清空表单

desinger对象代码

// 移除所有物料
clearWidget() {
    this.command.execute(
        new ClaerWidgetCommand(
            this.widgetList,
            this.selectWidget.bind(this),
            this.selectedWidget,
            this.widgetMap,
        ),
    )
}

命令代码

清空表单和从容器中删除物料的思路几乎是一样的。需要注意的是删除所有物料,不能使用 this.widgetList = [],因为这只是更改了本实例中widgetList的指向。

export class ClaerWidgetCommand {
  constructor(widgetList, selectWidget, selectedWidget, widgetMap) {
    this.widgetList = widgetList
    // 保存一份源数据,用于回退
    this.originWidgetList = [...widgetList]
    // 保存一份源数据,用于回退
    this.originMap = new Map(Array.from(widgetMap));
    this.selectWidget = selectWidget
    this.widgetMap = widgetMap
    this.selectedWidget = selectedWidget
  }
  execute() {
    // 删除所有物料
    this.widgetList.splice(0, this.widgetList.length)
    this.selectWidget(null)
    this.widgetMap.clear()
  }
  undo() {
    this.widgetList.push(...this.originWidgetList)
    this.selectWidget(this.selectedWidget)
    this.widgetMap = new Map(Array.from(this.originMap));
  }
}

5. 复制物料

该功能是选中一个物料后,点击复制按钮就会复制一份物料到父容器中。

命令代码

接收的参数分别是:需要复制的物料、父容器、map记录表。

当复制容器物料的时候,容器物料的子物料还是有可能有容器物料,所以需要递归的复制自身,当没有子物料时结束递归;同理,撤销时也需要递归的清除掉复制的物料。

// getRandomPropName: 随机生成组件唯一标识
import { getRandomPropName, deepclone } from "@/utils"
export class CopyWidgetToContainerCommand {
  constructor(widget, widgetList, widgetMap) {
    this.widget = deepclone(widget)
    this.widgetList = widgetList
    this.widgetMap = widgetMap
  }
  execute () {
    this.copySelf(this.widget)
    this.widgetList.push(this.widget)
  }
  undo () {
    this.widgetList.pop()
    this.cleanMap(this.widget)
  }
  copySelf (widget) {
    // 复制当前物料
    const randomPropName = getRandomPropName(widget.type)
    widget.options.propName = randomPropName
    widget.originConfig.propName = randomPropName
    this.widgetMap.set(randomPropName, widget)
    // 子物料继续复制
    const children = widget.cols ?? widget.widgetList ?? []
    for (let i of children) {
      this.copySelf(i)
    }
  }
  cleanMap(widget) {
    // 清除当前物料
    this.widgetMap.delete(widget.options.propName)
    // 子物料继续清除
    const children = widget.cols ?? widget.widgetList ?? []
    for (let i of children) {
      this.cleanMap(i)
    }
  }
}

6. 预览表单

暂时略过,等说到表单渲染器组件的时候再看

7. 导入JSON生成表单

打开一个弹框,然后根据相应的格式输入JSON,就可以生成表单。我定义的格式很简单,有如下两个属性即可。

{ 
  widgetList: [], 
  formConfig: {}
}

这里的代码编辑器我使用的是aceEditor,这里有一份中文文档可供参考

image.png

弹框代码

点击确定触发importJSON事件后将会调用designer的方法

<template>
  <el-dialog v-model="state.showDialog" title="导入JSON" width="80%">
    <el-alert
      type="info"
      :closable="false"
      title="导入的JSON内容须符合下述格式,以保证顺利导入."
    ></el-alert>
    <code-editor
      ref="codeEditorRef"
      :placeholder="state.placeholder"
      mode="javascript"
      v-model="state.content"
    ></code-editor>
    <template #footer>
      <div class="dialog-footer text-center">
        <el-button type="primary" @click="onSureHandle">确定</el-button>
        <el-button @click="onCloseHandle">关闭</el-button>
      </div>
    </template>
  </el-dialog>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import CodeEditor from '@/components/code-editor/index.vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
  showDialog: {
    type: Boolean,
    default: false
  },
  content: {
    type: String,
    default: ''
  }
})
const emits = defineEmits(['update:showDialog', 'importJSON'])

const state = reactive({
  showDialog: computed({
    get() {
      if (props.showDialog) {
        // eslint-disable-next-line vue/no-side-effects-in-computed-properties
        state.content = props.content
      }
      return props.showDialog
    },
    set(v) {
      emits('update:showDialog', v)
    }
  }),
  content: '',
  placeholder: ''
})
const codeEditorRef = ref(null)
const onSureHandle = () => {
  // 校验格式是否正确
  const codeHints = codeEditorRef.value.getEditorAnnotations()
  if (codeHints && codeHints.length > 0) {
    for (let i of codeHints) {
      console.log(i)
      if (i.type === 'error' || i.type === 'info') {
        ElMessage({
          message: '导入的JSON格式错误,请检查',
          type: 'error'
        })
        return
      }
    }
  }
  state.showDialog = false
  emits('importJSON', state.content)
}

const onCloseHandle = () => {
  state.showDialog = false
}
</script>

desinger对象代码

// 导入JSON
addWidgetListByJSON(json) {
    this.command.execute(
        new ImportWidgetCommand(
            json,
            { 
                widgetList: this.widgetList, 
                formConfig: this.formConfig
            },
            this.widgetMap,
        ),
    )
}

命令代码

import { deepclone } from '@/utils'
import { Widget } from '../widget'
// 导入JSON命令
export class ImportWidgetCommand {
  constructor(json, data, widgetMap) {
    this.widgetList = data.widgetList
    this.formConfig = data.formConfig
    this.json = json
    this.widgetMap = widgetMap
    this.originData = deepclone(data) // 记录原始数据,用于回退
  }
  // 替换物料列表,并且重新记录map对应的物料
  execute() {
    // 清空map表重新记录
    this.widgetMap.clear()
    // 替换物料列表
    const newList = this.json.widgetList.map((item) => new Widget(item))
    this.widgetList.splice(0, this.widgetList.length, ...newList)
    this.widgetList.forEach((item) => {
      this.widgetMap.set(item.options.propName, item)
    })
    // 替换表单配置
    this.formConfig = this.json.formConfig
  }
  undo() {
    this.widgetMap.clear()
    this.widgetList.splice(0, this.widgetList.length, ...this.originData.widgetList)
    this.widgetList.forEach((item) => {
      this.widgetMap.set(item.options.propName, item)
    })
    this.formConfig = this.originData.formConfig
  }
}

8. 导出表单JSON

这个功能比较简单,就是把designer对象的widgetListformConfig属性导出去,需要注意的是在执行某些命令的时候,为了达到回退的效果,我在物料上加了很多不需要导出的属性,所以需要处理下。

指令区组件代码

点击导出时调用exportJSON方法,该方法会调用handleWidgetList方法,handleWidgetList会递归的处理所有物料,只保留物料的typeoptionscategorycolswidgetList属性(因为表单渲染器组件只需要这些属性就可以渲染出物料)。处理完毕后弹框展示数据。

const exportJSON = () => {
  state.title = '导出JSON'
  // 处理掉render时不需要的属性
  const { widgetList } = handleWidgetList(designer)
  // 需要格式化JSON字符串,否则编辑插件显示不换行
  state.code = JSON.stringify(
    {
      widgetList,
      formConfig: designer.formConfig,
    },
    null,
    '  ',
  )
  state.mode = 'json'
  state.showCodeDialog = true
}
const handleWidgetList = (widget) => {
  // 导出时需要widget上的这三个属性
  const { type, options, category } = widget
  const data = {
    type,
    options,
    category,
  }
  // 继续处理子物料
  const children = widget.cols ?? widget.widgetList ?? []
  if (widget.cols) data.cols = []
  if (widget.widgetList) data.widgetList = []
  for (let i of children) {
    data.cols
      ? data.cols.push(handleWidgetList(i))
      : data.widgetList.push(handleWidgetList(i))
  }
  return data
}

9. 导出表单代码

9.1 导出表单代码

该功能导出的代码得配合本库才能生效,也就是在你自己的项目中npm安装了我这个库,以下代码才有意义

image.png

实现起来也很简单,只要把designer对象的widgetListformConfig属性取出来,再拼接到模板字符串里即可。

组件代码

const exportSFCCode = () => {
  state.code = generateCode({
    widgetList: designer.widgetList,
    formConfig: designer.formConfig,
  })
  state.mode = 'html'
  state.userWorker = false // 用来解决编辑器对vue格式报错的问题
  state.showCodeDialog = true
}
const generateCode = function (formJson) {
  const formJSONStr = JSON.stringify(formJson, null, '  ')

  return `<template>
  <div>
    <form-render :form-json="formJson" ref="formRenderRef">
    </form-render>
  </div>
</template>

<script setup>
  import { ref, reactive } from 'vue'
  import { ElMessage } from 'element-plus'

  const formJson = reactive(${formJSONStr})
  const formRenderRef = ref(null)
</script>`
}
9.2 导出通用代码

该功能导出的是通用代码,只要项目中安装了element-ui组件库即可。比较可惜的是我暂时没有找到js美化应该怎么做,导出的代码缩进总是有问题。

image.png

该功能的实现稍微复杂一点

组件代码

将表单的数据传入sfcGenerator,该方法将会生成两个部分代码,也就是SFC文件的templatescript

getTemplateStr方法需要说一下,当处理到容器物料的时候,需要拼接栅格组件以实现布局的效果。考虑到后续有其他种类的容器物料,此处应该使用策略模式进行改造。

// 导出代码
const exportSFCFile = () => {
  state.code = sfcGenerator({
    widgetList: designer.widgetList,
    formConfig: designer.formConfig,
  })
  state.mode = 'html'
  state.userWorker = false // 用来解决编辑器对vue格式报错的问题
  state.showCodeDialog = true
}
import { html_beautify } from 'js-beautify/js/lib/beautify-html'
import { beautifierOpts } from '@/utils/beautifier.js'
import { Designer } from '@/components/form-designer/designer'
// 生成template部分
const generateTemplate = (widgetList, formConfig) => {
  return `
  <el-form ref="${formConfig.formRefName}" label-width="${formConfig.formLabelWidth}">
      ${getTemplateStr({ widgetList, category: 'container' })}
  </el-form>`
}

// 生成js部分
const generateJavascript = (widgetList, formConfig) => {
  return `
    import { reactive, ref } from 'vue'
    const ${formConfig.formRefName} = ref()
    cosnt state = reactive({
      formData: ${Designer.GETJSON(widgetList, {})},
      rules: ${formConfig.rules}
    })
  `
}
// TODO: js的格式美化
// 生成代码
export const sfcGenerator = ({ widgetList, formConfig }) => {
  const template = `<template>${generateTemplate(widgetList, formConfig)}
  </template>`
  const js = `<script setup> ${generateJavascript(
    { widgetList },
    formConfig,
  )}</script>`
  return (
    html_beautify(template, beautifierOpts.html) + js
  )
}
// 拼接template字符串
const getTemplateStr = (widget) => {
  let str = ''
  // 非容器物料直接返回这段字符串
  if (widget.category !== 'container') {
    return `<el-form-item label="${widget.options.label}" prop="${widget.options.propName}">
    <el-${widget.type} v-model="state.formData.${widget.options.propName}"></el-${widget.type}>
</el-form-item>\n`
  }
  // 拼接容器物料
  if (widget.type === 'grid') {
    str += '<el-row>\n'
  }
  if (widget.type === 'grid-col') {
    str += `<el-col span=${widget.options.span}>\n`
  }
  // 递归处理容器物料的子物料
  const children = widget.cols ?? widget.widgetList
  for (let i of children) {
    str += getTemplateStr(i, str)
  }
  // 递归完毕后继续拼接容器物料
  if (widget.type === 'grid') {
    str += '</el-row>\n'
  }
  if (widget.type === 'grid-col') {
    str += '</el-col>\n'
  }
  return str
}

四、属性编辑区

选中物料之后,在属性编辑区展示该物料的所有可编辑属性。表单配置功能和组件配置差不多,所以下面就省略了。

image.png

按照以下步骤来完成编辑器的开发:

  1. 编写物料JSON时,定义好有哪些属性
  2. 将需要编辑功能的属性注册到属性表,这样便于管理
  3. 编写属性编辑区组件
  4. 编写各个属性编辑组件

1. 编写物料JSON

这个在第一模块的时候就已经说过了,下面还是以input物料为例说明属性编辑器的开发流程。options定义了属性,接下来将需要编辑的属性都注册到属性表里。

export const basicFields = [
  {
    type: 'input',
    displayName: '单行输入',
    icon: 'House',
    options: {
      propName: '', // 字段名称
      defaultValue: '', // 初始值
      label: 'input',
      labelAlign: 'left',
      labelWidth: '80',
      labelHidden: false,
      isRequired: false,
      requiredMessage: '请输入', // 必填校验提示
      rule: '', // 校验
      errorMessage: '', // 校验失败提示
      value: '', // 绑定值
      displayType: 'text',
      size: 'default',
      placeholder: '请输入',
      readonly: false,
      disabled: false,
      clearable: false,
      hidden: false,
      onBlur: '',
      onFocus: '',
      onChange: '',
      onInput: '',
      onClear: '',
    },
  },
]

2. 注册属性表

将属性分为普通属性和事件属性,分别放入对应的数组里。后续要通过属性表来判断是否展示options里的属性。

/**
 * 用此文件注册属性编辑器,便于分类和查找。
 * 含义:当属性名存在于此数组中时,认定为已注册属性,允许显示该属性编辑器组件
 */
export const COMMON_PROPERTIES = [
  'propName', // 字段名
  'label',
  'labelAlign',
  'labelWidth',
  'labelHidden',
  'isRequired',
  'displayType',
  'size',
  'requiredMessage',
  'rule',
  'errorMessage',
  'defaultValue',
  'placeholder',
  'readonly',
  'disabled',
  'clearable',
  'hidden',
  'multiple',
  'allowCreate',
  'filterable',
  'format',
  'optionItem',
  'url',
  // --------------时间选择器属性------------------
  'rangeSeparator',
  // --------------栅格属性------------------
  'colHeight', // 统一列高
  'gutter', // 栅格间距
  'span', // 栅格占比
  'offset', // 栅格左侧间隔格数
  'pull', // 栅格列左偏移
  'push', // 栅格列右偏移
]

export const EVENT_PROPERTIES = [
  'onBlur',
  'onFocus',
  'onChange',
  'onInput',
  'onClear',
  'onVisibleChange',
  'onRemoveTag',
  'onClick',
  'onFormMounted'
]

3. 属性编辑区组件

  1. 当在物料渲染区选中物料时,会触发select_widget事件,编辑区组件会监听该事件。当事件发生时,执行getPropertys方法,该方法会取出被选中物料注册在属性表里的所有属性并存入widgetPropertyLists
  2. 在页面上遍历widgetPropertyLists,通过hasEditProp判断是否存在该属性名的属性编辑器。若存在,再调用getEditorName方法,该方法需要判断是否有以该物料type为前缀的属性组件,有则返回该组件,若没有则使用通用属性组件
  3. 举个例子来说,有多种物料都有optionItem属性,譬如radioselectcheckbox。其中前两者都是单选项,第三个是多选项,所以它们的属性编辑器表现形式就不相同,为了区分特殊同名属性编辑器,需要给该属性编辑器加上前缀名,也就是checkboxoptionItem属性编辑器取名为checkbox-optionItem
  4. 将当前选中物料的options传入属性编辑器组件,开发属性编辑器组件。
<template>
  <div class="setting-panel fd-pa-8">
    <el-tabs v-model="state.activeTab" style="overflow: hidden;">
      <el-tab-pane label="组件配置" name="组件配置">
        <el-scrollbar :height="state.scrollHeight">
          <el-form
            :model="designer.getSeletedWidgetOptions()"
            label-position="left"
            label-width="85px"
          >
            <el-collapse v-model="state.activeCollapse">
              <el-collapse-item
                :key="index"
                v-for="(propertyList, index) in state.widgetPropertyLists"
                :title="propertyList.name"
                :name="index"
              >
                <div
                  v-for="propKey in propertyList.propertys"
                  :key="Math.random() + propKey"
                >
                  <!-- 当前组件存在该属性才展示对应的属性编辑器 -->
                  <component
                    :key="Math.random() + propKey"
                    v-if="hasEditProp(propKey)"
                    :is="getEditorName(propKey)"
                    :optionModel="designer.selectedWidget.options"
                    :designer="designer"
                    :widget="designer.selectedWidget"
                    @editEventProp="editEventProp"
                  ></component>
                </div>
              </el-collapse-item>
            </el-collapse>
          </el-form>
        </el-scrollbar>
      </el-tab-pane>
    </el-tabs>
    <!-- 事件属性编辑器 -->
    <div v-if="state.showCodeDialog">
      <CodeEditorDialog
        :designer="designer"
        v-model:code="state.optionModel[state.codeDialogTitle]"
        v-model:showCodeDialog="state.showCodeDialog"
        :title="state.codeDialogTitle"
        :tip="state.codeDialogTip"
      />
    </div>
  </div>
</template>
<script setup>
import { onMounted, reactive, inject, computed } from 'vue'
import CommPropEditors from './property-editor/commProp/index.js'
import EventEditors from './property-editor/eventProp/index.js'
import { COMMON_PROPERTIES, EVENT_PROPERTIES } from './propertyRegister.js'
import CodeEditorDialog from './components/codeEditorDialog.vue'
const designer = inject('designer')

defineOptions({
  components: {
    ...CommPropEditors,
    ...EventEditors,
  },
})
const state = reactive({
  optionModel: computed(() => designer.selectedWidget?.options),
  activeTab: '组件配置',
  activeCollapse: 0,
  scrollHeight: 0,
  widgetPropertyLists: [
    {
      name: '普通属性',
      propertys: [],
    },
    {
      name: '事件属性',
      propertys: [],
    },
  ],
  showCodeDialog: false, // 事件属性展示编辑器
  codeDialogTitle: '',
  codeDialogTip: '',
})

const getEditorName = (name) => {
  // 判断是否有以该物料type为前缀的属性组件,有则返回该组件,若没有则使用通用属性组件
  const editorName = `${designer.selectedWidget?.type}-${name}-editor`
  const hasComponent = CommPropEditors[editorName] ?? EventEditors[editorName]
  if (hasComponent) return editorName
  return name + '-editor'
}
const hasEditProp = (name) => {
  const editorName = getEditorName(name)
  return !!(CommPropEditors[editorName] ?? EventEditors[editorName])
}
// 获取当前选中组件的属性编辑组件
const getPropertys = () => {
  const options = designer.getSeletedWidgetOptions()
  // eslint-disable-next-line no-prototype-builtins
  const commonProp = Object.keys(options).filter((item) =>
    COMMON_PROPERTIES.includes(item),
  )
  // eslint-disable-next-line no-prototype-builtins
  const eventProp = Object.keys(options).filter((item) =>
    EVENT_PROPERTIES.includes(item),
  )
  state.widgetPropertyLists[0].propertys = commonProp
  state.widgetPropertyLists[1].propertys = eventProp
}
// 计算滚动条高度
const computedHeight = () => {
  state.scrollHeight = window.innerHeight - 75 + 'px'
}

const addEventsListener = () => {
  // 监听选中物料事件触发,用于更新当前物料的属性编辑器
  window.addEventListener('select_widget', getPropertys)
  window.addEventListener('resize', computedHeight)
}

// 点击事件属性编写代码按钮时触发
const editEventProp = ({ title, tip }) => {
  state.showCodeDialog = true
  state.codeDialogTitle = title
  state.codeDialogTip = tip
}
onMounted(() => {
  addEventsListener()
  computedHeight()
})
</script>
<style lang="less" scoped>
:deep(.el-tabs__content) {
  border-top: 1px solid #ccc;
}
:deep(.el-form-item) {
  margin-bottom: 5px;
}
:deep(.el-form-item__label) {
  font-size: 12px;
}
</style>

4. 开发属性编辑器组件

物料有多少个属性,就要写多少个编辑器组件。接下来介绍一个最简单的普通属性编辑器和一个点击事件编辑器是如何开发的。

标签属性编辑器

这个属性的功能就是用来控制表单项的label

image.png

该组件接收了当前物料的options属性,把options.label绑定到el-input组件上,此时在页面中修改数据,options.label的值也会变化,回想之前开发渲染组件时,options.label被渲染到了form-item-wrapper组件中,所以它的值会跟着发生变化。

<template>
  <el-form-item label="标签">
    <el-input size="small" type="text" v-model="state.optionModel.label"></el-input>
  </el-form-item>
</template>
<script setup>
import { computed, reactive } from 'vue'
defineOptions({
  name: 'label-editor'
})
const props = defineProps({
  optionModel: {
    type: Object,
    default: () => {}
  }
})
const state = reactive({
  optionModel: computed(() => props.optionModel)
})
</script>

事件编辑器

所有点击事件编辑器都是一个按钮,点击后弹出代码编辑框,下图是一个blur事件编辑器

<template>
  <el-form-item label-width="120" label="onBlur">
    <el-button type="primary" size="small" round @click="onClickHandle">编写代码</el-button>
  </el-form-item>
</template>
<script setup>
defineOptions({
  name: 'onBlur-editor'
})

const emits = defineEmits(['editEventProp'])

const onClickHandle = () => {
  emits('editEventProp', {
    title: 'onBlur',
    tip: '.onBlur(){'
  })
}
</script>

代码编辑框

点击事件编辑器按钮抛出的title属性用来表明是哪个事件,然后将该事件对应的属性绑定到代码编辑器上。这样在代码编辑器中修改内容,就会绑定到此事件对应的属性。

<CodeEditorDialog
    :designer="designer"
    v-model:code="state.optionModel[state.codeDialogTitle]"
    v-model:showCodeDialog="state.showCodeDialog"
    :title="state.codeDialogTitle"
    :tip="state.codeDialogTip"
/>
<template>
  <el-dialog v-model="state.showCodeDialog" :title="title" width="80%">
    <!-- 用来提示使用者,编写代码时只用编写方法体 -->
    <el-alert type="info" :closable="false" :title="state.preInfo"></el-alert>
    <code-editor mode="javascript" v-model="state.code"></code-editor>
    <el-alert type="info" :closable="false" title="}"></el-alert>
    <template #footer>
      <div class="dialog-footer text-center">
        <el-button type="primary" @click="onSureHandle">确定</el-button>
        <el-button @click="onCloseHandle">关闭</el-button>
      </div>
    </template>
  </el-dialog>
</template>
<script setup>
import { computed, reactive } from 'vue'

import CodeEditor from '@/components/code-editor/index.vue'
const props = defineProps({
  showCodeDialog: {
    type: Boolean,
    default: false
  },
  designer: {
    type: Object,
    default: () => {}
  },
  title: {
    type: String,
    default: ''
  },
  code: {
    type: String,
    default: ''
  },
  tip: {
    type: String,
    default: ''
  }
})
const emits = defineEmits(['update:showCodeDialog', 'update:code'])

const state = reactive({
  showCodeDialog: computed({
    get() {
      return props.showCodeDialog
    },
    set(v) {
      emits('update:showCodeDialog', v)
    }
  }),
  code: computed({
    get() {
      return props.code
    },
    set(v) {
      emits('update:code', v)
    }
  }),
  preInfo: computed(() => {
    //TODO: 表单事件不应该是当前选中物料的属性名作为前缀,有空再改
    return `${props.designer.getSeletedWidgetOptions().propName}${props.tip}`
  })
})
const onSureHandle = () => {
  state.showCodeDialog = false
  // 用于修改事件属性后保存表单
  props.designer.eventChange()
}
// eslint-disable-next-line vue/no-setup-props-destructure
const oCode = props.code // 用于不点确定按钮直接关闭弹框时,回退内容
const onCloseHandle = () => {
  state.code = oCode
  state.showCodeDialog = false
}
</script>

在这个例子中绑定的options.onBlur属性,在之前编写渲染组件时,通过registerEvent混入组件已经注册过了onBlur事件,并且通过new Function去执行。

混入组件

onBlurHandle() {
  const func = new AsyncFuntion('axios', this.widget.options.onBlur)
  func.call(this.designer, window.axios)
},

触发input组件的blur事件时,就会执行之前在代码编辑框中写入的方法。

至此,表单设计器组件基本功能已经全部弄完了,剩下的就是自己丰富物料和属性了。

表单渲染器组件

这个组件虽然和设计器组件并列,但是写起来很简单,引入之前写的渲染组件即可。由于容器物料在渲染时和设计时表现出来的样式不同,所以需要额外写一套容器物料的渲染组件,同之前的容器物料一样,需要注册为全局组件,否则会造成循环引用。

渲染器组件

<template>
  <div class="form-render">
    <el-form>
      <template
        v-for="widget in formJSON.widgetList"
        :key="widget.options.propName"
      >
        <component
          v-model:options="widget.options"
          :is-design="false"
          :is="getRenderName(widget)"
          :widget="widget"
          :designer="state.designer"
        ></component>
      </template>
    </el-form>
  </div>
</template>
<script setup>
import { getRenderName } from '@/utils/index'
import FieldComponents from '@/components/form-designer/form-widget/field-widget/index'
import { onMounted, provide, reactive } from 'vue'
import { Designer } from '@/components/form-designer/designer'
import useRegisterEvt from '@/utils/useRegisterEvt'
defineOptions({
  components: {
    ...FieldComponents,
  },
})
const props = defineProps({
  formJSON: {
    type: Object,
    default: () => {
      return {
        widgetList: [],
        formConfig: {},
      }
    },
  },
})
const state = reactive({
  designer: new Designer({
    widgetList: props.formJSON.widgetList,
    formConfig: props.formJSON.formConfig,
  }),
})
provide('designer', state.designer) // 必须也生成一个designer实例供render组件单独使用时调用api,
defineExpose({
  designer: state.designer
})
// 注册表单的事件
const { onFormMounted } = useRegisterEvt(state.designer, props.formJSON)
onMounted(() => {
  onFormMounted()
})
</script>

表单渲染器组件完成后,预览表单的功能也就随之实现了。

项目优化等杂项

1. 分离外部包

将需要分离的包添加到rollupOptions.external中,然后编写一下需要使用CDN引入的包,使用vite-plugin-cdn-import插件来导入这些包。可以使用rollup-plugin-visualizer插件来进行打包分析。

vite.config.js

import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import { visualizer } from 'rollup-plugin-visualizer';
import { Plugin as importToCdn } from 'vite-plugin-cdn-import';
import { cdnList } from './cdnDep.js'
import { VitePWA } from 'vite-plugin-pwa'
// https://vitejs.dev/config/
export default defineConfig({
  base: process.env.NODE_ENV === 'production' ? 'https://static.icytail-form-designer.online/' : '/',
  plugins: [
    vue(),
    vueJsx(),
    VitePWA({
      registerType: 'autoUpdate', // 注册更新模式方式  默认是autoUpdate,将会自动更新,其他还有prompt和skipWaiting
      injectRegister: 'auto', // 控制如何在应用程序中注册ServiceWorker 默认值是 'auto' ,其他如:'inline' 则是注入一个简单的注册脚本,内联在应用程序入口点中
      manifest: { // manifest.json 文件配置
        name: 'qborfy study website',
        short_name: 'qborfyStudy',
        description: 'qborfy study website',
        theme_color: '#ffffff',
        icons: [
          {
            src: 'avicon.ico',
            sizes: '192x192',
            type: 'image/ico'
          },
          {
            src: 'favicon.ico',
            sizes: '512x512',
            type: 'image/ico'
          }
        ]
      },
      devOptions: {
        enabled: false
      }
    }),
    visualizer({
      open: true,
    }),
    importToCdn({
      modules: cdnList
    })
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  },

  build: {
    commonjsOptions: {
        include: /node_modules/  //这里记得把lib目录加进来,否则生产打包会报错!!
    },
    rollupOptions: {
      external: ['vue', 'element-plus', '@element-plus/icons-vue'],
    }
  },
})

cdnList


// eslint-disable-next-line no-unused-vars
export const cdnList = [
  {
    name: 'vue',
    var: 'Vue',
    path: 'https://unpkg.com/vue@3.3.4/dist/vue.global.js'
  },
  {
    name: 'vue-demi',
    var: 'VueDemi',
    path: 'https://unpkg.com/vue-demi@0.14.5/lib/index.iife.js'
  },
  {
    name: 'element-plus',
    var: 'ElementPlus',
    path: 'https://unpkg.com/element-plus@2.3.7/dist/index.full.js',
    css: 'https://unpkg.com/element-plus@2.3.7/dist/index.css'
  },
  {
    name: '@element-plus/icons-vue',
    var: 'ElementPlusIconsVue',
    path: 'https://unpkg.com/@element-plus/icons-vue@2.1.0/dist/index.iife.min.js',
  }
]

2. 静态资源优化

将打包后的文件上传到CDN服务器上,进入网站时,只从自己的服务器上加载首页的html,其余静态资源都从该CDN服务器上获取,可以大大的加快网站的加载速度。主要是参考了这篇文章

  1. 注册七牛云账号,申请一个CDN服务器
  2. 编写文件上传代码,以下方法在七牛云文档里有说明,本文不做详细介绍。
/* eslint-disable no-console */
import { join } from 'path';
import { readdirSync, lstatSync } from 'fs';
import chalk from 'chalk';
import { config as _config } from './qiniu-config.js';
import { fileURLToPath } from 'url';  
import { dirname } from 'path';  
import qiniu from 'qiniu'


const { auth, conf, zone, rs, form_up } = qiniu
const { blue, red, green, yellow } = chalk; // 颜色模块

const __filename = fileURLToPath(import.meta.url);  
const __dirname = dirname(__filename);  

const { ak, sk, bucket } = _config;

const mac = new auth.digest.Mac(ak, sk); //生成鉴权对象

// 构建配置类
const config = new conf.Config();
config.zone = zone['cn-east-2']; // 对应地区配置 https://developer.qiniu.com/kodo/4088/s3-access-domainname
config.useCdnDomain = true; // 上传是否使用cdn加速
// 生成资源管理对象
const bucketManager = new rs.BucketManager(mac, config);

/**
 * 上传文件方法
 * @param key 文件名
 * @param file 文件路径
 * @returns {Promise<unknown>}
 */
const doUpload = (key, file) => {
  console.log(blue(`正在上传:${file}`));
  // 凭证配置
  const options = {
    scope: `${bucket}:${key}`, // :key用于覆盖上传,除了需要简单上传所需要的信息之外,还需要想进行覆盖的文件名称,这个文件名称同时可是客户端上传代码中指定的文件名,两者必须一致
    expires: 7200 // 凭证有效期
  };
  const formUploader = new form_up.FormUploader(config);
  const putExtra = new form_up.PutExtra();
  const putPolicy = new rs.PutPolicy(options);
  const uploadToken = putPolicy.uploadToken(mac); // 上传凭证
  return new Promise((resolve, reject) => {
    formUploader.putFile(uploadToken, key, file, putExtra, (err, body, info) => {
      if (err) {
        reject(err);
      }
      if (info.statusCode === 200) {
        resolve(body);
      } else {
        reject(body);
      }
    });
  });
};

const getBucketFileList = (callback, marker, list = []) => {
  !marker && console.log(blue('正在获取空间文件列表'));
  // @param options 列举操作的可选参数
  //                prefix    列举的文件前缀
  //                marker    上一次列举返回的位置标记,作为本次列举的起点信息
  //                limit     每次返回的最大列举文件数量
  //                delimiter 指定目录分隔符
  const options = {
    limit: 100,
  };
  if (marker) {
    options.marker = marker;
  }
  // 获取指定前缀的文件列表
  bucketManager.listPrefix(bucket, options, (err, respBody, respInfo) => {
    if (err) {
      console.log(red(`获取空间文件列表出错 ×`));
      console.log(red(`错误信息:${JSON.stringify(err)}`));
      throw err;
    }
    if (respInfo.statusCode === 200) {
      // 如果这个nextMarker不为空,那么还有未列举完毕的文件列表,下次调用listPrefix的时候,
      // 指定options里面的marker为这个值
      const nextMarker = respBody.marker;
      const { items } = respBody;
      const newList = [...list, ...items];
      if (!nextMarker) {
        console.log(green(`获取空间文件列表成功 ✓`));
        console.log(blue(`需要清理${newList.length}个文件`));
        callback(newList);
      } else {
        getBucketFileList(callback, nextMarker, newList);
      }
    } else {
      console.log(yellow(`获取空间文件列表异常 状态码${respInfo.statusCode}`));
      console.log(yellow(`异常信息:${JSON.stringify(respBody)}`));
    }
  });
};

const clearBucketFile = () =>
  new Promise((resolve, reject) => {
    getBucketFileList(items => {
      if (!items.length) {
        resolve();
        return;
      }
      console.log(blue('正在清理空间文件'));
      const deleteOperations = [];
      // 每个operations的数量不可以超过1000个,如果总数量超过1000,需要分批发送
      items.forEach(item => {
        deleteOperations.push(rs.deleteOp(bucket, item.key));
      });
      bucketManager.batch(deleteOperations, (err, respBody, respInfo) => {
        if (err) {
          console.log(red(`清理空间文件列表出错 ×`));
          console.log(red(`错误信息:${JSON.stringify(err)}`));
          reject();
        } else if (respInfo.statusCode >= 200 && respInfo.statusCode <= 299) {
          console.log(green(`清理空间文件成功 ✓`));
          resolve();
        } else {
          console.log(yellow(`获取空间文件列表异常 状态码${respInfo.deleteusCode}`));
          console.log(yellow(`异常信息:${JSON.stringify(respBody)}`));
          reject();
        }
      });
    });
  });

const publicPath = join(__dirname, '../dist');

const uploadAll = async (dir, prefix) => {
  if (!prefix){
    console.log(blue('执行清理空间文件'));
    await clearBucketFile();
    console.log(blue('正在读取打包文件'));
  }
  const files = readdirSync(dir);
  if (!prefix){
    console.log(green('读取成功 ✓'));
    console.log(blue('准备上传文件'));
  }
  files.forEach(file => {
    const filePath = join(dir, file);
    const key = prefix ? `${prefix}/${file}` : file;
    if (lstatSync(filePath).isDirectory()) {
      uploadAll(filePath, key);
    } else {
      doUpload(key, filePath)
        .then(() => {
          console.log(green(`文件${filePath}上传成功 ✓`));
        })
        .catch(err => {
          console.log(red(`文件${filePath}上传失败 ×`));
          console.log(red(`错误信息:${JSON.stringify(err)}`));
          console.log(blue(`再次尝试上传文件${filePath}`));
          doUpload(file, filePath)
            .then(() => {
              console.log(green(`文件${filePath}上传成功 ✓`));
            })
            .catch(err2 => {
              console.log(red(`文件${filePath}再次上传失败 ×`));
              console.log(red(`错误信息:${JSON.stringify(err2)}`));
              throw new Error(`文件${filePath}上传失败,本次自动化构建将被强制终止`);
            });
        });
    }
  });
};

uploadAll(publicPath).finally(() => {
  console.log(green(`上传操作执行完毕 ✓`));
  console.log(blue(`请等待确认所有文件上传成功`));
});
  1. package.json中写入命令,打包完成后执行该命令即可上传
"scripts": {
    "dev": "vite",
    "serve": "vite",
    "build": "run-p type-check build-only",
    "upload-cdn": "node ./cdn/qiniu.js",
  },

也可以集成到CI/CD中自动执行,在触发流水线的时候,build阶段执行该命令,接入gitlab-cicd的内容本文不做详细介绍。

.gitlab-ci.yml

image: node:alpine
stages:
  - build
  - deploy
cache:
  key: modules-cache
  paths:
    - node_modules
    - dist
   
# 构建任务
job_build:
  stage: build
  tags:
    - auto
  script:
    - npm i -g cnpm --registry=https://registry.npmmirror.com/
    - cnpm install
    - npm run build
    - npm run upload-cdn
job_deploy:
  stage: deploy
  image: docker
  tags:
    - auto
  script:
    - docker build -t form-designer .
    # 删除正在运行的容器
    - if [ $(docker ps -aq --filter name=form-designer) ];then docker rm -f form-designer;fi
    # 运行我们刚创建的新镜像
    - docker run -d -p 443:443 --name=form-designer form-designer
  1. 配置vite.config.js中的base属性,替换成你申请的CDN服务器地址

3. 发布NPM包

主要参考vite官方文档

vite.lib.config.js

我在打包时遇到了一些问题(不确定别人会不会这样),当fileName配置为普通字符串的时候,假设就配置为fileName: aaa,此时会生成两个文件aaa.js和 aaa.umd.cjs,问题如下:

  1. aaa.js 该文件打包后存在 import xx from 'vue'这行代码,在vue3项目中该代码是会报错的。发布之前需手动修改打包后的文件代码为 import * as xx from 'vue'

  2. aaa.umd.cjs 未配置.cjs解析的项目引入本库时会报错 Module parse failed: Unexpected token (1:11155)You may need an appropriate loader to handle this file type,为避免此问题,将fileName配置成方法,返回js格式文件。

import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import { resolve } from 'node:path'
// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    vueJsx(),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },

  build: {
    lib: {
      entry: resolve(__dirname, 'install.js'), // !!打包进库的依赖是从此入口开始拓扑的,如果不在此文件里面引入注册draggable,会导致使用本库时获取不到draggable
      name: 'IcyTailForm',
      fileName: (type) => `icytailform.${type}.js`, 
    },
    commonjsOptions: {
      include: /node_modules/,
    },
    rollupOptions: {
      // 确保外部化处理那些你不想打包进库的依赖
      external: ['vue', 'element-plus'],
      output: {
        exports: 'default', //要支持CDN引入必须设置此参数!!!
        // 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
        globals: {
          vue: 'Vue',
          'element-plus': 'ElementPlus',
        },
      },
    },
  },
})

入口文件

插件需要暴露一个install方法供vue调用,在该方法中注册所有需要用到的自定义组件,否则引入时报custom element无法渲染

import formDesigner from '@/components/form-designer/index.vue'
import formRender from '@/components/form-render/index.vue'
import draggable from 'vuedraggable'
import ContainerWidgets from '@/components/form-designer/form-widget/container-widget/index'
import ContainerRender from '@/components/form-render/container/index'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
const install = (app) => {
  app.use(ContainerWidgets)
  app.use(ContainerRender)
  // 注册所有图标
  for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
    if (!app.component(key)) {
      app.component(key, component)
    }
  }
  app.component('draggable', draggable)
  app.component('formDesigner', formDesigner)
  app.component('formRender', formRender)
}
formDesigner.install = (app) => {
  app.use(ContainerWidgets)
  app.use(ContainerRender)
  // 注册所有图标
  for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
    if (!app.component(key)) {
      app.component(key, component)
    }
  }
  app.component('draggable', draggable)
  app.component('formDesigner', formDesigner)
}
formRender.install = (app) => {
  app.use(ContainerRender)
  app.component('formRender', formRender)
}
export default { formDesigner, formRender, install }

打包完成之后,登录自己的npm账号,进行发布即可。