前端

Odoo Notebook 为什么不会“切个 tab 就把内容全忘了”:active page、默认页回退与失效提示链路讲透

很多人第一次看 Odoo 的 Notebook 组件,会把它理解成一个普通 tabs 容器:点击 tab,切换面板,结束。但从 `notebook.js` 和 `notebook.xml` 往下看,官方真正维护的是一套更克制的页面切换契约:当前激活页如何选、默认页失效时怎么回退、不可见页如何跳过、字段校验异常又如何回流到 tab 标题。本文把这条前端链路拆开讲透。

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

很多二开同学第一次写表单页 tabs,都会直觉地做成这样:

  • 准备一组页签配置;
  • 记录一个当前 tab id;
  • 点谁就渲染谁。

表面上看,Odoo 的 Notebook 组件当然也在做这些事。

但如果你只把它当成“一个 tabs 组件”,很快就会遇到三个解释不清的问题:

  1. defaultPage 指向的 tab 不可见时,为什么界面不会坏掉?
  2. 某些页签隐藏、禁用或重排后,当前页为什么还能平稳回退?
  3. 表单字段校验失败时,为什么错误提示能直接映射到具体 tab?

这些都不是 CSS 层的小技巧,而是 addons/web/static/src/core/notebook/notebook.js 明确维护的状态契约。

一、Notebook 的重点不是“画 tab”,而是决定哪一页有资格成为当前页

setup() 里第一批关键逻辑有三段:

  • this.pages = this.computePages(this.props)
  • this.state = useState({ currentPage: null })
  • this.state.currentPage = this.computeActivePage(this.props.defaultPage, true)

这说明 Notebook 的核心工作不是立即渲染所有 slot,而是先把“候选页面列表”标准化,再挑出一个合法的 currentPage

这里最重要的判断标准不是标题,而是页面身份和可见性

  • 每页最终都有一个 id
  • navItems 只保留 isVisible 为真的页;
  • computeActivePage() 只会在可见页集合中选择激活项。

也就是说,Notebook 真正在维护的是:

当前组件树里,哪一页现在既存在、又可见、还能被安全激活。

二、computePages() 先把 slot / pages 两种入口压成一份统一数据结构

Odoo 的 Notebook 同时支持两种供稿方式:

  • 在模板里写 slot;
  • 通过 pages props 传组件数组。

computePages() 的价值,就是把这两类输入合并成统一数组,再处理顺序、禁用态和页面 id。

这段逻辑里有几个很实用的细节:

1)id = v.id || k

外部若没显式传 id,Notebook 会退回键名。这样做的意义是:

  • 页面身份可以稳定;
  • defaultPage 有明确匹配目标;
  • tab 重排时不必把“显示顺序”和“页面身份”绑死。

2)带 index 的页会被单独抽出来插回指定位置

这说明官方并不把传入顺序当成唯一真相,而是允许页面声明自己的排序意图。

对复杂模块很重要:某些扩展页签可能来自继承组件或附加模块,若只能按注入顺序排,界面很容易失控。

3)禁用页和可见页是两套边界

isDisabled 会被放入 this.disabledPages,但并不会从页面集合里彻底删除。

这背后有个很清楚的设计判断:

  • 不可见:它不该出现在导航中;
  • 禁用:它还在结构里,但此刻不允许进入。

很多二开 tabs 一律把两者混成“隐藏”,结果既难调试,也难表达业务意图。

三、computeActivePage() 才是 Notebook 稳定感的来源

Notebook 为什么“不容易切崩”,关键就在 computeActivePage()

这段逻辑其实在处理三层回退:

1)先看 defaultPage 还在不在可见集合里

如果在,而且这轮应该激活默认页,就直接使用它。

2)默认页不存在时,记录 defaultVisible = false

这不是无关紧要的布尔值。后面 onWillUpdateProps() 会根据它判断:

  • 是继续沿用当前页;
  • 还是重新尝试默认页。

所以 defaultVisible 的本质作用,是让 Notebook 知道:

上一轮没法激活默认页,不代表下一轮也不行;如果可见性恢复,要给默认页重新上场的机会。

3)当前页无效时,回退到首个可见页

这一步是最关键的兜底。只要仍有可见页,Notebook 就不会把 currentPage 留在一个失效 id 上。

因此当页签动态显示/隐藏时,用户看到的不是空白页,而是自动跳到当前仍合法的第一个候选页

四、为什么切页前先移除 show,切完再通过 effect 补回去

activatePage() 里有一段很容易被忽略:

  • this.activePane.el?.classList.remove("show")
  • 再更新 this.state.currentPage

useEffect() 又会在当前页变化后:

  • 触发 onPageUpdate(this.state.currentPage)
  • activePane 再加回 show

这说明 Notebook 不是简单靠 Owl 重渲染完成视觉切换,而是主动控制 Bootstrap 式 fade/show 过渡类。

这样做的好处有两个:

  1. 切页时能显式重置当前面板的可见过渡状态;
  2. 页签切换后的回调与动画 class 更新,可以挂在同一轮响应式更新之后执行。

也就是说,Notebook 不只是在切“数据页”,也在切“过渡中的可见 pane”。

五、computeInvalidPages() 解释了为什么 tab 能感知表单字段错误

很多人会把 tab 上的小红标或错误态理解成视图层装饰,但源码给出的路径更直接。

computeInvalidPages() 会遍历 navItems,检查每个页签配置里的 fieldNames,再通过:

this.env.model?.root.isFieldInvalid(fieldName)

判断这些字段是否校验失败。

一旦某页包含无效字段,就把对应页 id 放进 invalidPages,最终在模板里加上 o_page_invalid class。

这件事非常重要,因为它暴露了 Odoo Notebook 的一个真实定位:

它不是纯视觉导航,而是表单状态的导航索引。

换句话说,tab 标题本身就是校验反馈的一部分。用户不用先点进去,先在页签层就能知道问题落在哪一页。

六、模板层只渲染当前页,意味着“页签多”不等于“页面都已挂载”

notebook.xml 里内容区很克制:

  • 只有一个 .tab-pane.active.fade
  • 里面只渲染当前 page.Component,或者当前 slot

这意味着 Odoo Notebook 默认不是“所有页签都挂着,只是切 display:none”,而是当前只挂载一页内容

这会带来两个直接结果:

1)性能更稳

表单中若每个 tab 都有重组件、子列表或复杂 hooks,同时挂满所有页会非常浪费。

2)局部状态要明确设计

因为切页可能伴随组件卸载/重建,所以如果你把某些临时状态错误地塞在页组件自身,而不是放到更稳定的 model / service / parent state 里,就会出现“切个 tab 状态没了”的错觉。

其实不是 Notebook 弄丢了数据,而是你把状态放在了会被替换的挂载层。

七、二开时最容易踩的坑

误区 1:拿标题当身份,而不是给稳定 id

标题会翻译、会改文案,也可能重复。真正该绑定的是页 id。

误区 2:动态隐藏页签,却不考虑当前页回退

如果你自己包一层 tabs,又没做 computeActivePage() 这种兜底,一旦当前页突然不可见,界面大概率直接空白。

误区 3:把“禁用”和“不可见”混成一回事

业务上它们表达的是不同边界。Notebook 源码把两者拆开,是对的。

误区 4:以为页签错误态要手搓 DOM 标记

如果页面本身就是字段分组容器,更合理的做法是沿着 fieldNames -> model invalid state -> nav class 这条链路去接,而不是单独维护一套红点状态。

八、结论

Odoo 的 Notebook 稳,不是因为它“标签页写得好看”,而是因为它把 tab 切换抽象成了一个明确的激活页协议:

  • computePages() 统一页面来源、排序和禁用边界;
  • computeActivePage() 负责默认页、当前页和首个可见页之间的安全回退;
  • computeInvalidPages() 把表单校验态抬升到导航层;
  • 模板层只挂当前页,让性能和状态边界都更清楚。

所以真正值得学的不是“怎么画一个 tab”,而是:

当页面结构会动态变化时,前端该如何持续维护一个合法、可解释、可恢复的当前页。

DISCUSSION

评论区

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