前端

Odoo 列表页为什么翻页、切动作后还能记住位置:Layout、Pager Hook 与滚动状态契约讲透

很多人把 Odoo Web Client 里的分页、控制面板和滚动位置恢复,当成三个互不相干的小功能:上面一个工具栏,中间一个 pager,离开页面再回来时顺手记住滚动条。但从 `layout.js`、`pager_hook.js` 与 `action_hook.js` 看,官方其实做的是一份页面状态契约:Layout 负责壳层,Pager 通过 sub env 注入统一分页状态,而 action hook 负责把本地状态与滚动位置纳入动作切换生命周期。本文讲清这三者为什么必须一起看。

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

很多人用 Odoo Web Client 时,会把下面这些体验当成理所当然:

  • 列表页上方有 Control Panel;
  • 右上角 pager 可以翻页;
  • 进入一条记录再返回,列表常常还能回到原来的滚动位置;
  • 弹窗里的布局又和主界面略有不同。

如果只从表面看,这像是三个小功能拼在一起:

  1. 一个布局组件;
  2. 一个分页组件;
  3. 一个“顺便记住滚动条”的小技巧。

但 Odoo 源码给出的答案更系统。

search/layout.jssearch/pager_hook.jssearch/action_hook.js 看,官方真正设计的是一份页面状态契约

  • Layout 决定页面壳层怎么组织;
  • usePager() 把分页状态通过 env 注入给控制面板;
  • useSetupAction() 把滚动位置、本地状态、全局状态挂进动作生命周期。

也就是说,这不是几个零散组件,而是一套“当前页面如何在动作系统里存活、离开、恢复”的约定。

一、Layout 不是外壳 div,而是搜索页和内容页的装配点

layout.js 看起来很短,但它非常像 Odoo 搜索页的总插座。

源码里最关键的是两件事:

  • extractLayoutComponents()env.config 里拿 ControlPanelSearchPanel
  • controlPanelSlots 会根据环境对插槽做裁剪。

这意味着 Layout 的职责不是“画一个上中下结构”,而是:

把控制面板、搜索面板、正文区按当前环境装配成一张可替换的页面壳。

为什么说这是装配点?因为它并不把 ControlPanelSearchPanel 写死成唯一实现,而是允许从配置里替换。

这给两类场景留下空间:

  • 不同页面类型换不同控制面板实现;
  • 不同入口(如弹窗)收缩某些布局能力。

env.inDialog 暴露了一个重要边界

controlPanelSlots 里有一段很值得注意:如果当前环境是 dialog,会删掉 layout-buttons 插槽。

这说明 Odoo 不是做一个“所有场景都一样的通用 Control Panel”,而是明确承认:

  • 主动作页有一套页面级控制能力;
  • 弹窗场景则要收缩能力边界,避免把整页动作按钮照搬进去。

很多二开 UI 做着做着变乱,往往就是因为没把“主页面”和“局部容器”分清,结果所有按钮都想塞进去。

二、usePager() 的关键不是翻页,而是把分页状态变成环境能力

很多人第一次看到 usePager() 会觉得奇怪:为什么它没直接创建一个 pager 组件,而是 useSubEnv()

源码里它做了两步:

  1. 创建一个响应式 pagerState
  2. 通过 useSubEnv()config.pagerProps 注入当前子环境;
  3. 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 状态。

如果不分层,会出两种典型问题:

  1. 不该跨动作保留的状态被错误复用;
  2. 应该恢复的页面细节在返回时丢失。

Odoo 用 useSetupAction() 把这两层都纳进协议里,本质是在回答一个更底层的问题:

“返回上一页”到底是重新打开一页,还是恢复上一页?

正确答案通常是“两者都不是,而是按协议恢复该恢复的那部分”。

五、为什么 LayoutPagerAction 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

评论区

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