Element-ui 之 Form 表单组件源代码分析

Form 组件是 Element-ui 中的一个重要的组件,用于创建和管理表单,本文将从 Form 组件的构成和 Form 组件的实现逻辑来分析源代码。

一. Form 组件的构成

先来看一下 Form 组件的构成,在 element-ui 源代码中,Form 组件包含三个部分:

  • 表单整体 Form
  • 表单项 Form-Item
  • 表单项前缀 lable-wrap
html 复制代码
------ Form
   |------ form.vue
   |------ form-item.vue
   |------ lable-wrap.vue

Form 组件的结构分析:

二. Form 组件的实现逻辑

Form 组件提供的功能进行拆分,主要可以拆分成如下几部分功能:

  • 基础表单域的展示。
  • 表单域内数据的重置功能。
  • 表单域标签的位置、宽度以及后缀可设置。
  • 表单域的验证功能。
  • 一些其他的功能:可以设置为行内表单、控件尺寸可控制、控件的启用/禁用可控制。

以上是拆分的 Form 组件的主要功能,接下来围绕每一个功能的代码实现逻辑来分析 Form 组件。

2.1 基础表单域

基础表单域实现的重点逻辑在于整理表单项集合:即在表单项挂载时,将表单项加入集合中,在表单项销毁时,将表单项从集合中去除。

  1. 建立 FormForm-ItemLabel-wrap 组件。
  2. 通过 Form 组件来整合每一个表单项字段的集合,在 Form 组件中设置 fields 变量,来存储每一个表单项。
  3. Form-Item 组件中的 mounted 钩子里面,触发 Form 组件的自定义方法 addField,并且将 Form-Item 组件的 this 作为参数传入。在 Form 组件监听 addField 方法,方法中将传入的 this 全部推入 fields 数组变量中。
  4. Form-Item 组件触发 beforeDestroy 钩子时,触发 Form 组件的自定义方法 removeField,并且将 Form-Item 组件的 this 作为参数传入。在 Form 组件监听 removeField 方法,将被销毁的组件对应的字段删除。

Form 组件:

  1. 首先是以插槽 slot 的形式,接受组件内部传入的表单项。
  2. 然后在 created 钩子中,监听 addFieldremoveField 两个自定义方法,整理表单项。
html 复制代码
<template>
  <form class="el-form">
    <slot></slot>
  </form>
</template>
<script>
export default {
  name: 'ElForm',
  componentName: 'ElForm',
  
  props: {
    model: Object, // 表单数据对象
  },
  
  data() {
    return {
      fields: [], // 设置 fields 变量用于存储表单项
    }
  },

  created() {
    // 监听 el.form.addField
    this.$on('el.form.addField', (field) => {
      if (field) {
        // 将每一个表单项都推入到 fields 数组中
        this.fields.push(field);
      }
    })
    // 监听 el.form.removeField
    this.$on('el.form.removeField', (field) => {
      if (field.prop) {
        // 找到对应的字段从 fields 数组中删除
        this.fields.splice(this.fields.indexOf(field), 1);
      }
    });
  }
}
</script>

Form-Item 组件:

  1. Form-Item 组件包含两部分:表单项的前缀和表单项的控件。表单项的前缀,也就是表单项的中文描述,这部分单独提出了一个组件 label-wrap。表单项的控件,也就是 InputSelect 等控件,这部分采用插槽 slot 的形式去接收传入 Form-Item 组件的控件。
  2. Form-Item 组件在 mounted 钩子中需要触发 Form 组件的 addField 方法,把字段添加到 Form 组件的 fields 数组中。在 beforeDestroy 钩子中需要触发 Form 组件的 removeField 方法,将字段从 fields 数组中删除。
html 复制代码
<template>
  <div class="el-form-item">
    <!-- label-wrap 组件 -->
    <label-wrap>
      <label class="el-form-item__label" v-if="label || $slots.label">
        <slot name="label">{{label}}</slot>
      </label>
    </label-wrap>
    <!-- 表单项控件 -->
    <div class="el-form-item__content">
      <slot></slot>
    </div>
  </div>
</template>
<script>
import LabelWrap from './label-wrap';
import emitter from '../../utils/mixins/emitter';

export default {
  name: 'ElFormItem',
  componentName: 'ElFormItem',
  mixins: [emitter],
  props: {
    label: String,
    prop: String,
  },
  components: {
    LabelWrap
  },
  mounted() {
    if (this.prop) {
      // 触发父组件的自定义方法 addField,添加表单项
      this.dispatch('ElForm', 'el.form.addField', [this]);
    }
  },
  beforeDestroy() {
    // 触发 Form 组件的 el.form.removeField 方法,在组件销毁时删除表单项
    this.dispatch('ElForm', 'el.form.removeField', [this]);
  }
}
</script>

Form-Item 组件中,关于 label 的展现处理如下:

html 复制代码
<label-wrap>
  <label class="el-form-item__label" v-if="label || $slots.label">
    <slot name="label">{{label}}</slot>
  </label>
</label-wrap>

label-wrap 组件中传入 label 标签,判断如果传入了 label,或者传入了名称是 label 的具名插槽,则展示 label 标签。

label 标签的内部接收父组件传过来的插槽内容,如果父组件没有传过来名为 label 的插槽内容,则展示 slot 标签内部的后备内容,也就是 label 属性传入的内容。

从这段代码可以看出,如果是 label 属性和插槽模板同时传值的情况下,插槽的优先级较高

Lable-wrap 组件:

label-wrap 组件内部采用 render() 函数来构建 DOM。

使用 render() 函数是因为 render() 函数更加灵活,可以处理更复杂的渲染逻辑,由于 label 支持很多属性设置(比如说 label-width 设置为 auto 的自动撑开宽度功能),需要根据设置渲染不同的标签,所以使用 render() 函数来更加灵活的渲染。

js 复制代码
<script>
export default {
  render() {
    const slots = this.$slots.default;
    if (!slots) return null;
    // render 函数来返回虚拟DOM节点
    return slots[0];
  }
}
</script>

2.2 表单域的重置功能

表单域内数据的重置功能是指在点击重置按钮时,将表单项的值重置为初始设置的值。

  1. 需要根据 Form 组件传入的 model 以及 Form-Item 组件的 prop,查询到 Form-Item 表单项的初始值,并且设置变量记录初始值。
  2. Form-Item 组件提供 resetField 方法,该方法将 model 中对应表单项的 prop 的值设置为初始值。
  3. Form 组件提供 resetFields 方法,该方法循环 Form-Item,调用其 resetField 方法。

Form-Item 组件:

  1. 通过循环向上查找,获取父级的表单组件。
  2. 获取组件的初始值,在 mounted 钩子中,用 initialValue 变量记录组件的初始值。
  3. 提供 resetField 方法,在该方法中,将 Form-Item 对应的值重置为初始值。
js 复制代码
computed: {
  // 获取表单组件
  form() {
    // 获取该组件的父级
    let parent = this.$parent;
    let parentName = parent.$options.componentName;
    // 如果父级不是 ElForm 则继续向上获取,直到获取到 ElForm
    while (parentName !== 'ElForm') {
      parent = parent.$parent;
      parentName = parent.$options.componentName;
    }
    // 返回父级
    return parent;
  },
  // 获取组件的 value 值
  fieldValue() {
    // 获取到 form 组建的 model 属性
    const model = this.form.model;
    // 如果 model 或者 prop 任意一个不存在,则直接 return
    if (!model || !this.prop) { return; }
    // 将 prop 属性的值作为在 model 对象寻找值的 path
    let path = this.prop;
    // 如果 prop 属性的值存在:则替换成.(作为属性下的属性)
    if (path.indexOf(':') !== -1) {
      path = path.replace(/:/, '.');
    }
    // 在 model 下寻找对应属性的值
    return getPropByPath(model, path, true).v;
  },
},
mounted() {
  if (this.prop) {
    ... 
    // 获取表单项的值作为初始值
    let initialValue = this.fieldValue;
    // 兼容数组类型
    if (Array.isArray(initialValue)) {
      initialValue = [].concat(initialValue);
    }
    // 将初始值绑定在 this 上
    Object.defineProperty(this, 'initialValue', {
      value: initialValue
    });
  }
},
methods: {
  resetField() {
    let model = this.form.model;
    let value = this.fieldValue;
    let path = this.prop;
    if (path.indexOf(':') !== -1) {
      path = path.replace(/:/, '.');
    }
    // 通过 getPropByPath 方法获取到表单项属性所在对象、属性的 key、以及属性的值
    let prop = getPropByPath(model, path, true);
    // 将属性的值重置为初始值
    if (Array.isArray(value)) {
      prop.o[prop.k] = [].concat(this.initialValue);
    } else {
      prop.o[prop.k] = this.initialValue;
    }
  },
},

getPropByPath 方法:

该方法根据传入的对象和路径,返回三个值:属性所在对象、属性的 key 和属性的值。

js 复制代码
export function getPropByPath(obj, path, strict) {
  let tempObj = obj;
  // 将中括号属性转换为 .
  path = path.replace(/\[(\w+)\]/g, '.$1');
  // 将属性前面的 . 去掉 比如:.into.name 转换为 into.name
  path = path.replace(/^\./, '');
  // 用 . 来分割
  let keyArr = path.split('.');
  // 一层一层的来找到对应的属性
  let i = 0;
  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], // 属性的 key
    v: tempObj ? tempObj[keyArr[i]] : null // 属性的值
  };
};

Form 组件:

提供 resetFields 方法,该方法循环 Form-Item,调用其 resetField 方法。

js 复制代码
methods: {
  resetFields() {
    // 循环表单项,依次调用每一个表单项的 resetField 方法
    this.fields.forEach(field => {
      field.resetField();
    });
  },
}

2.3 表单项标签宽度设置

表单项标签宽度是指表单项的前缀中文描述的宽度,设置标签宽度区分两种情况:

  1. 设置一个具体的宽度,所有表单项的宽度都遵循那个具体的宽度。
  2. 设置为 auto,表单项的宽度自适应,不会折行。

2.3.1 label-width 设置为具体宽度

label-width 被设置为具体的宽度数值时,需要将 label 的宽度设置为这个数值,并且将 label 后面表单项控件的 margin-left 设置为 label-width 的值。

相关代码:

js 复制代码
computed: {
  // lablel 的样式
  labelStyle() {
    // 设置 ret 变量为一个空对象
    const ret = {};
    // 如果 labelPosition 设置为 top 则直接返回
    if (this.form.labelPosition === 'top') return ret;
    // 如果设置了 label-width 优先取 form-item 的,再取 form 的
    const labelWidth = this.labelWidth || this.form.labelWidth;
    // 如果 labelWidth 存在,返回设置的 labelWidth
    if (labelWidth) {
      ret.width = labelWidth;
    }
    return ret;
  },
  // 包裹控件的 div 的样式
  contentStyle() {
    const ret = {};
    const label = this.label;
    // 如果 labelPosition 设置为 top,或者是行内表单,则直接返回
    if (this.form.labelPosition === 'top' || this.form.inline) return ret;
    // 如果不存在 label 属性,也不存在 label-width 属性,则直接返回
    if (!label && !this.labelWidth) return ret;
    const labelWidth = this.labelWidth || this.form.labelWidth;
    // 把右侧控件的 margin-left 设置为 labelWidth 的值
    ret.marginLeft = labelWidth;
    return ret;
  },
}

Template 上面绑定 labelStylecontentStyle

html 复制代码
<div class="el-form-item">
  <!-- label-wrap 组件 -->
  <label-wrap>
    <label
      ...
      :style="labelStyle"
    >
      <slot name="label">{{label}}</slot>
    </label>
  </label-wrap>
  <!-- 表单项控件 -->
  <div ... :style="contentStyle">
    <slot></slot>
  </div>
</div>

2.3.2 label-width 设置为 auto

label-width 被设置为 auto 时,表示根据 label 的文字宽度自适应,将 lable 中的文字保持在一行上面,根据最大的宽度作为 lable 的宽度。

实现步骤:

  1. 先去计算每一个 Form-Item 设置为 auto 时需要占用的宽度。
  2. 再去将每一个 Form-Item 占用的宽度在 Form 组件中记录下来,获取其中的最大值作为整个表单的 label 宽度。
  3. 用计算出的 label 的最大宽度,去给包裹控件的 div 赋值 margin-left,从而预留出 label 的宽度。
  4. 如果 label-position 设置为 right,则需要用最大的 label 宽度减去该项 label 本身占的宽度,作为 labelmargin-left,将 label 推向 right 方向。
  5. 处理销毁逻辑,如果 Form-Item 组件被销毁,相应的 label-wrap 组件也会被销毁,此时需要重新计算宽度。

相关代码:

第一步:先去计算每一个 Form-Item 设置为 auto 时需要占用的宽度

(1)label-wrap 组件传入属性 is-auto-width 和属性 update-all,用于判断设置的 label-width 是否设置为 auto

Form-Item 组件:

html 复制代码
<!-- Form-Item 组件 -->
<label-wrap
  :is-auto-width="labelStyle && labelStyle.width === 'auto'"
  :update-all="form.labelWidth === 'auto'"
>
  ...
</label-wrap>

label-wrap 组件:

js 复制代码
// label-wrap 组件
props: {
  isAutoWidth: Boolean,
  updateAll: Boolean
},

(2)label-wrap 组件内部计算设置为 auto 所需要的宽度

给元素增加 div 包裹:

js 复制代码
// label-wrap 组件
render() {
  const slots = this.$slots.default;
  if (!slots) return null;
  if (this.isAutoWidth) {
    // 如果 width 是 auto,则用一个 div 包裹,用于计算这个包裹元素的宽度
    return (<div class="el-form-item__label-wrap">
      { slots }
    </div>);
  } else {
    return slots[0];
  }
},

计算 label 的宽度:

js 复制代码
// label-wrap 组件
methods: {
  // 获取元素的宽度
  getLabelWidth() {
    if (this.$el && this.$el.firstElementChild) {
      // 使用 window.getComputedStyle 获取元素的宽度
      const computedWidth = window.getComputedStyle(this.$el.firstElementChild).width;
      // 使用 Math.ceil 将获取到的宽度向上取整
      return Math.ceil(parseFloat(computedWidth));
    } else {
      return 0;
    }
  },
  // 更新元素的宽度
  updateLabelWidth(action = 'update') {
    // 如果宽度设置的是 auto,并且获取到了组件根元素的第一个子元素
    if (this.$slots.default && this.isAutoWidth && this.$el.firstElementChild) {
      // action 是 update 去获取 label 的宽度
      if (action === 'update') {
        this.computedWidth = this.getLabelWidth();
      }
    }
  }
},
data() {
  return {
    computedWidth: 0 // label 的实时宽度计算
  };
},
mounted() {
  // 在 mounted 钩子里面获取 label 的宽度
  this.updateLabelWidth('update');
},

updated() {
  // 在组件发生更新之后的 updated 钩子里面获取 label 的宽度
  this.updateLabelWidth('update');
},

(3)计算后触发 Form-Item 组件的控件外包裹 divmargin-left 的更新

Form-Item 组件使用 provide 提供数据:

js 复制代码
// Form-Item 组件
provide() {
  return {
    elFormItem: this
  };
},

label-wrap 组件中使用 inject 接收数据,并且监听 computedWidth 的变化,如果变化了触发 Form-Item 组件的方法,去重新给控件包裹 div 赋值 margin-left

label-wrap 组件,监听 computedWidth 的变化,如果发生了变化就去触发 Form-Itemlabel 宽度的更新:

js 复制代码
// label-wrap 组件
inject: ['elFormItem'],

watch: {
  computedWidth(val) {
    if (this.updateAll) {
      this.elFormItem.updateComputedLabelWidth(val);
    }
  }
},

Form-Item 组件,触发 Form-Item 组件的方法,去重新给控件包裹 div 赋值 margin-left

js 复制代码
// Form-Item 组件
data() {
  return {
    computedLabelWidth: '' // 计算的 label 宽度
  }
},
methods: {
  // 更新 label 为 auto 时,计算出来的 label 宽度
  updateComputedLabelWidth(width) {
    // 调用 Form-Item 组件的 updateComputedLabelWidth 去更新当前 label 的宽度
    this.computedLabelWidth = width ? `${width}px` : '';
  },
},
computed: {
  contentStyle() {
    const ret = {};
    const label = this.label;
    // 如果 labelPosition 设置为 top,或者是行内表单,则直接返回
    if (this.form.labelPosition === 'top' || this.form.inline) return ret;
    // 如果不存在 label 属性,也不存在 label-width 属性,则直接返回
    if (!label && !this.labelWidth) return ret;
    const labelWidth = this.labelWidth || this.form.labelWidth;
    // 如果设置的宽度包含 auto
    if (labelWidth === 'auto') {
      if (this.labelWidth === 'auto') {
        // 如果是表单项设置的 auto 宽度,则将 margin-left 设置为计算的表单项的 label 宽度
        ret.marginLeft = this.computedLabelWidth;
      }
    } else {
      // 如果设置的宽度均为固定宽度,则把右侧控件的 margin-left 设置为 labelWidth 的值
      ret.marginLeft = labelWidth;
    }
    return ret;
  },
}

第二步:再去将每一个 Form-Item 占用的宽度在 Form 组件中记录下来,获取其中的最大值作为整个表单的 label 宽度

(1)在 label-wrap 组件中的监听 computedWidth 变化的方法中,调用 Form 组件的方法,用于记录 label 的宽度。

Form 组件增加 provide

js 复制代码
provide() {
  return {
    elForm: this
  };
},

label-wrap 增加调用 Form 组件的方法:

js 复制代码
// label-wrap 组件
inject: ['elForm' ...],

watch: {
  computedWidth(val, oldVal) {
    if (this.updateAll) {
      // 调用 Form 组件的 registerLabelWidth 去记录每一个 label 的宽度
      this.elForm.registerLabelWidth(val, oldVal);
      ...
    }
  }
},

(2)Form 组件设置数组 potentialLabelWidthArr,用于记录所有 label 的宽度,并在其中找到最大值。

Form 组件:

js 复制代码
// Form 组件
data() {
  return {
    ...
    potentialLabelWidthArr: [], // 设置 potentialLabelWidthArr 用于存储所有计算的 label 的 width
  }
},
computed: {
  autoLabelWidth() {
    if (!this.potentialLabelWidthArr.length) return 0;
    // 找到 potentialLabelWidthArr 数组中的最大值
    const max = Math.max(...this.potentialLabelWidthArr);
    return max ? `${max}px` : '';
  }
},
methods: {
  ...
  // 找到对应 label-width 的索引
  getLabelWidthIndex(width) {
    const index = this.potentialLabelWidthArr.indexOf(width);
    if (index === -1) {
      throw new Error('[ElementForm]unpected width ', width);
    }
    return index;
  },
  // 在数组 potentialLabelWidthArr 中存储 label 的 width
  registerLabelWidth(val, oldVal) {
    if (val && oldVal) {
      // 更新宽度,找到旧宽度的索引,将新宽度更新上去
      const index = this.getLabelWidthIndex(oldVal);
      this.potentialLabelWidthArr.splice(index, 1, val);
    } else if (val) {
      // 新增宽度,直接 push 到 potentialLabelWidthArr 数组中
      this.potentialLabelWidthArr.push(val);
    }
  },
}

第三步:用计算出的 label 的最大宽度,去给包裹控件的 div 赋值 margin-left,从而预留出 label 的宽度

js 复制代码
// Form-Item 组件
computed: {
  contentStyle() {
    const ret = {};
    const label = this.label;
    // 如果 labelPosition 设置为 top,或者是行内表单,则直接返回
    if (this.form.labelPosition === 'top' || this.form.inline) return ret;
    // 如果不存在 label 属性,也不存在 label-width 属性,则直接返回
    if (!label && !this.labelWidth) return ret;
    const labelWidth = this.labelWidth || this.form.labelWidth;
    // 如果设置的宽度包含 auto
    if (labelWidth === 'auto') {
      if (this.labelWidth === 'auto') {
        // 如果是表单项设置的 auto 宽度,则将 margin-left 设置为计算的表单项的 label 宽度
        ret.marginLeft = this.computedLabelWidth;
      } else if (this.form.labelWidth === 'auto') {
        // 如果 Form 组件的 label-width 是 auto,则将计算的所有 Form-Item 的最大值赋值给 margin-left
        ret.marginLeft = this.elForm.autoLabelWidth;
      }
    } else {
      // 如果设置的宽度均为固定宽度,则把右侧控件的 margin-left 设置为 labelWidth 的值
      ret.marginLeft = labelWidth;
    }
    return ret;
  },
  ...

第四步:如果 label-position 设置为 right,则需要用最大的 label 宽度减去该项 label 本身占的宽度,作为 label 的 margin-left,将 label 推向 right 方向

label-wrap 组件中,计算其 margin-left 的值:

js 复制代码
render() {
  const slots = this.$slots.default;
  if (!slots) return null;
  if (this.isAutoWidth) {
    // 获取 Form 组件的 autoLabelWidth
    const autoLabelWidth = this.elForm.autoLabelWidth;
    const style = {};
    // 如果 form 组件计算出了 autoLabelWidth,并且其 label-position 设置的不是 left,则计算其 margin-left 用于右对齐
    if (autoLabelWidth && this.elForm.labelPosition !== 'left') {
      // 用 Form 组件计算出的最大的 label 宽度,减去该项 label 本身占的宽度,取整后则为 label 的 margin-left
      const marginLeft = parseInt(autoLabelWidth, 10) - this.computedWidth;
      if (marginLeft) {
        style.marginLeft = marginLeft + 'px';
      }
    }
    // 如果 width 是 auto,则用一个 div 包裹,用于计算这个包裹元素的宽度
    return (<div class="el-form-item__label-wrap" style={style}>
      { slots }
    </div>);
  } else {
    return slots[0];
  }
},

这里与源代码有所不同,源代码在设置了 label-widthauto 时,没有 this.elForm.labelPosition !== 'left' 的判断,导致如果设置了 label-position="left",也不会生效,在这里修改了一下,增加了 this.elForm.labelPosition !== 'left' 的判断。

第五步:处理 label-wrap 组件销毁逻辑,在 label-wrap 组件销毁时,清除 potentialLabelWidthArr 数组中对应的值

label-wrap 组件,在 beforeDestroy 钩子中,触发 Form 组件的删除 potentialLabelWidthArr 数组中该 label 宽度的方法:

js 复制代码
// label-wrap 组件
beforeDestroy() {
  // 在组件触发 beforeDestroy 钩子时,去将宽度删除
  this.updateLabelWidth('remove');
},
methods: {
  // 更新元素的宽度
  updateLabelWidth(action = 'update') {
    // 如果宽度设置的是 auto,并且获取到了组件根元素的第一个子元素
    if (this.$slots.default && this.isAutoWidth && this.$el.firstElementChild) {
      // action 是 update 去获取 label 的宽度
      if (action === 'update') {
        ...
      } else if (action === 'remove') {
        // update 为 remove 时,从数组中删除该 label 的宽度
        this.elForm.deregisterLabelWidth(this.computedWidth);
      }
    }
  }
}

Form 组件,从数组中删除该 label 的宽度:

js 复制代码
// Form 组件
methods: {
  // 从 potentialLabelWidthArr 数组中删除对应宽度
  deregisterLabelWidth(val) {
    const index = this.getLabelWidthIndex(val);
    this.potentialLabelWidthArr.splice(index, 1);
  }
}

2.4 表单域的验证功能

表单域的验证是引入的 async-validator 进行校验。

实现步骤:

  1. 需要先安装并引入 async-validator
  2. 实现 Form-Item 组件(单个表单项)的校验。
  3. 通过循环全部字段,依次调用 Form-Item 组件的校验方法,来实现整体 Form 组件的校验。
  4. 增加清空校验的方法 clearValidate
  5. 在重置方法 resetField 中增加关于验证的处理。

具体实现:

第一步:需要先安装并引入 async-validator

执行 npm install async-validator 去安装。

Form-Item 组件中引入 async-validator

js 复制代码
import AsyncValidator from 'async-validator';

第二步:实现 Form-Item 组件(单个表单项)的校验

接收 rules 属性,采用 async-validator 进行验证。

(1)接收 rules 属性;提供 getRules 方法,整理传入的规则;并且提供 getFilteredRule 方法,根据校验触发的方式(如:blurchange 等)来去筛选对应的规则。

js 复制代码
// Form-Item 组件
props: {
  rules: [Object, Array],
},
methods: {
  // 将规则整理成数组形式
  getRules() {
    // 获取 Form 组件传入的 rules
    let formRules = this.form.rules;
    // 获取 Form-Item 组件本身的 rules
    const selfRules = this.rules;
    // 判断是否传入了 required,如果传入了 required 将其转换为布尔值
    const requiredRule = this.required !== undefined ? { required: !!this.required } : [];
    // 获取到表单项对应 prop 的规则的值
    const prop = getPropByPath(formRules, this.prop || '');
    formRules = formRules ? (prop.o[this.prop || ''] || prop.v) : [];
    // 将规则整理成数组形式(增加 required 传参的影响)
    return [].concat(selfRules || formRules || []).concat(requiredRule);
  },
  // 根据 trigger 去筛选对应的规则
  getFilteredRule(trigger) {
    const rules = this.getRules();
    // 循环所有规则
    return rules.filter(rule => {
      if (!rule.trigger || trigger === '') return true;
      // 筛选包含传入的 trigger 的规则
      if (Array.isArray(rule.trigger)) {
        return rule.trigger.indexOf(trigger) > -1;
      } else {
        return rule.trigger === trigger;
      }
    }).map(rule => Object.assign({}, rule));
  },
}

(2)提供记录校验情况的变量

js 复制代码
// Form-Item 组件
data() {
  return {
    validateState: '', // 校验的状态(success or error)
    validateMessage: '', // 校验失败的提示信息
    validateDisabled: false, // 是否禁止校验
  }
},

(3)提供 validate 方法来实现校验

js 复制代码
// Form-Item 组件的校验方法
validate(trigger, callback = () => {}) {
  // 将 validateDisabled 设置为 false,表示可以验证
  this.validateDisabled = false;
  // 使用 getFilteredRule 获取验证规则,根据 trigger 来筛选
  const rules = this.getFilteredRule(trigger);
  // 如果没有验证规则,并且也没有传入 required 属性,则直接返回 true
  if ((!rules || rules.length === 0) && this.required === undefined) {
    callback();
    return true;
  }
  // 将验证状态设置为 validating,表示验证中
  this.validateState = 'validating';

  // 整理传入校验器的数据的格式
  const descriptor = {};
  // 将 rules 规则中的 trigger 删除,避免传入多余的字段
  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;
  // 使用 validate 方法:model 是校验的数据;firstFields 表示指定的第一个校验规则生成错误时调用回调;
  validator.validate(model, { firstFields: true }, (errors, invalidFields) => {
    // 将校验状态存储到 validateState 变量中
    this.validateState = !errors ? 'success' : 'error';
    // 将错误信息存储到 validateMessage 变量中
    this.validateMessage = errors ? errors[0].message : '';
    // 回调函数:第一个参数是校验的错误信息,第二个参数是错误的字段及未通过的规则
    callback(this.validateMessage, invalidFields);
    // 让 Form 组件可以监听到 validate 事件
    this.elForm && this.elForm.$emit('validate', this.prop, !errors, this.validateMessage || null);
  });
},

第三步:通过循环全部字段,依次调用 Form-Item 组件的校验方法,来实现整体 Form 组件的校验

Form 组件的校验需要增加两个方法:

  • validate 方法:用于校验整个表单。
  • validateField 方法:用于校验部分传入的字段。

(1)validate 方法

js 复制代码
// Form 组件校验整个表单的 validate 方法
validate(callback) {
  // 如果没有传 model,返回一个警告信息
  if (!this.model) {
    console.warn('[Element Warn][Form]验证方法需要 model 参数');
    return;
  }

  let promise;
  // 如果没有 callback,则返回 promise
  if (typeof callback !== 'function' && window.Promise) {
    promise = new window.Promise((resolve, reject) => {
      callback = function(valid, invalidFields) {
        // 如果 valid 为 true,则 resolve,否则 reject(用户既可以传入callback,也可以用 .then、.catch)
        valid ? resolve(valid) : reject(invalidFields);
      };
    });
  }

  let valid = true; // 校验状态,初始值为true
  let count = 0; // 校验次数,初始值为0
  // 如果 fields 为空,则立即返回 true
  if (this.fields.length === 0 && callback) {
    callback(true);
  }
  let invalidFields = {}; // 存入错误的字段
  this.fields.forEach(field => {
    field.validate('', (message, field) => {
      if (message) {
        valid = false;
      }
      invalidFields = Object.assign({}, invalidFields, field);
      // 如果 callback 是 function,并且全部字段都校验了
      if (typeof callback === 'function' && ++count === this.fields.length) {
        // 则把校验状态和错误的字段返回
        callback(valid, invalidFields);
      }
    });
  });
  // 如果用户没传入 callback,则返回 promise
  if (promise) {
    return promise;
  }
}, 

(2)validateField 方法

js 复制代码
// Form 组件验证传入的部分字段的 validateField 方法
validateField(props, cb) {
  props = [].concat(props);
  // 从全部字段中筛选传入的字段
  const fields = this.fields.filter(field => props.indexOf(field.prop) !== -1);
  // 如果没有传入字段则抛出警告
  if (!fields.length) {
    console.warn('[Element Warn]需要传入字段!');
    return;
  }
  // 依次调用表单项的 validate 方法
  fields.forEach(field => {
    field.validate('', cb);
  });
},

第四步:增加清空校验的方法 clearValidate

首先,Form-Item 组件需要增加 clearValidate 方法,来清空当前表单项的校验。

js 复制代码
// Form-Item 组件的清空校验
clearValidate() {
  // 将校验状态和错误信息置为空,将是否禁用校验置为 false
  this.validateState = '';
  this.validateMessage = '';
  this.validateDisabled = false;
},

Form 组件的 clearValidate 方法为根据字段去循环调用 Form-Item 组件的 clearValidate 方法,如果没有传入字段,则清空整个表单的校验。

js 复制代码
// Form 组件的清除表单校验
clearValidate(props = []) {
  // 筛选传入的字段:1. 如果没有传入的字段,则校验全部字段 2. 如果传入的字段为单个字符串,则整理为数组形式
  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;
  // 依次调用每一个表单项的 clearValidate 方法
  fields.forEach(field => {
    field.clearValidate();
  });
},

第五步:在重置方法 resetField 中增加关于验证的处理

Form-Item 组件在 resetField 方法中增加将验证状态和验证信息重置的处理,并且在重置表单的值之前先去禁用表单的验证,防止触发了 change 方法的验证。

js 复制代码
// Form-Item 组件在 resetField 方法中增加验证的处理
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(/:/, '.');
  }
  // 通过 getPropByPath 方法获取到表单项属性所在对象、属性的 key、以及属性的值
  let prop = getPropByPath(model, path, true);
  // 先禁用验证,因为之后需要重置表单项,触发 change 方法
  this.validateDisabled = true;
  // 将属性的值重置为初始值
  if (Array.isArray(value)) {
    prop.o[prop.k] = [].concat(this.initialValue);
  } else {
    prop.o[prop.k] = this.initialValue;
  }
  // 重置字段后,再将是否禁用验证重置为 false,表示目前可以验证
  this.$nextTick(() => {
    this.validateDisabled = false;
  });
},

三. 总结

此篇将 Element-uiForm 组件提供的功能进行了拆分,分析了一下 Form 组件的源代码中的几个重点功能,包含:表单域的展示,表单域的重置功能,表单域前缀的宽度计算,表单域的验证这几个功能。在看源代码的过程中,可以先根据 Form 提供的属性和方法,将 Form 提供的功能进行分类,这样通过 Form 提供的功能层层递进的去看源代码。

并且,在看源代码的过程中,发现了如果 label-width 设置为 auto,那么 label-position 设置为 left 也不会生效,这里还对源代码进行了修改。

在看稍微复杂一些的组件的源代码时,可以先根据功能点进行拆分,然后边看边一步一步的去实现组件的各个功能。

相关推荐
好_快几秒前
Lodash源码阅读-slice
前端·javascript·源码阅读
好_快几秒前
Lodash源码阅读-baseSlice
前端·javascript·源码阅读
好_快2 分钟前
Lodash源码阅读-isIterateeCall
前端·javascript·源码阅读
子洋5 分钟前
AI 开发者必备:Vercel AI SDK 轻松搞定多厂商 AI 调用
前端·人工智能·后端
好_快10 分钟前
Lodash源码阅读-eq
前端·javascript·源码阅读
kill bert4 小时前
第27周JavaSpringboot电商进阶开发 1.企业级用户验证
java·前端·数据库
answerball7 小时前
🔥 Vue3响应式源码深度解剖:从Proxy魔法到依赖收集,手把手教你造轮子!🚀
前端·响应式设计·响应式编程
Slow菜鸟8 小时前
ES5 vs ES6:JavaScript 演进之路
前端·javascript·es6
小冯的编程学习之路8 小时前
【前端基础】:HTML
前端·css·前端框架·html·postman
Jiaberrr9 小时前
Vue 3 中搭建菜单权限配置界面的详细指南
前端·javascript·vue.js·elementui