本文case使用的技术栈: vue + ant-design-vue(elementui实现可借鉴本demo)
指令提供的能力:
- 校验props required属性,如果存在未传入的props的,界面提示出报错
- 业务组件集成表单校验能力,校验失败时限止表单提交
为什么要写这样一个指令?
- 普通vue项目,在没有接入ts之前,无法实现类型约束,虽然较新版本的vue2中指定某个props required: true时,如果该props未传入,会在控制台显示一个warning告警,不够直观,希望可以直接在界面上报红提示,展示更醒目
- 表单校验能力一般集成在form/formItem的rules属性上,某些情况下,我们需要对业务组件进行封装复用,且需要提供基本的表单难能力,比如input输入框校验:手机号、邮箱、url等基础能力,这种时候无法直接在业务组件上触发表单验证,表单校验失败后也无法阻止到submit事件,希望能有一种可以直接在业务组件触发表单验证的能力
原ant表单校验流程梳理
通过流程梳理发现,form组件会在formitemmounted阶段会调用form.addField(this),统一收集了各个formitem.validate,在form.submit阶段,依次触发各formItem.validate方法进行校验。因此业务组件也可以借鉴该思路,mounted时调用addField(this),然后实现一个validate方法实现具体的校验逻辑。
为什么选择自定义指令:
- 满足共用,逻辑抽离复用,将props必填性校验统一在此处理
- 可以获取的相应dom元素,方便在校验失败时,获取的dom,并将报错提示塞到该dom的子元素OR兄弟元素
- 自定义指令的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参数时
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是会无法提交的