Skip to content

动态表单引擎:基于Vue2.7 加 Element UI 的实践

动态表单引擎组件目录结构

markdown
FormBuilder/
├── components/
│ ├── BuilderItemRender.vue
├── hooks/
│ ├── use-form-builder.js
└── FormBuilder.vue

具体代码

vue
<script>
import {computed, set, watch, toRef} from 'vue'
import {Row, Form} from 'element-ui'
import BuilderItemRender from './components/BuilderItemRender.vue'

export default {
    props: {
        formProps: Object,
        builderItems: Array,
        modelValue: Object,
        refsMap: Object,
        rowProps: {
            default: () => ({gutter: 20})
        },
        span: {
            default: 24
        }
    },
    setup(builderProps, {expose}) {
        const formProps = computed(() => {
            return {
                ...{
                    size: 'mini',
                    labelPosition: 'left',
                    labelWidth: 'auto'
                },
                ...builderProps.formProps
            }
        })

        const {eventSubMap, defineWatchMap} = getBuilderItemHandler(builderProps.builderItems)

        const modelValue = toRef(builderProps, 'modelValue')

        if (process.env.NODE_ENV === 'development') {
            watch(() => modelValue.value, () => {
                console.log('form')
                console.log(modelValue.value)
            }, {deep: true})
        }

        expose({
            validate: (...args) => builderProps.refsMap['formRef']?.validate?.(...args),
            resetFields: () => builderProps.refsMap['formRef']?.resetFields?.(),
            clearValidate: (props) => props.refsMap['formRef']?.clearValidate?.(props),
            validateField: (prop) => builderProps.refsMap['formRef']?.validateField?.(prop)
        })

        function processBuilderItems(builderItems) {
            const rowGroup = []
            let currentGroup = []
            for (const item of builderItems) {

                item.onInput = val => {
                    if (item.prop) {
                        set(modelValue.value, item.prop, val)
                    }
                }
                if (item.vIf === false || item.vShow === false || item.invisible === true) {
                    item.hiddenCallBack?.()
                }

                if (item.newRow) {
                    rowGroup.push(currentGroup)
                    currentGroup = []
                }
                currentGroup.push(item)
            }
            rowGroup.push(currentGroup)
            return rowGroup
        }

        const rowGroup = computed(() => processBuilderItems(builderProps.builderItems))

        return () => {
            return <Form ref={el => builderProps.refsMap['formRef'] = el}
                         props={{...formProps.value, model: modelValue.value}}
            >
                {
                    rowGroup.value.map((group, index) => {
                        const {rowProps} = group[0]
                        return <Row key={`row-${index}`} props={{...builderProps.rowProps, ...rowProps}}>
                            {
                                group.map(builderItem => <BuilderItemRender
                                    key={builderItem.key}
                                    value={modelValue.value[builderItem.prop]}
                                    onInput={builderItem.onInput}
                                    builderItem={builderItem}
                                    builderProps={builderProps}
                                    modifiers={builderItem.modifiers}
                                    watchHandlers={defineWatchMap.get(builderItem.prop)}
                                    eventHandlers={eventSubMap.get(builderItem.prop)}
                                />)
                            }
                        </Row>
                    })
                }
            </Form>
        }

    }
}

function getBuilderItemHandler(builderItems) {
    const eventSubMap = new Map()
    const defineWatchMap = new Map()

    for (const item of builderItems) {
        if (item.eventSub) {
            Object.entries(item.eventSub).forEach(([dependFormKey, dependFormKeyEventHandlers]) => {
                if (!eventSubMap.has(dependFormKey)) {
                    eventSubMap.set(dependFormKey, [dependFormKeyEventHandlers])
                } else {
                    eventSubMap.get(dependFormKey).push(dependFormKeyEventHandlers)
                }
            })
        }
        if (item.watch) {
            Object.entries(item.watch).forEach(([prop, handler]) => {
                if (!defineWatchMap.has(prop)) {
                    defineWatchMap.set(prop, [handler])
                } else {
                    defineWatchMap.get(prop).push(handler)
                }
            })
        }

    }
    return {eventSubMap, defineWatchMap}
}


</script>
vue
<script>
import {computed, onUpdated, watch} from "vue";
import {Col, FormItem, Input} from "element-ui";
import CollapseTransition from 'element-ui/lib/transitions/collapse-transition'

export default {
  props: ['builderItem', 'builderProps', 'value', 'modifiers', 'watchHandlers', 'eventHandlers'],
  emits: ['input'],
  setup(builderItemProps, {emit}) {
    const {builderProps, modifiers, watchHandlers} = builderItemProps

    if (process.env.NODE_ENV === 'development') {
      onUpdated(() => {
        console.log('onUpdated', builderItemProps.builderItem.label)
      })
    }

    const builderItem = computed(() => {
      if (!builderItemProps.builderItem.component) {
        builderItemProps.builderItem.component = Input
      }
      return builderItemProps.builderItem
    })

    const modelValue = computed({
      get: () => builderItemProps.value,
      set: val => emit('input', val)
    })

    let immediate = true
    watch(() => modelValue.value, (val, oldV) => {
      modelValue.value = modifiers?.reduce((acc, cur) => cur(acc), val) ?? val
      if (val !== oldV) {
        watchHandlers?.forEach(fn => fn(val, oldV, immediate))
      }
      immediate = false
    }, {immediate: true})

    const eventHandlers = computed(() => {
      return (builderItem.value.component?.emit ?? builderItem.value.component?.emits ?? []).reduce((acc, cur) => {
        acc[cur] = (...args) => {
          builderItem.value.on?.[cur]?.(...args)
          builderItemProps.eventHandlers?.map(m => m?.[cur]?.(...args))
        }
        return acc
      }, {})
    })

    const colStyle = computed(() => ({
      visibility: builderItem.value.invisible ? 'hidden' : 'visible'
    }))

    const colProps = computed(() => {
      return {
        span: builderItem.value.span ?? builderProps.span,
        ...builderItem.value.colProps
      }
    })

    function onInput(val) {
      modelValue.value = modifiers?.reduce((acc, cur) => cur(acc), val) ?? val
    }

    const renderContent = () => {
      const {
        prop,
        component: ComponentObj,
        props: componentProps = {},
        slots,
        style,
        clazz,
        scopedSlots
      } = builderItem.value

      if (typeof ComponentObj === 'function') {
        return ComponentObj()
      }

      return <ComponentObj
          ref={el => {
            if (prop) {
              builderProps.refsMap[prop] = el
            }
          }}
          props={componentProps}
          attrs={componentProps}
          on={eventHandlers.value}
          value={modelValue.value}
          onInput={onInput}
          style={style}
          class={clazz}
          scopedSlots={scopedSlots}
      >
        {slots?.map(m => m())}
      </ComponentObj>
    }

    const renderFormItem = (content) => {
      const {prop, label, rules, labelWidth, showMessage, formItem} = builderItem.value
      return formItem === false
          ? content
          : <FormItem
              prop={prop}
              label={label}
              rules={rules}
              label-width={labelWidth}
              show-message={showMessage}
          >
            {content}
          </FormItem>
    }

    return () => <CollapseTransition>
      {
        builderItem.value.vIf === false
            ? null
            : <Col vShow={builderItem.value.vShow !== false}
                   style={colStyle.value}
                   props={colProps.value}
            >
              {renderFormItem(renderContent())}
            </Col>
      }
    </CollapseTransition>
  }
}
</script>
js
import {ref, set, defineComponent} from 'vue'
import FormBuilder from '../FormBuilder.vue'
import {zhEn} from '@/composables/use-i18n'
import _ from 'lodash'

export const ModifiersFn = {
    // 两端去除空格
    trim: val => typeof val === 'string' ? val.trim() : val,
    // 不允许有任何空白
    noBlank: val => typeof val === 'string' ? val.replace(/\s/g, '') : val,
    // 不允许有任何换行符
    noNewline: val => typeof val === 'string' ? val.replace(/[\r\n]/g, '') : val,
}

export const RequiredRule = {
    required: true,
    message: zhEn('不能为空', 'Must not be empty'),
    trigger: ['change', 'blur']
}

/**
 *
 * @param props
 * @param props.formProps
 * @param props.builderItems
 * @param [props.rowProps]
 * @param [props.span]
 * @param form
 */
export const userFormBuilder = (props, form) => {

    const formRef = ref(null)
    const refsMap = ref({})
    const Component = defineComponent({
        setup() {
            return () => <FormBuilder
                ref={el => formRef.value = el}
                modelValue={form.value}
                formProps={props.formProps}
                builderItems={props.builderItems.value}
                refsMap={refsMap.value}
                rowProps={props.rowProps}
                span={props.span}
            />
        }
    })

    function setFormProp(key, value) {
        set(form.value, key, value)
    }

    function assignForm(obj) {
        form.value = Object.assign({}, form.value, obj)
    }

    const BuilderItemCache = new Map()

    /**
     * @param {String} key
     * @param {Function} builderItemSupplier 返回BuilderItem
     * @param {Array} [updateDepends] 组件更新依赖  在任意项变化后更新组件dom
     */
    function createItem(key, builderItemSupplier, updateDepends) {
        if (BuilderItemCache.has(key)) {
            const {builderItem, updateDepends: oldUpdateDepends} = BuilderItemCache.get(key);
            const needUpdate = updateDepends ? oldUpdateDepends.some((val, index) => val !== updateDepends[index]) : false
            if (needUpdate === false) {
                return builderItem
            }
        }
        const builderItem = builderItemSupplier();
        builderItem.key = key
        BuilderItemCache.set(key, {builderItem, updateDepends})
        return builderItem
    }

    function getFormValue() {
        return _.cloneDeep(form.value)
    }

    return {formRef, Component, refsMap, assignForm, setFormProp, createItem, getFormValue}
}


export class BuilderItem {

    /**
     *
     * @param {Array | String} [label]
     * @param {String} [prop]
     * @param {Function | Object} [component]
     * @param {Number | String} [spanOrLabelWidth]
     * @param {String} [labelWidth]
     */
    constructor(label, prop, component, spanOrLabelWidth, labelWidth) {
        if (label) {
            if (Array.isArray(label)) {
                this.label = zhEn(label[0], label[1])
            } else {
                this.label = label
            }

        }
        if (prop) {
            this.prop = prop
        }
        if (component) {
            this.component = component
        }
        if (spanOrLabelWidth) {
            if (typeof spanOrLabelWidth === 'string') {
                this.labelWidth = spanOrLabelWidth
            } else {
                this.span = spanOrLabelWidth
            }

        }
        if (labelWidth) {
            this.labelWidth = labelWidth
        }
    }

    /**
     * 该项开始 另起一行 row
     * @param {Boolean} [newRow]
     * @param {Object} [rowProps]
     */
    NewRow(newRow = true, rowProps) {
        this.newRow = newRow
        this.rowProps = rowProps
        return this
    }

    /**
     * @param {String} labelWidth
     */
    LabelWidth(labelWidth) {
        this.labelWidth = labelWidth
        return this
    }

    /**
     * @param {Boolean} formItem
     */
    FormItem(formItem) {
        this.formItem = formItem
        return this
    }

    /**
     * @param {Boolean} vIf
     */
    VIf(vIf) {
        this.vIf = vIf
        return this
    }

    /**
     * @param {Boolean} vShow
     */
    VShow(vShow) {
        this.vShow = vShow
        return this
    }

    /**
     * @param {Boolean} invisible
     */
    Invisible(invisible) {
        this.invisible = invisible
        return this
    }

    /**
     * @param {Function} fn
     */
    HiddenCallBack(fn) {
        this.hiddenCallBack = fn;
        return this
    }

    /**
     * @param {Number} span
     */
    Span(span) {
        this.span = span
        return this
    }

    /**
     * @param {Object} colProps
     */
    ColProps(colProps) {
        this.props = colProps
        return this
    }

    /**
     * @param {Function | Object} component
     */
    Component(component) {
        this.component = component
        return this
    }

    /**
     *
     * @param {Array<Function> | Function} slots
     * @returns {BuilderItem}
     */
    Slots(slots) {
        if (Array.isArray(slots)) {
            this.slots = slots
        } else {
            this.slots = [slots]
        }
        return this
    }

    /**
     *
     * @param {Object} scopedSlots
     */
    ScopedSlots(scopedSlots) {
        this.scopedSlots = scopedSlots
        return this
    }

    /**
     *
     * @param {Boolean} showMessage
     */
    ShowMessage(showMessage) {
        this.showMessage = showMessage
        return this
    }

    /**
     * @param {Object} props
     */
    Props(props) {
        this.props = props
        return this
    }

    /**
     * @param {Object} on
     */
    On(on) {
        this.on = on
        return this
    }

    /**
     * @param {Object} style
     */
    Style(style) {
        this.style = style
        return this
    }

    Clazz(clazz) {
        this.clazz = clazz
        return this
    }

    /**
     * @param {Array | Object} rules
     */
    Rules(rules) {
        if (Array.isArray(rules)) {
            this.rules = rules
        } else {
            this.rules = [rules]
        }
        return this
    }

    /**
     * @param {Object} watch
     */
    Watch(watch) {
        this.watch = watch
        return this
    }

    /**
     * @param {Object} eventSub
     */
    EventSub(eventSub) {
        this.eventSub = eventSub
        return this
    }

    /**
     * @param {Array<Function> | Function} modifiers
     */
    Modifiers(modifiers) {
        if (Array.isArray(modifiers)) {
            this.modifiers = modifiers
        } else {
            this.modifiers = [modifiers]
        }

        return this
    }

}

使用示例

vue

/src/technology/dateblog/2025/07/20250717-%E5%8A%A8%E6%80%81%E8%A1%A8%E5%8D%95%E5%BC%95%E6%93%8E%EF%BC%9A%E5%9F%BA%E4%BA%8Evue2-7-%E5%8A%A0-element-ui-%E7%9A%84%E5%AE%9E%E8%B7%B5.html