element form 实现思路解析
简介
element 表单主要分为三层结构:
- ElForm
- ElFormItem
- 具体的表单输入组件
ElForm 组件作为 ElFormItem 组件的容器,负责统一管理所有的 ElFormItem 及其事务,包括:
- ElFormItem 列表的维护:新增、删除
- 表单任务(验证、清除验证、重置)的调度与分发
- 收集 Item 任务执行完毕的结果并反馈给任务发起方(也就是业务组件)
ElFormItem 作为具体输入组件的容器,负责执行 ElForm 分发的任务(验证、清除验证、重置),以及在其内部输入组件触发事件时自动执行某些任务。
今天主要分析 ElForm 和 ElFormItem 验证的实现思路,不涉及样式部分的变量和逻辑,也不涉及具体的表单输入组件。
详细分析
以下分析会将数据和逻辑根据功能进行拆分,方便理解各部分的实现。
建立组件联系
ElForm.vue
provide() {
return {
elForm: this
};
},
ElFormItem.vue
provide() {
return {
elFormItem: this
};
},
inject: ['elForm'],
computed: {
// 这里与 inject 相同,都是获取 form 组件实例的引用;除此之外,还判断了当前字段是否被嵌套(非 form 组件的直接子组件)
form() {
let parent = this.$parent;
let parentName = parent.$options.componentName;
while (parentName !== 'ElForm') {
if (parentName === 'ElFormItem') {
this.isNested = true;
}
parent = parent.$parent;
parentName = parent.$options.componentName;
}
return parent;
},
}
具体输入组件
inject: ['elFormItem'],
维护字段列表
如上所述,ElForm 负责统一管理所有的 item 及其事务,但不参与具体执行。相当于提供了对外的统一接口,当需要执行表单验证、重置等任务时,通过调用 ElForm 的方法,ElForm 内部会自行通知所有的 ElFormItem 。因此,首先就是维护一个 ElFormItem 列表(所有字段,包括需要验证或无需验证):
ElForm.vue
data() {
return {
fields: [], // 字段列表
};
},
created() {
// 通过自定义的 emitter 和 dispatch 函数侦听字段增加和删除事件,具体实现可以查看源码 node_modules/element-ui/src/mixins/emitter.js
this.$on('el.form.addField', (field) => {
if (field) {
this.fields.push(field);
}
});
/* istanbul ignore next */
this.$on('el.form.removeField', (field) => {
if (field.prop) {
this.fields.splice(this.fields.indexOf(field), 1);
}
});
},
ElFormItem.vue
// 针对动态字段
mounted() {
// 当字段设置了验证规则时,在 item 组件挂载时通知 form 组件,将该字段添加到列表
if (this.prop) {
this.dispatch('ElForm', 'el.form.addField', [this]);
// 保存初始的字段值;用于重置时恢复到初始值
let initialValue = this.fieldValue;
if (Array.isArray(initialValue)) {
initialValue = [].concat(initialValue);
}
Object.defineProperty(this, 'initialValue', {
value: initialValue
});
// 添加输入组件的事件监听器;具体实现在下方
this.addValidateEvents();
}
},
beforeDestroy() {
// 当字段被移除时,通知 form 组件,将该字段从列表中删除
this.dispatch('ElForm', 'el.form.removeField', [this]);
}
获取待验证数据和验证规则
ElForm.vue
props: {
model: Object, // 整个表单的值列表
rules: Object, // 整个表单的规则列表
},
ElFormItem.vue
props: {
prop: String, // ElForm 组件绑定的 model 和 rules 对象中,当前字段对应的 key 值,用于从中获取待验证的值和验证规则
required: {
type: Boolean,
default: undefined
},
rules: [Object, Array], // 当前字段的验证规则,用于单独传递规则;会覆盖在表单组件上设置的规则
},
computed: {
fieldValue() {
const model = this.form.model;
if (!model || !this.prop) { return; }
let path = this.prop;
if (path.indexOf(':') !== -1) {
path = path.replace(/:/, '.');
}
return getPropByPath(model, path, true).v;
},
isRequired() {
let rules = this.getRules();
let isRequired = false;
if (rules && rules.length) {
rules.every(rule => {
if (rule.required) {
isRequired = true;
return false;
}
return true;
});
}
return isRequired;
},
},
methods: {
// 获取当前字段的校验规则
getRules() {
let formRules = this.form.rules;
const selfRules = this.rules;
const requiredRule = this.required !== undefined ? { required: !!this.required } : [];
const prop = getPropByPath(formRules, this.prop || '');
formRules = formRules ? (prop.o[this.prop || ''] || prop.v) : [];
// 优先选择为当前字段设置的规则,然后取表单组件上设置的规则,最后取为当前字段单独设置的 require 规则
return [].concat(selfRules || formRules || []).concat(requiredRule);
},
// 获取不同事件被触发时所对应的校验规则;参数表示的是 rules 列表中的触发事件,如 change/blur 等
getFilteredRule(trigger) {
const rules = this.getRules();
return rules.filter(rule => {
if (!rule.trigger || trigger === '') return true;
if (Array.isArray(rule.trigger)) {
return rule.trigger.indexOf(trigger) > -1;
} else {
return rule.trigger === trigger;
}
}).map(rule => objectAssign({}, rule));
},
}
验证 prop 的值是否属于合法的对象 key 路径并返回(源码路径:element-ui/src/utils/util.js)
/**
当字段组件的 prop 属性对于 model/rules 对象是一个合法的 key 路径时,返回 model/rules 对象本身,以及该路径下获取到的键值对;
未通过则报错
*/
export function getPropByPath(obj, path, strict) {
let tempObj = obj;
path = path.replace(/\[(\w+)\]/g, '.$1');
path = path.replace(/^\./, '');
let keyArr = path.split('.');
let i = 0;
// 若 prop 路径合法,则取得表单 model 嵌套对象中最低一层包含属性名与 prop 的对象,以及对应的 key 和 value
for (let len = keyArr.length; i < len - 1; ++i) {
if (!tempObj && !strict) break;
let key = keyArr[i];
if (key in tempObj) {
tempObj = tempObj[key];
} else {
if (strict) {
throw new Error('please transfer a valid prop path to form item!');
}
break;
}
}
return {
o: tempObj,
k: keyArr[i],
v: tempObj ? tempObj[keyArr[i]] : null
};
};
测试 getPropByPath:
let obj = { a: { b: { c: { d: 1 } } } };
let result = getPropByPath(obj, 'a.b.c.d');
测试结果:
表单任务派发
ElForm.vue
props: {
validateOnRuleChange: {
type: Boolean,
default: true
},
},
watch: {
rules() {
// remove then add event listeners on form-item after form rules change
this.fields.forEach(field => {
field.removeValidateEvents();
field.addValidateEvents();
});
if (this.validateOnRuleChange) {
this.validate(() => {});
}
}
},
methods: {
// 批量重置字段
resetFields() {
if (!this.model) {
console.warn('[Element Warn][Form]model is required for resetFields to work.');
return;
}
this.fields.forEach(field => {
field.resetField();
});
},
// 批量重置清除校验结果
clearValidate(props = []) {
const fields = props.length
? (typeof props === 'string'
? this.fields.filter(field => props === field.prop) // 提供字符串,表示重置特定的单个字段
: this.fields.filter(field => props.indexOf(field.prop) > -1) // 提供数组,表示重置特定的若干字段
) : this.fields; // 默认重置所有设置了校验规则的字段
fields.forEach(field => {
field.clearValidate();
});
},
// 批量校验
validate(callback) {
if (!this.model) {
console.warn('[Element Warn][Form]model is required for validate to work!');
return;
}
// 既可通过回调形式处理验证结果,也可通过 Promise 形式
// 若通过 promise 机制处理,则业务组件传递的 resolve 和 reject 回调会传给这里的 callback 函数
// 之后,在验证通过或未通过,callback 函数被调用时, valid 和 invalidFields 参数会进入 callback 函数
// callback 函数再根据 valid 的值选择是调用 resolve 或是 reject 回调
// 至于 resolve, reject 以及 valid, invalidFields 两组参数是何时传递给 callback 函数,在这里并不重要;
// 而且可能涉及到 js 语言底层的实现,暂不了解,这里就不作分析了
let promise;
// if no callback, return promise
if (typeof callback !== 'function' && window.Promise) {
promise = new window.Promise((resolve, reject) => {
callback = function(valid, invalidFields) {
valid ? resolve(valid) : reject(invalidFields);
};
});
}
let valid = true;
let count = 0; // 当前验证到的字段序号
// 如果需要验证的 fields 为空,调用验证时立刻返回 callback
if (this.fields.length === 0 && callback) {
callback(true);
}
let invalidFields = {};
this.fields.forEach(field => {
field.validate('', (message, field) => {
if (message) {
valid = false;
}
// 将验证未通过的字段添加到对象,效果类似 arr.push()
invalidFields = objectAssign({}, invalidFields, field);
if (typeof callback === 'function' && ++count === this.fields.length) {
callback(valid, invalidFields);
}
});
});
if (promise) {
return promise;
}
},
// 验证单独的字段;这里倒是没有提供 Promise 形式的回调
validateField(props, cb) {
props = [].concat(props);
const fields = this.fields.filter(field => props.indexOf(field.prop) !== -1);
if (!fields.length) {
console.warn('[Element Warn]please pass correct props!');
return;
}
fields.forEach(field => {
field.validate('', cb);
});
},
},
输入组件事件监听
ElFormItem.vue
methods: {
// 事件 handlers
onFieldBlur() {
this.validate('blur');
},
onFieldChange() {
if (this.validateDisabled) {
this.validateDisabled = false;
return;
}
this.validate('change');
},
// 内部输入组件所派发事件的监听器
addValidateEvents() {
const rules = this.getRules();
if (rules.length || this.required !== undefined) {
this.$on('el.form.blur', this.onFieldBlur);
this.$on('el.form.change', this.onFieldChange);
}
},
removeValidateEvents() {
this.$off();
}
},
字段值校验
ElFormItem.vue
// 引入 AsyncValidator
import AsyncValidator from 'async-validator';
props: {
validateStatus: String,
},
watch: {
validateStatus(value) {
this.validateState = value;
},
rules(value) {
if ((!value || value.length === 0) && this.required === undefined) {
this.clearValidate();
}
}
},
data() {
return {
validateState: '', // 可能的值 '' | 'valitating' | 'sucess' | 'error'
validateMessage: '', // 错误提示
validateDisabled: false, // 未知用途的变量
validator: {}, // 未被使用的变量;在 validate 方法中有一个同名变量是 AsyncValidator 构造函数的实例
}
},
methods: {
validate(trigger, callback = noop) {
this.validateDisabled = false;
const rules = this.getFilteredRule(trigger);
// 无需校验时直接调用回调
if ((!rules || rules.length === 0) && this.required === undefined) {
callback();
return true;
}
this.validateState = 'validating';
// 将 Form 组件上绑定的 model/rules 对象重新整理成符合 AsyncValidator 规范的格式
const descriptor = {};
if (rules && rules.length > 0) {
rules.forEach(rule => {
delete rule.trigger;
});
}
descriptor[this.prop] = rules;
const validator = new AsyncValidator(descriptor);
const model = {};
model[this.prop] = this.fieldValue;
validator.validate(model, { firstFields: true }, (errors, invalidFields) => {
this.validateState = !errors ? 'success' : 'error';
this.validateMessage = errors ? errors[0].message : ''; // 若存在多条校验规则未通过,仅返回第一条错误信息
// 这里回传 message 的目的暂不清楚,如果按照 form.validate 相同的处理方式的话,返回布尔值就够了;
// 可能是为了方便基于错误提示做特殊处理
callback(this.validateMessage, invalidFields);
// 这里使用了自定义的 validate 事件,允许开发者使用事件侦听的方式而非通过 $refs.ruleForm 的方式处理验证结果
this.elForm && this.elForm.$emit('validate', this.prop, !errors, this.validateMessage || null);
});
},
clearValidate() {
this.validateState = '';
this.validateMessage = '';
this.validateDisabled = false;
},
resetField() {
this.validateState = '';
this.validateMessage = '';
let model = this.form.model;
let value = this.fieldValue;
let path = this.prop;
if (path.indexOf(':') !== -1) {
path = path.replace(/:/, '.');
}
let prop = getPropByPath(model, path, true);
this.validateDisabled = true;
// 修改深层次嵌套对象中 prop 路径对应的值,表单组件 model 对象中的值就会同步修改,因为是同个内存地址
if (Array.isArray(value)) {
prop.o[prop.k] = [].concat(this.initialValue);
} else {
prop.o[prop.k] = this.initialValue;
}
// reset validateDisabled after onFieldChange triggered
this.$nextTick(() => {
this.validateDisabled = false;
});
this.broadcast('ElTimeSelect', 'fieldReset', this.initialValue);
},
}
显示错误提示
这部分都是在模板中完成的。下面只贴出错误信息所在的插槽部分。
<slot
v-if="validateState === 'error' && showMessage && form.showMessage"
name="error"
:error="validateMessage">
<div
class="el-form-item__error"
:class="{
'el-form-item__error--inline': typeof inlineMessage === 'boolean'
? inlineMessage
: (elForm && elForm.inlineMessage || false)
}"
>
{{validateMessage}}
</div>
</slot>
相关技术分析
功能部分介绍完毕,接下来简单分析一下组件中实现各部分功能所使用到的相关技术。name、components、data、props 部分都是常规用法,就不过多赘述了。
(真)自定义属性
唯一的一个(真)自定义属性 componentName
,其值与 name 属性相同。主要有两大用途:
- 子组件获取父组件实例的引用,方便调用父组件的方法或获取部分属性
- 子组件定向触发父元素的自定义事件,然后由父元素自身接收并处理
这里的“真”指的是没有通过 Vue 的机制,而是给原生 js 对象添加属性。
依赖注入
对每个组件都只有一个属性,用于向子元素注入父元素的实例引用,方便调用父元素的方法或获取部分属性:
- ElForm 组件: elForm
- ElFormItem 组件: elFormItem
由于组件在实际使用中可能存在嵌套,因此通过依赖注入可以跨层级获取“父组件”。
自定义的事件分发函数 dispatch
element-ui 的组件体系中,存在大量通过 dispatch 形式触发的自定义事件。
dispatch(componentName, eventName, params) {
var parent = this.$parent || this.$root;
var name = parent.$options.componentName;
// 遍历所有父组件,找到指定名称的那个
while (parent && (!name || name !== componentName)) {
parent = parent.$parent;
if (parent) {
name = parent.$options.componentName;
}
}
if (parent) {
// 触发对应父组件上的自定义事件,后续会由该父组件自身接收并处理;
// 接收示例可以翻看上面 ElForm 组件的 created 钩子
// 触发示例可以翻看上面 ElFormItem 组件的 mounted 和 beforeDestroy 钩子
parent.$emit.apply(parent, [eventName].concat(params));
}
},
传递组件实例引用
这里的 ElForm 和 ElFormItem 都通过或直接或间接的方式获取到对方的实例引用,主要是两类用途:
- Form 组件获取子组件实例,是为了调用 Item 组件的方法,也就是上文提到的 Form 组件只负责任务的派发与向任务发起方反馈执行结果,并不参与具体执行,因此需要通过这种形式主动通知任务的执行者,也就是 ElFormItem
- ElFormItem 获取父组件实例,是为了获取父元素上设置的一些样式相关属性:labelPosition、inline、autoLabelWidth,而且有两种形式的引用,包括依赖注入和 $parents,按理说,父元素上的这类属性应该全部通过依赖注入获取,以降低父子组件的耦合。这里可能是为了保持属性的响应性,因为 Vue 中的依赖注入是静态注入,通过依赖注入原始类型变量,上级组件的变量值改变后,下级组件并不能收到更新后的值,除非传递的是一个响应式对象。比如,表单允许通过按钮动态设置 labelPosition,此时就必须保持该属性的响应性。
此外,两种方式获取的其实是同一个 ElForm 实例的引用,存在两个目的相同但用法不同的变量也容易造成困惑。
相比于 Vuex 等独立的状态管理方法,依赖注入适合在开发像表单和表单项这类具有严格包含关系的组件时,提供统一的状态,而且耦合程度也较低。Vuex 这里状态库则更适合项目内的公共状态管理。
但不论哪种,都比直接引用父组件的属性更好维护,因尽量避免直接引用。
多样化的反馈形式
考虑到开发者有各自不同的习惯和喜好,开发这类基础组件时,有时也需要提供不同的结果反馈机制,方便开发者自由选择更适合的方式。
在上文的分析中也有提到,表单组件内部针对一些任务的反馈提供了不同的方式:
- ElForm 的 validate 方法提供了回调和 Promise 两种方式接收验证结果
- ElFormItem 的 validate 方法在满足 ElForm 批量验证的基础上,还额外提供了自定义事件
this.elForm.$emit('validate', ...)
;不过这里由子组件触发父组件自定义事件的形式可能不是太好,这超出了子组件的权责范围