很多人用 Odoo Web Client 时,会把下面这些体验当成理所当然:
- 列表页上方有 Control Panel;
- 右上角 pager 可以翻页;
- 进入一条记录再返回,列表常常还能回到原来的滚动位置;
- 弹窗里的布局又和主界面略有不同。
如果只从表面看,这像是三个小功能拼在一起:
- 一个布局组件;
- 一个分页组件;
- 一个“顺便记住滚动条”的小技巧。
但 Odoo 源码给出的答案更系统。
从 search/layout.js、search/pager_hook.js、search/action_hook.js 看,官方真正设计的是一份页面状态契约:
Layout决定页面壳层怎么组织;usePager()把分页状态通过 env 注入给控制面板;useSetupAction()把滚动位置、本地状态、全局状态挂进动作生命周期。
也就是说,这不是几个零散组件,而是一套“当前页面如何在动作系统里存活、离开、恢复”的约定。
一、Layout 不是外壳 div,而是搜索页和内容页的装配点
layout.js 看起来很短,但它非常像 Odoo 搜索页的总插座。
源码里最关键的是两件事:
extractLayoutComponents()从env.config里拿ControlPanel和SearchPanel;controlPanelSlots会根据环境对插槽做裁剪。
这意味着 Layout 的职责不是“画一个上中下结构”,而是:
把控制面板、搜索面板、正文区按当前环境装配成一张可替换的页面壳。
为什么说这是装配点?因为它并不把 ControlPanel 和 SearchPanel 写死成唯一实现,而是允许从配置里替换。
这给两类场景留下空间:
- 不同页面类型换不同控制面板实现;
- 不同入口(如弹窗)收缩某些布局能力。
env.inDialog 暴露了一个重要边界
controlPanelSlots 里有一段很值得注意:如果当前环境是 dialog,会删掉 layout-buttons 插槽。
这说明 Odoo 不是做一个“所有场景都一样的通用 Control Panel”,而是明确承认:
- 主动作页有一套页面级控制能力;
- 弹窗场景则要收缩能力边界,避免把整页动作按钮照搬进去。
很多二开 UI 做着做着变乱,往往就是因为没把“主页面”和“局部容器”分清,结果所有按钮都想塞进去。
二、usePager() 的关键不是翻页,而是把分页状态变成环境能力
很多人第一次看到 usePager() 会觉得奇怪:为什么它没直接创建一个 pager 组件,而是 useSubEnv()?
源码里它做了两步:
- 创建一个响应式
pagerState; - 通过
useSubEnv()把config.pagerProps注入当前子环境; - 在
onWillRender()时不断把最新分页参数写进去。
这说明 Odoo 的思路不是“谁想翻页谁自己拿 props”,而是:
当前页面如果有分页,就把分页信息作为页面环境的一部分公开出去。
这样谁收益最大?当然是控制面板。
因为 Control Panel 往往不是具体列表渲染器的子组件,它更像页面壳层里共享的操作区。若没有 env 注入,它很难天然拿到某个 renderer 内部的翻页状态。
换句话说,usePager() 并不是为了少传几个 props,而是为了让:
- 页面内容区知道当前 offset / limit / total;
- Control Panel 也能同步读到同一份状态;
- 最终分页入口在 UI 上统一,但状态来源仍由当前页面负责。
这是一种非常典型的 Web Client 架构:共享状态进 env,而不是强塞父子链。
三、useSetupAction() 说明页面不是一次性渲染物,而是动作栈中的“可恢复状态体”
真正把这篇文章串起来的,其实是 action_hook.js。
很多人只记得 Odoo 有 action service、router、action stack,但容易忽略一件事:
- 当你从列表进入详情;
- 再从详情返回;
- 或者切换动作、切标签、开弹窗;
页面如果想“像没离开过一样”,就必须在离开前交出自己该保存的状态,在回来时再把状态恢复回来。
这正是 useSetupAction() 在做的事情。
1)它把页面状态接到动作生命周期钩子上
源码里可以看到,它会和环境里的这些 recorder 对接:
__beforeLeave____getGlobalState____getLocalState____getContext____getOrderBy__
这说明页面组件不是随便暴露几个 callback,而是按 Web Client 的动作协议,把自己的状态能力注册到统一容器里。
2)滚动位置就是 local state 的一部分
源码里最容易被低估的是 scrollSymbol 相关逻辑。
当页面提供 rootRef 时,useSetupAction() 会在离开前收集:
- 小屏下根容器的
scrollTop/scrollLeft; - 大屏下内容容器
.o_content或带 search panel 的 renderer 容器滚动位置。
回来时,setScrollFromState() 再把这些值写回去。
这意味着滚动恢复根本不是一个浏览器“自动记忆”的副产品,而是 Odoo 明确纳入动作状态管理的一部分。
四、为什么要区分 global state 和 local state
这个问题很少有人专门讲,但它恰好是 Web Client 是否稳的关键。
从命名就能猜到:
- global state 更像动作级别、跨局部重建仍应保留的状态;
- local state 更像当前页面实例自己的临时状态,比如 sample ORM、局部滚动位置、某些暂存 UI 状态。
如果不分层,会出两种典型问题:
- 不该跨动作保留的状态被错误复用;
- 应该恢复的页面细节在返回时丢失。
Odoo 用 useSetupAction() 把这两层都纳进协议里,本质是在回答一个更底层的问题:
“返回上一页”到底是重新打开一页,还是恢复上一页?
正确答案通常是“两者都不是,而是按协议恢复该恢复的那部分”。
五、为什么 Layout、Pager、Action Hook 必须一起看
如果你只看其中一个,很容易得出片面的理解。
只看 Layout
你会以为它只是壳层布局。
只看 usePager()
你会以为它只是把 offset / total 传给控制面板。
只看 useSetupAction()
你会以为它只是保存一下滚动条。
但把三者合起来,才会看到完整设计:
Layout把页面壳层和控制区装起来;usePager()把分页能力暴露进壳层可访问的 env;useSetupAction()把壳层与内容区的状态放进动作系统,确保离开和恢复都可控。
这三者共同定义的是:
一个 Odoo 页面在 Web Client 里如何成为“可导航、可恢复、可共享状态”的动作节点。
六、二开里最容易踩的坑
误区 1:自己写一个 pager,却不接 usePager()
这样你表面上也能翻页,但 Control Panel、动作恢复、统一 UI 状态就很可能对不上。
误区 2:滚动容器选错
如果你的页面把真正可滚动区域塞进了自定义壳里,却没让 rootRef 或 .o_content 对应到它,返回时就会出现“记了状态但没恢复到用户看到的地方”。
误区 3:把 dialog 当普通页面
源码已经明确 dialog 会收缩部分 layout slot。若二开仍然照搬整页按钮,交互通常会显得又重又乱。
误区 4:把页面状态都塞进一个大对象
没有 global/local 分层,返回路径一复杂,状态污染几乎是迟早的事。
七、结论
Odoo Web Client 里的分页、控制面板和滚动恢复,并不是三个彼此独立的小功能。
从源码看,它们构成了一份完整的页面状态契约:
Layout负责页面壳层与控制区装配;usePager()通过子环境暴露统一分页状态;useSetupAction()把本地状态、全局状态和滚动位置纳入动作生命周期。
所以更准确的理解应该是:
Odoo 页面之所以能“翻页、离开、回来、继续接着看”,不是因为组件碰巧配合得不错,而是因为 Web Client 为页面定义了一套明确的状态保存与恢复协议。
理解这套协议后,你做自定义列表页、搜索页、主从详情页时,就会少走很多弯路:先想状态契约,再想组件长什么样,而不是反过来。
DISCUSSION
评论区