鸿蒙表单异步校验实战:@hmkit/validator 1.3 的取消、超时与任务清理

用户名查重有一个很常见的场景:用户输入 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。这些真实的交互路径,会是后续迭代最有价值的用例。

相关推荐
Flutter_OH1 小时前
Flutter OHOS 核心对象架构原理说明
harmonyos
贾伟康2 小时前
【健康菜谱助手|04】HarmonyOS ArkTS 一周膳食计划实战:管理日期、餐次与菜谱替换的联动
软件测试·移动开发·harmonyos·arkts·arkui·1024程序员节
贾伟康2 小时前
【健康菜谱助手|02】HarmonyOS ArkTS 烹饪步骤实战:把分步操作、完成状态和返回恢复做稳
软件测试·移动开发·harmonyos·arkts·arkui
用户29469405448163 小时前
DSH Desktop - HarmonyOS Edition
harmonyos·deepseek
OH_TPC3 小时前
【鸿蒙优选三方库】@ohos/ffmpeg-kit:把 FFmpeg 的全部能量带进鸿蒙
华为·ffmpeg·harmonyos·鸿蒙
贾伟康5 小时前
【健康菜谱助手|03】HarmonyOS ArkTS 冰箱食材推荐实战:从本地食材选择生成可解释的候选菜谱
软件测试·移动开发·harmonyos·arkts·arkui·1024程序员节
万物智能信息科技7 小时前
LoRa串口透传驱动开发—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
驱动开发·嵌入式硬件·华为·开源·harmonyos
贾伟康16 小时前
【化学实验室|17】HarmonyOS ArkTS 亮暗色与视觉令牌实战:集中颜色、间距和交互状态避免页面割裂
前端·移动开发·harmonyos·arkts·arkui
贾伟康20 小时前
【化学实验室|18】HarmonyOS ArkTS 权限与隐私实战:让 module.json5、功能说明和拒绝路径一致
安全·移动开发·harmonyos·arkts·arkui