HarmonyOS 7 ArkTS+Ajv:动态表单热切换缓存污染与错误归一化
这轮问题是从一张很普通的审核表开始的。商家入驻页允许在“商户资料”和“配送人员”两套 Schema 间切换,运营后台还能下发小版本更新。测试同学连续切换 18 次后发现两件怪事:表单越来越卡,且明明停留在 merchant-v12,页面却短暂显示了上一套 courier-v7 的错误。更麻烦的是,错误列表中的路径顺序每次不完全一致,截图回归把同一份数据判成不同结果。
Demo 命名为 SchemaDock,页面是 DynamicReviewPage,任务号 FORM-1041。最终样本使用 Ajv 8.20.0,Schema 为 merchant-v12,内容哈希 7c9e2af1,包含 24 个字段。10:41 的回归结果是:冷编译 36 ms,缓存命中 0.8 ms,两套 Schema 总编译次数 2,丢弃旧代次结果 1 次,最终稳定输出 3 个错误。本文写的不是 JSON Schema 语法清单,而是一次缓存语义、并发结果和 UI 错误定位同时失控后的收拾过程。

一、缓存命中的前提不是“Schema 内容看起来相同”
Ajv 会把 Schema 编译成验证函数。验证执行很快,编译相对昂贵,因此官方建议每份 Schema 只编译一次并复用验证函数。最初代码在每次切换时对远端 JSON 做一次展开和字段排序,得到一个新对象,再调用 ajv.compile(schema)。内容虽然相同,对象身份却不同;Ajv 以 Schema 对象作为缓存键时,这种写法仍可能重复编译。
另一个坑来自 $id。运营端曾把修订后的 Schema 继续标为 merchant,客户端已有同名条目时,addSchema() 会遇到冲突。把异常吞掉再沿用旧 validator,看起来“应用没崩”,实际验证的还是上一版规则。我们最后规定,运行时键由业务名、版本和构建阶段计算的 SHA-256 短哈希共同组成:urn:schemadock:merchant:v12:7c9e2af1。同内容只能对应同键,不同内容绝不复用旧键。
二、Registry 只接收经过签名清单认可的 Schema
SchemaDock 没有把任意远端 JSON 直接交给 Ajv 编译。服务端清单先给出 schemaId、version、sha256 和字段数,客户端验签并核对本地包或下载文件的哈希;只有命中白名单的对象才进入 Registry。这样做既防止缓存污染,也避免把不受信任的正则或自定义关键字带入验证器。
第一段代码负责单例 Ajv 与显式缓存。strict: true 让未知关键字在编译期暴露;allErrors: true 让页面一次拿到全部问题;removeAdditional: false 避免验证器在检查过程中偷偷改写草稿。缓存键不依赖对象身份,而是使用清单中的稳定键。
import Ajv, { ValidateFunction } from 'ajv';
export interface SchemaBundle {
name: string;
version: number;
sha256: string;
fieldCount: number;
schema: object;
}
export class ValidatorRegistry {
private ajv = new Ajv({ strict: true, allErrors: true, removeAdditional: false });
private cache = new Map<string, ValidateFunction>();
public compileCount = 0;
get(bundle: SchemaBundle): ValidateFunction {
const key = `urn:schemadock:${bundle.name}:v${bundle.version}:${bundle.sha256}`;
const hit = this.cache.get(key);
if (hit) return hit;
if (bundle.name === 'merchant' && bundle.fieldCount !== 24) {
throw new Error('SCHEMA_MANIFEST_MISMATCH');
}
this.ajv.addSchema(bundle.schema, key);
const validate = this.ajv.getSchema(key);
if (!validate) throw new Error('SCHEMA_COMPILE_EMPTY');
this.cache.set(key, validate);
this.compileCount += 1;
return validate;
}
}
Registry 的边界很重要。它不会根据 $id 猜版本,也不会在冲突时调用 removeSchema() 后原地替换,因为旧页面可能仍持有旧 validator。旧函数可以继续完成自己的验证,新函数使用新键进入缓存;当页面和业务任务都不再引用旧函数时,再由资源策略释放整个 Registry。若在每次页面销毁时都清空 Ajv,下一次进入又会冷编译,缓存就失去了意义。
三、validate.errors 必须立刻复制,并和页面代次绑定
第二个异常不是 Ajv 算错了,而是我们保存错了。Ajv 的验证函数会在每次调用时覆盖自身的 errors 属性。原实现把 validate.errors 的引用交给一个异步格式化任务,期间用户切换 Schema,新一次验证把同一个属性换成了别的错误数组。格式化任务醒来后,页面、validator 和错误已经不是同一代。
修复方式有两步:验证结束的同一同步片段内深复制错误快照;异步格式化完成后再比较 generation。只有仍属于当前页面代次的结果才能进入 UI。
import { ErrorObject } from 'ajv';
export interface ValidationSnapshot {
generation: number;
schemaKey: string;
valid: boolean;
errors: ErrorObject[];
}
export class ValidationRunner {
private generation = 0;
public staleDrops = 0;
nextGeneration(): number { return ++this.generation; }
async run(schemaKey: string, validate: Function, draft: object): Promise<ValidationSnapshot> {
const mine = this.generation;
const valid = Boolean(validate(draft));
const errors = ((validate as { errors?: ErrorObject[] }).errors ?? []).map((e) => ({
...e,
params: { ...e.params }
}));
await Promise.resolve(); // 模拟字段映射与文案本地化异步边界
if (mine !== this.generation) {
this.staleDrops += 1;
throw new Error(`STALE_VALIDATION:${mine}`);
}
return { generation: mine, schemaKey, valid, errors };
}
}
这里没有使用“最后完成者获胜”,而是“当前代次才有资格提交”。如果 merchant-v12 验证先发起、courier-v7 后发起,但前者更晚完成,前者会被丢弃。任务号 FORM-1041 属于一次审核会话,generation 属于页面内切换;两者不是同一个概念。重新打开同一任务时可以保留 taskId,但必须从新 generation 开始。
四、错误路径要从 JSON Pointer 变成稳定字段 ID
Ajv 8 的 instancePath 使用 JSON Pointer。required 错误比较特殊:instancePath 指向父对象,缺少的字段名位于 params.missingProperty。直接把错误按返回顺序渲染,会让同一输入在 Schema 结构调整后产生不稳定列表;直接拿中文标题做 key,则标题改文案后焦点和动画都会错位。
第三段代码先补齐 required 路径,再映射为不会随文案变化的 fieldId,最后按页面预先定义的字段顺序排序。这样截图回归、焦点跳转和无障碍朗读使用的是同一套稳定定位。
import { ErrorObject } from 'ajv';
const ORDER: Record<string, number> = {
'company.name': 10,
'contact.phone': 20,
'license.expireAt': 30
};
function pointerOf(error: ErrorObject): string {
if (error.keyword !== 'required') return error.instancePath || '/';
const missing = String((error.params as { missingProperty: string }).missingProperty)
.replace(/~/g, '~0').replace(/\//g, '~1');
return `${error.instancePath}/${missing}`;
}
function fieldIdOf(pointer: string): string {
return pointer.slice(1).replace(/~1/g, '/').replace(/~0/g, '~').replace(/\//g, '.');
}
export function normalizeErrors(errors: ErrorObject[]): Array<{ fieldId: string; text: string }> {
return errors.map((error) => {
const fieldId = fieldIdOf(pointerOf(error));
return { fieldId, text: `${fieldId}: ${error.message ?? error.keyword}` };
}).sort((a, b) => (ORDER[a.fieldId] ?? 999) - (ORDER[b.fieldId] ?? 999));
}
这版样本最终固定得到三个路径:/company/name、/contact/phone、/license/expireAt。页面显示的中文可以分别是“企业名称不能为空”“联系电话格式不正确”“许可证有效期已过”,但稳定 key 仍是 company.name、contact.phone、license.expireAt。对于数组项,还需要把索引与业务主键结合,否则插入一行后 /items/3/code 会指向另一条记录。

五、把性能数字和错误事实放到同一页
DynamicReviewPage 没有只显示“校验失败”。顶部状态条显示当前 Schema、哈希和字段数;诊断卡显示冷编译耗时、缓存命中耗时、编译次数与旧结果丢弃数;错误区按稳定字段顺序展示三条结果。用户再次切换 Schema 时,状态先从 INVALID 回到 SCHEMA_READY,再进入 VALIDATING,旧错误在新结果提交前保留淡化状态,避免页面闪成“暂时通过”。
10:41 的完整状态链为 SCHEMA_READY → VALIDATING → INVALID。18 次快速切换只编译两份 Schema,merchant-v12 冷编译 36 ms,命中缓存 0.8 ms;旧代次结果丢弃 1 次,最终错误数 3。我们还把 compileCount=2 加进自动化断言:如果一次回归后数字大于已知 Schema 数量,就说明某处又在构造新键或绕过 Registry。

六、三个不能省略的工程边界
第一,不要把远端 Schema 当普通配置直接编译。至少需要来源鉴权、大小限制、哈希清单和关键字白名单;包含复杂正则时还要评估拒绝服务风险。第二,不要开启 removeAdditional、useDefaults 或 coerceTypes 后仍把验证称为“只读检查”,这些选项会改变输入数据,必须在产品语义上明确。第三,不要长期持有无限增长的 Schema 缓存。SchemaDock 只保留当前发布线和上一条回滚线,老版本审核任务完成后按整套 Registry 释放,而不是在验证中间删除单个函数。
这次最有价值的结论也不是“Ajv 很快”。验证库的缓存、错误属性和页面状态各有自己的生命周期,只有给它们加上稳定键、错误快照和 generation,动态表单才不会在切换越快时越不可信。对审核页面来说,稳定地告诉用户哪三个字段错了,比偶尔更快地闪出一个错误列表重要得多。
参考资料:
更多推荐



所有评论(0)