很多人第一次二开 Odoo 字段组件,最容易低估的一件事就是:
字段组件看上去像普通前端组件,实际上它更像“记录系统的前端端口”。
你如果只把它理解成:
- 传进来一个 value;
- 显示一下;
- 用户改完再回传;
很快就会在真实业务里踩到各种怪问题:
- 为什么有的字段明明改了,模型没更新;
- 为什么 onchange 一回来,输入中的值没被覆盖;
- 为什么有的字段显示值和原始值不是一回事;
- 为什么同一个 widget 在 list / form / 不同 jsClass 下还能切不同实现。
只要顺着 field.js、standard_field_props.js、input_field_hook.js、formatters.js、parsers.js 和具体字段组件例如 char_field.js 看下去,就会发现 Odoo 维护的根本不是“组件接值”,而是一条完整的字段值流。
一、字段组件的入口不是组件本身,而是 field.js 的注册与选型
field.js 里最关键的不是模板,而是 getFieldFromRegistry() 和 Field.parseFieldNode()。
这两段代码说明:
- 字段最终渲染哪个组件,不是写死的;
- 它会根据
fieldType、widget、viewType、jsClass去 registry 里找; - 找不到 widget 时还会回退;
- 不支持当前字段类型时会显式警告。
这说明 Odoo 的字段体系首先是注册驱动而不是组件直连。
也就是说,字段 UI 从一开始就不是“某字段永远绑定某组件”,而是:
当前视图语境下,这个字段该用哪种实现。
这也是为什么 Odoo 字段二开,很多时候应该先想 registry 入口,而不是直接去改某个组件模板。
二、standardFieldProps 暗示字段组件并不是自由发挥的普通组件
standard_field_props.js 看起来很简单,但意义很大。
它至少明确了几件事:
namerecordreadonlyid
这些不是随意传参,而是字段组件的最小契约。
其中最关键的是 record。
因为这说明字段组件面对的不是单个裸值,而是一整个记录上下文。于是字段组件天然可以接触:
record.datarecord.fields- 字段元信息
- 当前编辑状态
- 验证状态
- 更新方法
所以字段组件本质上不是“value component”,更像是record-bound component。
三、显示值与存储值本来就不是一回事,formatters.js 只是把这个事实写明了
很多初学者会默认认为:
- 模型里存什么;
- 页面上就显示什么。
Odoo 明确不是这么做的。
formatters.js 提供了大量格式化入口,例如:
formatCharformatFloatformatDateformatDateTimeformatBooleanformatBinary
这说明字段显示层真正关心的是:
用户现在应该看到什么文本 / 标记,而不是数据库里原始值长什么样。
例如:
- 密码字段可以显示成星号;
- 浮点数要考虑本地化分隔符与精度;
- 日期显示分 numeric / locale 风格;
- 布尔值甚至可以格式成一段标记化 HTML。
所以 field widget 二开时,如果只盯着 record.data[name],往往只看到了值流的一半。
四、输入时也不是“边敲边写模型”,useInputField 管的是脏态与提交边界
input_field_hook.js 是字段体系里非常值得反复读的文件。
它解决的核心问题是:
用户正在编辑时,界面输入框和底层 record 更新并不总是同一步。
源码里能看到几个关键状态:
isDirtylastSetValuependingUpdate
这三个变量一下就把问题讲清楚了。
1)为什么需要 dirty
因为用户在输入框里改的内容,可能还没真正提交给 record。
2)为什么要记 lastSetValue
因为组件必须区分:
- 当前输入框内容是用户正在打字形成的;
- 还是最近一次已提交给模型的稳定值。
3)为什么要有 pendingUpdate
因为某次 record.update() 可能还在路上,比如 onchange 未返回。这时如果发生紧急保存,系统得知道有哪些改动尚未被确认。
这说明 Odoo 字段不是简单的“双向绑定”,而是一个前端输入态与模型态之间的同步协议。
五、onInput、onChange、commitChanges 三层时机,说明提交不是单一事件
useInputField 至少分了三层动作:
onInput:标记 dirty、处理局部校验状态onChange:blur / change 时尝试 parse 并提交commitChanges:在更关键时机强制同步,例如 urgent save
这个拆分特别符合业务表单的现实。
因为真实字段输入不只是“失焦提交”这么简单,还会遇到:
- Tab / Enter 导航
- 紧急保存
- 模型请求本地变更
- onchange 异步回写
如果没有 commitChanges 这层,很多“用户明明刚打完字,怎么保存没带上”的问题就会不断出现。
六、parsers.js 说明“用户输入的字符串”也不是最终值
和 formatter 对应,parsers.js 负责把用户输入变成模型能接的值。
这里最容易被忽略的一点是:Odoo parser 不是只做基础类型转换,它还吸收了大量业务型输入习惯。
比如:
- 按本地化千分位 / 小数点解析数字;
- 支持
=1+2这种数学表达式; - 对整数边界做约束;
- 对百分比、金额、时间格式做专门处理;
- 某些场景还能解析运算型输入如
+= 3。
这就说明字段系统的职责不只是“把值写回去”,还包括:
把用户语言翻译成模型语言。
这也是为什么 parser 失败时,不是简单忽略,而是会把字段标记成 invalid。
七、char_field.js 暗示具体字段组件其实是在“拼接公共值流能力”
看 char_field.js 很有代表性。
它并没有把所有逻辑自己重写,而是组合:
standardFieldPropsuseInputFieldformatChar- 自己的
parse()规则 - 以及字段元信息里的
trim、size、translate
这说明具体字段组件的职责更像:
- 声明自己支持什么字段类型;
- 决定显示和解析的细节;
- 把公共输入 / 提交流程接上。
官方不是把每个字段都写成一套孤立世界,而是在同一条值流框架上做变体。
八、为什么 onchange 回来时不会随便覆盖用户输入
useInputField 里有一段非常关键的逻辑:
- 如果当前
isDirty; - 或字段仍是 invalid;
- patch 时就不要随便把输入框值改掉。
这个细节特别体现 Odoo 对真实表单体验的理解。
因为在业务系统里,模型更新可能来自:
- onchange 返回;
- 其他字段联动;
- 服务端修正;
- 外部刷新。
如果每次 record 有更新就强行覆盖输入框,用户会感觉自己刚打的内容“被系统吃掉了”。
所以 Odoo 在这里维护的是一个很重要的原则:
模型可以更新,但不能粗暴夺走用户当前编辑权。
九、字段二开里最常见的几个误区
误区 1:把字段组件当纯展示组件
结果一到编辑态、校验态、readonly 切换就开始出问题。
误区 2:绕过 parser / formatter,直接字符串进字符串出
短期看能跑,长期一定会在本地化、精度、金额、日期这些问题上翻车。
误区 3:自己监听 input 然后直接 record.update
如果不接入现成 hook,就很容易把 dirty、pending update、urgent save 边界全弄丢。
误区 4:忽略 registry 选型层
有些问题其实不该在组件内部硬改,而应该从 widget 注册和适用范围上解决。
十、结论
Odoo 的字段组件从来不是“拿个值渲染一下”的轻量视图。
从源码链路看得很清楚:
field.js负责字段节点解析与 widget 选型;standardFieldProps定义字段组件契约;formatters.js决定显示层长什么样;parsers.js把用户输入翻译成模型值;useInputField维护 dirty、提交与同步边界;- 具体字段组件在这套公共值流之上再补自己的规则。
所以更准确的理解应该是:
Odoo field widget 不是一个接 value 的小组件,而是 record、显示、输入、校验与提交之间的前端值流节点。
理解了这一点,你再做字段二开,很多“明明只是改个 input,怎么越改越乱”的问题,就能更早避开。
DISCUSSION
评论区