前端

Odoo patch() 为什么最容易补错“边界”:原型、静态成员、accessor 与可枚举性陷阱讲透

很多人会用 Odoo 的 patch(),但真正容易出事故的地方并不是语法,而是补丁边界:到底该 patch 类本身还是 prototype、getter/setter 只补一半会怎样、class method 的 enumerable 为什么要特殊处理。结合 patch.js 源码,本文专门拆开这些“看起来小、升级时最疼”的边界问题。

前端
进阶 开发者 1 分钟阅读
0 评论 0 点赞 0 收藏 5 阅读

很多人学会 Odoo 前端 patch() 之后,马上就会进入一个危险阶段:

  • 会写;
  • 能跑;
  • 但不知道自己到底补到了哪一层。

这类问题平时未必爆炸,一到升级、叠补丁、多模块共存时就开始疼。

addons/web/static/src/core/utils/patch.js,会发现官方其实早就把这些边界问题写进实现里了。真正值得反复看的,不只是 superunpatch,而是下面四个边界:

  1. 类本身和类原型不是同一个补丁目标;
  2. accessor 属性不能只看“新 patch 写了什么”,还要看祖先描述符;
  3. class prototype 上的方法枚举语义必须保住;
  4. patch 的对象边界一旦选错,后面的 super 链再优雅也没用。

一、最常见的误解:patch 类,还是 patch prototype?

patch.js 里有一句注释其实已经把规则说透了:

如果想 patch 一个 class,别忘了通常应该 patch 它的 prototype;除非你本来就想 patch 静态属性或静态方法。

这句话看似基础,实战里却最容易被忽略。

因为在 JavaScript 里:

  • MyClass 上放的是静态成员
  • MyClass.prototype 上放的是实例方法

所以:

  • 你想改实例上调用的 setup()render()onClick(),应当 patch prototype
  • 你想改类级别 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 组件或服务打补丁时,我更推荐按这个顺序判断:

  1. 这个行为是实例级,还是类级?
  2. 原属性是普通函数、数据字段,还是 getter/setter?
  3. 这层对象本来是 class prototype 还是普通对象?
  4. 有没有别的扩展点能替代 patch,比如 registry、service、hook、subclass?

如果这四个问题没答清,就别急着写 patch。

七、这篇文章真正想强调的点

patch() 真正危险的地方,从来不是“会不会写”。

而是你是否搞清楚:

  • 改的是类还是原型;
  • 改的是 value 还是 accessor;
  • 改完后对象语义有没有被偷偷改变;
  • 未来别的补丁叠上来时,这个边界还能不能成立。

这也是为什么 Odoo 官方实现里会出现:

  • isClassPrototype()
  • findAncestorPropertyDescriptor()
  • originalProperties
  • skeleton

这些看上去比“直接覆盖函数”麻烦很多的设计。

因为真正难的不是 patch 一次成功,而是在长期演进里补得准、退得回、叠得住

结语

如果你把 Odoo 的 patch() 只看成“官方版 monkey patch”,会很容易高估自己的安全边界。

更准确的理解应该是:

patch() 是一个围绕对象描述符与原型边界构建的受控扩展机制。

写补丁前先问自己一句:

我到底在补哪一层?

这个问题答对了,后面的 super、回滚、叠补丁才有意义。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。