[Element Plus 源码解析] Form 表单组件

3,884 阅读4分钟

一、组件介绍

el-form表单组件,是日常开发中使用频率很高的一个组件,通常由输入框、选择器、单选框、多选框等控件组成,用以收集、校验、提交数据。

大家应该对el-form组件十分熟悉,直接看源码吧^_^

二、源码分析

el-form的源码有3部分:form form-item label-wrap

2.1 form.vue

// packages\components\form\src\form.vue
<template>
  <form
    class="el-form"
    :class="[
      labelPosition ? 'el-form--label-' + labelPosition : '',
      { 'el-form--inline': inline }
    ]"
  >
    <slot></slot>
  </form>
</template>

<script lang="ts">
import { computed, defineComponent, provide, reactive, ref, toRefs, watch } from 'vue'
import { FieldErrorList } from 'async-validator'
import mitt from 'mitt'
import { elFormEvents, elFormKey } from '@element-plus/tokens'

import type { PropType } from 'vue'
import type { ComponentSize } from '@element-plus/utils/types'
import type { FormRulesMap } from './form.type'
import type { ElFormItemContext as FormItemCtx, ValidateFieldCallback } from '@element-plus/tokens'

// label自动宽度相关
function useFormLabelWidth() {
  // 潜在的label宽度数组,是指那些没有指定label-width的form-item的label所计算出来的应该占用的宽度
  const potentialLabelWidthArr = ref([])
  // 所有潜在宽度中的最大值作为所有auto label-width form-item的label占用宽度
  const autoLabelWidth = computed(() => {
    if (!potentialLabelWidthArr.value.length) return '0'
    const max = Math.max(...potentialLabelWidthArr.value)
    return max ? `${max}px` : ''
  })
  // 根据label宽度,获取潜在宽度数组中对应元素的index
  function getLabelWidthIndex(width: number) {
    const index = potentialLabelWidthArr.value.indexOf(width)
    if (index === -1) {
      console.warn('[Element Warn][ElementForm]unexpected width ' + width)
    }
    return index
  }
  // form-item中的label注册label宽度,在label-warp中label宽度变化时调用
  function registerLabelWidth(val: number, oldVal: number) {
    if (val && oldVal) {
      // 有新值和旧值,说明之前注册过,则找到旧的index,进行值的替换
      const index = getLabelWidthIndex(oldVal)
      potentialLabelWidthArr.value.splice(index, 1, val)
    } else if (val) {
      // 只有新值,则视为新增
      potentialLabelWidthArr.value.push(val)
    }
  }
  // 反注册,潜在宽度数组中删除对应宽度数值
  function deregisterLabelWidth(val: number) {
    const index = getLabelWidthIndex(val)
    index > -1 && potentialLabelWidthArr.value.splice(index, 1)
  }

  return {
    autoLabelWidth,
    registerLabelWidth,
    deregisterLabelWidth,
  }
}

export interface Callback {
  (isValid?: boolean, invalidFields?: FieldErrorList): void
}

export default defineComponent({
  name: 'ElForm',
  props: {
    // form绑定的值
    model: Object,
    // 字段校验rules
    rules: Object as PropType<FormRulesMap>,
    // 标签所在位置 可选 top right left
    labelPosition: String,
    // label的宽度,可以指定数值,也可以设置成'auto'
    labelWidth: {
      type: [String, Number],
      default: '',
    },
    // label的后缀,比如可以设置成冒号:
    labelSuffix: {
      type: String,
      default: '',
    },
    // 行内布局,一个form-item不单独占一行
    inline: Boolean,
    // 行内形式,展示校验信息
    inlineMessage: Boolean,
    // 是否在input框内展示校验结果图标
    statusIcon: Boolean,
    // 是否显示校验信息
    showMessage: {
      type: Boolean,
      default: true,
    },
    // 尺寸 medium small mini
    size: String as PropType<ComponentSize>,
    // 禁用
    disabled: Boolean,
    // rules改变时,立即触发一次校验
    validateOnRuleChange: {
      type: Boolean,
      default: true,
    },
    // 隐藏必填项前面的红色星号
    hideRequiredAsterisk: {
      type: Boolean,
      default: false,
    },
    // 滚动到错误的field
    scrollToError: Boolean,
  },
  emits: ['validate'],
  setup(props, { emit }) {
    // 创建一个事件总线
    const formMitt = mitt()
    // 存储子form-item
    const fields: FormItemCtx[] = []
    // 监听rules变化
    watch(
      () => props.rules,
      () => {
        // 每一个form-item
        fields.forEach(field => {
          // 先移除对el.form.blur el.form.change事件的监听
          field.removeValidateEvents()
          // 根据新的rules,重新监听
          field.addValidateEvents()
        })
        // 立即触发一次校验
        if (props.validateOnRuleChange) {
          validate(() => ({}))
        }
      },
    )
    // 事件总线,监听form-item注册事件
    formMitt.on<FormItemCtx>(elFormEvents.addField, field => {
      if (field) {
        fields.push(field)
      }
    })
    // 事件总线,监听form-item注销事件
    formMitt.on<FormItemCtx>(elFormEvents.removeField, field => {
      if (field.prop) {
        fields.splice(fields.indexOf(field), 1)
      }
    })
    // 重置表单,并移除校验结果
    const resetFields = () => {
      if (!props.model) {
        console.warn(
          '[Element Warn][Form]model is required for resetFields to work.',
        )
        return
      }
      fields.forEach(field => {
        // 逐个调用form-item的resetField方法
        field.resetField()
      })
    }
    // 清除校验结果,参数可以是字符串数组或字符串
    const clearValidate = (props: string | string[] = []) => {
      const fds = props.length
        ? typeof props === 'string'
          ? fields.filter(field => props === field.prop)
          : fields.filter(field => props.indexOf(field.prop) > -1)
        : fields
      // 对匹配到的字段逐个执行clearValidate
      fds.forEach(field => {
        field.clearValidate()
      })
    }
    // 校验整个form
    const validate = (callback?: Callback) => {
      if (!props.model) {
        console.warn(
          '[Element Warn][Form]model is required for validate to work!',
        )
        return
      }

      let promise: Promise<boolean> | undefined
      // 如果没传入callback,则return 出去一个promise,用户可以使用validate().then进行下一步处理
      if (typeof callback !== 'function') {
        promise = new Promise((resolve, reject) => {
          callback = function (valid, invalidFields) {
            if (valid) {
              resolve(true)
            } else {
              reject(invalidFields)
            }
          }
        })
      }

      if (fields.length === 0) {
        callback(true)
      }
      let valid = true
      let count = 0
      let invalidFields = {}
      let firstInvalidFields
      // 逐个字段校验
      for (const field of fields) {
        // 第一个参数是trigger,传入''表示匹配该字段所有的rule
        field.validate('', (message, field) => {
          if (message) {
            // 有一个字段校验失败,则整个form的校验标志位置为false
            valid = false
            // 记录第一个校验失败的字段
            firstInvalidFields || (firstInvalidFields = field)
          }
          invalidFields = { ...invalidFields, ...field }
          if (++count === fields.length) {
            // 校验结束,调用传入的callback方法
            callback(valid, invalidFields)
          }
        })
      }
      //如果form校验失败,并且scrollToError为true
      if (!valid && props.scrollToError) {
        // 滚动到第一个校验失败的字段
        scrollToField(Object.keys(firstInvalidFields)[0])
      }
      return promise
    }
    // 校验指定字段
    const validateField = (props: string | string[], cb: ValidateFieldCallback) => {
      props = [].concat(props)
      // 根据props找到对应的字段
      const fds = fields.filter(field => props.indexOf(field.prop) !== -1)
      if (!fields.length) {
        console.warn('[Element Warn]please pass correct props!')
        return
      }
      // 匹配到的字段,逐个进行校验
      fds.forEach(field => {
        field.validate('', cb)
      })
    }
    // 滚动到指定field
    const scrollToField = (prop: string) => {
      fields.forEach(item => {
        if (item.prop === prop) {
          // 调用dom的原生api
          item.$el.scrollIntoView()
        }
      })
    }

    const elForm = reactive({
      formMitt,
      ...toRefs(props),
      resetFields,
      clearValidate,
      validateField,
      emit,
      ...useFormLabelWidth(),
    })
    // 向子孙组件提供数据
    provide(elFormKey, elForm)

    return {
      validate, // export
      resetFields,
      clearValidate,
      validateField,
      scrollToField,
    }
  },
})
</script>

2.2 form-item.vue

// packages\components\form\src\form-item.vue
<template>
  <div ref="formItemRef" class="el-form-item" :class="formItemClass">
    <!-- label部分 -->
    <LabelWrap
      :is-auto-width="labelStyle.width === 'auto'"
      :update-all="elForm.labelWidth === 'auto'"
    >
      <label
        v-if="label || $slots.label"
        :for="labelFor"
        class="el-form-item__label"
        :style="labelStyle"
      >
        <slot name="label" :label="label + elForm.labelSuffix">{{ label + elForm.labelSuffix }}</slot>
      </label>
    </LabelWrap>
    <!-- 内容部分 -->
    <div class="el-form-item__content" :style="contentStyle">
      <slot></slot>
      <!-- 校验失败的提示信息 -->
      <transition name="el-zoom-in-top">
        <slot v-if="shouldShowError" name="error" :error="validateMessage">
          <div
            class="el-form-item__error"
            :class="{
              'el-form-item__error--inline':
                typeof inlineMessage === 'boolean'
                  ? inlineMessage
                  : elForm.inlineMessage || false
            }"
          >{{ validateMessage }}</div>
        </slot>
      </transition>
    </div>
  </div>
</template>

<script lang="ts">
import {
  computed,
  defineComponent,
  getCurrentInstance,
  inject,
  nextTick,
  onBeforeUnmount,
  onMounted,
  provide,
  reactive,
  ref,
  toRefs,
  watch,
} from 'vue'
import AsyncValidator from 'async-validator'
import mitt from 'mitt'
import { NOOP } from '@vue/shared'
import { addUnit, getPropByPath, useGlobalConfig } from '@element-plus/utils/util'
import { isValidComponentSize } from '@element-plus/utils/validators'
import LabelWrap from './label-wrap'
import { elFormEvents, elFormItemKey, elFormKey } from '@element-plus/tokens'

import type { PropType, CSSProperties } from 'vue'
import type { ComponentSize } from '@element-plus/utils/types'
import type { ElFormContext, ValidateFieldCallback } from '@element-plus/tokens'
import type { FormItemRule } from './form.type'

export default defineComponent({
  name: 'ElFormItem',
  componentName: 'ElFormItem',
  components: {
    LabelWrap,
  },
  props: {
    label: String,
    labelWidth: {
      type: [String, Number],
      default: '',
    },
    prop: String,
    required: {
      type: Boolean,
      default: undefined,
    },
    rules: [Object, Array] as PropType<FormItemRule | FormItemRule[]>,
    error: String,
    validateStatus: String,
    for: String,
    inlineMessage: {
      type: [String, Boolean],
      default: '',
    },
    showMessage: {
      type: Boolean,
      default: true,
    },
    size: {
      type: String as PropType<ComponentSize>,
      validator: isValidComponentSize,
    },
  },
  setup(props, { slots }) {
    const formItemMitt = mitt()
    const $ELEMENT = useGlobalConfig()

    const elForm = inject(elFormKey, {} as ElFormContext)
    const validateState = ref('')
    const validateMessage = ref('')
    const validateDisabled = ref(false)

    const computedLabelWidth = ref('')

    const formItemRef = ref<HTMLDivElement>()

    const vm = getCurrentInstance()
    const isNested = computed(() => {
      let parent = vm.parent
      while (parent && parent.type.name !== 'ElForm') {
        if (parent.type.name === 'ElFormItem') {
          return true
        }
        parent = parent.parent
      }
      return false
    })


    let initialValue = undefined

    watch(
      () => props.error,
      val => {
        validateMessage.value = val
        validateState.value = val ? 'error' : ''
      }, {
      immediate: true,
    },
    )
    watch(
      () => props.validateStatus,
      val => {
        validateState.value = val
      },
    )

    const labelFor = computed(() => props.for || props.prop)
    // label部分style: 占的宽度
    const labelStyle = computed(() => {
      const ret: CSSProperties = {}
      if (elForm.labelPosition === 'top') return ret
      // addUnit是添加单位的方法
      // 若参数是数字,则转换成带px的字符串,eg:addUnit(80) = '80px';若参数是字符串,则直接return字符串
      // form-item传入的labelWidth优先级高于form上的labelWidth
      const labelWidth = addUnit(props.labelWidth) || addUnit(elForm.labelWidth)
      if (labelWidth) {
        ret.width = labelWidth
      }
      return ret
    })
    // 内容区style:没有label时,需要margin-left
    const contentStyle = computed(() => {
      const ret: CSSProperties = {}
      if (elForm.labelPosition === 'top' || elForm.inline) {
        return ret
      }
      if (!props.label && !props.labelWidth && isNested.value) {
        return ret
      }
      const labelWidth = addUnit(props.labelWidth) || addUnit(elForm.labelWidth)
      // 当没有label时,内容需要向右偏移(有label时,label占了一定的宽度,内容会自动往右)
      if (!props.label && !slots.label) {
        ret.marginLeft = labelWidth
      }
      return ret
    })
    const fieldValue = computed(() => {
      const model = elForm.model
      // 如果form没有绑定model,或者form-item没有prop属性,返回undefined
      if (!model || !props.prop) {
        return
      }

      let path = props.prop
      // 路径中的:替换成.
      if (path.indexOf(':') !== -1) {
        path = path.replace(/:/, '.')
      }
      // 根据path,在form的model中取得对应的值
      return getPropByPath(model, path, true).v
    })
    // 是否必填
    const isRequired = computed(() => {
      let rules = getRules()
      let required = false

      if (rules && rules.length) {
        rules.every(rule => {
          if (rule.required) {
            required = true
            return false
          }
          return true
        })
      }
      return required
    })
    const elFormItemSize = computed(() => props.size || elForm.size)
    const sizeClass = computed<ComponentSize>(() => {
      return elFormItemSize.value || $ELEMENT.size
    })

    const validate = (trigger: string, callback: ValidateFieldCallback = NOOP) => {
      validateDisabled.value = false
      const rules = getFilteredRule(trigger)
      if ((!rules || rules.length === 0) && props.required === undefined) {
        callback()
        return
      }
      validateState.value = 'validating'
      const descriptor = {}
      if (rules && rules.length > 0) {
        rules.forEach(rule => {
          delete rule.trigger
        })
      }
      descriptor[props.prop] = rules
      const validator = new AsyncValidator(descriptor)
      const model = {}
      model[props.prop] = fieldValue.value
      validator.validate(
        model,
        { firstFields: true },
        (errors, invalidFields) => {
          validateState.value = !errors ? 'success' : 'error'
          validateMessage.value = errors ? errors[0].message : ''
          callback(validateMessage.value, invalidFields)
          elForm.emit?.(
            'validate',
            props.prop,
            !errors,
            validateMessage.value || null,
          )
        },
      )
    }

    const clearValidate = () => {
      validateState.value = ''
      validateMessage.value = ''
      validateDisabled.value = false
    }
    // 重置表单项
    const resetField = () => {
      // 清除校验结果
      validateState.value = ''
      validateMessage.value = ''
      let model = elForm.model
      let value = fieldValue.value
      let path = props.prop
      if (path.indexOf(':') !== -1) {
        path = path.replace(/:/, '.')
      }
      // 在model中根据path,找到本form-item对应的属性
      let prop = getPropByPath(model, path, true)
      // 重置值时,禁止校验
      validateDisabled.value = true
      // 恢复成在mounted时记录的初始值
      if (Array.isArray(value)) {
        prop.o[prop.k] = [].concat(initialValue)
      } else {
        prop.o[prop.k] = initialValue
      }
      // reset validateDisabled after onFieldChange triggered
      nextTick(() => {
        validateDisabled.value = false
      })
    }
    // 获得当前form-item的rules
    const getRules = () => {
      // form的总rules,包含所有form-item的rules
      const formRules = elForm.rules
      // form-item自身的rules
      const selfRules = props.rules
      // 传入的required属性
      const requiredRule =
        props.required !== undefined ? { required: !!props.required } : []
      // 根据prop,从form的总rules中找到当前form-item的rules
      const prop = getPropByPath(formRules, props.prop || '', false)
      const normalizedRule = formRules
        ? (prop.o[props.prop || ''] || prop.v)
        : []
      // selfRules优先于normalizedRule
      return [].concat(selfRules || normalizedRule || []).concat(requiredRule)
    }
    // 根据trigger类型,获得对应的rules
    const getFilteredRule = trigger => {
      const rules = getRules()

      return rules
        .filter(rule => {
          // 如果rule没有设置trigger或者trigger设置为''
          if (!rule.trigger || trigger === '') return true
          if (Array.isArray(rule.trigger)) {
            // rule中的trigger是数组时,判断过滤的trigger在数组中
            return rule.trigger.indexOf(trigger) > -1
          } else {
            // 否则 rule.trigger与过滤的trigger相同
            return rule.trigger === trigger
          }
        })
        // 展开,重新组装,是为了浅拷贝吗?
        .map(rule => ({ ...rule }))
    }
    // 失去焦点时,触发validate blur
    const onFieldBlur = () => {
      validate('blur')
    }
    // 值改变时,触发validate change
    const onFieldChange = () => {
      // 禁用validate标志位为true时
      if (validateDisabled.value) {
        // 标志位设置为false
        validateDisabled.value = false
        // 本次不做validate
        return
      }

      validate('change')
    }
    // 更新计算的label width,在label-wrap中被调用
    const updateComputedLabelWidth = (width: string | number) => {
      computedLabelWidth.value = width ? `${width}px` : ''
    }
    // 添加validate事件监听
    const addValidateEvents = () => {
      const rules = getRules()
      // 如果form-item存在rules
      // props.required !== undefined 没有必要吧,因为props.required有值时,rules一定是有元素的啊
      if (rules.length || props.required !== undefined) {
        // 使用Mitt事件总线,监听el.form.blur el.form.change事件
        formItemMitt.on('el.form.blur', onFieldBlur)
        formItemMitt.on('el.form.change', onFieldChange)
      }
    }
    // 注销监听
    const removeValidateEvents = () => {
      formItemMitt.off('el.form.blur', onFieldBlur)
      formItemMitt.off('el.form.change', onFieldChange)
    }

    const elFormItem = reactive({
      ...toRefs(props),
      size: sizeClass,
      validateState,
      $el: formItemRef,
      formItemMitt,
      removeValidateEvents,
      addValidateEvents,
      resetField,
      clearValidate,
      validate,
      updateComputedLabelWidth,
    })

    onMounted(() => {
      // 如果传入了prop字段
      if (props.prop) {
        // formMitt总线发出el.form.addField 事件 参数是elFormItem
        elForm.formMitt?.emit(elFormEvents.addField, elFormItem)

        let value = fieldValue.value
        // 记录初始值,在resetField时用作恢复初始状态 
        // 如果是数组,则通过扩展+重新组装进行浅拷贝
        initialValue = Array.isArray(value)
          ? [...value] : value
        // 监听el.form.blur el.form.change事件
        addValidateEvents()
      }
    })
    onBeforeUnmount(() => {
      // formMitt总线发出el.form.removeField 事件 参数是elFormItem
      elForm.formMitt?.emit(elFormEvents.removeField, elFormItem)
    })
    // 向子孙组件提供数据
    provide(elFormItemKey, elFormItem)

    // 动态class
    const formItemClass = computed(() => [
      {
        // statusIcon是指是否在输入框中显示校验结果反馈图标
        'el-form-item--feedback': elForm.statusIcon,
        // 各种状态
        'is-error': validateState.value === 'error',
        'is-validating': validateState.value === 'validating',
        'is-success': validateState.value === 'success',
        'is-required': isRequired.value || props.required,
        // 隐藏必填字段label旁边的红色星号
        'is-no-asterisk': elForm.hideRequiredAsterisk,
      },
      sizeClass.value ? 'el-form-item--' + sizeClass.value : '',
    ])
    // 是否应该展示错误信息
    const shouldShowError = computed(() => {
      // 校验失败,且form和form-item绑定的showMessage为true(默认是true)
      return validateState.value === 'error' && props.showMessage && elForm.showMessage
    })

    return {
      formItemRef,
      formItemClass,
      shouldShowError,
      elForm,
      labelStyle,
      contentStyle,
      validateMessage,
      labelFor,
      resetField,
      clearValidate,
    }
  },
})
</script>

2.3 label-wrap.ts

// packages\components\form\src\label-wrap.ts
import { defineComponent, Fragment, h, inject, nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from "vue";
import { addResizeListener, removeResizeListener, ResizableElement } from "@element-plus/utils/resize-event";
import { elFormItemKey, elFormKey } from "@element-plus/tokens";

import type { CSSProperties } from "vue";
import type { Nullable } from "@element-plus/utils/types";

export default defineComponent({
  name: "ElLabelWrap",
  props: {
    isAutoWidth: Boolean,
    // 当el-form的labelWidth是auto时,为true
    updateAll: Boolean,
  },
  setup(props, { slots }) {
    const el = ref<Nullable<HTMLElement>>(null);
    const elForm = inject(elFormKey);
    const elFormItem = inject(elFormItemKey);

    const computedWidth = ref(0);
    // 监测宽度变化
    watch(computedWidth, (val, oldVal) => {
      if (props.updateAll) {
        elForm.registerLabelWidth(val, oldVal);
        // 更新form-item中的label宽度
        elFormItem.updateComputedLabelWidth(val);
      }
    });

    const getLabelWidth = () => {
      if (el.value?.firstElementChild) {
        // 第一个子节点元素的宽度
        const width = window.getComputedStyle(el.value.firstElementChild).width;
        // 向上取整
        return Math.ceil(parseFloat(width));
      } else {
        return 0;
      }
    };
    const updateLabelWidth = (action = "update") => {
      nextTick(() => {
        // label是自动宽度时
        if (slots.default && props.isAutoWidth) {
          if (action === "update") {
            // update情况下:重新读取子节点元素的宽度
            computedWidth.value = getLabelWidth();
          } else if (action === "remove") {
            elForm.deregisterLabelWidth(computedWidth.value);
          }
        }
      });
    };
    const updateLabelWidthFn = () => updateLabelWidth("update");

    // 挂载时
    onMounted(() => {
      // 给第一个子节点元素添加resize监听,在resize时,更新label宽度
      addResizeListener(el.value.firstElementChild as ResizableElement, updateLabelWidthFn);
      updateLabelWidthFn();
    });
    // 更新时,重新计算当前form-item label元素所占宽度
    onUpdated(updateLabelWidthFn);

    // 卸载时
    onBeforeUnmount(() => {
      updateLabelWidth("remove");
      removeResizeListener(el.value.firstElementChild as ResizableElement, updateLabelWidthFn);
    });
    // render函数
    function render() {
      if (!slots) return null;
      if (props.isAutoWidth) {
        // 获取当前form的label自动宽度
        const autoLabelWidth = elForm.autoLabelWidth;
        const style = {} as CSSProperties;
        if (autoLabelWidth && autoLabelWidth !== "auto") {
          // 需要偏移的距离 = form的label自动宽度 - 与当前label-wrap计算出来的宽度
          const marginWidth = Math.max(0, parseInt(autoLabelWidth, 10) - computedWidth.value);
          const marginPosition = elForm.labelPosition === "left" ? "marginRight" : "marginLeft";
          if (marginWidth) {
            style[marginPosition] = marginWidth + "px";
          }
        }
        // 如果label宽度是自动的,则在外面包裹一层div,通过margin控制对齐
        return h(
          "div",
          {
            ref: el,
            class: ["el-form-item__label-wrap"],
            style,
          },
          slots.default?.()
        );
      } else {
        // 如果label-width是指定的,则不需要包裹div,直接渲染label
        return h(Fragment, { ref: el }, slots.default?.());
      }
    }

    return render;
  },
});

2.4 总结:

  1. label-wrap中通过ResizeObserver监听label子元素的宽度变化,并将宽度注册到el-form中的potentialLabelWidthArr中,el-formpotentialLabelWidthArr中的最大值设置为label的自动宽度值;如果form-item的label设置成auto时,label-wrap在label元素外包裹一层div,通过设置margin-top/margin-left进行label的对齐;
  2. 使用Mitt事件总线进行组件间的事件通讯,但是既然已经通过provide向子组件注入form实例了,为什么不直接调用实例的方法,还要使用总线呢;
  3. rules中不设置trigger,则表示任何时候都参与校验,设置了trigger则只在指定的触发下才参与校验;form的校验就是逐个校验form-itemform-item的校验中使用async-validator进行校验;
  4. form-item的prop属性很重要,在label宽度和校验时,都是通过prop属性作为路径,找到model中对应的值;