拦截器不返回真值就报错:响应式对象的三个契约|洹拓科技

一、三条契约,各自都有一种很具体的失败现场
一个状态对象上有几十个字段,逐字段手写读写函数会迅速变成样板代码;给它加一个自定义的写方法看着省事,代价在第五节再说。更常见的做法是用代理把赋值统一拦下来,在拦截点上注入日志、事件与副作用。这条路能走通的前提是你承认:赋值陷阱不是一段随便写的回调,而是一条有契约的通道。
先把最容易踩的三条约定摆出来,以及它们各自会长成什么样的事故。
| 契约 | 违反时的现场 | 可判定的写法 |
|---|---|---|
| 陷阱要返回一个表示“这次赋值成功了”的真值 | 严格模式下赋值语句抛类型错误,信息只到属性名,不到原因 | 把写入交回目标对象的标准实现,原样返回它的结果,不自拟返回值 |
| 赋值前先做同值比较 | 同一个值被反复写入,日志与事件成倍打爆,回调链越拖越长 | 每次写入只与上一次的值比,判为相同就结束,不进入回调 |
| 不直接把初始对象包成代理 | 外部仍握着原始引用,改动绕过拦截,同一份状态悄悄变成两份 | 先按约定的深度复制一份再交给代理,深度写进注释 |
这三行看着像提醒,其实是会被语言层和运行时兑现的规矩。下面逐条说清它为什么成立。
二、返回值与接收者:这条通道由不变式把关
赋值陷阱收到四个参数:目标对象、属性名、新值、接收者。前三个好理解,第四个最容易被忽略,而它恰恰是继承与嵌套场景里唯一的行为来源。
返回值不是写给人看的,它是语言做不变式检查的输入。你返回真值,等于宣称“这个值现在能从该对象上读到”;系统随后去目标对象上核对。如果那个属性不可写、不可配置,或者只有读函数而没有写函数,核对就会失败并抛错。你返回假值,非严格模式下只是静默失败,严格模式下赋值直接抛出类型错误。两种抛出现场都很笼统,于是排查时最容易误判成“拦截器本身有问题”,绕开语言规则去改返回值——这是错的方向。正确的第一问是这个属性在目标对象上到底可不可写;如果不可写,那本来就不存在“给它赋值还要成功”这条路。
多数情况下正确的写法是把活儿交回标准实现:让标准实现完成写入,并把它返回的布尔值原路带回。这时候接收者参数必须跟着传下去,两个场景最直观。一是类继承:真正发起写入的是子类实例,属性的读写语义应当由实例那一侧的定义决定,把它压回目标对象就抹掉了这层差异。二是嵌套代理:外层代理的目标对象本身又是一个代理,用目标对象去写等于绕过下层,只有最里层的普通数据被更新,下层本该触发的陷阱一次都没跑。把接收者传下去,链路才是完整的,正例是写入结果由带接收者的标准实现返回,反例是拦截器里直接对目标对象赋值、外层记了一次而内层毫无动静。
一条可以进评审清单的规则:拦截器里不出现对目标对象的直接属性赋值。凡是这种写法,都要问一句接收者去哪了。
三、浅比较的三条边界与它管不住的一类改动
不比较就触发,代价很实在:初始化阶段一个字段常常连着写三次——默认值一次、接口数据覆盖一次、本地缓存合并再一次——每次都走一遍日志与事件,下游还会被这份噪音带着重算。可就算比较了,仍有三种情况会判错。
第一条边界是它与自身。按严格相等去比,一个非数值的特殊值和自己不相等,于是把它换成同一个值会被判成“变化了”,触发一次毫无意义的回调。要么显式加一条“两边同类且都非数值就视为相同”,要么统一采用把它视为等于自身的比较语义,并把这条固定在公共工具函数里,别在每个拦截点各写一遍。
第二条边界是引用类型只比地址。列表、字典、日期,每次赋一个新建的字面量进去,内容看着一样、地址不同,浅比较判定为变化,于是重复触发。反过来,原地往数组里加一项再把同一个引用赋回去,浅比较判定为没变,于是漏触发。两个方向都会出事,这不是比较函数不够聪明,而是浅比较在语义上只回答“是不是同一个东西”。
第三条边界是深度:浅比较只看顶层,嵌套对象内部的变化它一概看不见。你只比较顶层字段,就要接受“子树被改动而顶层未变”这一类漏判。
处置有两条路。一条是内容级深比:代价是每次赋值都要遍历,字段一多就把写入路径变成性能黑洞,还得处理循环引用。另一条是约定不可变更新——状态要变就交新引用,禁止原地改,浅比较的判定就此变得可靠。我们倾向第二条,因为它把不确定性从比较函数搬到了代码约定上,能靠评审守住;选第一条的,请把每次赋值的遍历成本与最大遍历深度写进注释,这属于必须留痕的取舍。
四、先复制一份,再交给代理
第三条契约的现场最安静,所以最难查。你把初始对象直接交给代理,拿到一个新对象交给业务;而传进去的那个初始对象还在别处活着:被某个模块持有、被写进缓存、被当作默认值留在配置里。任何一处按原始引用改写字段,代理毫无感知,因为改动发生在拦截之外。更麻烦的是,代理读取嵌套对象时若不加处理,会把目标对象内部的引用直接递出去,等于在拦截墙上留了个后门。
所以入参要先复制、再包。复制范围必须写清楚:只复制一层,保护的就是顶层字段,嵌套对象依然是同一个引用;递归到底,就要付出与对象规模相关的开销,还会把原型链上的方法丢掉——除非你显式保留。我们的做法是把两件事拆开:入口处做一次深度明确的复制,嵌套层要么在读取时按需再包一层,要么在约定上禁止把内部引用交出。不要把“复制一次”当成一句浅拷贝或者一次深拷贝就完事,然后默认它解决了全部污染路径。
还有一条边界:代理拦的是属性读写,拦不住整体换引用。给状态容器换一个新对象时,拦截点要跟着换,否则新旧两套并存,一半写入在被拦、一半没有。
五、拦截点放在代理层,而不是自定义写方法
看起来更简单的方案是给对象加一个写方法,在里面做校验、记日志、发事件。这条路在原生语法面前是输的:解构取的是属性,展开运算符复制的是属性,属性枚举列的是属性,序列化只看属性,各类现成库的绑定与依赖追踪也按属性语义工作。你的东西长得像一个状态对象,而整个生态看它的方式不是这样。校验逻辑放在写方法里,还会留下“绕过去直接赋值”的窗口,这类窗口通常在集成测试里不出现、在生产数据上出现。
代理层的价值在于它是唯一入口:属性式读写就是被拦的那条路,没有旁门。这也解释了为什么前面那些契约必须守——你选了这个位置,就把语言层的属性语义借了过来,借来的检查规则也得一起承担。
六、可观测三项、性能三纪律、四类断言与选型
拦截层的常见事故不是报错,是卡顿:回调链越拖越长,一次写入触发一串下游;看不见就没法治。三项做成可查询的读数,而不是打印在控制台:每个键的写入次数,用来发现某个字段在一屏之内被写了多少回;最后一次写入的来源标识,一处调用指纹或一个显式标签,用来回答“是谁写的”;触发回调所花的时间,用来定位是哪条下游慢。三项都应能按键取出,接一个调试面板即可。
性能上守三条。不要为整个大对象建代理,按热点字段建,拦截范围越小越容易验证契约。回调里禁止同步做重活,序列化、深度比较、跨网络请求都放进调度。写入风暴要有合并调度,同一键在短时间内只让最后一次进入下游——但要在文档里写清,这会让“写入次数”这项指标失去原来的意义。
四类断言,缺一条就说明契约没守住:
- 同值重复写入不触发回调,并覆盖那个特殊值的自比用例;
- 代理不改动原始入参,写入之后入参仍保持入口时的值;
- 赋值失败时返回值正确,对不可写属性发起赋值,严格模式抛出、非严格模式静默,两条都断住;
- 嵌套代理向下传播,外层记了一次而内层陷阱未跑,这条测试必须失败。
选型一句话:需要细粒度响应,按字段订阅、按需重算,用现成库,别自己造;只需要审计、兜底校验与非法写入拦截,自己写代理是划算的,代码短、边界清楚——但“短”的前提是那三条契约被当成必须项。洹拓在交付里给自己定的入门条件就一句:新增拦截器必须连同这四类断言一起提交,缺断言不算完成。