前端开发里一提到 patch,很多人第一反应都是:
- 临时覆盖一个方法;
- 跑起来就行;
- 以后升级再说。
如果只把 Odoo 的 patch() 当成这种“官方允许的 monkey patch”,会低估它很多。
看 addons/web/static/src/core/utils/patch.js,你会发现官方真正解决的是下面几个难题:
- 覆盖之后,
super还能不能正常工作; - 同一个对象被多次 patch 时,回滚一层后剩下的扩展还能不能保持顺序;
- getter / setter 只改一半时,会不会把另一半搞丢;
- 类原型和普通对象在属性可枚举性上差异这么大,怎么尽量不破坏原语义。
所以 Odoo 的 patch 不是“一锤子把原方法改掉”,而是一种可叠加、可回放、可回滚的对象扩展机制。
一、patch 的核心不是覆盖,而是先建立补丁描述表
源码先用一个 WeakMap:
patchDescriptions
去保存每个被 patch 对象对应的描述信息,里面至少有三块:
originalPropertiesskeletonextensions
这三样东西决定了 Odoo 的 patch 和“直接 obj.method = fn”完全不是一个级别。
originalProperties:记住第一次 patch 前长什么样
originalProperties 只记录“第一次 patch 之前”的属性描述。
这意味着什么?
意味着之后不管叠多少层扩展,unpatch 的时候都能回到原始基线,而不是回到某次中间态。
skeleton:给 super 链搭骨架
skeleton 初始是:
Object.create(Object.getPrototypeOf(objToPatch))
然后每次 patch 前,如果对象上已经有旧属性,就把旧属性定义到 skeleton 上。
这是整套设计最有味道的地方。
因为它不是简单保存一个“旧函数引用”,而是在构造一条补丁可继承的中间原型链。
extensions:记住所有仍然有效的补丁
每个 extension 都会放进 extensions 集合。这样在某个补丁被移除时,官方可以:
- 先恢复原始属性;
- 再把剩余 extension 按顺序重新 patch 一遍。
也就是说,Odoo 不是在“从当前态扣掉一层补丁”,而是在“从原始态重新重放有效补丁集”。
这才是多层 patch 可维护的关键。
二、为什么它要专门处理 class prototype
源码里有个 isClassPrototype(),专门判断当前 patch 的是不是类原型。
随后会做一件很细的事:
- 如果 patch 的目标是 class prototype,就把新属性设成
enumerable = false
这一步很多人会忽略,但它特别工程化。
因为:
- 普通对象字面量里的属性通常是 enumerable;
- class prototype 上的方法默认不是 enumerable。
如果不处理,给类原型打补丁之后,枚举行为就会和原类不一致。短期也许没感觉,长期却可能在:
- 序列化;
- 遍历;
- 调试;
- 元编程兼容性;
这些场景里冒出很奇怪的问题。
官方显然不希望 patch 一下,就把对象的“类型气质”改坏。
三、super 为什么能在 patch 里继续工作
这是 patch.js 最值得讲透的一点。
最后那句:
description.skeleton = Object.setPrototypeOf(extension, description.skeleton)
决定了 extension 自己会被挂到当前 skeleton 上。
于是当 extension 里的方法使用 super.xxx() 时,它沿着的不是 JavaScript 原始类继承链,而是这条被 patch 系统重新组织出来的补丁骨架链。
通俗点说:
- 第一层 patch 的
super指向原始实现; - 第二层 patch 的
super指向第一层 patch; - 再往后继续类推。
这就让多层补丁不只是“后写覆盖前写”,而是能形成明确的调用接力。
所以在 Odoo 前端里,patch 最理想的用法不是把别人方法整段复制过来,而是:
- 在必要处插逻辑;
- 调
super; - 保留原有语义。
这也是为什么它比粗暴 monkey patch 更适合模块共存。
四、getter / setter 为什么要补全另一半
源码里有一段异或判断:
- 如果新属性只定义了
get或只定义了set - 就去祖先描述符里把另一半补上
这其实是在补 JavaScript 属性描述符的一个坑。
因为 accessor property 往往是成对存在的。你只改其中一半时,如果直接 defineProperty,另一半可能就丢了。
Odoo 这里做的是:
- 顺着原型链找祖先 descriptor;
- 如果旧 descriptor 上另一半存在,就抄回来。
这保证 patch accessor 时更像“局部修改”,而不是“整扇门拆掉换半块门板”。
五、为什么 unpatch 不是简单恢复旧值,而是“先清空再重放”
patch() 返回一个 unpatch 函数。
很多人以为 unpatch 只会把当前这一层恢复掉。实际上官方做得更稳:
- 删除当前对象的 patch description;
- 先把所有属性恢复到第一次 patch 前的原始状态;
- 从
extensions里去掉当前 extension; - 把剩余 extension 重新调用
patch()再打一遍。
这套逻辑非常像“rebuild”,而不是“subtract”。
为什么这样更可靠?
因为多层 patch 下,后来的补丁已经可能建立在前一个补丁形成的 skeleton 之上。直接从当前态硬减一层,容易把链条拆歪。
从原始态重放有效补丁,虽然看起来笨一点,但语义最清楚,也最不容易留下幽灵状态。
六、这套设计背后的真正目标:让 patch 尽量像“受控扩展”,而不是“紧急入侵”
很多团队一说 patch,就默认它是最后手段。这没错,但 Odoo 的实现说明官方并不想让它成为“不可控的最后手段”。
相反,它希望 patch 至少满足几个条件:
- 能叠加;
- 能调用 super;
- 能可靠撤销;
- 尽量不破坏属性语义;
- 多模块共存时不会一撤就全乱。
所以 Odoo patch 的真正定位,更接近:
在公开扩展点不够用时,提供一个相对可维护的底层切口。
它不是鼓励你到处乱 patch,而是在告诉你:如果不得不 patch,也别用最原始的方式。
七、实战里最常见的几个误区
误区 1:把 patch 当成“复制原方法后改几行”
这样最容易在升级时把上游修复一起覆盖掉。
更稳的姿势通常是:
- 尽量局部插入;
- 尽量调用
super; - 只改你真正要改的分支。
误区 2:patch 类本身,而不是 patch prototype
源码注释已经明确提醒:
- patch class 时,通常应该 patch
prototype - 除非你要改的是静态属性/静态方法
这是 Odoo 前端 patch 最常见的基础错误之一。
误区 3:忘记 unpatch 语义会重放其他补丁
如果测试里多个补丁叠在同一对象上,unpatch 的结果不是“简单回到上一层”,而是“恢复原始态后重放剩余层”。理解错这一点,断言就很容易写偏。
误区 4:把 patch 当成 registry/service 的替代品
如果一个需求本来有公开注册点,就不该优先 patch。patch 适合的是:
- 公开扩展面不够;
- 你必须进入内部行为;
- 而且你清楚升级成本。
八、结论
Odoo 的 patch() 真正厉害的地方,不是“官方也允许覆盖方法”,而是它把覆盖这件事做成了一种更受控的机制。
它通过:
originalProperties记住原始基线;skeleton维持super链;extensions管理多层补丁集合;- accessor 补全逻辑保住 getter/setter 语义;
- unpatch 重放机制维持可回滚的一致性。
所以更准确地说:
Odoo patch 不是简单改代码,而是在运行时重建一条可扩展、可撤销的方法继承链。
理解这一点后,你再决定“这事该用 registry、service、subclass 还是 patch”,判断会成熟很多。patch 依然要谨慎,但至少你会知道它到底在帮你兜什么底。
DISCUSSION
评论区