随着项目越来越大,最近遇到了一个问题。需求需要修改账户,密码和电话号码的验证规则。 现在前端总共维护7个项目,5个pc项目和2微信页面。表单项涉及很多,并且验证规则分配在各个页面,导致修改验证规则,需要到每个页面中寻找修改,这样不仅耗时长并且容易出现漏改的情况。所以就在考虑如何基于现有的框架(element)把验证规则抽象到一个文件里面。

面临的问题

  • 将验证规则和过滤规则抽象出来
  • 不能影响现有规则,因为项目太多,不可能一次性把所有的验证都抽象出来,所以需要设计一个模式,可以每次抽出一部分,但是有不影响之前的验证。
  • 最好不要加入太多的上限文,导致开发者疑惑,最好是对于使用者是透明的。
  • 使用方式最好简单,使用者不需要了解太多。
  • 考虑到规则中有限制输入长度的,所以解决的方式中应该包含自动设置input的maxlength才行。

解决方式

使用vue的函数组件,重新封装el-form-item组件,并且将规则注入,并且在入口文件重新以el-form-item的名称进行注册。这样既能保证原先的代码可正常运行,又能将规则注入集中。

开发中遇到的问题

  • vue的函数组件无法传入具名slot。之前用.vue封装透明组件的时候,需要为了透明传入slot,需要自己用具名template slot重新包裹。这样slot少的情况下还行,多的情况下,代码就显得不美观了。本来以为使用vue的函数组件能够解决这个问题。但是通过ctx.chilren传入的只是slots.default的内容,根本不能传入具名slot内容。但是可以通过vue作者的一个helper来解决
function mapSlots (h, slots = {}) {
        return Object.keys(slots).map(name => {
          return h('template', { slot: name }, slots[name])
        })
      }
复制代码
  • vue的函数组件的渲染时间很快。甚至不能通过ctx.parent来获取到正确的父节点,只能获取到当前单文件组件的实例。
  • 由于在BaseFormItem中操作了原生dom设置了maxlength和placeholder等属性。在element-ui中2.0.11版本中会出现,触发验证placeholder失效和maxlength失效的问题。但是在最新的element-ui中没有这种问题,探究原因是之前的版本的el-input组件,采用v-bind="$props"来透传属性。而最新的版本通过v-bind="$attrs"来透传属性。
  • element-ui中的v-model触发在2.0.11中没有对键盘输入中文的情况做处理。导致每次输入文字都会触发input而不是在中文输入完成并且选择之后触发,这样会导致触发验证的时机不正确,出现意想不到到输入效果。在最新的element-ui版本中已经修复了这个问题。
  • 还有由于项目的问题,轻易不能升级element-ui的版本,心累呀,这些问题只能自己处理。

代码实现

基于element-ui el-form-item封装的base-form-item组件
.
├── bindRules.js   // 在input组件上直接绑定过滤规则和验证规则的指令
├── index.js       // 入口文件
├── install.js    // 注册函数
└── utils.js 

复制代码
index.js
import FormItem from 'element-ui/packages/form-item';
import { mix, handleMessageLabel, bindRuleAndFilter, debounce } from './utils'

const parseRuleName = (ruleName) => {
  return ruleName;
};

const rulesCensus = {};
const ids = {};

const pushId = (id) => {
  ids[id] = true;
};

const pushRules = (rulesName, label, id) => {
  let rules = rulesCensus[rulesName];
  if (!rules) {
    rulesCensus[rulesName] = {
      count: 1,
      label: label,
      name: rulesName,
      id: [id]
    }
  } else {
    rules.count++;
    rules.id.push(id)
  }
};

const print = debounce(function () {
  for (let k in rulesCensus) {
    if (rulesCensus.hasOwnProperty(k)) {
      const item = rulesCensus[k];
      console.log(`校验名称:${item.name}, 校验字段:${item.label}, 使用次数:${item.count}, 对应页面url->${item.id}}`)
    }
  }
}, 300);

export default (
  BuildInRules = {},
  mixProp = {},
  buildInFilter = {},
  debug = false
) => {
  debug && console.warn(`[components/baseFormItem]:debugger参数只应该用于在修改页面校验规则时,打印页面所用校验规则,方便查找规则进行修改`);
  return {
    functional: true,
    components: {
      FormItem
    },
    render (h, context) {
      // issue https://github.com/vuejs/vue/issues/7710
      function mapSlots (h, slots = {}) {
        return Object.keys(slots).map(name => {
          return h('template', { slot: name }, slots[name])
        })
      }
      const props = context.props || {};
      const ruleName = props.rules;
      const label = props.label;
      let rules = {};
      const ruleNameIsStr = typeof ruleName === 'string';
      if (ruleNameIsStr) {
        const name = parseRuleName(ruleName);
        rules = BuildInRules[name];
        rules && mix(rules, mixProp);
        if (!rules) {
          console.error(`[components/baseFormItem]:找不到对应的校验${ruleName}规则`);
          return h(FormItem, context.data, context.children);
        }
        rules = handleMessageLabel(rules, label);
        // 处理规则引用统计和debug打印,用于在后期修改规则的时候,更容易查看到页面引用的规则和规则名称
        if (debug) {
          const id = ruleName + label + window.location.href;
          if (!ids[id]) {
            pushRules(ruleName, label, window.location.href);
          }
          pushId(id);
          print();
          console.log(` 表单字段名称:${label} 该字段对应的校验规则名称:${ruleName}`);
        }
        context.data.attrs.rules = rules;
      }
      const child = (context.children || [])[0];
      const setChild = child && ruleNameIsStr;
      setChild && setTimeout(() => {
        bindRuleAndFilter(child, rules, buildInFilter)
      });

      return h(FormItem, context.data, mapSlots(h, context.slots()));
    }
  }
}
复制代码
utils.js
export const toArray = (v) => {
  if (!Array.isArray(v)) {
    return [v];
  }
  return v;
};

export const getValue = (rules, field = 'max', defaultVal = Infinity) => {
  const rulesArr = toArray(rules);
  return (rulesArr.find(i => i.hasOwnProperty(field)) || {})[field] || defaultVal;
};

export const replaceLabel = (target, label) => {
  return target.replace(/\$/g, label).replace(/[::]/g, '');
};

export const handleMessageLabel = (rules, label = '') => {
  const targetArr = toArray(rules);
  return targetArr.map(i => {
    const cloneRules = Object.assign({}, i);
    ['placeholder', 'message'].forEach(filed => {
      if (cloneRules.hasOwnProperty(filed)) {
        cloneRules[filed] = replaceLabel(cloneRules[filed], label);
      }
    });
    // 给自定义的validator函数添加第四个位置的参数为当前要验证的label
    if (i.hasOwnProperty('validator')) {
      const validator = cloneRules.validator;
      cloneRules.validator = function () {
        const argv = [].slice.call(arguments);
        argv[3] = label;
        validator.apply(this, argv);
      };
    }
    return cloneRules;
  });
};

export const mix = (target, mixProp) => {
  const targetArr = toArray(target);
  targetArr.forEach(i => {
    Object.assign(i, mixProp);
  });
};

export const bindRuleAndFilter = (child, rules, buildInFilter, isAutoComplete, isDirective) => {
  let component = child.componentInstance;
  if (!component) {
    return;
  }
  let input = component.$refs.input;
  if (isAutoComplete) {
    component = input;
    input = component.$refs.input;
  }
  if (input && input.tagName === 'INPUT') {
    if (!input.getAttribute('maxlength')) {
      input.setAttribute('maxlength', getValue(rules, 'max', Infinity));
    }
    input.setAttribute('maxlength', getValue(rules, 'max', Infinity));
    if (!input.getAttribute('placeholder')) {
      input.setAttribute('placeholder', getValue(rules, 'placeholder', ''));
    }
    const filterName = getValue(rules, 'filterName', (v) => v);
    let filterFun = filterName;
    if (typeof filterName === 'string') {
      filterFun = buildInFilter[filterName];
      if (filterFun === undefined) {
        console.error(`[components/baseFormItem]:找不到对应的过滤${filterName}规则`);
        return;
      }
    }
    if (filterFun) {
      let isCompositions = false;
      if (!input.beforeListener) {
        // 处理中文输入问题
        input.addEventListener('compositionstart', (e) => {
          isCompositions = true;
        });
        input.addEventListener('input', (e) => {
          if (isCompositions) {
            return;
          }
          e.target.value = filterFun(e.target.value);
          component.handleInput(e);
        });
        input.addEventListener('compositionend', (e) => {
          isCompositions = false;
          e.target.value = filterFun(e.target.value);
          component.handleInput(e);
        });
        input.beforeListener = true;
      }
    }
  }
}

/* eslint-disable */
export const debounce = (func, delay) => {
  let timer = null;
  return function () {
    const argv = arguments;
    const ctx = this;
    if (timer) {
      clearInterval(timer);
    }
    timer = setTimeout(function () {
      func.call(ctx, ...argv)
    }, delay);
  }
}
复制代码
bindRules.js
import { bindRuleAndFilter, handleMessageLabel } from './utils';
// 支持在el-input组件上传入校验规则的指令,书写方式为v-rule={ label: '联系电话',rule: 'phoneCode' }
// 或者v-rule:联系电话="'phoneCode'"
export default (buildInRules, buildInFilters) => ({
  name: 'rule',
  update (el, bind, vnode) {
    let label, ruleName;
    let isAutoComplete = false;
    if (typeof bind.value === 'object') {
      const desc = bind.value;
      ruleName = desc.rule;
      label = desc.label;
      isAutoComplete = desc.isAutoComplete;
    } else {
      label = bind.arg;
      ruleName = bind.value;
    }
    if (!ruleName) return;
    let rules = buildInRules[ruleName];
    if (rules === undefined) {
      throw new TypeError(`[directive-rule]:找不到对应的${ruleName}规则`);
    }
    rules = handleMessageLabel(rules, label);
    bindRuleAndFilter(vnode, rules, buildInFilters, isAutoComplete, true)
  }
})
复制代码

使用方式

// FormItem对应于上面的install文件,rules和inputFilter对应于抽象出来的验证规则和过滤规则
Vue.use(FormItem(rules, { trigger: 'change, blur' }, inputFilter));
复制代码
rule定义
password: {
    // field为注入的label名称
    validator (rules, value, cb, field) {
      const reg = /\d/;
      if (!reg.test(value)) {
        return cb(new Error(`请输入正确格式的${field}`));
      }
      cb();
    },
    required: true,]
    // 其中$会被替换成el-form-item的label名称。这样写的目的是方便规则复用和定制
    placeholder: `$限制2到30个字符`,
    max: 50
  },
复制代码

filter定义

trim (v) {
    if (!v) return v;
    return v.replace(/\s/g, '');
  },
复制代码