很多人在 OWL 里写页面,第一反应都很自然:
setup()里拿服务;onWillStart()调 RPC;- 请求回来以后塞进
useState(); - props 变了再手动重拉。
对于小组件,这样没问题。
但 Odoo 的很多页面不是小组件,而是:
- 带搜索参数;
- 会被 action stack 反复恢复;
- 可能切公司、切 domain、切排序;
- 需要处理空数据占位;
- 还得面对“上一次请求还没回来,下一次请求又发出去了”的竞态。
这就是为什么 addons/web/static/src/model/model.js 没让大家直接在组件里乱写,而是单独抽了一层 Model。
更准确地说,Odoo 想表达的是:
页面数据层应该有独立生命周期,而不是只是某个组件的几个 state 字段。
一、Model 不是 ORM 包装器,而是页面数据状态机的基类
源码里 Model 非常克制,但信息量很大。
它默认带几样东西:
envormbusisReadywhenReadysetup()load()notify()
第一眼会觉得简单,第二眼就会发现它的定位并不是“请求工具类”,而是页面级数据状态机。
为什么这么说?
1)它自带事件总线,而不是强绑组件 render
notify() 做的不是改 state,而是 bus.trigger("update")。
这说明 Model 不想把自己绑死在某个组件实例内部,而是希望:
- 数据层负责更新自己;
- 视图层决定如何响应 update。
这让模型具备更好的独立性。否则一旦页面里多个区域共享同一模型,所有更新都只能通过父组件 state 嵌套传下去,很快会变乱。
2)它有显式的 ready 语义
whenReady 是 Deferred,首次成功 load 后才 resolve。
这非常关键,因为很多页面并不是“组件挂上去就 ready”,而是:
- 字段和搜索参数准备好;
- 首次数据加载完成;
- 必要时 sample data 也已经替代进去;
- 才算进入真正可渲染状态。
有了 whenReady,页面就能更明确地区分:
- 已挂载但未就绪;
- 已拿到首屏数据;
- 后续更新中。
二、useModel() 做的不是简化代码,而是统一页面数据装配顺序
useModel() 里有几个很典型的动作:
- 按
ModelClass.services自动注入服务; - 默认补上
orm; - 创建模型实例;
onWillStart()里执行首次 load;onWillUpdateProps()时按新的搜索参数重载。
最重要的是这里的 getSearchParams()。它不是把所有 props 都丢给模型,而是只提取 SEARCH_KEYS 对应那部分。
这说明 Odoo 很清楚:
页面模型的“数据语义输入”不是整个组件 props,而是经过约束的一组搜索参数。
这样做的好处是:
- 模型不会被无关 UI props 牵着重载;
- 数据层边界更稳定;
- 搜索驱动页面的行为更容易预测。
三、useModelWithSampleData() 暴露了一个很 Odoo 的现实:空页面也要可教化
这段源码很有 Odoo 产品味道。
很多企业系统空数据页往往只有一句“暂无数据”。但 Odoo 在不少视图里希望:
- 页面结构先跑起来;
- 用户先看到“像真的一样”的示例视图;
- 再引导用户理解这个页面本来该长什么样。
于是 useModelWithSampleData() 做了几件额外的事:
- 如果组件未提供
isAlive,默认注入一个基于组件状态的活性判断; - 监听模型
update事件主动触发组件 render; - 若真实 ORM 加载后
hasData()返回 false,就构造sampleORM; - 再用 sampleORM 重跑一次
model.load(); - 最后把
useSampleModel状态写回。
这说明 sample data 在 Odoo 里不是“假数据演示脚本”,而是数据层正式支持的兜底路径。
四、为什么要用 Race 控制加载竞态
useModelWithSampleData() 里非常值得注意的一行是:
const race = new Race();
然后每次加载都走:
race.add(_load(props))
这背后的场景特别真实:
- 用户刚改了筛选;
- 请求 A 发出去;
- 还没回来,用户又切排序发请求 B;
- 如果 A 比 B 晚回来却覆盖了 B,页面就会倒退。
这类 bug 很常见,而且很难排查,因为它通常不是“必现”,而是网速稍慢时才冒出来。
Odoo 这里用 Race,本质上是在说:
页面加载不是一次性事件,而是一个持续可能被新输入打断的异步流。
数据层如果没有竞态控制,就算组件写得再优雅,也迟早会被异步结果回灌打穿。
五、sample ORM 不是 mock server 玩具,而是模型的第二执行后端
很多人会把 buildSampleORM() 误解成测试假对象。
但这里它进入的方式非常正式:
- 先保存真实
orm; - 判断当前数据是否为空;
- 若需要 sample model,则把
model.orm临时替换成sampleORM; - 再次执行同一个
model.load(searchParams); - 最后把真实
orm恢复回来。
这个设计很妙,因为它复用了同一份模型加载逻辑。
也就是说,模型作者不需要专门再写一套“示例模式渲染代码”,而是让:
- 真实 ORM 与 sample ORM 都遵守同样接口;
- 模型继续按同样的
load()协议工作; - 只是在后端实现上换了一个数据来源。
这比“if empty then 构造另一棵 UI”干净太多。
六、useSetupAction() 再次出现,说明模型状态也要能跟动作系统对接
useModelWithSampleData() 里还调用了 useSetupAction(),把:
useSampleModelsampleORM
分别写到 global state 和 local state。
这非常值得注意。
因为它说明模型层并不是孤立存在于组件内部,而是要和动作恢复机制对接。否则用户返回页面时:
- 之前是不是 sample 模式;
- sampleORM 是否已建立;
- 页面该恢复成哪种显示;
都会丢掉。
所以 Odoo 的页面模型,其实同时跨了三层:
- 数据加载;
- 组件渲染通知;
- 动作恢复状态。
七、二开时最容易踩的坑
误区 1:把所有数据逻辑塞进组件 state
短期看最快,长期最难维护。尤其当页面既要响应搜索参数,又要支持动作恢复时,组件很容易膨胀成一个混杂的异步大杂烩。
误区 2:忽略竞态
只要页面允许快速切筛选、分页、排序,就一定会遇到“旧请求晚回覆盖新请求”的问题。没做串行化/竞态控制,迟早出事。
误区 3:把 sample data 当测试专用
在 Odoo 里,它其实是产品体验的一部分,尤其适合新手首次进入空页面的引导。
误区 4:模型没有 ready 语义
这样页面经常会出现“组件 mounted 了,但数据还没稳定”的闪烁和边界错乱。
八、结论
Odoo 前端模型层之所以不鼓励“组件里直接调 RPC 然后 setState”,不是为了显得架构高级,而是因为真实页面需要解决的问题更多:
- 服务注入;
- 搜索参数驱动重载;
- 首次 ready 语义;
- 模型主动通知;
- 竞态控制;
- 空数据 sample 兜底;
- 与动作系统的状态恢复对接。
所以更准确的理解应该是:
Odoo 的
Model不是一个请求帮助类,而是 Web Client 页面数据生命周期的统一承载层。
理解这一点后,你写复杂 OWL 页面时,就不会再急着把所有异步逻辑塞进组件里,而会先想:这个页面有没有自己的模型、它的 ready 边界在哪、它如何抵抗竞态、以及它回到动作栈时应该恢复什么。
DISCUSSION
评论区