很多人学会 Odoo 前端 patch() 之后,马上就会进入一个危险阶段:
- 会写;
- 能跑;
- 但不知道自己到底补到了哪一层。
这类问题平时未必爆炸,一到升级、叠补丁、多模块共存时就开始疼。
看 addons/web/static/src/core/utils/patch.js,会发现官方其实早就把这些边界问题写进实现里了。真正值得反复看的,不只是 super 和 unpatch,而是下面四个边界:
- 类本身和类原型不是同一个补丁目标;
- accessor 属性不能只看“新 patch 写了什么”,还要看祖先描述符;
- class prototype 上的方法枚举语义必须保住;
- patch 的对象边界一旦选错,后面的
super链再优雅也没用。
一、最常见的误解:patch 类,还是 patch prototype?
patch.js 里有一句注释其实已经把规则说透了:
如果想 patch 一个 class,别忘了通常应该 patch 它的 prototype;除非你本来就想 patch 静态属性或静态方法。
这句话看似基础,实战里却最容易被忽略。
因为在 JavaScript 里:
MyClass上放的是静态成员;MyClass.prototype上放的是实例方法。
所以:
- 你想改实例上调用的
setup()、render()、onClick(),应当 patchprototype; - 你想改类级别 helper、factory、常量入口,才 patch 类本身。
很多补丁“看起来没生效”,本质上不是 patch 机制失灵,而是补错层了。
二、为什么官方专门写了 isClassPrototype()
源码里有个专门的判断:isClassPrototype(objToPatch)。
它不是装饰性函数,而是在告诉你:
Odoo 并不把“给普通对象打补丁”和“给 class prototype 打补丁”视为完全同一件事。
关键差异在可枚举性。
普通对象字面量里的属性大多是 enumerable,但 class prototype 上的方法默认不是。于是 Odoo 在 patch class prototype 时,会显式把新属性的 enumerable 设成 false。
这一步特别工程化。
因为如果你给 prototype 打补丁后,把方法不小心变成可枚举,短期可能完全没事,但长期会在这些场景里出现很怪的问题:
for...in或对象遍历结果变了;- 元编程工具观察到的方法集变了;
- 某些兼容代码把它当普通数据属性处理;
- 调试时你以为只是补了一层,实际上改坏了对象“气质”。
所以 Odoo 的态度很明确:patch 可以改行为,但尽量不要改对象类别语义。
三、accessor 补丁为什么最危险
比“补错类还是原型”更隐蔽的,是 getter / setter。
源码里有一段很关键的逻辑:如果新 patch 只定义了 getter 或只定义了 setter,Odoo 会沿原型链去找祖先属性描述符,把缺的那半补回来。
这说明什么?
说明官方默认承认一个现实:
accessor 不是“一个属性”,而是一个成对契约。
如果你只写:
- 一个新 getter;
- 却没保留旧 setter;
那么按原生 defineProperty 的直觉,另一半很可能就丢了。
Odoo 不接受这种“补半套把另一半抹掉”的结果,所以专门写了 findAncestorPropertyDescriptor() 去追祖先描述符。
这也是为什么 accessor patch 不能只从“我想改哪一面”思考,而要从“这个属性原本是如何成对工作的”思考。
四、真正的边界不是方法名,而是描述符
很多人谈 patch,只盯着函数覆盖:
- 同名方法替换;
super往上调;- 没报错就算成功。
但 patch.js 在底层处理的是 PropertyDescriptor,不是只处理函数值。
这意味着 Odoo 关心的边界包括:
- value 属性还是 accessor 属性;
- writable / configurable / enumerable 怎么保留;
- 旧属性是否原本存在;
- 补丁移除后应该回到“没有这个属性”还是“原属性描述”。
也正因为如此,originalProperties 记录的是第一次 patch 之前的描述符,skeleton 暂存的是旧层实现,最终回滚与重放都围绕描述符而不是简单值替换展开。
五、为什么“补丁目标选错”会让 super 也救不了你
Odoo 的 super 之所以成立,是因为每次 patch 后都会把 extension 接到当前 skeleton 上,形成一条人为组织出来的继承链。
但这条链有个前提:
你 patch 的是正确对象。
如果你本来要改实例方法,却 patch 到类静态成员:
super照样可能成立;- unpatch 照样可能回放;
- 甚至测试也未必立刻炸。
但运行时调用入口根本不经过这层,你等于优雅地补错地方。
所以在 patch 设计里,目标对象识别比 patch 写法本身更先决。
六、实战里该怎么判断边界
给 Odoo 组件或服务打补丁时,我更推荐按这个顺序判断:
- 这个行为是实例级,还是类级?
- 原属性是普通函数、数据字段,还是 getter/setter?
- 这层对象本来是 class prototype 还是普通对象?
- 有没有别的扩展点能替代 patch,比如 registry、service、hook、subclass?
如果这四个问题没答清,就别急着写 patch。
七、这篇文章真正想强调的点
patch() 真正危险的地方,从来不是“会不会写”。
而是你是否搞清楚:
- 改的是类还是原型;
- 改的是 value 还是 accessor;
- 改完后对象语义有没有被偷偷改变;
- 未来别的补丁叠上来时,这个边界还能不能成立。
这也是为什么 Odoo 官方实现里会出现:
isClassPrototype()findAncestorPropertyDescriptor()originalPropertiesskeleton
这些看上去比“直接覆盖函数”麻烦很多的设计。
因为真正难的不是 patch 一次成功,而是在长期演进里补得准、退得回、叠得住。
结语
如果你把 Odoo 的 patch() 只看成“官方版 monkey patch”,会很容易高估自己的安全边界。
更准确的理解应该是:
patch()是一个围绕对象描述符与原型边界构建的受控扩展机制。
写补丁前先问自己一句:
我到底在补哪一层?
这个问题答对了,后面的 super、回滚、叠补丁才有意义。
DISCUSSION
评论区