很多开发者学 Odoo 时,脑子里还留着一个很早期的模型:
- 前端改一个字段
- 后端
onchange方法返回{'value': ..., 'warning': ...} - 前端把这个结果直接灌回表单
这个理解不能说全错,但已经远远不够了。
从 /home/ubuntu/odoo-temp/addons/web/models/models.py 的源码看,现代 Odoo 的 onchange 真实链路更接近:
- 根据当前表单值构造一个内存中的临时 record
- 先做一份
RecordSnapshot - 把 changed values 写进缓存并触发 recompute / onchange
- 多轮判断还有哪些字段发生变化需要继续处理
- 再做一份新的
RecordSnapshot - 最后只把 snapshot diff 回传给客户端
也就是说:
onchange 的重点早就不是“返回了什么”,而是“最终 diff 出了什么”。
一、第一步不是执行 onchange,而是先把表单态数据组装成 record
源码开头做了几件很关键的事:
flush_all(),保证测试和环境一致性- 首次调用时补
default_get - 预取 x2many 行及其子字段,尽量减少后续新记录上的昂贵计算
- 把
values切成initial_values与changed_values
然后,Odoo 会根据当前场景创建内存记录:
- 如果原本有真实记录,就
self.new(cache_values, origin=self)再叠加表单值 - 如果是纯新建,就
self.new(initial_values)
这一段非常关键,因为它决定了后续 onchange 根本不是直接改数据库,而是在一份表单态缓存对象上运行。
二、为什么会先做 RecordSnapshot
Odoo 随后会创建:
snapshot0 = RecordSnapshot(record, fields_spec, fetch=(not first_call))
RecordSnapshot 本质上是一个按字段树展开的值快照:
- 普通字段直接记当前值
- x2many 字段则递归存成“行 id → 子快照”的结构
这个设计很重要,因为 Odoo 不想把整份 record 全量重新发给前端,它想知道的是:
- 哪些字段真的变了
- x2many 哪些行被删了、改了、加了
- 对已有行应该生成 update,还是 link,还是 create
如果没有 snapshot 机制,复杂表单的 onchange 返回值要么极其冗余,要么很难保证一致。
三、真正的“脏值”不是返回字典,而是缓存中的 changed values
源码里 changed_values 会先被写回 record cache:
record._update_cache(changed_values)
然后再通过 record.modified(...) 标记字段已被修改,推动:
- 依赖字段重算
- inherited 字段同步到父记录
- 下一轮 onchange 判定
这意味着 onchange 的主战场并不是 Python 返回字典,而是:
- 当前内存 record 的缓存状态
- 依赖图驱动下哪些字段被视为已修改
- 哪些字段在多轮处理后又发生了变化
所以很多“我明明在 onchange 里给某字段赋值了,为什么界面没变”的问题,根源往往不在返回值格式,而在于:
- 这个值最后有没有体现在 snapshot diff 里
- 它是不是又被后续重算覆盖了
- 当前字段是否真的在
fields_spec这棵树里
四、为什么 onchange 可能会跑多轮
源码里维护了 todo、done,并在每一轮后判断:
- 哪些字段在
snapshot0对比下已经变化 - 这些字段还没处理过
- 如果允许 recursive_onchanges,就继续下一轮
这说明 Odoo 的 onchange 不是“每个字段只调用一次方法就结束”,而是一个带递归传播特征的稳定化过程。
一个字段变化后:
- 可能先触发它自己的 onchange
- 再改动另一个字段
- 后者又触发第二轮 onchange
- 最终直到没有新的 relevant 变化为止
所以你看到的最终结果,常常不是某个方法单独返回的结果,而是多轮传播后的稳定态。
五、RecordSnapshot.diff() 才是真正发回前端的“协议层”
后半段源码很关键:
snapshot1 = RecordSnapshot(record, fields_spec)
result['value'] = snapshot1.diff(snapshot0, force=first_call)
这意味着:
- Odoo 不会简单把当前 record 全量序列化回前端
- 它会比较“前快照”和“后快照”
- 只发差异
对于普通字段,这个 diff 通过 web_read() 格式化。
对于 x2many,逻辑更复杂:
- 消失的行生成 remove / unlink / delete
- 已有来源的行根据
_origin决定是LINK还是UPDATE - 新行生成
CREATE
这就是为什么复杂 one2many 表单里,onchange 看起来总像“有一套隐藏协议”:
因为它确实有,而且这套协议的核心就是 snapshot diff + commands。
六、开发时最容易误会的三个点
1)“我 return 了 value,所以前端一定会看到”
不一定。
真正决定前端收到什么的,是最终 diff,而不是你在某个 onchange 方法里短暂返回了什么。
2)“onchange 会直接把数据写库”
不会。主流程运行在 new() 构造的表单态 record 上,本质是内存缓存对象。
3)“x2many 的 onchange 很玄学”
其实不玄。只是它不是按简单字典回填,而是按:
- 行身份
_origin- snapshot 差异
- commands 协议
来表达。
结语
理解 Odoo onchange 最重要的一步,是把脑中模型从“函数返回值”升级成“临时 record + RecordSnapshot + diff 协议”。
只有这样,你才能真正解释这些常见现象:
- 为什么有些赋值看似不生效
- 为什么 one2many 编辑结果总像被框架“重写”
- 为什么多轮 onchange 后最终值和你单个方法返回值不完全一致
onchange 从来不是“后端算一下,前端抄一下”这么简单。
它本质上是 Odoo 为复杂表单状态同步设计的一套 内存态稳定化与差异回传机制。
DISCUSSION
评论区