很多人第一次看 Odoo 前端跳转,会把它理解成一种“更复杂一点的单页路由”:
- 点菜单,改 URL;
- 点记录,改 URL;
- 刷新后再从 URL 还原回来。
这话只对了一半。
真正把 Odoo Web Client 撑起来的,不只是一个 router,而是 addons/web/static/src/core/browser/router.js 和 addons/web/static/src/webclient/actions/action_service.js 共同维护的一套状态接力系统。
它解决的不是“地址栏怎么变”,而是下面这些更麻烦的问题:
- 一个用户动作背后可能不只是一层页面,而是一串 action 栈;
- 地址栏里既要可分享,又不能把所有内部状态全暴露出去;
- 浏览器前进后退、刷新、从旧
/web#...链接跳进来,都要尽量回到同一个界面; - 某些状态适合进 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 里可能有:
actionmodelview_typeresIdactive_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,有几个现实意义:
- 刷新后恢复更稳:尤其当 URL 已经被裁剪成“适合公开展示”的版本时;
- 避免某些隐含状态在 URL 往返中丢失;
- 给 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 还额外接管了:
popstatepageshow- 浏览器里的内部
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,会有几个问题:
- 地址栏过长、难读;
- 暴露内部实现细节;
- 升级时 URL 兼容性变脆;
- 分享给别人时,带上大量本不该共享的临时状态。
所以 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.js 和 action_service.js 可以看出,官方把这件事拆成了几层:
- URL path:承载最核心、最适合分享的定位信息;
- query:承载补充参数;
- history state:承载更完整的当前运行时状态;
- sessionStorage:承载短期恢复快照;
- actionStack:承载工作流级的导航上下文。
所以更准确的一句话是:
Odoo router 管的不是“页面地址”,而是 Web Client 的状态记忆系统。
理解这一点,你再做菜单跳转、client action、深链接、内嵌动作或移动端壳层适配时,很多“为什么明明 URL 对了,界面还是不对”的问题就会顺很多。
DISCUSSION
评论区