likes
comments
collection
share

从零开始手搓一个低代码

作者站长头像
站长
· 阅读数 20

前言(废话)

低代码这玩意提出来也好多年了,这东西啊难以满足复杂的业务需求,简单的需求呢又懒得去用……超级的尴尬。有的人说它是智商税、是大佬开发出来搞出来的KPI。不管了,反正我可以不用,但是得懂。对,前端就是这么卷!

生态

目前有很多开源的低代码,还有很多收费的。

比如某些低代码平台已经把前后端集成在了一起,通过拖拉拽就可以生成页面,后端的接口也写好了,不需要接口联调,直接完成了开发。确实这样省去了很大一部分力气,比如开发的联调、代码规范统一、测试等环节。对于复杂的业务要求,还可以生成对应的前后端代码,然后根据生成的前后端代码去修改代码,做对应的开发,所以这才叫低代码。而不是零代码。

表单生成器

后端的不管,咱就搞前端这部分的活,还是大家对应低代码的传统印象。左侧是组件,中间渲染区域,右侧为配置项。如下图所示:

从零开始手搓一个低代码

在线体验

点我体验

从零开始手搓一个低代码

开始实施

npm create vue@latest

技术栈主要就是vue3+ant-design-vue+ts

傻瓜式一顿操作项目就创建好了,技术栈用的都是最新的正式版

拖拽

拖拽用什么去做呢?我可不想自己造轮子或者用什么原生去写。经过调研,选择用vue-draggable-plus去实现

双列表拖拽排序

复制vue-draggable-plus官网的demo很快,我就画了一个雏形

从零开始手搓一个低代码

简单润色

根据我们印象中的低代码样子,再简单润色一番。 就得到了下面的样子

从零开始手搓一个低代码

数据双向绑定

ant-desing-vue的表单组件都是用v-model双向绑定的,每个组件的情况不一样,大多组件是v-model:value,有的是v-model:checked。仔细分析一把v-model,其实就是语法糖!用多了别忘记了!

v-model:checked其实就是checked+onUpdate:checked,我们需要通过属性传参给组件。我把事件处理函数都放在了on下面

export type IListens = Record<string, (...any: any[]) => void>
export type IItem = {
  title: string
  component: string
  width?: number
  slot?: string
  componentProps?: Record<string, any>
  on?: IListens
}

然后通过handleOn处理函数把on里面的内容处理好放到了componentProps下

import type { IListens, IItemContent } from '@/views/aboutView/types'
export const handleOn = (item: IItemContent): IItemContent => {
  const { componentProps = {}, on = {} } = item
  const listens = Object.keys(on).reduce((pre, key) => {
    const value = on[key]
    const props = `on${key.slice(0, 1).toUpperCase()}${key.slice(1)}`
    pre[props] = value
    return pre
  }, {} as IListens)
  return {
    ...item,
    componentProps: {
      ...componentProps,
      ...listens
    }
  }
}

响应式对象

我们前面画的页面,修改右侧的数据不会改变渲染区域的。所以我定义了一个响应式对象,进行绑定。

这里面有个坑,经过数据传输后,item虽然是ref类型数据的其中一项,修改item.xxx并不会造成视图更新,需要对ref对象push一个reactive(item)才行,直接push item是不行的!

  const widthRef = ref(item.width)
    res.push({
    component: 'a-slider',
    title: '宽度',
    width: 50,
    componentProps: {
      value: widthRef,
      min: 100,
      max: 500
    },
    on: {
      'update:value': (val: number) => {
        item.width = val
        widthRef.value = val
      }
    }
  })

唯一标识

左侧的组件都是固定的数据,并没有id,拖拉拽到渲染区域需要搞个id。我们就用uuid去做

import { v4 as uuidv4 } from 'uuid'
item.id = uuidv4() // Generate a unique ID

css调整+代码优化

从零开始手搓一个低代码 贴一下现在的代码

<template>
  <div class="flex h-screen">
    <div class="left">
      <VueDraggable
        class="flex flex-col gap-2 p-4 w-300px h-full m-auto bg-gray-500/5 rounded overflow-auto"
        v-model="componentList"
        :animation="150"
        ghostClass="ghost"
        :clone="clone"
        :sort="false"
        :group="{ name: 'people', pull: 'clone', put: false }"
      >
        <div
          v-for="item in componentList"
          :key="item.component"
          class="cursor-move h-30 bg-gray-500/5 rounded p-3"
          @click="addComponent(item)"
        >
          {{ item.title }}
        </div>
      </VueDraggable>
    </div>
    <div class="right flex-1">
      <a-form
        :model="formState"
        v-bind="formProps"
        autocomplete="off"
        @finish="onFinish"
        class="p-4 h-full m-auto bg-gray-500/5 overflow-auto"
      >
        <VueDraggable
          class="flex flex-col gap-2 h-full"
          v-model="schemas"
          :animation="150"
          group="people"
          ghostClass="ghost"
        >
          <a-row v-for="item in schemas" :key="item.id">
            <a-col :span="item.span">
              <a-form-item class="bg-gray-500/5" :label="item.title" :name="item.id">
                <component
                  :is="item.component"
                  :style="item.width ? { width: item.width + 'px' } : {}"
                  v-bind="item.componentProps"
                  @click="compileConfigList(item)"
                >
                  {{ item.slot }}
                </component>
              </a-form-item>
            </a-col>
          </a-row>
        </VueDraggable>
      </a-form>
    </div>
    <div class="config w-60 p-4 h-full overflow-auto">
      <a-tabs v-model:activeKey="activeKey">
        <a-tab-pane key="1" tab="表单">
          <div v-for="item in formConfigList" :key="item.id">
            {{ item.title }}: <component :is="item.component" v-bind="item.componentProps" /></div
        ></a-tab-pane>
        <a-tab-pane key="2" tab="组件">
          <div v-for="item in configList" :key="item.id">
            {{ item.title }}:
            <component :is="item.component" v-bind="item.componentProps" />
          </div>
        </a-tab-pane>
      </a-tabs>
    </div>
  </div>
</template>
<script lang="ts" setup>
import { VueDraggable } from 'vue-draggable-plus'
import { v4 as uuidv4 } from 'uuid'
import type { IItem, IItemContent } from './types'
import { componentList } from './constant'
import { cloneDeep } from 'lodash-es'
import { handleOn } from '@/utils'
const activeKey = ref('1')
const formState = reactive<Record<string, any>>({})
const onFinish = (values: any) => {
  console.log('Success:', values)
}
const formProps = reactive({
  labelCol: { span: 8 },
  wrapperCol: { span: 16 },
  layout: 'horizontal'
})
type IConfigList = Omit<IItemContent, 'span'>
const formConfigList = ref<IConfigList[]>([])
const compileFormConfigList = () => {
  const res: IConfigList[] = []
  const labelCol = ref(formProps.labelCol.span)
  res.push({
    id: uuidv4(),
    component: 'a-slider',
    title: 'labelCol',
    componentProps: {
      value: labelCol,
      min: 1,
      max: 24
    },
    on: {
      'update:value': (val: number) => {
        labelCol.value = val
        formProps.labelCol.span = val
      }
    }
  })
  const wrapperCol = ref(formProps.wrapperCol.span)
  res.push({
    id: uuidv4(),
    component: 'a-slider',
    title: 'wrapperCol',
    componentProps: {
      value: wrapperCol,
      min: 1,
      max: 24
    },
    on: {
      'update:value': (val: number) => {
        wrapperCol.value = val
        formProps.wrapperCol.span = val
      }
    }
  })
  const layout = ref(formProps.layout)
  res.push({
    id: uuidv4(),
    component: 'a-select',
    title: 'layout',
    componentProps: {
      value: layout,
      options: [
        {
          value: 'horizontal',
          label: 'horizontal'
        },
        {
          value: 'vertical',
          label: 'vertical'
        },
        {
          value: 'inline',
          label: 'inline'
        }
      ]
    },
    on: {
      'update:value': (val: string) => {
        layout.value = val
        formProps.layout = val
      }
    }
  })
  formConfigList.value = res.map((item) => handleOn(item) as IConfigList)
}
compileFormConfigList()
const configList = ref<IConfigList[]>([])
const compileConfigList = (item: IItemContent) => {
  const res: IConfigList[] = []
  const titleRef = ref(item.title)
  res.push({
    id: uuidv4(),
    component: 'a-input',
    title: '标题',
    componentProps: {
      placeholder: '请输入',
      value: titleRef
    },
    on: {
      'update:value': (val: string) => {
        item.title = val
        titleRef.value = val
      }
    }
  })
  const spanRef = ref(item.span)
  res.push({
    id: uuidv4(),
    component: 'a-slider',
    title: '栅格',
    componentProps: {
      value: spanRef,
      min: 1,
      max: 24
    },
    on: {
      'update:value': (val: number) => {
        item.span = val
        spanRef.value = val
      }
    }
  })
  if (item.width) {
    const widthRef = ref(item.width)
    res.push({
      id: uuidv4(),
      component: 'a-slider',
      title: '宽度',
      componentProps: {
        value: widthRef,
        min: 100,
        max: 500
      },
      on: {
        'update:value': (val: number) => {
          item.width = val
          widthRef.value = val
        }
      }
    })
  }

  if (item.slot) {
    const slotRef = ref(item.slot)
    res.push({
      id: uuidv4(),
      component: 'a-input',
      title: '按钮文本',
      componentProps: {
        placeholder: '请输入',
        value: slotRef
      },
      on: {
        'update:value': (val: string) => {
          item.slot = val
          slotRef.value = val
        }
      }
    })
  }
  if (!item.componentProps) return
  const { placeholder, showCount } = item.componentProps
  if (placeholder) {
    const placeholderRef = ref(item.componentProps!.placeholder)
    res.push({
      id: uuidv4(),
      component: 'a-input',
      title: 'placeholder',
      componentProps: {
        placeholder: '请输入',
        value: placeholderRef
      },
      on: {
        'update:value': (val: string) => {
          item.componentProps!.placeholder = val
          placeholderRef.value = val
        }
      }
    })
  }
  if (showCount) {
    const showCountRef = ref(item.componentProps!.showCount)
    res.push({
      id: uuidv4(),
      component: 'a-checkbox',
      title: '显示数字',
      componentProps: {
        checked: showCountRef
      },
      on: {
        'update:checked': (val: boolean) => {
          item.componentProps!.showCount = val
          showCountRef.value = val
        }
      }
    })
  }

  configList.value = res.map((item) => handleOn(item) as IConfigList)
}
const schemas = ref<IItemContent[]>([])
const getItemContent = (item: IItem): IItemContent => {
  const itemClone = cloneDeep(item)
  const id = uuidv4()
  const itemContent = {
    ...itemClone,
    id,
    span: 24,
    componentProps: {
      ...(item.componentProps || {})
    }
  }
  if (item.component === 'a-switch') {
    const valRef = ref(false)
    itemContent.componentProps.checked = valRef
    itemContent.on = {
      'update:checked': (val: boolean) => {
        formState[id] = val
        valRef.value = val
      }
    }
  } else {
    const valRef = ref(undefined)
    itemContent.componentProps.value = valRef
    itemContent.on = {
      'update:value': (val: any) => {
        formState[id] = val
        valRef.value = val
      }
    }
  }
  return handleOn(itemContent) as IItemContent
}
function clone(element: IItem) {
  const itemContent = reactive(getItemContent(element))
  compileConfigList(itemContent)
  activeKey.value = '2'
  return itemContent
}
const addComponent = (item: IItem) => {
  const itemContent = reactive(getItemContent(item))
  compileConfigList(itemContent)
  activeKey.value = '2'
  schemas.value.push(itemContent)
}
</script>
<style lang="scss" scoped></style>

tag

此刻,我在git代码仓库打了一个标签叫simplest。个人觉大部分东西都在一个文件,没有拆分出去。目前看起来是比较容易看懂的,后面我准备开始把组件拆分出去了

拆分

组件拆分就不过多介绍了,贴一下代码大家就知道我拆分的结构了。考虑到后面组件层级多了,数据传输太麻烦,我直接用的是provide+inject的方式。也没有用store去存数据,就简单的用eventBus去做了

<template>
  <div class="flex h-screen">
    <div class="left">
      <componentDictionary />
    </div>
    <div class="right flex-1">
      <renderForm />
    </div>
    <div class="config w-60 p-4 h-full overflow-auto">
      <optionConfig />
    </div>
  </div>
</template>
<script lang="ts" setup>
import type { IItemContent, IFormProps, IFormState } from './types'
import componentDictionary from './components/componentDictionary.vue'
import renderForm from './components/renderForm.vue'
import optionConfig from './components/optionConfig.vue'
import { useProvideFormState, useProvideFormProps, useProvideSchemas } from './hooks'
import { getInitFormProps } from './utils'
const formState = reactive<IFormState>({})
const formProps = reactive<IFormProps>(getInitFormProps())
const schemas = ref<IItemContent[]>([])
useProvideFormState(formState)
useProvideFormProps(formProps)
useProvideSchemas(schemas)
</script>
<style lang="scss" scoped></style>

代码优化

细心的同学肯定注意到前面有这样的代码


  const labelCol = ref(formProps.labelCol.span)
  res.push({
    id: uuidv4(),
    component: 'a-slider',
    title: 'labelCol',
    componentProps: {
      value: labelCol,
      min: 1,
      max: 24
    },
    on: {
      'update:value': (val: number) => {
        labelCol.value = val
        formProps.labelCol.span = val
      }
    }
  })

类似的代码好多,难道每次我都得这么写吗?就不能优化一下代码?答案是肯定可以优化

优化一

我可以写一个公共函数去创建,大概是这样:


const getComponent = (option: IItem, initValue: string | number) => {
  const dataRef = ref(initValue)
  const { componentProps = {}, on = {} } = option
  const modelKey = getModelKey(component)
  const updateModelKey = 'update:' + modelKey
  return {
    ...option,
    id: uuidv4(),
    componentProps: {
      ...componentProps,
      [modelKey]: dataRef
    },
    on: {
      ...on,
      [updateModelKey]: (val: number | string) => {
        dataRef.value = val
        if (option.on?.[updateModelKey]) {
          option.on[updateModelKey](val)
        }
      }
    }
  }
}

省去了每次都要传id,componentProps、on等参数都实现了”继承“

用法是这样的:

 const titleComponent = getComponent(
      {
        component: 'input',
        title: '标题',
        componentProps: {
          placeholder: '请输入'
        },
        on: {
          'update:value': (val: string) => {
            item.title = val
          }
        }
      },
      item.title
    )

优化二

猛的一看,还要写update:value函数去修改item.title真的好恶心,能不能再优化一下呢?答案是可以的!我们可以思考一下,vue3 不是有一个toRef函数可以把reactive转为一个ref对象,并且两边的数据保持同步吗?注:item为一个reactive对象

直接传item.title这样的值进来是不行的,我们必须传item和key到getComponent函数里面


const getComponent = (option: IItem, obj: Record<string, any>, key: string) => {
  const dataRef = toRef(obj, key)
  const { component, componentProps = {}, on = {} } = option
  const modelKey = getModelKey(component)
  const updateModelKey = 'update:' + modelKey
  return {
    ...option,
    id: uuidv4(),
    component: 'a-' + component,
    componentProps: {
      ...componentProps,
      [modelKey]: dataRef
    },
    on: {
      ...on,
      [updateModelKey]: (val: number | string) => {
        dataRef.value = val
        if (option.on?.[updateModelKey]) {
          option.on[updateModelKey](val)
        }
      }
    }
  }
}

这样的话,只需要这样去写就行了.两边的数据保持同步,页面显示也一致

    const titleComponent = getComponent(
      {
        component: 'input',
        title: '标题',
        componentProps: {
          placeholder: '请输入'
        }
      },
      item,
      'title'
    )

优化三

当我们传入getComponent(option,formProps,'wrapperCol.span')这样的场景时候,toRef(obj, key)这里面的key是不支持.去做分割查找的。所以我自己实现了一个toNestedRef函数

function toNestedRef(obj: Record<string, any>, path: string) {
  const keys = path.split('.')
  const lastKey = keys.pop()!
  const parent = keys.reduce((acc, key) => acc[key], obj)
  const nestedRef = ref(parent[lastKey])
  watch(nestedRef, (newValue) => {
    parent[lastKey] = newValue
  })
  watch(
    () => parent[lastKey],
    (newValue) => {
      nestedRef.value = newValue
    }
  )
  return nestedRef
}

这样传入的key不管带不带.都是可以支持的了

优化四

以上代码使用还是有心智负担的,后面我又优化了一版,根据constant.ts文件中formConfigOptions和componentConfigOptions就可以得到编译后的options。详细可以看最新代码仓库

export const componentConfigOptions: IConfigOptions[] = [
  {
    options: {
      component: 'a-input',
      title: '标题',
      componentProps: {
        placeholder: '请输入'
      }
    },
    path: 'title'
  },
  // ……
  ]

编辑框

编辑框的组件我们之前还没有实现,选中高亮点击事件我是写在了表单组件上面。现在我创建了一个editComponent组件作为编辑框,有复制、删除的功能,选中高亮显示。

<template>
  <div
    class="editComponent"
    @click="updateConfigList(item)"
    :class="activeVisible ? 'editComponent--active' : ''"
  >
    <slot></slot>
    <div class="absolute top-0 right-0 z-999" @click.stop v-show="activeVisible">
      <CopyOutlined class="cursor-pointer mx-2" @click="copyItem" />
      <DeleteOutlined class="cursor-pointer mx-2" @click="deleteItem" />
    </div>
  </div>
</template>
<script lang="ts" setup>
import type { IItemContent } from '../types'
import { emitter } from '../mitt'
import {
  useInjectSchemas,
  useInjectActiveComponent,
  useInjectFormState,
  useItemContent
} from '../hooks'
import { CopyOutlined, DeleteOutlined } from '@ant-design/icons-vue'

const activeComponent = useInjectActiveComponent()
const schemas = useInjectSchemas()
const props = defineProps<{
  item: IItemContent
}>()
const updateConfigList = (item?: IItemContent) => {
  activeComponent.value = item ?? null
  emitter.emit('update:configList', item)
}
const activeVisible = computed(() => activeComponent.value === props.item)
const formState = useInjectFormState()
const copyItem = () => {
  const currentIndex = schemas.value.findIndex((item) => item === props.item)
  if (currentIndex === -1) return
  const itemContent = useItemContent(props.item, formState)
  updateConfigList(itemContent)
  schemas.value.splice(currentIndex + 1, 0, itemContent)
}
const deleteItem = () => {
  const currentIndex = schemas.value.findIndex((item) => item === props.item)
  if (currentIndex === -1) return
  updateConfigList()
  schemas.value.splice(currentIndex, 1)
}
</script>
<style lang="scss" scoped>
.editComponent {
  @apply relative cursor-pointer;
  &--active {
    border: 1px solid red;
  }
}
</style>

没啥好讲的,比较简单,样式是丑了点,能用就行!

从零开始手搓一个低代码

栅格

简单调整代码,用a-row做到栅格布局

从零开始手搓一个低代码


    <a-row>
      <VueDraggable
        v-model="schemas"
        :animation="150"
        group="people"
        ghostClass="ghost"
        class="w-full flex flex-wrap"
      >
        <a-col :span="item.span" v-for="item in schemas" :key="item.id">
          <editComponent :item>
            <a-form-item class="bg-gray-500/5" :label="item.title" :name="item.id">
              <component
                :is="item.component"
                :style="item.width ? { width: item.width + 'px' } : {}"
                v-bind="item.componentProps"
              >
                {{ item.slot }}
              </component>
            </a-form-item>
          </editComponent>
        </a-col>
      </VueDraggable>
    </a-row>

其他实现方式

我这种实现方式是简单的把单个组件搞了个栅格布局,其实还有另外一种实现方式就是写一个栅格组件,然后把组件往栅格里面拖……

表单预览

做个预览很简单,加个按钮,点击打开弹窗,内容就跟中间区域的表单显示一样。 但是有点区别:

  • 不可以拖动
  • 选中表单项无需高亮
  • 点击确定需要进行表单校验

下面直接贴代码

    <div class="right flex-1 flex flex-col">
      <div class="edit p-2">
        <a-button type="primary" @click="previewFormModalVisible = true">预览</a-button>
        <previewFormModal v-model="previewFormModalVisible" />
      </div>
      <div class="flex-1 h-0">
        <renderForm />
      </div>
    </div>

注:previewFormModal必须provide新的formState、schemas给renderForm才行

 <!-- previewFormModal.vue -->
<template>
  <a-modal v-model:open="open" title="预览表单" @ok="handleOk" @cancel="handleCancel">
    <renderForm ref="renderFormRef" />
  </a-modal>
</template>
<script lang="ts" setup>
import renderForm from './renderForm.vue'
import type { IFormState } from '../types'
import {
  useProvideFormState,
  useProvidePreview,
  useInjectSchemas,
  useProvideSchemas,
  usePreviewSchemas
} from '../hooks'
const formState = reactive<IFormState>({})
useProvideFormState(formState)
useProvidePreview(true)
const schemas = useInjectSchemas()
const newSchemas = usePreviewSchemas(schemas, formState)
useProvideSchemas(newSchemas)
const open = defineModel<boolean>({
  required: true
})
const renderFormRef = ref()
const handleOk = () => {
  renderFormRef.value!.formRef.validate().then((res: IFormState) => {
    console.log(res)
    open.value = false
  })
}
const handleCancel = () => {
  renderFormRef.value!.formRef.resetFields()
}
</script>

// hooks.ts
export const usePreviewSchemas = (schemas: ISchemasRef, formState: IFormState) => {
  const newSchemas = ref<IItemContent[]>([])
  watch(
    schemas,
    (data) => {
      newSchemas.value = data.map((item) => {
        const { id } = item //保持原来的id
        Reflect.deleteProperty(item, 'on') //相关的事件删除 避免影响最外层的formState
        const itemContent = useGetItemContent(item, formState, id)
        return itemContent
      })
    },
    {
      immediate: true,
      deep: true
    }
  )
  return newSchemas
}

表单绑定值-双向绑定

利用toRef修改代码

 // const valRef = ref(undefined)
  const valRef = toRef(formState, id)
   const itemContent = {
    ...itemClone,
    id,
    span: 24,
    componentProps: {
      ...componentProps,
      [modelKey]: valRef
    },
    on: {
      ...on,
      [updateModelKey]: (val: any) => {
        formState[id] = val //这一行删除
        valRef.value = val
        if (itemClone.on?.[updateModelKey]) {
          itemClone.on[updateModelKey](val)
        }
      }
    }
  }

禁止拖动

VueDraggable组件设置属性disabled为true即可

选中表单项无需高亮

<!-- editComponent.vue -->
<template>
  <div
    class="editComponent"
    :class="activeVisible ? 'editComponent--active' : ''"
    v-if="!isPreview"
    @click="updateConfigList(item)"
  >
    <slot></slot>
    <div class="absolute top-0 left-0 z-999" @click.stop v-show="activeVisible">
      <CopyOutlined class="cursor-pointer mx-2" @click="copyItem" />
      <DeleteOutlined class="cursor-pointer mx-2" @click="deleteItem" />
    </div>
  </div>
  <slot v-else></slot>
</template>

这里有个小bug,点击预览弹窗的label项目还是会触发updateConfigList事件,就很奇怪。后面发现是a-form导致的,给它加上name区分form就好了

表单校验

formRules

根据schemas直接生成formRules,提示语先不做自定义的

const formRules = computed(() => {
  return schemas.value.reduce(
    (pre, cur) => {
      if (!cur.required) return pre
      const message = cur.componentProps?.placeholder ?? '请完善' + cur.title
      pre[cur.id] = [{ message, required: cur.required, trigger: 'change' }]
      return pre
    },
    {} as Record<string, Rule[]>
  )
})

调用

我在renderForm直接导出了form的对象,外面调用得 renderFormRef.value!.formRef.xxx()感觉有点鸡肋,目前没有想到其他好办法

// renderForm.vue
const formRef = ref<FormInstance>()
defineExpose({
  formRef: formRef
})
// previewFormModal.vue
const renderFormRef = ref()
const handleOk = () => {
  renderFormRef.value!.formRef.validate().then((res: IFormState) => {
    console.log(res)
    open.value = false
  })
}
const handleCancel = () => {
  renderFormRef.value!.formRef.resetFields()
}

选项数据配置

从零开始手搓一个低代码 前面关于select组件的options配置,我们是没有去做的,下面我们去简单实现一下。远程获取的方式就不去搞了,搞个本地的

分析

这个组件不是单一的一个表单组件,而是由table+按钮组成。我写了一个全局组件optionDataConfig作为集中展示,模拟表单的行为,抛出事件,做到数据双向绑定。

添加配置

如果有componentProps.options那么就会生成“选项数据”的配置项

从零开始手搓一个低代码

optionDataConfig

<template>
  <div>
    <a-table :columns="columns" :data-source="dataSource" :pagination="false">
      <template #bodyCell="{ column, index }">
        <template v-if="column.key === 'label' || column.key === 'value'">
          <a-input v-model:value="dataSource[index][column.key]" />
        </template>
        <template v-else-if="column.key === 'action'">
          <a-button type="link" @click="handleDelete(index)">删除</a-button>
        </template>
      </template>
    </a-table>
    <a-button type="primary" @click="handleAdd">添加一项</a-button>
  </div>
</template>
<script lang="ts" setup>
const columns = [
  {
    title: 'label',
    key: 'label'
  },
  {
    title: 'value',
    key: 'value'
  },

  {
    title: '操作',
    key: 'action'
  }
]
type IDataSource = {
  value: string
  label: string
}[]
const dataSource = defineModel<IDataSource>('value', {
  required: true
})
const handleDelete = (index: number) => {
  dataSource.value = dataSource.value.filter((_, i) => i !== index)
}
const handleAdd = () => {
  dataSource.value.push({
    value: '',
    label: ''
  })
}
</script>

<style lang="scss" scoped></style>

必填

从零开始手搓一个低代码

分析

主要由switch+input两部分组成

所以我在IItem新增两个字段requiredmessage类型分别为booleanstring

配置

新增必填配置项,需要注意的是,这边需要绑定两个值,所以我们还需要把path类型拓展为string | string[]

// constants.ts下的componentConfigOptions
  {
    options: {
      component: 'requiredConfig',
      title: '必填'
    },
    path: ['required', 'message']
  },
// hooks.ts
const getOption = <T extends IConfigList = IConfigList>(
  option: T,
  obj: Record<string, any>,
  key: string | string[]
): T => {
  if (typeof key === 'string') {
    const dataRef = toNestedRef(obj, key)
    const { component, componentProps = {}, on = {} } = option
    const modelKey = getModelKey(component)
    const updateModelKey = 'update:' + modelKey
    return {
      ...option,
      componentProps: {
        ...componentProps,
        [modelKey]: dataRef
      },
      on: {
        ...on,
        [updateModelKey]: (val: number | string) => {
          dataRef.value = val
          if (option.on?.[updateModelKey]) {
            option.on[updateModelKey](val)
          }
        }
      }
    }
  } else {
    const result = cloneDeep(option)
    key.forEach((item) => {
      const dataRef = toNestedRef(obj, item)
      const modelKey = item.split('.').pop()!
      const updateModelKey = 'update:' + modelKey
      if (!result.componentProps) {
        result.componentProps = {}
      }
      result.componentProps[modelKey] = dataRef
      if (!result.on) {
        result.on = {}
      }
      result.on[updateModelKey] = (val: number | string) => {
        dataRef.value = val
        if (option.on?.[updateModelKey]) {
          option.on[updateModelKey](val)
        }
      }
    })
    return result
  }
}

requiredConfig

新建requiredConfig全局组件

<template>
  <div>
    <a-switch v-model:checked="switchChecked" />
    <a-input
      v-model:value="inputVal"
      placeholder="请输入提示信息"
      v-if="switchChecked"
      class="w-50 ml-4"
    />
  </div>
</template>
<script lang="ts" setup>
const switchChecked = defineModel<boolean>('required', {
  required: true
})
const inputVal = defineModel<string>('message', {
  required: true
})
</script>

<style lang="scss" scoped></style>

关联表单-隐藏条件

当一个表单配置规则为其他表单的值等于xxx的时候隐藏。目前只做简单的,所有的条件都是等于、并且的关系

hideCondition

IItem类型新增hideCondition字段,类型为Record<string, any>,动态计算showSchemas作为展示


const showSchemas = computed(() => {
  return schemas.value.filter((item) => {
    if (!item.hideCondition || isEmpty(item.hideCondition)) return true
    const hidden = Object.keys(item.hideCondition).every((key) => {
      return item.hideCondition![key] === currentFormState[key]
    })
    return !hidden
  })
})

这里还需要考虑到拖拽排序,所以showSchemas应该改为这样

const getIndex = (array1: IItemContent[], array2: IItemContent[], id: string) => {
  const array1Index = array1.findIndex((item) => item._id === id)
  if (array1Index !== -1) return array1Index
  return array2.findIndex((item) => item._id === id)// 隐藏的表单保持原来的位置
}
const showSchemas = computed({
  get() {
    return schemas.value.filter((item) => {
      if (!item.hideCondition || isEmpty(item.hideCondition)) return true
      const hidden = Object.keys(item.hideCondition).every((key) => {
        return item.hideCondition![key] === currentFormState[key]
      })
      return !hidden
    })
  },
  set(value) {
    schemas.value.sort((a, b) => {
      const aIndex = getIndex(value, schemas.value, a._id)
      const bIndex = getIndex(value, schemas.value, b._id)
      return aIndex - bIndex
    })
  }
})

visibleConfig

创建visibleConfig.vue作为右侧配置项使用的组件,用来控制显示隐藏规则。

  • 点击按钮后打开弹窗
  • 选择不同的表单
  • 设置想要的值
  • 设置条件后有高亮状态
<template>
  <div>
    <a-button type="primary" :danger="buttonDanger" @click="open = true">设置隐藏条件</a-button>
    <a-modal v-model:open="open" title="隐藏条件" @ok="handleOk">
      <div class="flex items-center p-4" v-for="(item, index) in formStateList" :key="item.key">
        <a-select
          v-model:value="formStateList[index].key"
          style="width: 120px"
          :options="getOptions(formStateList[index].key)"
          @change="() => (formStateList[index].value = undefined)"
        ></a-select>
        <span class="mx-4">等于</span>
        <div class="flex-1">
          <dynamicRenderingComponent :item="renderItem(item)!" v-if="renderItem(item)" />
        </div>
        <span class="mx-4 c-red cursor-pointer" @click="delFormStateList(index)"> 删除 </span>
      </div>
      <a-button @click="addFormStateList">新增一条</a-button>
      <a-button @click="restState">重置隐藏条件</a-button>
    </a-modal>
  </div>
</template>
<script lang="ts" setup>
import { useGetItemContent } from '@/views/HomeView/hooks'
import dynamicRenderingComponent from '@/views/HomeView/components/dynamicRenderingComponent.vue'
import { useDragFormStore } from '@/stores/dragForm'
import { isEmpty } from 'lodash-es'
const dragFormStore = useDragFormStore()
const hideCondition = defineModel<Record<string, any>>('value', {
  required: true
})
const open = ref<boolean>(false)
const buttonDanger = computed(() => !isEmpty(hideCondition.value))
type IFormStateListItem = {
  key?: string
  value?: string | number
}
const getFormStateList = (): IFormStateListItem[] => {
  const res = Object.keys(hideCondition.value).map((key) => {
    return {
      key,
      value: hideCondition.value[key]
    }
  })
  if (res.length === 0) return [{}]
  return res
}
const formStateList = ref(getFormStateList())
const filterNull = (list: IFormStateListItem[]) => list.filter((item) => item.value !== undefined)
const handleOk = () => {
  open.value = false
  const formStateListFilterNull = filterNull(formStateList.value)
  hideCondition.value = formStateListFilterNull.reduce(
    (pre, cur) => {
      if (!cur.key) return pre
      pre[cur.key] = cur.value
      return pre
    },
    {} as Record<string, any>
  )
}
const renderItem = (options: IFormStateListItem) => {
  if (!options.key) return
  const thatComponent = dragFormStore.schemas.find((item) => item._id === options.key)
  if (!thatComponent) return
  const { span } = thatComponent //保持原来的id和span
  Reflect.deleteProperty(thatComponent, 'on') //相关的事件删除 避免影响最外层的formState
  return useGetItemContent(
    thatComponent,
    options,
    { id: options.key, _id: options.key, span },
    'value'
  )
}
const restState = () => {
  formStateList.value = [{}]
}
const selectOptions = computed(() =>
  dragFormStore.schemas
    .filter((item) => item !== dragFormStore.activeComponent)
    .map((item) => ({
      value: item._id,
      label: item.title || item._id
    }))
)
const getOptions = (selectId?: string) => {
  let selectIds = formStateList.value.map((item) => item.key).filter(Boolean) as string[]
  selectIds = selectIds.filter((item) => item !== selectId)
  return selectOptions.value.filter((item) => !selectIds.includes(item.value))
}
const addFormStateList = () => {
  formStateList.value.push({})
}
const delFormStateList = (index: number) => {
  formStateList.value.splice(index, 1)
}
</script>
<style lang="scss" scoped></style>

_id

右侧的配置表单需要记住对应的中间那个渲染表id,由于之前我们把id开放给用户可以去手动编辑,所以变得不可用了。下面我新增了一个内部使用的_id,创建时候和id保持一致,后期不会变动

// hooks.ts
  const updateConfigList = (item?: IItemContent) => {
    if (!item) {
      configList.value = []
      return
    }
    item.required = item.required ?? false
    item.visible = item.visible ?? true
    // 把表单id和配置项目对应 visibleConfig.vue
    componentConfigOptions.forEach(({ options }) => {
      if (!options.componentProps) {
        options.componentProps = {}
      }
      options.componentProps._id = item._id
    })
    configList.value = getComponent(componentConfigOptions, item)
  }
// types.d.ts
export interface IItemContentOther {
  id: string
  _id: string
  span: number
}

组件升级

为了支持更多的配置,参考了相关低代码产品,我决定把它设计为这样 从零开始手搓一个低代码

类型调整

hideCondition的类型从Record<string,any>调整为ICondition

export enum ECondition {
  AND = 'and',
  OR = 'or'
}
export enum EType {
  CONDITION = 'condition',
  CONDITION_GROUP = 'conditionGroup'
}
export enum ESymbol {
  EQUAL_TO = '=',
  NOT_EQUAL_TO = '!='
}
export type IRight = {
  type: EType
  formId?: string
  symbol?: ESymbol
  value?: any
  conditionGroup?: ICondition
}
export type ICondition = {
  left?: ECondition
  right?: IRight[]
}

调整visibleConfig

<template>
  <div>
    <a-button type="primary" :danger="buttonDanger" @click="open = true">设置隐藏条件</a-button>
    <a-modal v-model:open="open" title="隐藏条件" @ok="handleOk" :width="800">
      <renderTreeCondition :condition="conditionCloned" />
      <a-button @click="restState">重置隐藏条件</a-button>
    </a-modal>
  </div>
</template>
<script lang="ts" setup>
import { cloneDeep, isEmpty } from 'lodash-es'
import renderTreeCondition from './renderTreeCondition.vue'
import type { ICondition, IRight } from './types'
import { EType } from './types'
import { getVisibleConfig } from './utils'
const conditionList = defineModel<ICondition>('value', {
  required: true
})

const getConditionList = (): ICondition => {
  const res = cloneDeep(conditionList.value)
  if (!res || isEmpty(res)) return getVisibleConfig()
  return res
}
const conditionCloned = ref(getConditionList())
const open = ref<boolean>(false)
watch(open, (val) => {
  if (!val) return
  conditionCloned.value = getConditionList()
})
const buttonDanger = computed(() => !isEmpty(conditionList.value))
const filterRight = (rightList: IRight[]): IRight[] => {
  return rightList.filter((right) => {
    if (right.type === EType['CONDITION']) {
      return right.formId && right.symbol && right.value
    } else {
      return !isEmpty(filterNull(right.conditionGroup))
    }
  })
}
const filterNull = (condition?: ICondition): ICondition => {
  if (!condition?.right?.length) return {}
  condition.right = filterRight(condition.right)
  if (!condition.right.length) return {}
  return condition
}
const handleOk = () => {
  open.value = false
  const formStateListFilterNull = filterNull(conditionCloned.value)
  conditionList.value = formStateListFilterNull
}
const restState = () => {
  conditionCloned.value = getVisibleConfig()
}
</script>
<style lang="scss" scoped></style>

renderTreeCondition

由于渲染的组件是一个tree数据,所以可能有很多嵌套,所以我单独写了一个递归组件去做渲染

<template>
  <div class="flex items-center p-4">
    <a-select
      ref="select"
      v-model:value="selectValue"
      style="width: 120px"
      :options="conditionSelectOptions"
      placeholder="请选择条件"
    >
    </a-select>
    <div class="flex-1" v-if="condition.right">
      <div class="flex items-center p-4" v-for="(item, inx) in condition.right" :key="inx">
        <template v-if="item.type === EType.CONDITION">
          <a-select
            v-model:value="item.formId"
            style="width: 120px"
            :options="getOptions(condition.right, item.formId)"
            @change="() => (item.value = undefined)"
            placeholder="关联表单"
          ></a-select>
          <a-select
            v-model:value="item.symbol"
            style="width: 120px"
            class="mx-4"
            :options="symbolSelectOptions"
            placeholder="关联关系"
          ></a-select>
          <div class="flex-1">
            <dynamicRenderingComponent :item="renderItem(item)!" v-if="renderItem(item)" />
          </div>
          <span class="mx-4 c-red cursor-pointer" @click="delFormStateList(condition.right, inx)">
            删除
          </span>
        </template>
        <template v-else>
          <renderTreeCondition :condition="item.conditionGroup!" />
        </template>
      </div>
      <a-button @click="addCondition(condition.right)">新增条件</a-button>
      <a-button @click="addConditionGroup(condition.right)">新增条件组</a-button>
    </div>
  </div>
</template>
<script lang="ts" setup>
import type { ICondition, IRight } from './types'
import { ECondition, EType, ESymbol } from './types'
import { useDragFormStore } from '@/stores/dragForm'
import { useGetItemContent } from '@/views/HomeView/hooks'
import dynamicRenderingComponent from '@/views/HomeView/components/dynamicRenderingComponent.vue'
import { getCondition, getConditionGroup } from './utils'
const dragFormStore = useDragFormStore()
defineOptions({ name: 'renderTreeCondition' })
const conditionSelectOptions = [
  {
    label: 'AND',
    value: ECondition['AND']
  },
  {
    label: 'OR',
    value: ECondition['OR']
  }
]

const symbolSelectOptions = [
  {
    label: '等于',
    value: ESymbol['EQUAL_TO']
  },
  {
    label: '不等于',
    value: ESymbol['NOT_EQUAL_TO']
  }
]

const props = defineProps<{
  condition: ICondition
}>()
const selectValue = toRef(props.condition, 'left')
const renderItem = (options: IRight) => {
  if (!options.formId) return
  const thatComponent = dragFormStore.schemas.find((item) => item._id === options.formId)
  if (!thatComponent) return
  const { span } = thatComponent //保持原来的id和span
  Reflect.deleteProperty(thatComponent, 'on') //相关的事件删除 避免影响最外层的formState
  return useGetItemContent(
    thatComponent,
    options,
    { id: options.formId, _id: options.formId, span },
    'value'
  )
}
const getOptions = (list: IRight[], selectId?: string) => {
  let selectIds = list.map((item) => item.formId).filter(Boolean) as string[]
  selectIds = selectIds.filter((item) => item !== selectId)
  return selectOptions.value.filter((item) => !selectIds.includes(item.value))
}

const selectOptions = computed(() =>
  dragFormStore.schemas
    .filter((item) => item !== dragFormStore.activeComponent)
    .map((item) => ({
      value: item._id,
      label: item.title || item._id
    }))
)

const addCondition = (array: IRight[]) => {
  array.push(getCondition())
}
const addConditionGroup = (array: IRight[]) => {
  array.push(getConditionGroup())
}
const delFormStateList = (array: IRight[], index: number) => {
  array.splice(index, 1)
}
</script>
<style lang="scss" scoped></style>

增加逻辑判断符号

在条件选择中,选中输入框等于、不等于、包含、不包含、为空、不为空逻辑判断,而选中开关的话,只有等于、不等于逻辑判断

所以我新增symbols字段定义好每个字段的逻辑符号,类型为ESymbols[]

export enum ESymbols {
  EQUAL = '=',
  NOT_EQUAL = '!=',
  GREATER_THAN = '>',
  GREATER_THAN_EQUAL = '>=',
  LESS_THAN = '<',
  LESS_THAN_EQUAL = '<=',
  CONTAIN = 'contain',
  NOT_CONTAIN = '!contain',
  EMPTY = 'empty',
  NOT_EMPTY = '!empty'
}

调整matchSingleCondition函数


    switch (right.symbol) {
      case ESymbols.EQUAL:
        return formValue === right.value
      case ESymbols.NOT_EQUAL:
        return formValue !== right.value
      case ESymbols.GREATER_THAN:
        return formValue > right.value
      case ESymbols.GREATER_THAN_EQUAL:
        return formValue >= right.value
      case ESymbols.LESS_THAN:
        return formValue < right.value
      case ESymbols.LESS_THAN_EQUAL:
        return formValue <= right.value
      case ESymbols.CONTAIN:
        return formValue.includes(right.value)
      case ESymbols.NOT_CONTAIN:
        return !formValue.includes(right.value)
      case ESymbols.EMPTY:
        return isEmpty(formValue)
      case ESymbols.NOT_EMPTY:
        return !isEmpty(formValue)
      default:
        return false
    }

区分表单组件

  • 表单组件有输入框、开关、选择器等,非表单组件主要是布局组件,比如按钮、文字、标签等。
  • 非表单组件是不需要做逻辑判断的,所以在选择条件的时候,可以把非表单组件过滤掉不显示
  • 非表单组件是不需要用id作为formState绑定的

const handleConfigOptions = (
  configOptions: IConfigOptions,
  obj: Record<string, any>,
  ignoreNull = true
) => {
  const { options, path } = configOptions
  if (!options.symbols && !ignoreNull)  return options //过滤非表单组件
  if (typeof path === 'string') {
    if (get(obj, path) === undefined && ignoreNull) return
  } else {
    for (const item of path) {
      if (get(obj, item) === undefined && ignoreNull) return
    }
  }
  const item = getOption(options as IConfigList, obj, path)
  return handleOn(item)
}

在form中,非表单组件不需要绑定labelname

            <a-form-item :label="item.title" :name="item._id" v-if="item.symbols">
              <dynamicRenderingComponent :item />
            </a-form-item>
            <dynamicRenderingComponent :item v-else />

form标签的位置

从零开始手搓一个低代码

查询antdv官网可知,由两个字段控制layoutlabelAlign分别控制表单布局和标签文本对齐方式。

export enum EFormLayout {
  HORIZONTAL = 'horizontal',
  VERTICAL = 'vertical',
  INLINE = 'inline'// 不考虑
}
export enum EFormLabelAlign {
  LEFT = 'left',
  RIGHT = 'right'
}
export interface IFormProps {
  labelCol: { span: number }
  wrapperCol: { span: number }
  layout: EFormLayout
  labelAlign: EFormLabelAlign
}

配置

和必填的配置一样,需要配置两个字段,并且自定义全局组件formLabelConfig

// constants.ts下的formConfigOptions
  {
    options: {
      component: 'formLabelConfig',
      title: '标签的位置'
    },
    path: ['layout', 'labelAlign']
  }

formLabelConfig

既然要整合为一个组件去控制,那么我们需要写一个自定义组件去背地里修改对应的值

<template>
  <a-radio-group v-model:value="value" :options="radioOptions"> </a-radio-group>
</template>
<script lang="ts" setup>
import { EFormLabelAlign, EFormLayout } from '@/views/HomeView/types'
enum ERadioOptionsValue {
  LEFT_ALIGNMENT = 'leftAlignment',
  RIGHT_ALIGNMENT = 'rightAlignment',
  TOP = 'top'
}
const radioOptions = [
  {
    label: '左对齐',
    value: ERadioOptionsValue['LEFT_ALIGNMENT']
  },
  {
    label: '右对齐',
    value: ERadioOptionsValue['RIGHT_ALIGNMENT']
  },
  {
    label: '顶部',
    value: ERadioOptionsValue['TOP']
  }
]
const value = computed({
  get() {
    if (layout.value === EFormLayout['VERTICAL']) return ERadioOptionsValue['TOP']
    if (labelAlign.value === EFormLabelAlign['LEFT']) return ERadioOptionsValue['LEFT_ALIGNMENT']
    return ERadioOptionsValue['RIGHT_ALIGNMENT']
  },
  set(val) {
    switch (val) {
      case ERadioOptionsValue['LEFT_ALIGNMENT']:
        layout.value = EFormLayout['HORIZONTAL']
        labelAlign.value = EFormLabelAlign['LEFT']
        break
      case ERadioOptionsValue['RIGHT_ALIGNMENT']:
        layout.value = EFormLayout['HORIZONTAL']
        labelAlign.value = EFormLabelAlign['RIGHT']
        break
      default:
        layout.value = EFormLayout['VERTICAL']
        break
    }
  }
})
const layout = defineModel<EFormLayout>('layout', {
  required: true
})
const labelAlign = defineModel<EFormLabelAlign>('labelAlign', {
  required: true
})
</script>

<style lang="scss" scoped></style>

导入导出

导出

主要就是把schemasformProps两个数据导出去,需要注意的是schemas.componentProps下面的modelKeyupdateModelKey这两个双向绑定的东西得去掉,最后把schemas放到formProps作为一个json整体导出


const handleExport = () => {
  const schemasClone = cloneDeep(schemas.value)
  const result = schemasClone.map((item) => {
    const modelKey = getModelKey(item.component)
    const updateModelKey = 'onUpdate:' + modelKey
    Reflect.deleteProperty(item.componentProps!, modelKey)
    Reflect.deleteProperty(item.componentProps!, updateModelKey)
    return item
  })
  const formPropsClone = {
    ...cloneDeep(formProps),
    schemas: result
  }
  downloadJson(formPropsClone)
}
// utils.ts
export const downloadJson = (data: IExportAndImportJSON) => {
  // 2. 创建 Blob 对象
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
  // 3. 创建 URL 对象
  const url = URL.createObjectURL(blob)
  // 4. 创建下载链接
  const a = document.createElement('a')
  a.href = url
  a.download = 'data.json' // 设置下载文件的文件名
  a.style.display = 'none' // 隐藏链接
  document.body.appendChild(a) // 将链接添加到文档中
  a.click() // 触发点击事件
  document.body.removeChild(a) // 移除链接
  URL.revokeObjectURL(url) // 释放 URL 对象
}

导入

导入要注意的是,用Object.assign直接混合formProps会导致labelCol.spanwrapperCol.span响应式丢失,我这边就直接赋值了。最后把schemas相关的双向绑定事件注册上去,idspan等字段依据json数据为主


const handleImport = () => {
  uploadJson().then((res) => {
    const {
      schemas: schemasJson,
      labelCol: { span: labelColSpan },
      wrapperCol: { span: wrapperColSpan },
      layout
    } = res
    Reflect.deleteProperty(res, 'schemas')
    // Object.assign(formProps, res) //不能这样,会导致响应式丢失
    formProps.labelCol.span = labelColSpan
    formProps.wrapperCol.span = wrapperColSpan
    formProps.layout = layout
    const result = schemasJson.map((options) => {
      return useItemContentByItemContent(options, formState)
    })
    schemas.value = result
  })
}
// utils.ts
export const uploadJson = () => {
  return new Promise<IExportAndImportJSON>((resolve, reject) => {
    // 创建隐藏的文件输入元素
    const input = document.createElement('input')
    input.type = 'file'
    input.accept = '.json'
    input.style.display = 'none'
    // 文件选择事件
    input.addEventListener('change', (event) => {
      const target = event.target as HTMLInputElement
      if (target.files?.length) {
        const file = target.files[0]
        const reader = new FileReader()
        reader.onload = function (e) {
          try {
            const json = JSON.parse(e.target?.result as string)
            resolve(json)
          } catch (error) {
            console.error('Error parsing JSON:', error)
            reject(error)
          }
        }
        reader.onerror = reject
        reader.readAsText(file)
      }
    })
    // 将文件输入元素添加到文档中
    document.body.appendChild(input)
    // 触发文件选择对话框
    input.click()
    // 移除文件输入元素(选择文件后或取消选择后)
    input.addEventListener('change', () => {
      document.body.removeChild(input)
    })
  })
}

store

该规范的还是得规范一下,把之前provide、inject使用的数据都放到pinia里面。需要注意的是,在预览组件里面独有的一套 formStateschemas,然后往下属性传参isPreviewtrue

// src\stores\dragForm.ts
import { getInitFormProps } from '@/views/HomeView/utils'
import type { IItemContent, IFormProps, IFormState, IActiveComponent } from '@/views/HomeView/types'
import { usePreviewSchemas } from '@/views/HomeView/hooks'
export const useDragFormStore = defineStore('dragForm', () => {
  const formState = reactive<IFormState>({})
  const formProps = reactive<IFormProps>(getInitFormProps())
  const schemas = ref<IItemContent[]>([])
  const activeComponent = ref<IActiveComponent>(null)

  const formStatePreview = reactive<IFormState>({})
  const newSchemas = usePreviewSchemas(schemas, formStatePreview)

  const renderComponentGetData = (isPreview: boolean) =>
    isPreview
      ? {
          formState: formStatePreview,
          schemas: newSchemas
        }
      : { formState, schemas }

  return {
    formState,
    formProps,
    schemas,
    activeComponent,
    formStatePreview,
    newSchemas,
    renderComponentGetData
  }
})

至于为什么pinia没有导出修改数据的方法,别问!问就是没有。哈哈哈

  • 本身pinia就支持不写修改数据的方法,不同于vuex里面的mutations、actions
  • 为了图省事,干脆就偷懒了
  • 在 Pinia 中,虽然你可以直接修改数据,但仍然可以通过响应式的数据流来跟踪数据的变化

其他小优化

左侧组件字典项调整

一行三个组件,添加一个小图标,一起显示。再用collapse组件区分归类表单组件和非表单组件

从零开始手搓一个低代码

非表单组件

不需要在form中显示label,绑定name,不需要参与form的校验,右侧也不需要展示修改标题、字段ID、必填等配置项

从零开始手搓一个低代码

类型不同如何做校验

有js基本数据类型和复杂数据类型,还有Dayjs类型,传入不同的类型需要先判断类型,然后做相关的等于、包含、大于、为空等逻辑判断。

后面有新增的组件,有不一样的情况再考虑,不必一步到位!

 const matchSingleCondition = (right: IRight): boolean => {
    if (right.type === EType['CONDITION_GROUP']) {
      return matchCondition(formState, right.conditionGroup)
    }
    if (!right.formId) return false
    const formValue = formState[right.formId]
    // 自定义比较函数
    const customizer = (objValue: Dayjs, othValue: Dayjs) => {
      if (dayjs.isDayjs(objValue) && dayjs.isDayjs(othValue)) {
        return objValue.isSame(othValue, 'day')
      }
      // 继续使用默认比较方法
      return undefined
    }
    const isObject = typeof formValue === 'object'
    switch (right.symbol) {
      case ESymbols.EQUAL:
        return isEqualWith(formValue, right.value, customizer)
      case ESymbols.NOT_EQUAL:
        return !isEqualWith(formValue, right.value, customizer)
      case ESymbols.GREATER_THAN:
        return formValue > right.value
      case ESymbols.GREATER_THAN_EQUAL:
        return formValue >= right.value
      case ESymbols.LESS_THAN:
        return formValue < right.value
      case ESymbols.LESS_THAN_EQUAL:
        return formValue <= right.value
      case ESymbols.CONTAIN:
        if (!isObject) return includes(formValue, right.value)
        {
          if (!formValue?.length) return false
          const includesList = formValue.filter((item: string) => right.value.includes(item))
          return !!includesList.length
        }
      case ESymbols.NOT_CONTAIN:
        if (!isObject) return !includes(formValue, right.value)
        {
          if (!formValue?.length) return true
          const includesList = formValue.filter((item: string) => right.value.includes(item))
          return !includesList.length
        }
      case ESymbols.EMPTY:
        return isObject ? isEmpty(formValue) : !formValue
      case ESymbols.NOT_EMPTY:
        return isObject ? !isEmpty(formValue) : !!formValue
      default:
        return false
    }

隐藏条件弹窗规则设置样式调整

从零开始手搓一个低代码

防止点击

为了在配置表单的时候方便操作,在配置的时候应该禁止操作表单内容。很简单,做一个层级比较高的遮罩即可

从零开始手搓一个低代码

最后

项目源码已经上传到GitHub上了,点击传送门跳转。

转载自:https://juejin.cn/post/7394382577585799218
评论
请登录