前端

Odoo Web Client 为什么刷新后还能回到原界面:router、actionStack 与 URL 状态接力讲透

很多人以为 Odoo Web Client 的页面切换只是“改一下 hash 或 query 参数”。但从 `core/browser/router.js` 与 `webclient/actions/action_service.js` 看,真正稳定的是一整套状态编排:路径段、查询参数、actionStack、history state 与 sessionStorage 在一起接力,才让刷新、前进后退与深链接保持一致。

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

很多人第一次看 Odoo 前端跳转,会把它理解成一种“更复杂一点的单页路由”:

  • 点菜单,改 URL;
  • 点记录,改 URL;
  • 刷新后再从 URL 还原回来。

这话只对了一半。

真正把 Odoo Web Client 撑起来的,不只是一个 router,而是 addons/web/static/src/core/browser/router.jsaddons/web/static/src/webclient/actions/action_service.js 共同维护的一套状态接力系统

它解决的不是“地址栏怎么变”,而是下面这些更麻烦的问题:

  1. 一个用户动作背后可能不只是一层页面,而是一串 action 栈;
  2. 地址栏里既要可分享,又不能把所有内部状态全暴露出去;
  3. 浏览器前进后退、刷新、从旧 /web#... 链接跳进来,都要尽量回到同一个界面;
  4. 某些状态适合进 URL,某些状态只适合进 history state 或 sessionStorage。

所以 Odoo 的路由不是“页面路径映射”,而是界面状态的分层序列化

一、router 先做的不是跳转,而是把“状态”拆成 path、search 与 history state

router.js 一上来就把几件事说清楚了:

  • PATH_KEYS = ["resId", "action", "active_id", "model"]
  • stateToUrl() 会把部分状态编码进路径段
  • 其他状态走查询串
  • 还有一部分状态根本不会出现在 URL,而是留在 history state 里

这说明 Odoo 并不认为“所有前端状态都应该进地址栏”。

它更像在做三层分发:

第一层:适合人类阅读和分享的主状态

像:

  • 当前 action
  • 当前 model
  • 当前 active_id
  • 当前 resId

这些信息会尽量被编码进 /odoo/... 路径。

比如单记录动作会形成一种近似:

  • active_id/action/resId

这带来的好处是:

  • URL 更像业务路径,不只是参数垃圾堆;
  • 深链接更稳定;
  • 浏览器历史记录也更容易看懂。

第二层:适合进 query 的补充状态

view_type、某些上下文键、调试参数、语言等,更适合保留在查询串。

第三层:不适合公开暴露的运行时状态

源码里最典型的是:

  • actionStack
  • hideKeyFromUrl() 隐掉的键,比如 globalState

这些状态对恢复界面很重要,但不一定适合出现在 URL 里。于是 Odoo 把它们放进 history.replaceState/pushState 携带的 nextState,并在某些时刻额外写进 sessionStorage.current_state

这不是绕,而是现实:可分享 URL可恢复运行时状态 不是一回事。

二、actionStack 才是“为什么返回上一步还像回到原工作流”的关键

如果只看最后一个 action,你会以为当前界面只是一层。但 action_service.js 里的 makeState() 说明,Odoo 会把 controller 栈序列化成 actionStack

每一层 actionState 里可能有:

  • action
  • model
  • view_type
  • resId
  • active_id
  • 以及当前 controller 自己的局部状态

这非常关键。

因为 Odoo 的很多界面切换,实际上不是“替换掉旧页面”,而是沿着业务上下文继续往里走:

  • 从列表进表单;
  • 从主 action 打开 client action;
  • 从上层动作再进入子动作或嵌入动作;
  • 弹出某个记录、再退回上一层上下文。

如果 router 只记“当前页是什么”,浏览器后退时就很容易丢掉整个操作链。actionStack 的存在,等于告诉前端:

我不仅要记住你现在在哪一页,还要记住你是怎么走到这里的。

所以 Odoo 的前进后退体验,比很多“单页应用只是切路由”的实现更接近桌面应用工作流。

三、为什么还要把 current_state 写进 sessionStorage

pushState() 里除了调用 router.pushState(newState, { replace: true }),还会先:

  • browser.sessionStorage.setItem("current_state", JSON.stringify(newState))

这一步很容易被忽略。

源码后面 _controllersFromState() 又会优先尝试读取 sessionStorage.current_state,并拿它和当前 router state 做 URL 比较。

这说明 Odoo 已经意识到一个问题:

浏览器地址栏能表达的,不一定是恢复整条 controller 栈时最完整、最可靠的状态。

把一份最近状态放进 sessionStorage,有几个现实意义:

  1. 刷新后恢复更稳:尤其当 URL 已经被裁剪成“适合公开展示”的版本时;
  2. 避免某些隐含状态在 URL 往返中丢失
  3. 给 action 栈重建提供一个更完整的参考。

所以别把它理解成“多余缓存”。它更像是 Web Client 给自己准备的一份短期恢复快照。

四、router 不是马上 push,而是做了 debounced push

router.pushState / replaceState 最终都来自 makeDebouncedPush()

这意味着 Odoo 并不是每有一点状态变化就立刻改地址栏,而是先汇总到 pushArgs,再在一个很短的节奏里统一推送。

这么做有三个明显好处:

1)减少连续状态抖动

一个动作切换时,常常不是只变一个键,而是:

  • action 变了;
  • view_type 变了;
  • resId 变了;
  • 甚至 globalState 也变了。

如果每一步都立即 push,浏览器历史会被刷得很碎。

2)保持 URL 与最终稳定界面一致

比起记录中间态,Odoo 更想把“这一轮动作稳定下来的结果”写进历史。

3)尽量保住历史标题

源码里甚至专门在 push 前暂时改 document.title,只是为了让生成出来的 history entry 拿到更合适的标题。说明官方很在意浏览器历史记录的可读性,而不只是技术上“能跳”。

五、为什么 popstate、pageshow、内部链接点击都要单独接管

很多前端项目只处理一种场景:自己代码触发的 router push。

router.js 还额外接管了:

  • popstate
  • pageshow
  • 浏览器里的内部 a 标签点击

这代表官方在处理的是浏览器真实世界,不是理想中的组件沙盒。

popstate:解决前进后退

浏览器 back/forward 触发 popstate 时,Odoo 会直接采用 history 里的 nextState,再触发 ROUTE_CHANGE,让 Web Client 用旧状态重装当前界面。

重点不是“重新解析 URL”,而是尽量沿用当时那份状态

pageshow:处理 Safari 的 bfcache

源码里专门提到 Safari 可能通过 bfcache 恢复页面,而 Odoo 又没完全按 bfcache 友好模型设计。于是只要发现 ev.persisted,就主动触发一次 ROUTE_CHANGE

这是非常工程化的一笔:不是优雅不优雅的问题,而是先把页面一致性保住。

拦截内部链接点击:避免整页刷新

/odoo 内部链接,router 会拦截默认跳转,自己改 state 并触发 ROUTE_CHANGE。这样:

  • Web Client 不会被整页刷新打断;
  • 移动端壳层也不容易弹到外部浏览器;
  • 同一套路由还可以继续受 action 栈恢复逻辑保护。

六、旧 /web#... 链接兼容,说明 Odoo 在做“状态迁移”而不是一次性推翻

urlToState() 里有一段很有代表性的 retrocompatibility 逻辑:

  • 如果路径还是 /web
  • 就把 hash 里的旧参数解析出来
  • 把旧的 id 重映射成 resId
  • 再生成 canonical /odoo/... URL

这件事说明:

Odoo 的 router 不只是解析新规则,还承担旧状态格式到新格式的迁移责任。

这对企业应用很重要。因为用户收藏夹、外部邮件、文档系统里,都会残留很多历史链接。你不能简单说“新版本换了 URL 结构,旧链接自己作废”。

七、为什么 globalState 被 hideKeyFromUrl

action_service.js 里一开始就:

  • router.hideKeyFromUrl("globalState")

这很值得开发者记住。

globalState 往往属于:

  • 当前控制器的运行时 UI 状态
  • 搜索条、布局、局部缓存、临时参数
  • 适合“恢复当前界面”,不适合“生成可分享链接”

如果把这些信息一股脑塞进 URL,会有几个问题:

  1. 地址栏过长、难读;
  2. 暴露内部实现细节;
  3. 升级时 URL 兼容性变脆;
  4. 分享给别人时,带上大量本不该共享的临时状态。

所以 Odoo 的做法很克制:让 URL 只承载对外有意义的状态,让 history state 承载更完整的运行时状态。

八、实战里最容易踩的几个坑

误区 1:把 Odoo router 当成普通前端页面路由

如果你只盯着 pathname/query,就会忽略 actionStack、history state 和 sessionStorage 这三层。

结果就是:

  • 自定义跳转能开,但后退异常;
  • 刷新能进,但 breadcrumb 不对;
  • 深链接能分享,但恢复不到原来的工作流。

误区 2:把所有状态都 push 到 URL

很多临时 UI 状态并不适合对外暴露。能恢复,不等于应该分享。

误区 3:自定义 client action 时只管自己,不管 ROUTE_CHANGE

如果一个 client action 会自己操作 history,却不遵守 router 约定,很容易让 Web Client 以为当前状态没变或被绕过加载。

误区 4:忽略旧链接兼容

企业系统里历史链接会活很久。改路由结构时不做迁移,通常不是“技术债”,而是直接破坏用户入口。

九、结论

Odoo Web Client 的路由,本质上不是“地址栏怎么写”,而是界面状态怎样被分层保存、恢复和迁移

router.jsaction_service.js 可以看出,官方把这件事拆成了几层:

  • URL path:承载最核心、最适合分享的定位信息;
  • query:承载补充参数;
  • history state:承载更完整的当前运行时状态;
  • sessionStorage:承载短期恢复快照;
  • actionStack:承载工作流级的导航上下文。

所以更准确的一句话是:

Odoo router 管的不是“页面地址”,而是 Web Client 的状态记忆系统。

理解这一点,你再做菜单跳转、client action、深链接、内嵌动作或移动端壳层适配时,很多“为什么明明 URL 对了,界面还是不对”的问题就会顺很多。

DISCUSSION

评论区

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