用户名查重有一个很常见的场景:用户输入 taken,请求还没回来,又改成了 available。第二次请求先完成,第一次请求稍后才返回。
这时候,页面应该展示哪一次结果?
我维护的纯 ArkTS 校验库 @hmkit/validator,之前已经通过版本检查,避免旧结果覆盖新输入的状态。但继续完善时,我发现还需要回答另一个问题:结果被拦住之后,旧任务本身怎么办?
如果它一直不返回,调用方会不会一直等?页面已经退出,远程规则有没有收到取消通知?新一轮提交还在进行,上一轮的 finally 会不会提前关掉 loading?
上一篇《不只校验手机号:我的鸿蒙开源校验库,开始处理更复杂的表单场景》介绍了 1.2 的递归局部更新、导入诊断和异步执行控制。这次的 1.3,我把重点放在表单控制器与异步规则之间的衔接,同时补充了输入保护、数组业务去重和 JSON Schema 导入能力。
本文新增能力对应 1.3.0,安装示例使用 1.3.1 维护版。1.3.1 保持相同公共 API,并增加 API 12/API 23 消费构建验证。
1. 防抖、旧结果保护和取消,各自解决什么问题?
这三个能力经常出现在同一个表单里,但负责的事情不同:
| 能力 | 解决的问题 |
|---|---|
| 防抖 | 连续输入时,减少实际启动的校验次数 |
| 版本检查 | 请求乱序返回时,旧结果不能改写新状态 |
| 取消与超时 | 旧任务失效或等待过久时,结束调用方等待,并通知规则清理资源 |
防抖只能减少尚未启动的工作。请求已经发出去之后,仍然需要处理它的结果和生命周期。
1.2 已经提供取消订阅和执行超时,1.3 进一步把它们接入 FormValidator 的字段校验:控制器为每次调用管理独立令牌,新校验替换旧校验时,旧调用会结束等待,规则也可以收到取消通知。
2. 让远程规则收到取消通知
先用一个定时器模拟用户名查重。它保留了真实请求适配中最重要的两件事:正常完成后退订,取消时释放资源。
typescript
import { liteV } from '@hmkit/validator/lite';
import { FormValidator, FormTrigger } from '@hmkit/validator/form';
import { ValidationOptions } from '@hmkit/validator/execution';
function checkUsername(
value: string,
options?: ValidationOptions
): Promise<boolean> {
return new Promise<boolean>((resolve): void => {
let unsubscribe: (() => void) | undefined;
const delay = value === 'taken' ? 800 : 60;
const timer = setTimeout((): void => {
unsubscribe?.();
resolve(value !== 'taken');
}, delay);
unsubscribe = options?.cancellation?.onCancel((): void => {
clearTimeout(timer);
resolve(false);
});
});
}
const usernameSchema = liteV.string()
.required('请输入用户名')
.min(3, '用户名至少 3 个字符')
.customAsync(checkUsername, '用户名已被占用');
const form = new FormValidator({ 'username': usernameSchema }, {
triggers: [FormTrigger.CHANGE, FormTrigger.BLUR],
debounceMs: 300,
timeoutMs: 3000,
fields: {
'username': { timeoutMs: 1500 }
}
});
字段配置会覆盖全局超时。这里的用户名校验最多等待 1500 毫秒;未配置字段级超时的其他字段,继承全局的 3000 毫秒。两处都不配置时,没有默认截止时间。
接着显式启动两次校验,观察替换行为。下面的函数可以从测试或页面事件中调用;显式 validateFieldAsync 不经过 onChange 的防抖等待。
typescript
async function demonstrateReplacement(): Promise<void> {
try {
const previous = form.validateFieldAsync('username', 'taken');
const current = form.validateFieldAsync('username', 'available');
await Promise.all([previous, current]);
console.info(`${form.getFieldState('username').error}`); // null
console.info(`${form.getFieldState('username').validating}`); // false
} finally {
form.dispose();
}
}
第二次校验启动后,第一次调用不会继续等待原先的 800 毫秒,模拟规则的定时器也会被清除。最后保留下来的,是 available 的校验状态。
取消回调中的 resolve(false) 用于结束这个模拟规则自己的 Promise。控制器通过取消令牌和版本检查处理过期调用,不会把它当成最新输入的"用户名已被占用"。
接真实网络时,要把 clearTimeout 换成请求适配器提供的停止操作,并处理好请求完成后的退订。取消通知需要底层适配器配合;如果规则不响应,控制器可以停止等待,已经启动的底层工作仍可能继续。
3. 只在失焦时校验,也需要报告输入变化
有一种容易漏掉的情况:表单只在 blur 时做远程校验。
用户先输入 A,失焦,启动校验;接着重新输入 B,但还没有再次失焦。如果只在"启动下一次校验"时才让旧任务失效,A 的请求仍可能在这个空档回来。
1.3 中,change 事件会立即使该字段旧任务失效,即使它没有被配置为校验触发事件。
typescript
const blurForm = new FormValidator({ 'username': usernameSchema }, {
triggers: [FormTrigger.BLUR],
timeoutMs: 1500
});
接入页面时,输入变化仍调用 blurForm.onChange(...),失焦调用 blurForm.onBlur(...)。前者负责报告新输入、取消旧任务;后者按配置执行新的字段校验。仅仅修改页面里的变量,控制器无法知道输入已经变化。
重置和销毁也接入了同一套生命周期:
| 操作 | 在途校验如何处理 |
|---|---|
| 新校验或输入变化 | 使该字段旧任务失效,并发送取消通知 |
reset() |
取消旧任务,清空交互和错误状态,控制器可以继续使用 |
dispose() |
取消旧任务、清理防抖等待,停止后续字段事件校验 |
| 字段超时 | 结束本次等待,产生超时结果,并通知规则清理 |
依赖字段也需要考虑事件版本。例如个人/企业模式切换后会重校验企业名称,过期的模式事件不应再拿着旧数据派发一次依赖校验。这次也补上了这条路径的检查。
4. 重复提交时,旧的 finally 不能关掉新的 loading
除了字段错误,submitting 也有类似的竞态。
假设提交 A 已通过校验,进入业务处理;随后又启动提交 B。当 A 先结束、B 仍在进行时,A 的 finally 如果直接设置 submitting = false,按钮就会提前恢复可点击。
1.3 为提交增加了版本检查:只有当前这一轮提交,才能在结束时清除对应的 busy 状态。校验阶段被新输入、重置或新提交取代的旧提交,也不会继续调用业务 handler。
这里的处理范围需要分清:已经进入 handler 的业务请求不会被撤回。如果是创建订单等操作,页面仍应控制重复点击,业务接口仍需要自己的重复提交处理。
另一个修复针对整表异步校验的异常路径:如果第三方 Schema 抛出异常,控制器会通知同一批次剩余任务取消。普通的"字段校验未通过"与异常不同;customAsync 的回调异常仍沿用原行为,转换成配置的校验错误。
5. 两个按需入口:输入保护和业务去重
异步任务之外,这次还新增了 safety 和 collections。它们都通过独立子路径引入,没有继续增加主入口的导出。
输入太深、节点太多,先做预检
支持递归 Schema 以后,输入本身也需要合理限额。1.3 提供 inspectInput 单独检查,以及 guarded 包装已有 Schema:
typescript
import { guarded, inspectInput } from '@hmkit/validator/safety';
const safeNumbers = guarded(liteV.array(liteV.number()), {
maxDepth: 8,
maxNodes: 4
});
safeNumbers.validate([1, 2, 3]).valid; // true:根数组 + 3 个元素
safeNumbers.validate([1, 2, 3, 4]).errors[0].code; // input_nodes
const cyclic: Record<string, Object> = {};
cyclic['self'] = cyclic;
inspectInput(cyclic).errors[0].code; // input_cycle
maxDepth 的根深度是 0,节点数包含根和每次访问的属性值。默认限额是深度 64、节点数 10000;超限返回 input_depth 或 input_nodes,错误保留发现问题的路径。
预检使用迭代遍历,并区分共享子对象和祖先回指。两个字段引用同一个子对象可以通过;对象沿祖先路径引用自己会产生 input_cycle。JSON 文本本身不能表达这种循环,但内存中组装的数据可能出现。
guarded 会在校验或解析原始输入前检查,并保留原 Schema 的解析行为。它不限制字符串长度、正则耗时或自定义回调,也不检查 transform 后新生成的数据;这些仍需要各自的业务约束。
商品明细按 sku 去重,错误直接落到对应行
一个订单可以有多条明细,每行字段都合法,却仍可能重复录入同一个商品。uniqueBy 用来表达这类数组整体约束:
typescript
import { uniqueBy } from '@hmkit/validator/collections';
const skuSchema = liteV.string().required().transform<string>(
(value: string): string => value.trim().toUpperCase()
);
const rows = uniqueBy(
liteV.array(liteV.object<Record<string, Object>>({ 'sku': skuSchema })),
(row: Record<string, Object>): string => row['sku'] as string,
{ path: 'sku', message: '商品编号重复' }
);
const rowData: Record<string, Object>[] = [
{ 'sku': ' a-01 ' },
{ 'sku': 'A-01' }
];
const duplicate = rows.validate(rowData);
// duplicate.valid === false
// duplicate.errors[0].code === 'unique_by'
// duplicate.errors[0].path === '1.sku'
这里的去重发生在内部解析之后,因此去掉空格、统一大小写后的两个 sku 会被识别为重复。第一条保留,后续重复项返回对应下标,页面可以直接定位到第二行的 sku。
也因为如此,uniqueBy.validate() 会执行内部的 parse/transform。转换回调适合保持纯粹的数据处理,避免把业务请求放进去。元素解析失败时,不会继续调用去重 selector。
输入保护和 selector 都属于运行时能力,不能承诺通过 JSON Schema 序列化后完整恢复。
6. 上篇不支持的 multipleOf,这次补上了
上一篇 1.2 文章曾用 multipleOf 演示"发现未支持的关键词"。1.3 已经支持它,连同下面几组约束一起进入导入子集:
- 数值:
multipleOf、数值型exclusiveMinimum/exclusiveMaximum。 - 数组:
uniqueItems,按元素结构比较,对象属性的插入顺序不影响相等判断。 - 包含:
contains、minContains/maxContains,限制匹配某个 Schema 的元素数量。 - 条件:
if/then/else,执行选中的分支,并继续满足同级约束。
例如导入一个数值步长约束:
typescript
import { fromJSONSchema } from '@hmkit/validator/interop';
const source = JSON.parse(
'{"type":"number","multipleOf":0.01,"exclusiveMinimum":0,"exclusiveMaximum":5}'
) as Record<string, Object>;
const stepSchema = fromJSONSchema(source, { strict: true });
stepSchema.validate(4.02).valid; // true
stepSchema.validate(4.021).valid; // false
stepSchema.validate(0).valid; // false,排他下界
小数倍数检查基于输入 Number 的规范十进制表示,不使用 epsilon 容差。因此 0.3 可以满足 multipleOf: 0.1,而 0.1 + 0.2 得到的 0.30000000000000004 不会通过。这项能力无法恢复进入 Number 之前已经丢失的精度。
严格导入继续提供能力诊断,支持范围仍是 JSON Schema 子集。uniqueItems 的结构比较最坏为二次复杂度,更适合有限规模的业务数组。
7. 这次验证了哪些路径?
1.3.0 发布记录中,本地测试从 263 项增加到 293 项。新增用例覆盖了字段替换、超时恢复、重置销毁、并发提交、过期依赖事件、输入限额和转换后去重等行为。
| 检查 | 1.3.0 发布结果 |
|---|---|
| 本地回归测试 | 293/293 通过 |
| 行 / 函数 / 分支覆盖率 | 93.27% / 83.21% / 85.46% |
| 主入口 API 合约 | 原有 83 个符号保持 |
| 完整 release HAR | 约 158.35 KiB,通过 160 KiB 预算 |
| 独立消费工程 | 主入口、按需入口均构建通过 |
| 模拟器集成冒烟 | Pura 90 API 23,覆盖启动、重复执行、取消后恢复及返回重入 |
这里的 293 项是本地测试数量,模拟器执行的是独立 HAR 消费场景。真机、弱网和真实网络请求释放尚未完成验收,实际接入网络后仍需要验证适配器是否响应取消。
后续的 1.3.1 维护版还在同一套 DevEco SDK 上增加了 API 12/API 23 两组目标配置的消费编译。它验证的是构建兼容性,没有新增 API 12 设备运行验收。详细过程分别保留在 1.3.0 验证报告和 1.3.1 验证报告中。
安装与项目地址
bash
ohpm install @hmkit/validator@1.3.1
项目仍然是零依赖、纯 ArkTS 实现,采用 MIT 协议。主入口继续保留已有 API,safety 和 collections 按需使用。
这轮改动让我更在意校验过程的收尾:用户修改输入以后,旧调用能结束;页面退出以后,规则能收到通知;新提交还在处理时,旧任务不能改掉它的状态。
如果你也遇到过难以复现的异步表单问题,欢迎带着输入顺序、预期行为和最小复现来提 Issue。这些真实的交互路径,会是后续迭代最有价值的用例。