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