精读el-form

1,196 阅读8分钟

表单

个人使用el-form组件开发表单编辑场景已有一年之余,先后基于el-form组件几次封装了动态表单组件,封装的动态表单组件说白了就是预先写了一套全量的form模版,并内置了基础的校验逻辑,增加了一些定制的插槽,这样使用动态表单组件的时候,可以传入配置项来选择性的加载想要的基础表单组件。同时,也对基础表单组件进行了一些简化,比如同化了select、radioGroup、checkGroup这些复合组件,包装成了一个高阶组件。

解决的问题:开发一些交互不是特别复杂的编辑场景的时候,可以忽略校验,忽略复合组件,直接定制配置项。

弊端:复杂的编辑场景,如策略的编辑、商品的编辑等等这种交互特别复杂的场景就支撑不住了,原因就是

  • 没办法全量的内置所有的逻辑、校验规则和模版。

  • 层次深的组件难以校验

    为什么难以校验呢?校验是表单场景非常头疼的一个问题,总体可以分为:同步校验、异步校验、联动校验。如果使用el-form都清楚,el-form如果实现校验的话,需要给el-form-item组件传入绑定变量相较于model大对象的路径(prop),这样el-form内置的校验逻辑才能找到值,并校验。这样设计会存在一个问题:

    • 页面都会因为需要满足交互而设计为动态联动的,所以prop很可能需要动态计算,组件层次一深,prop找不到,校验失败是常有的事情。
    • 灵活的场景都会分割成独立维护的组件,然后还需要在父组件维护一个大对象,这个对象可能需要同时满足UI和数据的设计要求,然后还需要组件间双向数据绑定(编辑时灌数据),难度较大。
    • 抽象难度高,如果想把校验和基础组件绑定很难,比如把年级、学科、体系这三个表单及他们的业务关系绑定到一起,同时要求它们可以内置进任何一个表单场景,同时还不影响宿主表单的布局等等,难度也很大。
  • DDD驱动,可复用性低,公共逻辑仅用mixin不够。

以上问题既有el-form的问题也有vue自身的问题,关于vue的问题,在vue下表单场景的思考有总结

综上,使用el-form去写大型表单场景确实令我十分不爽,重复的工作较多,且配置十分分散,因此比较迫切需要提出设计切合公司业务的表单解决方案。

el-form是如何实现的

element-ui作为应用广泛的开源库,它的每一个组件的设计一定会有它存在的原因,首先来了解一下el-form组件是如何设计的:

源码结构:

——form-item.vue
——form.vue

来看核心的几个部分:

// template
<form class="el-form" :class="[
    labelPosition ? 'el-form--label-' + labelPosition : '',
    { 'el-form--inline': inline }
    ]">
    <slot></slot>
</form>

首先form是一个组件,它有根节点和真实的dom,有自己的样式,而不是一个不会实例化的函数组件,可以称为功能组件,因此在表单场景中,form组件既是功能组件又是容器组件,form组件如果不替换样式会影响到UI布局。

// provide
provide() {
    return {
      elForm: this
    };
},

provide是Vue提供的一个语法糖,与之配合的是inject: ['elForm'],使用这个属性form组件就可以向后代的所有组件注入依赖(form组件中注入的依赖其实是form组件本身的实例),

form组件有两个重要的props,分别为:modelrules,重要的方法:clearValidate、validate(validateField)、resetFields

重置

我们首先来看form是如何实现重置的:

// form.vue
resetFields() {
  ...
  this.fields.forEach(field => {
    field.resetField();
  });
}

这里的field其实对应的是form-item组件,resetField方法:

// form-item.vue
resetField() {
    // 重置校验状态
    this.validateState = '';
    this.validateMessage = '';
    // 拿到form接受的model值
    let model = this.form.model;
    // form-item会接受一个prop路径,
    // form-item会根据路径去获取值
    // fieldValue其实是旧值
    let value = this.fieldValue;
    let path = this.prop;
    if (path.indexOf(':') !== -1) {
      path = path.replace(/:/, '.');
    }
    
    // getPropByPath返回一个对象:
    // o: tempObj,绑定值所在父级的引用
    // k: keyArr[i]
    // v: tempObj ? tempObj[keyArr[i]] : null
    let prop = getPropByPath(model, path, true);
    
    // 
    this.validateDisabled = true;
    if (Array.isArray(value)) {
      prop.o[prop.k] = [].concat(this.initialValue);
    } else {
      prop.o[prop.k] = this.initialValue;
    }

    ...
  }

首先完成重置逻辑的其实是form-item组件,form-item组件在实例化阶段时会先把prop绑定的值缓存起来,既fieldValue。其次,form-item这里通过getPropByPath获取了一个对象,比如我定义了一个对象obj,内容:

let obj = {
    a: {
        b: {
            c: 1
        }
    }
}

将obj连同c所在的相对位置a.b.c路径传入该函数,就会得到:

{
    o: { c:1 },
    v: 1,
    key: 'c'
}

注意,这里的o是obj中b属性的引用。

从上面的思路不难看出,form使用了一个比较hack的方法实现了重置功能(引用传递),,重要的是它并没有遵循vue提倡的单向数据流机制。通过model传入form组件的对象,其挂载的值被隐形改变了。

这样做的好处是简单暴力做到重置,但坏处也有:

  • 不能重置复杂类型,对象或者对象数组无能为力,比如我们重置一块面板数组,很可能只能手动重置。
  • 非常依赖vue提供的依赖注入功能,当出现form组件嵌套form组件时,最外层的form组件会失去内层组件的控制权。
  • 需要手动传入路径,当然这是因为form组件和基础组件分离导致的设计。

不难想象,因为prop的存在,form-item在开发复杂表单的时候,表现实在是差强人意。但这么做但目的其实也符合一个组件库的设计,form-item意在对基础组件功能进行增强,假如我们把重置校验等功能集成进基础组件,就破坏了单一职责,不利于用户对组件进行扩展,这也是一个很严重的问题。

校验

校验是整个表单场景一个非常大的瓶颈问题,首先,使用element-ui提供的原生校验方式的话,那么很多固定的输入项都得手撸正则,难以复用。其次,校验本身的功能也严重依赖prop属性。

首先来看一下form-item跟校验有关的比较重要的props:prop、rules。这里的prop的功能同上面重置,rules就是开发者传入的字段校验规则。格式可以有:

{ type: "string", required: true,validator: (rule, value) => valu=== 'muji'}, trigger: 'blur'

// 或者
[
  { type: "string", required: true,validator: (rule, value) => valu=== 'muji',trigger:'blur'}
]

el-form底层校验是借助async-validate这个校验库实现的,了解这个库的开发者可能知道,rules参数的格式不完全符合该库的要求,所以form-item首先对rule做了一次处理,然后再调用了该库进行校验:

首先,来看一下form-item中执行校验功能的主体逻辑:

// trigger代表当前函数触发的事件类型
validate(trigger, callback = noop) {
    this.validateDisabled = false;
    // 根据trigger过滤一次数据
    const rules = this.getFilteredRule(trigger);
    // 未定义校验规则的都视为校验通过
    if ((!rules || rules.length === 0) && this.required === undefined) {
      callback();
      return true;
    }
    // 校验中
    this.validateState = 'validating';
    
    // 将数据处理成async-validate可接受的格式
    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 : '';
      // 执行回调
      callback(this.validateMessage, invalidFields);
      // 通知el-form组件完成校验,并传入校验结果和prop
      this.elForm && this.elForm.$emit('validate', this.prop, !errors, this.validateMessage || null);
    });
  }

validate方法的逻辑比较简单,就是根据trigger拿到对应rules,然后调用validator,传入rule和value进行校验,最后将校验结果统一返回给el-form组件,这里其实很关键,就是el-form所有的子孙form-item组件都会将校验结果返回给el-form组件,这也就是为什么调用el-form组件的validate方法,可以拿到整个表单校验结果来。

再来看一下el-form是如何拿到所有子孙组件的校验结果的:

this.fields.forEach(field => {
    field.validate('', (message, field) => {
      if (message) {
        valid = false;
      }
      invalidFields = objectAssign({}, invalidFields, field);
      if (
        typeof callback === 'function' &&
        ++count === this.fields.length
      ) {
        callback(valid, invalidFields);
      }
    });
  });

这里做了一个累加操作,当所有子孙form-item组件校验通过后,再调用回调函数,逻辑非常简单。

我们再回到form-item组件中的校验功能,这里还有一个比较重要的逻辑需要了解一下,我们知道,rules是定义字段校验规则的属性,但rules可以定在el-form上,也可以定义再form-item上,那么如果同时在两个位置定义了rules字段,form-item是如何处理的呢?

rules规则的处理是在getFilteredRule函数内部完成的,来看一下该函数的内部逻辑:

// 获取绑定字段对应的rules
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) : [];

    return [].concat(selfRules || formRules || []).concat(requiredRule);
  },
// 根据trigger过滤rules
getFilteredRule(trigger) {
    const rules = this.getRules();
    // 浅拷贝,防止删除原对象的trigger字段
    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));
}

对rules的处理主要是先根据指定的字段(字段路径)拿到el-form组件定义的对应的rule。再根据trigger进行过滤,拿到最终的校验规则。

以上就是el-form的校验流程。