☰
HarmonyOS 7 ArkTS+Ajv:动态表单热切换缓存污染与错误归一化
2026/10/7 8:11:42 网站建设 项目流程

这轮问题是从一张很普通的审核表开始的。商家入驻页允许在“商户资料”和“配送人员”两套 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避免验证器在检查过程中偷偷改写草稿。缓存键不依赖对象身份,而是使用清单中的稳定键。

importAjv,{ValidateFunction}from'ajv';exportinterfaceSchemaBundle{name:string;version:number;sha256:string;fieldCount:number;schema:object;}exportclassValidatorRegistry{privateajv=newAjv({strict:true,allErrors:true,removeAdditional:false});privatecache=newMap<string,ValidateFunction>();publiccompileCount=0;get(bundle:SchemaBundle):ValidateFunction{constkey=`urn:schemadock:${bundle.name}:v${bundle.version}:${bundle.sha256}`;consthit=this.cache.get(key);if(hit)returnhit;if(bundle.name==='merchant'&&bundle.fieldCount!==24){thrownewError('SCHEMA_MANIFEST_MISMATCH');}this.ajv.addSchema(bundle.schema,key);constvalidate=this.ajv.getSchema(key);if(!validate)thrownewError('SCHEMA_COMPILE_EMPTY');this.cache.set(key,validate);this.compileCount+=1;returnvalidate;}}

Registry 的边界很重要。它不会根据$id猜版本,也不会在冲突时调用removeSchema()后原地替换,因为旧页面可能仍持有旧 validator。旧函数可以继续完成自己的验证,新函数使用新键进入缓存;当页面和业务任务都不再引用旧函数时,再由资源策略释放整个 Registry。若在每次页面销毁时都清空 Ajv,下一次进入又会冷编译,缓存就失去了意义。

三、validate.errors必须立刻复制,并和页面代次绑定

第二个异常不是 Ajv 算错了,而是我们保存错了。Ajv 的验证函数会在每次调用时覆盖自身的errors属性。原实现把validate.errors的引用交给一个异步格式化任务,期间用户切换 Schema,新一次验证把同一个属性换成了别的错误数组。格式化任务醒来后,页面、validator 和错误已经不是同一代。

修复方式有两步:验证结束的同一同步片段内深复制错误快照;异步格式化完成后再比较 generation。只有仍属于当前页面代次的结果才能进入 UI。

import{ErrorObject}from'ajv';exportinterfaceValidationSnapshot{generation:number;schemaKey:string;valid:boolean;errors:ErrorObject[];}exportclassValidationRunner{privategeneration=0;publicstaleDrops=0;nextGeneration():number{return++this.generation;}asyncrun(schemaKey:string,validate:Function,draft:object):Promise<ValidationSnapshot>{constmine=this.generation;constvalid=Boolean(validate(draft));consterrors=((validateas{errors?:ErrorObject[]}).errors??[]).map((e)=>({...e,params:{...e.params}}));awaitPromise.resolve();// 模拟字段映射与文案本地化异步边界if(mine!==this.generation){this.staleDrops+=1;thrownewError(`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';constORDER:Record<string,number>={'company.name':10,'contact.phone':20,'license.expireAt':30};functionpointerOf(error:ErrorObject):string{if(error.keyword!=='required')returnerror.instancePath||'/';constmissing=String((error.paramsas{missingProperty:string}).missingProperty).replace(/~/g,'~0').replace(/\//g,'~1');return`${error.instancePath}/${missing}`;}functionfieldIdOf(pointer:string):string{returnpointer.slice(1).replace(/~1/g,'/').replace(/~0/g,'~').replace(/\//g,'.');}exportfunctionnormalizeErrors(errors:ErrorObject[]):Array<{fieldId:string;text:string}>{returnerrors.map((error)=>{constfieldId=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,动态表单才不会在切换越快时越不可信。对审核页面来说,稳定地告诉用户哪三个字段错了,比偶尔更快地闪出一个错误列表重要得多。

参考资料:

  • Ajv 8.20.0 npm 包信息
  • Ajv:Managing schemas
  • Ajv API:Validation errors
  • Ajv:Getting started 与编译缓存说明

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询