前端

Odoo 前端模板为什么不是“后加载覆盖前加载”:QWeb 继承、运行时合并与翻译上下文讲透

很多人把 Odoo 前端模板继承理解成“谁最后加载,谁把前面的模板盖掉”。但从 `template_inheritance.js` 和 `templates.js` 来看,官方做的是一套运行时模板合并机制:先注册 primary template,再登记 extension,再按 blockId、URL 过滤、XPath/属性匹配与翻译上下文把节点补进最终模板。本文结合源码,讲清 Odoo 为什么敢在浏览器里继续做模板继承,而不是只依赖服务端把模板一次性编平。

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

很多人第一次看 Odoo 前端模板,会有一个很自然的想法:

  • 基础模板先注册;
  • 扩展模板后注册;
  • 后面把前面覆盖掉;
  • 最后页面拿到一个“最终版本”。

这个直觉不算全错,但它过于粗糙。

如果你顺着 addons/web/static/src/core/template_inheritance.jstemplates.js 读下去,会发现 Odoo 浏览器端并不是在做“字符串覆盖”,而是在做一套可增量、可继承、可回放的模板合并系统

它关心的核心不是“谁最后生效”,而是:

多个模块、多个扩展块、多个 URL 场景下,同一份模板应该如何被稳定地、可解释地拼成最终可运行结构。

这也是为什么 Odoo 前端模板体系能长期承载:

  • 主模板;
  • 扩展模板;
  • 不同 addon 注入;
  • 不同页面场景过滤;
  • 翻译上下文保留;
  • 甚至节点移动与属性级修改。

一、templates.js 先把模板分成两类:primary template 与 extension

templates.js 里最重要的不是“如何渲染”,而是“如何登记来源”。

它至少维护了几类状态:

  • templates:主模板内容;
  • templateExtensions:扩展模板;
  • info:模板来自哪个 URL、属于哪个 block;
  • processedTemplates:已经计算好的结果缓存;
  • registered:避免重复注册。

这里有一个非常关键的设计:blockTypeblockId

每当模板注册从“主模板阶段”切到“扩展阶段”,或者反过来,blockId 会推进。这样做的意义是:

Odoo 不是只记“有什么扩展”,还记“这些扩展是按哪一批次进入系统的”。

这给后面的合并顺序提供了稳定基线。不是“谁执行得晚谁赢”,而是“同一模板的扩展按注册块顺序回放”。

二、_getTemplate() 体现的不是渲染,而是“构建最终模板”

getTemplate(name) 最终会调用 _getTemplate(name)。这一步非常像编译器里的 build 阶段。

它大致会做四件事:

  1. 解析模板字符串成 XML DOM;
  2. 如果模板声明了 t-inherit,先拿到父模板;
  3. 把当前模板的继承操作应用到父模板上;
  4. 再按 blockId 顺序继续应用所有 extension。

注意这个顺序非常重要。

也就是说,前端不是简单保存一份“原始模板 + 补丁数组”,等渲染时临时处理,而是会主动把继承链折叠成一棵最终 DOM 树,然后缓存结果。

这就是为什么 processedTemplates 存在:

  • 继承和扩展可以很复杂;
  • 但最终结果不该每次现算;
  • 所以 Odoo 把“模板合并”当成一个值得缓存的计算过程。

三、template_inheritance.js 的重点不是 XPath,而是“安全地改树”

很多人读到 xpath 就会把注意力全放在选择器上,以为前端继承的难点就是“找到目标节点”。

其实源码真正难的地方在于:

  • 找到后怎么改;
  • 改的时候如何不把文本节点、兄弟节点、翻译上下文弄乱;
  • move / before / after / attributes 这些操作怎样保持结构稳定。

1)getNode() / getElement() 负责定位目标

如果扩展节点是 <xpath expr="...">,源码会把服务端可用的 hasclass() 函数改写成浏览器 XPath 可理解的条件表达式。

这一步很有意思,因为它说明:

浏览器端继承并不是另起一套规则,而是在尽量对齐服务端模板继承语义。

如果不是 xpath,它还支持基于 tagName + attributes 的匹配。这让一些简单扩展不必写完整 XPath。

2)addBefore() / getNodes() 负责节点插入与移动

扩展模板的子节点不一定都是“新建节点”。如果子节点本身是 position="move" 的 xpath,源码会先把目标节点从原位置摘出来,再插到新位置。

这意味着 Odoo 前端继承不只是“增删改属性”,还支持真正的结构重排

这对复杂 UI 很关键。很多模块扩展不是加一个按钮,而是把已有区域换个位置、挪到别的容器里、再顺手改几个属性。

3)文本节点处理比想象中更麻烦

addBefore() 里有一段看起来不起眼,其实很讲究:它会处理前一个兄弟节点是文本节点时的右侧空白裁剪。

为什么?

因为 XML 模板继承不是浏览器真实手写 DOM 场景。继承前后的换行、缩进、空白,很容易让文本节点边界变脏。

如果不处理,你最后得到的不是“结构对了,显示也对了”,而是:

  • 文本莫名多空格;
  • 换行位置不稳定;
  • 有时甚至影响翻译文本切分。

这类问题特别烦,因为视觉上像随机 bug,本质却是模板树操作没把 whitespace 当一等公民。

四、翻译上下文为什么要跟着模板继承走

template_inheritance.js 里最值得低估的一段,是 t-translation-context 的传递。

源码里做了两件事:

  • 给元素或文本节点记录当前翻译上下文;
  • 最后通过 applyContextToTextNode() 给文本节点包一层临时 <t>,把上下文真正落到文本上。

这说明 Odoo 不是把翻译看成“最终渲染时再查一遍字符串”,而是认为:

模板继承、节点复制、节点移动之后,翻译上下文必须依旧准确附着在对应文本上。

否则会出什么问题?

  • 同一句英文出现在不同 addon,却该译成不同语义;
  • 继承后的文本节点失去来源上下文;
  • 前端提取翻译时分不清它属于哪个模块。

这也是 deepClone() 不是普通 cloneNode(true) 的原因之一:克隆时还得把文本节点上下文映射一起带过去。

五、为什么 Odoo 还要按 URL 过滤模板扩展

templates.js 里有 urlFilters,并且 extension 在真正应用前会经过过滤。

这个设计非常像“按场景装配模板”。它的意义是:

  • 不是所有扩展都该在所有页面生效;
  • 同一模板名,在不同 bundle / 页面入口里,实际激活的 extension 可能不同;
  • 系统需要有能力只回放与当前 URL 匹配的扩展。

这点很像资源包与模板系统之间的桥。

如果没有这层过滤,前端模板继承很容易失控:

  • 网站页面带进后台模板扩展;
  • 某个功能区专属 extension 污染全站;
  • 同名模板在不同入口里互相踩。

所以这里不是“附加条件”,而是让模板继承可规模化的必要边界。

六、primary template 与 extension 的差别,本质上是“谁定义骨架,谁追加变更”

registerTemplate()registerTemplateExtension() 的实现能看出,Odoo 很明确地区分了两类动作:

registerTemplate()

  • 声明一个模板名字对应的主内容;
  • 如果同名模板已经存在且 URL / 内容不一致,会直接报错;
  • 说明主模板必须保持唯一骨架身份。

registerTemplateExtension()

  • 针对已有模板登记附加操作;
  • 可以按 blockId 分批加入;
  • 最终在 _getTemplate() 里按顺序叠加。

这两者的差别非常重要。因为如果 extension 也能像 primary template 那样直接“重定义”,模板生态就会很快退化成谁先谁后碰运气。

Odoo 没这么做,而是把 extension 明确限定为对既有骨架做操作

七、这套机制解决了哪些二开痛点

误区 1:把模板继承理解成简单覆盖

如果你这样理解,就会不明白为什么有些扩展会“插进去”而不是“替换掉”,也不理解 move、attribute 修改、translation context 保留这些细节为什么存在。

误区 2:觉得前端模板继承只是服务端遗留包袱

恰恰相反。前端继续保留模板继承,说明 Odoo 想让浏览器端也拥有模块化装配能力,而不是把所有模板都预先编平成静态产物。

误区 3:忽视缓存与注销

processedTemplatesregistered、以及 unregisterTemplate() / unregisterTemplateExtension() 的返回值,都说明模板系统不是一次性全局常量,而是可以动态注册与回收的。测试环境、热更新、模块切换都需要这种能力。

误区 4:只盯 XPath,不看结构语义

很多模板 bug 最后不是 XPath 写错,而是:

  • 你没意识到目标节点可能是文本节点边界;
  • 你做了 move 却没考虑翻译上下文;
  • 你让扩展跨 URL 场景误生效;
  • 你误把 extension 当 primary template 去思考。

八、结论

Odoo 前端模板继承,远不是“后加载覆盖前加载”这么简单。

从源码看,它真正做的是:

  • 用 primary template 定义骨架;
  • 用 extension 记录增量修改;
  • 用 blockId 维持稳定回放顺序;
  • 用 XPath / 属性匹配去定位节点;
  • 用节点移动、插入、属性修改去改树;
  • 用翻译上下文把文本来源一路保住;
  • 再按 URL 场景过滤后构建最终模板并缓存。

所以更准确的理解应该是:

Odoo 前端模板系统不是“覆盖式模板替换”,而是一套浏览器端可回放的继承与装配引擎。

理解了这一点,你以后看模板扩展,就不会再问“为什么它没把原模板盖掉”,而会开始问更对的问题:

  • 它改的是骨架还是扩展;
  • 它插的是新节点还是移动旧节点;
  • 它在哪个 block、哪个 URL 场景生效;
  • 它会不会破坏翻译上下文与最终缓存结果。

DISCUSSION

评论区

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