前端

Odoo 前端模型为什么不是“组件里调 RPC 然后 setState”:Model、Sample Data 与竞态加载讲透

很多人学 Odoo OWL 页面时,会把数据层理解成“组件启动时调一次 RPC,把结果塞进 state,后面 props 变了再调一次”。但从 `model.js` 来看,官方把页面模型单独抽成 `Model` 类,用 `useModel()` / `useModelWithSampleData()` 管初始化、服务注入、首次加载、props 变化重载、竞态串行化,以及空数据时的 sample ORM 兜底。本文讲清 Odoo 为什么不鼓励把页面数据逻辑直接糊在组件里。

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

很多人在 OWL 里写页面,第一反应都很自然:

  • setup() 里拿服务;
  • onWillStart() 调 RPC;
  • 请求回来以后塞进 useState()
  • props 变了再手动重拉。

对于小组件,这样没问题。

但 Odoo 的很多页面不是小组件,而是:

  • 带搜索参数;
  • 会被 action stack 反复恢复;
  • 可能切公司、切 domain、切排序;
  • 需要处理空数据占位;
  • 还得面对“上一次请求还没回来,下一次请求又发出去了”的竞态。

这就是为什么 addons/web/static/src/model/model.js 没让大家直接在组件里乱写,而是单独抽了一层 Model

更准确地说,Odoo 想表达的是:

页面数据层应该有独立生命周期,而不是只是某个组件的几个 state 字段。

一、Model 不是 ORM 包装器,而是页面数据状态机的基类

源码里 Model 非常克制,但信息量很大。

它默认带几样东西:

  • env
  • orm
  • bus
  • isReady
  • whenReady
  • setup()
  • load()
  • notify()

第一眼会觉得简单,第二眼就会发现它的定位并不是“请求工具类”,而是页面级数据状态机

为什么这么说?

1)它自带事件总线,而不是强绑组件 render

notify() 做的不是改 state,而是 bus.trigger("update")

这说明 Model 不想把自己绑死在某个组件实例内部,而是希望:

  • 数据层负责更新自己;
  • 视图层决定如何响应 update。

这让模型具备更好的独立性。否则一旦页面里多个区域共享同一模型,所有更新都只能通过父组件 state 嵌套传下去,很快会变乱。

2)它有显式的 ready 语义

whenReadyDeferred,首次成功 load 后才 resolve。

这非常关键,因为很多页面并不是“组件挂上去就 ready”,而是:

  • 字段和搜索参数准备好;
  • 首次数据加载完成;
  • 必要时 sample data 也已经替代进去;
  • 才算进入真正可渲染状态。

有了 whenReady,页面就能更明确地区分:

  • 已挂载但未就绪;
  • 已拿到首屏数据;
  • 后续更新中。

二、useModel() 做的不是简化代码,而是统一页面数据装配顺序

useModel() 里有几个很典型的动作:

  1. ModelClass.services 自动注入服务;
  2. 默认补上 orm
  3. 创建模型实例;
  4. onWillStart() 里执行首次 load;
  5. 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(),把:

  • useSampleModel
  • sampleORM

分别写到 global state 和 local state。

这非常值得注意。

因为它说明模型层并不是孤立存在于组件内部,而是要和动作恢复机制对接。否则用户返回页面时:

  • 之前是不是 sample 模式;
  • sampleORM 是否已建立;
  • 页面该恢复成哪种显示;

都会丢掉。

所以 Odoo 的页面模型,其实同时跨了三层:

  1. 数据加载;
  2. 组件渲染通知;
  3. 动作恢复状态。

七、二开时最容易踩的坑

误区 1:把所有数据逻辑塞进组件 state

短期看最快,长期最难维护。尤其当页面既要响应搜索参数,又要支持动作恢复时,组件很容易膨胀成一个混杂的异步大杂烩。

误区 2:忽略竞态

只要页面允许快速切筛选、分页、排序,就一定会遇到“旧请求晚回覆盖新请求”的问题。没做串行化/竞态控制,迟早出事。

误区 3:把 sample data 当测试专用

在 Odoo 里,它其实是产品体验的一部分,尤其适合新手首次进入空页面的引导。

误区 4:模型没有 ready 语义

这样页面经常会出现“组件 mounted 了,但数据还没稳定”的闪烁和边界错乱。

八、结论

Odoo 前端模型层之所以不鼓励“组件里直接调 RPC 然后 setState”,不是为了显得架构高级,而是因为真实页面需要解决的问题更多:

  • 服务注入;
  • 搜索参数驱动重载;
  • 首次 ready 语义;
  • 模型主动通知;
  • 竞态控制;
  • 空数据 sample 兜底;
  • 与动作系统的状态恢复对接。

所以更准确的理解应该是:

Odoo 的 Model 不是一个请求帮助类,而是 Web Client 页面数据生命周期的统一承载层。

理解这一点后,你写复杂 OWL 页面时,就不会再急着把所有异步逻辑塞进组件里,而会先想:这个页面有没有自己的模型、它的 ready 边界在哪、它如何抵抗竞态、以及它回到动作栈时应该恢复什么。

DISCUSSION

评论区

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