yup 使用 2 - 获取默认值,循环依赖,超大数字验证,本地化
上一篇的使用在这里:yup 基础使用以及 jest 测试,这篇讲的是比较基础的东西,
获取默认值
之前用的都是 cast({})
,然后如果有些值是必须的,又没有提供默认值,yup 就会抛出异常。另一种可以直接获取默认值而不抛出异常的方式,可以使用内置的 getDefault()
:
const res = demoSchema.getDefault();
类型异常
如果使用 JavaScript 应该没什么问题,但是如果使用 TypeScript 的话,可能会抛出如下的异常:
这是因为导出的数据类型使用的是 Infer
,schema 中没有定义默认值,因此就会出现 undefined
和字符串不匹配的情况,这种解决的方式可以通过重写 default
来实现:
export let demoSchema = object({// ...enumField: string().required().default(() => undefined as undefined | string).oneOf(Object.keys(getTestEnum() || [])),
});
大多数情况下这应该不会有什么问题,只有在直接获取默认值并需要重新赋值的时候需要注意
循环依赖
这也是我们项目里存在的一个比较罕见的案例,就是需要同时检查 A 和 B
正常情况下,如果直接使用下面的实现,则会抛出异常:
export let demoSchema = object({// ...dependentField1: number().required().when("dependentField2", ([dependentField2], schema) => {return schema;}),dependentField2: number().required().when("dependentField1", ([dependentField1], schema) => {return schema;}),
});
造成这个错误的原因是,当 dependentField2
需要验证的时候,它需要去找 dependentField1
的值,而 dependentField1
又需要对 dependentField2
进行判断……
解决的方式可以使用 shape()
,并且将依赖作为 dependency array 放到第二个参数中:
export let demoSchema = object().shape({// ...dependentField1: number().default(0).required().when("dependentField2", ([dependentField2], schema) => {return schema;}),dependentField2: number().default(0).required().when("dependentField1", ([dependentField1], schema) => {return schema;}),},[["dependentField1", "dependentField2"]]
);
具体实现的验证为:
number().default(0).required().when("dependentField2", ([dependentField2], schema) => {return schema.test({name: "none-zero",test: (dependentField1) =>!(dependentField1 === 0 && dependentField2 === 0),message: "DependentField1 and DependentField2 cannot be both 0.",});});
这就会检查 dependentField1
和 dependentField2
是否同时为 0:
⚠️:在查文档的时候,我看到的目前 yup 支持是两两相对的,因此 dep array 中只能放 [[a, b], [b, c], [c, a]]
这样的实现,而不能使用 [[a, b, c]]
这样的实现
👀:我看到一些其他、实际的使用案例为地址,如一旦输入了地址,那么就需要验证城市、省/直辖区和邮政编码,如果没有输入地址,则不需要进行验证
数字最大值问题
这个实际上不是 yup 的问题,而是 JavaScript 的问题,如 JS 中有一个 MAX_SAFE_INTEGER
值,并且进行转换后,就会失去精确值:
而且对于 JS 本身来说,任何超过 MAX_SAFE_INTEGER
的操作,都会导致丢失精确值,而这里的挑战是:
- 一旦使用任何的数字,并且保存到 JavaScript 中,就像使用
parseFloat
这个案例,精确值直接就丢了 - 如果使用
number
,在调用 yup 的时候,yup 内部会使用类似parseFloat
的实现,因此也会导致精度丢失
因为后端暂时还是只接受 decimal/long,所以我们目前的解决方式是用 decimal.js,这个库的实现方式类似于 Java 的 big decimal,所以只要不转成浮点数,而是用字符串,就能够保持我们项目需要的精度
util 实现
主要是重写一些 Decimal
内部的比较方法,因为 Decimal
不接受 undefined
,所以不写 util 会导致报错
import Decimal from "decimal.js";export const compareTo = (a: Decimal.Value | undefined,b: Decimal.Value | undefined
): number => new Decimal(a ?? 0).comparedTo(b ?? 0);export const equalTo = (a: Decimal.Value | undefined,b: Decimal.Value | undefined
): boolean => new Decimal(a ?? 0).equals(b ?? 0);
更新 yup 验证
因为精确的关系,所以就需要把所有的数字转成字符串,并且手动重写验证,大体实现如下:
const MAX_DECIMAL = new Decimal(Number.MAX_SAFE_INTEGER).div(10 ** 6);export let demoSchema = object().shape({// ...dependentField1: string<string>().default(() => "0" as string).required().test({name: "max-num",test: (dependentField1) => {return compareTo(dependentField1, MAX_DECIMAL) <= 0;},message: `DependentField1 must be smaller than or equal to ${MAX_DECIMAL}.`,}),},[["dependentField1", "dependentField2"]]
);
最后的验证如下:
这里对象的值为:
const res = demoSchema.getDefault();
res.dependentField1 = "9007199254.740991";
res.dependentField2 = "9007199254.740992";demoSchema.validate(res, { abortEarly: false }).then((validatedRes) => console.log(validatedRes)).catch((e: ValidationError) => {e.inner.forEach((e) => {console.log(e.path, e.errors);});});
⚠️:max-num
的这个对象是可以改成一个函数,这样可以稍微减少一些代码:
export const maxNumTest = (fieldName: string,maxValue: number | Decimal.Value
) => ({name: "max-num",test: (value: any) => {return compareTo(value, maxValue) <= 0;},message: `${fieldName} must be smaller than or equal to ${maxValue}.`,
});
补充 - 精确值计算
这里主要就是 stack overflow 的解决方案:How can I deal with floating point number precision in JavaScript
大致运行是这样:
> var x = 0.1
> var y = 0.2
> var cf = 10
> x * y
0.020000000000000004
> (x * cf) * (y * cf) / (cf * cf)
0.02
里面提出的解决方式是:
var _cf = (function () {function _shift(x) {var parts = x.toString().split(".");return parts.length < 2 ? 1 : Math.pow(10, parts[1].length);}return function () {return Array.prototype.reduce.call(arguments,function (prev, next) {return prev === undefined || next === undefined? undefined: Math.max(prev, _shift(next));},-Infinity);};
})();Math.a = function () {var f = _cf.apply(null, arguments);if (f === undefined) return undefined;function cb(x, y, i, o) {return x + f * y;}return Array.prototype.reduce.call(arguments, cb, 0) / f;
};Math.s = function (l, r) {var f = _cf(l, r);return (l * f - r * f) / f;
};Math.m = function () {var f = _cf.apply(null, arguments);function cb(x, y, i, o) {return (x * f * (y * f)) / (f * f);}return Array.prototype.reduce.call(arguments, cb, 1);
};Math.d = function (l, r) {var f = _cf(l, r);return (l * f) / (r * f);
};
我们内部使用的也是这个方式去计算还原,目前对于还原到 MAX_SAFE_INTEGER
来说问题不大……
setLocale
这是一个本地可以解决一些报错信息的方式,目前我找到的是内嵌的方法,如 required
这种,大致实现方式如下:
// 写在了另一个 const 文件里
export const FIELD_NAME: Record<string, string> = {description: "Description",enumField: "Dropdown Enum",
};// 在 schema util 里的实现……或许放到 const 或者 i18 也行
setLocale({mixed: {required: ({ path }) => `${FIELD_NAME[path]} is a required field.`,oneOf: ({ path, values }) =>`${FIELD_NAME[path]} must have one of the following fields: ${values}.`,},string: {min: ({ path, min }) =>`${FIELD_NAME[path]} must be at least ${min} characters.`,max: ({ path, max }) =>`${FIELD_NAME[path]} must be at at most ${max} characters.`,},
});
这个 setLocale
只需要实现一次,所有的 schema 就会沿用这个设定,如:
做 i8 是个比较方便的设置
我目前还没有找到特别好的能够解决 test
和 when
里的报错信息,可能说最终只会写一些其他的函数用来解决这个问题吧