分享 / Odoo / 从 Owl 2 到 Owl 3

从 Owl 2 到 Owl 3

一、Odoo 的前端框架 OWL

你每天在写的 form、list、kanban 视图,聊天、设置中心的每一个面板,底层都是一个个 Owl 组件。组件就是一段自带逻辑和 HTML 模板、可以嵌套复用的界面单位。Owl 是 Odoo 自研的前端组件框架。Odoo 不用 React 或 Vue,是因为 ERP 前端有自己的口味:极小的包体,XML 模板(和后端 QWeb 视图一脉相承),精细的局部更新:一个几万行的表单里改一个字段,只重画那一小块。

现在上游有两条版本线:Owl 2 稳定线停在 2.8.4,Odoo 17/18/19 用的都是它;Owl 3 还在 3.0.0-alpha 阶段,而 Odoo 20 随车发货的就是这个 alpha。把一个在 17 上跑得好好的模块搬到 20,第一件撞上的事就是 Owl 3。

这篇文章会介绍:Owl 3 解决了 Owl 2 的什么问题、优化了什么。接下来每一节都是"Owl 2 的问题 → Owl 3 的解法"成对出现,覆盖响应式、props、模板作用域、生命周期、env 和性能六个方面。你只需要写过 Odoo 前端,不需要系统学过 Owl;文末有一份落地清单,和一个我们自己踩的小坑作为"旧写法靠不住"的实证。

二、响应式:状态不该长在组件上

先看一段 Owl 2 上最典型的组件,17/18/19 到处都是这种写法:


import { Component, useState } from "@odoo/owl";

export class Counter extends Component {
    static template = "my.Counter";
    setup() {
        this.state = useState({ count: 0 });
    }
    increment() { this.state.count++; }
}

Owl 2 的问题:useState() 建立的是一个响应式对象。响应式指数据一变,,用到它的界面自动跟着更新。但这个能力依附于组件实例:状态挂在 this.state 上,一变就整体重渲染这个组件;想让另一个组件用同一份数据,只能沿组件树一层层传参数,传到孙组件还得中间那层接力。状态想搬到组件外面住?Owl 2 没有这扇门。

Owl 3 的解法:整套响应式重写,核心是三个原语。signal 是一个"会通知别人我变了"的值,谁在渲染里读过它,它一变就通知谁重画;proxy 是包在普通对象外面的代理对象,改它任何一层属性都会被察觉;computed 是派生值,依赖变了它自动跟着变。关键区别一句话:这些值不再和组件绑定:放在组件外、服务里、任何地方都成立。

对多数组件来说,迁移只是换个名字:useState 在 Owl 3 里已删除:


// 改前(Owl 2)
this.state = useState({ diffOnly: false });

// 改后(Owl 3)
this.state = proxy({ diffOnly: false });

状态脱离组件之后,共享变成一件朴素的事:把一个 signal 或 proxy 放进服务里,谁用谁读,不用再传参接力:


// 改后(Owl 3):不属于任何组件的一份共享状态
export const filters = signal({});
// 任何组件里:filters() 读;filters.set(next) 写;读过它的界面自动更新

日常还配得到 computed(派生值)和 effect(副作用)等原语,不过迁移时用得最多的就是 proxy。

三、props:声明代替不了校验

props 是父组件传给子组件的参数。Owl 2 把声明写在类的静态字段上:


// 改前(Owl 2)
static props = {
    presets: Array,
    currentId: { type: Number, optional: true },
};
static defaultProps = { currentId: 0 };

Owl 2 的问题:这份声明只是"尽力而为":开发模式下看看类型,看不住就放过去;默认值要另开一个 defaultProps 手写;哪个必填、哪个可选,光看声明也说不清。写错了不会当场报,要等界面渲染出诡异的空白,你才回头怀疑参数。

Owl 3 的解法:static props 和 defaultProps 一起退场,换成实例字段加一个 hook。hook 是组件里向框架借能力的函数,名字都以 use 开头。新的 useProps() 接收一份用 t.* 校验器写出的 schema。schema 就是"每个参数是什么类型、默认值多少、是否必填"的一份契约:


// 改后(Owl 3)
props = useProps({
    presets: t.array(),
    currentId: t.number().optional(0),
    onCommit: t.function(),
});

类型、默认值、必填,四行说清:没标 optional() 的参数一律按必填对待,写错当场报。这不是凭空变严:早在 18 分支,一个初始化成 null 的 object 型 prop 就被开发模式的校验拒收过,Owl 3 只是把这个方向贯彻到底。另有一个连带变化要知道:schema 是实例字段,子类想扩展参数,必须重述基础类的键,Owl 2 那种静态合并救不了。

四、模板作用域:隐式解析难以查错

Owl 2 的问题:模板里可以省略前缀:rows、select(row.id) 直接裸写,不加 this.,框架隐式帮你解析到组件身上。写起来顺手,代价是模板没法静态分析:工具分不清一个名字是组件成员还是手滑拼错的词,拼错了它也不报,“这个名字从哪来"全靠脑补,调错自然费劲。

Owl 3 的解法:隐式作用域整个删掉,组件成员统一放到渲染上下文的 this. 上,必须写全:


<!-- 改前(Owl 2,17/18/19 仍正确) -->
<tr t-foreach="rows" t-as="row" t-on-click="() => select(row.id)">

<!-- 改后(Owl 3,Odoo 20) -->
<tr t-foreach="this.rows" t-as="row" t-on-click="() => this.select(row.id)">

多敲五个字母,换来实打实的可静态分析:每个名字来源显式,拼错一个标识符工具一扫就知道。就算漏了,报错也比 Owl 2 直接:裸标识符静默变成 undefined,模板读到它的属性时炸出的那个属性名,正对应你没写 this. 的标识符:


TypeError: Cannot read properties of undefined (reading 'diffFields')

还有个小坑顺带记住:连字符的动态属性绑定被砍了,t-att-data-icon 这类写法静默失效,常量场景直接写静态的 data-icon;Owl 3 有原生 t-model,但同样必须 this. 前缀。

五、生命周期与 hook:渲染交给框架

生命周期指组件从创建、挂载到销毁的各个时点,hook 让你在这些时点上插代码。Owl 2 的问题:一批 hook 语义含糊:onRendered 到底是"每次渲染后"还是"首次挂载后”,文档和直觉常有出入;更要命的是 this.render() 要手动调,什么时候调、调几次全凭自觉,“忘了 render"和"渲染了两次"都是日常事故。

Owl 3 的解法:删掉一批、改名一批,渲染改由框架负责:你只管改状态,框架自己安排何时重画:

Owl 2Owl 3说明useChildRef()signal.ref()取子组件引用onRenderedonMounted / onWillRender拆成挂载后/渲染前,语义各自清楚useChildSubEnvuseSubEnv合并useComponent已删除this.render()不要了改状态即可,框架负责重画 this.render() 的消失值得单独看一眼:


// 改前(Owl 2):改完状态,记得手动喊一嗓子
this.state.items = next;
this.render();

// 改后(Owl 3):只改状态,重画是框架的事
this.state.items = next;

最常见的一个 hook 对照,取子组件引用:


// 改前(Owl 2)
this.popoverRef = useChildRef();

// 改后(Owl 3)
this.popoverRef = signal.ref();

另一批是改名统一:props hook → useProps、config → useConfig、plugin → usePlugin。

六、env:从内置对象到按需插件

env 是组件树共享的全局环境,服务、事件总线、动作配置都挂在上面。Owl 2 的问题:它是一个内置对象,随手就能取到:this.env.bus、this.env.config,任何组件任何地方都能碰。后果是:一个组件依赖了什么共享能力,代码里看不出来;想替换其中一件做测试,只能动整锅。

Owl 3 的解法:把 env 拆成 Plugin(插件):挂在应用上、按需取用、可整体替换的一块共享能力。谁需要事件总线,谁就显式声明:


// 改前(Owl 2)
this.env.bus.addEventListener("MENUS:APP-CHANGED", () => this.loadApps());

// 改后(Owl 3)
this.bus = usePlugin(GlobalBusPlugin).bus;
this.bus.addEventListener("MENUS:APP-CHANGED", () => this.loadApps());

配置读取同理,不再从 env 上拿:


// 改前(Owl 2)
const atRoot = this.env.config.breadcrumbs.length === 0;

// 改后(Owl 3)
const atRoot = this.actionService.currentController.config.breadcrumbs.length === 0;

依赖显式化之后,组件用到什么共享能力,代码里就有一行 usePlugin 自我介绍;测试时想换个假实现,也只改这一处。env 本身还是冻结的,this.env = x 这种赋值会直接抛错。

七、性能与包体:更小的包体,更细的渲染粒度

有实测数字的一处:Owl 3 本体约 228 KB(实测 227,715 字节),比 Owl 2 的约 260 KB 小约 13%。框架要发到每一个浏览器标签页,这省下的是每个用户的加载时间;对用户量大的 ERP 场景,包体每小一分,弱网下的首屏就快一分。

渲染粒度这一条是推断,没有实测就不编数字:Owl 2 的响应式以组件为单位,状态一变、整个组件重渲染;Owl 3 的 signal 在读取时记录依赖,更新时只重画真正读过这个值的那部分界面。表单越大、组件树越深,这个机制上的差距越值得关注;具体幅度要等基准测试,此处只说机制。

八、兼容层:旧写法为什么还能运行,以及一次白屏

读到这里你一定会问:既然删了这么多,为什么很多没改过的模块在 Odoo 20 上还在跑?

因为车上装了一个兼容层:一段桥接代码,跟在 Owl 3 本体后面加载,把旧写法翻译成新 API:你写 useState 它给你指到 proxy,你不声明 props 它在构造器里自动补一个。这是上游给生态的过渡期善意:对上游来说,兼容层买的是时间,让几千个第三方模块和核心自己分批迁移,而不是一刀切。

但兼容层只是过渡机制,不是长期基础。从 5 月底落地到 9 月底,上游差不多每一两周拆一块:6 月把 useState 改指 proxy,7 月底禁止 defaultProps,8 月删 onRendered,9 月下旬更进一步:任何组件只要带着静态 props,构造器直接抛错。而 Odoo 20 的镜像是滚动的,每次拉取都可能换一个兼容层版本,“同一个 Odoo 20"在不同日期行为不一样。

核心自己也是靠它过渡的:到 9 月中,Odoo 20 核心 web 源码里 useState 已一处不剩,九成以上组件文件改用了 useProps,但仍有零星文件踩在桥上。所以 20 分支的真实生态是新旧并存:核心大多原生,你的老模块靠兼容层继续运行,直到某次更新把兼容层的支持撤掉。

我们被抽掉过一次,值得完整记一遍,它是"兼容层会收紧"的实证:

  • 现象:10 月初,镜像更新后的第二天,我们主题模块的入口选择器在所有页面上一起消失,代码没人动过,页面其余一切正常。

  • 弯路:第一反应是怀疑自己改坏了渲染逻辑,从头翻了一遍一无所获;其实控制台里一直有一句报错:“组件声明了静态 props,Owl 3 会忽略它”,我们没第一时间读它。

  • 根因:正撞上 9 月下旬那次收紧,选择器还是静态 props 的旧写法,在挂载之前就被兼容层抛死了。

  • 修法:按前文对照改写:props 换 useProps、事件总线换插件,姊妹模块按同样路线逐个移植。

  • 余震:修完入场动画偶发消失,原因是 Owl 3 重建节点导致就绪时拿到旧节点,改成重新解析当前活跃节点再挂载。

九、落地清单

20 分支(Odoo 20)

  • props 一律 props = useProps({t.*});“没有 props“的组件也不要留 static props,继承来的也算,兼容层沿原型链检查。

  • 状态用 proxy/signal;事件总线走 usePlugin;配置不再读 env。

  • 模板成员全部 this. 前缀,没有例外。

  • 禁止清单:static props、defaultProps、useState、env.bus、this.render()。

报错对症

  • defines a static "props" ... which Owl 3 ignores:组件还留着静态 props(继承也算)→ 改 useProps,子类要重述基础键。

  • Cannot read properties of undefined,属性名看着眼熟:模板里有裸标识符没写 this.,报错里的属性名就是它。

  • 组件整块不渲染、却几乎没报错:很可能在构造阶段就被兼容层抛掉了,你的错误处理没机会接到。

  • 图标类全部失效:20 分支不再随带旧图标字体,fa 这类外部图标类要换掉。

十、总结:从隐式约定到显式契约

回头看这六件事,方向是同一个:把"隐式约定"换成"显式契约”。

状态不再隐式地长在组件身上,而是明写的 signal/proxy;props 不再是尽力而为的声明,而是 t.* 写出的类型契约;模板名字不再隐式解析,而是 this. 前缀的显式来源;渲染不再靠手动 render(),共享能力不再来自一个整体的 env 对象。每一处都是上手多敲几个字,换来出错更早、更好维护。

六件事的一行版:状态 useState → proxy/signal;props static → useProps;模板全员 this.;渲染交给框架;env 拆成插件;包体更小、粒度更细。