很多二开同学第一次写表单页 tabs,都会直觉地做成这样:
- 准备一组页签配置;
- 记录一个当前 tab id;
- 点谁就渲染谁。
表面上看,Odoo 的 Notebook 组件当然也在做这些事。
但如果你只把它当成“一个 tabs 组件”,很快就会遇到三个解释不清的问题:
defaultPage指向的 tab 不可见时,为什么界面不会坏掉?- 某些页签隐藏、禁用或重排后,当前页为什么还能平稳回退?
- 表单字段校验失败时,为什么错误提示能直接映射到具体 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;
- 通过
pagesprops 传组件数组。
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 过渡类。
这样做的好处有两个:
- 切页时能显式重置当前面板的可见过渡状态;
- 页签切换后的回调与动画 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
评论区