通过一个vue指令完成组件必填参数校验

432 阅读3分钟

本文case使用的技术栈: vue + ant-design-vue(elementui实现可借鉴本demo)

指令提供的能力:

  • 校验props required属性,如果存在未传入的props的,界面提示出报错
  • 业务组件集成表单校验能力,校验失败时限止表单提交

为什么要写这样一个指令?

  1. 普通vue项目,在没有接入ts之前,无法实现类型约束,虽然较新版本的vue2中指定某个props required: true时,如果该props未传入,会在控制台显示一个warning告警,不够直观,希望可以直接在界面上报红提示,展示更醒目
  2. 表单校验能力一般集成在form/formItem的rules属性上,某些情况下,我们需要对业务组件进行封装复用,且需要提供基本的表单难能力,比如input输入框校验:手机号、邮箱、url等基础能力,这种时候无法直接在业务组件上触发表单验证,表单校验失败后也无法阻止到submit事件,希望能有一种可以直接在业务组件触发表单验证的能力

原ant表单校验流程梳理

image.png

通过流程梳理发现,form组件会在formitemmounted阶段会调用form.addField(this),统一收集了各个formitem.validate,在form.submit阶段,依次触发各formItem.validate方法进行校验。因此业务组件也可以借鉴该思路,mounted时调用addField(this),然后实现一个validate方法实现具体的校验逻辑。

为什么选择自定义指令:

  1. 满足共用,逻辑抽离复用,将props必填性校验统一在此处理
  2. 可以获取的相应dom元素,方便在校验失败时,获取的dom,并将报错提示塞到该dom的子元素OR兄弟元素
  3. 自定义指令的inserted勾子,在webpack热更新时,会自动触发一遍,在这里校验可以最大程度满足功能,节省性能

终极版代码实现

import Vue from "vue";
import { hasOwn } from "./utils";
/**
 * 作者: xiaotong
 * 注意事项:
 * 1. props定义必须是对象形式: 比如 { max: { required: true }},这种写法在较高版本vue(vue2),也能在控制台报一个warning提示
 * 2. 如果想修改默认提示,则传入一个map对象: v-check-props="{ errorMap: { min: 'min不能为空' } }"
 * 3. 需要校验表单时,传入一个onValidate方法, 校验时会自动调用该方法 v-check-props="{ onValidate: () => '' }"
 * 4. 业务组件需要注入 inject: ['FormContext'],表样校验时需要用到,不加这个将无法阻止表单提交
 *
 * 调用该指令后会做如下事情:
 * 1. 自动注册表单校验能力,会在原组件基础上新增一个,valiate方法,如组件在change时可以调用该方法进行校验
 * 2. 报错信息将会注入到组件的validateMessage字段中
 * 3. 校验props的requird属性,是否存在未传入的props
 */

Vue.directive("check-props", {
  inserted: (el, binding, vnode) => {
    const { errorMap } = binding.value || {};
    const errTipsMap = errorMap || {};

    el.style.position = "relative";

    const originComp = vnode.context;
    if (originComp) {
      console.log("originComp", originComp);
      // 注册rules
      registerFormRules(el, binding, originComp);
      const errTips = checkProps(originComp, errTipsMap);

      if (errTips.length) {
        // 创建错误提示tip
        createErrorTips(el, errTips, {
          className: "el-props-check-error",
          style: `position: absolute;top: ${
            el.offsetHeight || 30
          }px; width: 100%; padding: 0 10px;line-height: normal; color: red;background: rgba(0,0,0,.75);z-index: 1111;`,
        });
      }
    }
  },
  unbind(el, binding, vnode) {
    // 组件卸载时,解绑表单校验
    const { FormContext } = vnode.context;
    if (FormContext) {
      const removeField = FormContext.removeField;
      removeField && removeField(vnode.context);
    }
  },
  componentUpdated(el, binding, vnode) {
    const component = vnode.context;
    const { FormContext } = component;
    if (!FormContext) { // 如果脱离了form表单元素,则手动触发
      component.validate();
    }
  },
});

// 创建报错提示
const createErrorTips = (el, errTips, options = {}) => {
  const { className, style } = options;
  let dom = el.querySelector(`.${className}`);
  if (!dom) {
    dom = document.createElement("div");
    dom.classList.add(className);
    const isInput = el.tagName.toLowerCase() === "input";
    if (isInput) {
      el.after(dom);
    } else {
      el.appendChild(dom);
    }
  }

  Object.assign(dom, {
    style,
  });

  dom.innerHTML = errTips.map((val) => `<div>${val}</div>`).join("");
};

function noop() {}

// 校验props的必填性
const checkProps = (component, tipsMap) => {
  const errTips = [];
  const { $options } = component || {};
  const definedProps = $options.props; // 组件定义需要传入的props
  const passProps = $options.propsData; // 实际传入的props

  Object.entries(definedProps).forEach((prop) => {
    const [propKey] = prop;
    // 定义了但未传入的必填props, required: false的允许通过
    if (
      hasOwn(definedProps, propKey) &&
      !hasOwn(passProps, propKey) &&
      definedProps[propKey].required
    ) {
      console.log("propKey error");
      errTips.push(tipsMap[propKey] || `缺少【${propKey}】参数`);
    }
  });
  return errTips;
};

// 注入表单校验,调用组件的onValidate方法,返回值不为空,则表示校验失败
const registerFormRules = (el, binding, component) => {
  const { value } = binding;
  const { onValidate } = value || {};

  const { FormContext } = component;
  if (FormContext) {
    const addField = component.FormContext.addField;
    addField && addField(component);
  }
  component.validate = function (trigger) {
    const callback =
      arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : noop;

    let message = "";
    if (onValidate) {
      message = onValidate();
    }
    createErrorTips(
      el.parentElement || el,
      [message].filter((v) => v),
      {
        className: "cus-form-explain",
      }
    );
    component.validateMessage = message;
    callback(message, { trigger, message });
  };
};

如何使用?

  • main.js引入该指令文件
  • 在需要校验的组件上开启指令:v-check-props
  • 组件注入inject: ['FormContext'],表单校验时需要用到该能力

case1:

<template>
  <a-input-number
    ref="inputNumberRef"
    v-model="inputVal"
    v-bind="$attrs"
    v-on="$listeners"
    v-check-props
  />
</template>

<script>
import { isNotEmpty, hasOwn } from "../utils";

export default {
  name: "InputNumber",
  inject: ['FormContext'],
  props: {
    value: {
      required: true,
    },
    max: {
      required: true,
    },
    min: {
      required: true,
    },
    precision: {
      // 允许输入的小数点位数
      required: true,
      type: Number,
    },
  },
  data() {
    return {
      inputVal: this.value
    };
  },
  watch: {
    inputVal(val) {
      this.emit(val);
    },
    value(val) {
      this.inputVal = val;
    },
  },
  methods: {
    emit(value) {
      this.$emit("change", value);
      this.$emit("input", value);
    },
  },
};
</script>

<style></style>

报错示例,未传入max参数时

image.png

case2: 对input组件进行二次封装,并提供type属性,完成对email,url, phone等类型的校验

config.js

export const regMap = {
  phone: /^((13[0-9])|(14[5,7])|(15[0-3,5-9])|(17[0,3,5-9])|(18[0-9])|166|198|199|191|(147))\\d{8}$/,
  email:
    /^(([^<>()\\[\]\\.,;:\s@"]+(\.[^<>()\\[\\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
  url: new RegExp(
    "^(?!mailto:)(?:(?:http|https|ftp)://|//)(?:\\S+(?::\\S*)?@)?(?:(?:(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}(?:\\.(?:[0-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))|(?:(?:[a-z\\u00a1-\\uffff0-9]+-*)*[a-z\\u00a1-\\uffff0-9]+)(?:\\.(?:[a-z\\u00a1-\\uffff0-9]+-*)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))|localhost)(?::\\d{2,5})?(?:(/|\\?|#)[^\\s]*)?$",
    "i"
  ),
  hex: /^#([a-f0-9]{6}|[a-f0-9]{3})$/i,
};

export const errRegMap = {
  phone: "手机号不合法",
  email: "邮箱不合法",
  url: "url不合法",
  hex: "颜色值不合法,例如:#000",
};

input组件

<template>
  <a-input
    ref="inputRef"
    :class="{
      'has-error': validateMessage,
    }"
    v-model="inputVal"
    v-bind="$attrs"
    v-on="$listeners"
    v-check-props="{ onValidate }"
  />
</template>

<script>
import { regMap, errRegMap } from "./config";
export default {
  name: "CommonInput",
  inject: ["FormContext"],
  props: {
    value: {
      required: true,
    },
    type: {
      required: true,
      type: String, // text | email | url | phone | hex(颜色值校验)
      default: "",
    },
  },
  data() {
    return {
      inputVal: this.value,
      validateMessage: '',
    };
  },
  watch: {
    inputVal(val) {
      // 注入v-check-props指令后,会自动往该组件注入validate方法,如果不想在change时触发,这里可不调用
      this.validate("change");
      this.$emit("input", val);
      this.$emit("change", val);
    },
    value(val) {
      this.inputVal = val;
    },
  },
  methods: {
    onValidate() {
      // 在这里实现具体的验证逻辑,返回值不为空时,表示验证失败,报错内容会更新在validateMessage属性上
      const { type, inputVal } = this;
      const reg = regMap[type];
      const message = reg ? (reg.test(inputVal) ? "" : errRegMap[type]) : "";
      return message;
    },
    emit(value) {
      this.$emit("change", value);
      this.$emit("input", value);
    },
  },
};
</script>

<style scoped>
.has-error {
  border-color: #f5222d;
}
.cus-form-explain {
  line-height: normal;
  color: #f5222d;
}
.has-error.ant-input:focus {
  border-color: #ff4d4f;
  border-right-width: 1px !important;
  outline: 0;
  box-shadow: 0 0 0 2px rgb(245 34 45 / 20%);
}
</style>

case2报错示例,此时点击submit是会无法提交的

image.png