Odoo 19 前端 OWL 框架开发指南
并入教材:本指南全文已合并进
32-OWL框架.md(第三十二章,含教材体练习与截图占位)。印刷或统一维护请以该章为准;本文件可作独立副本保留。版本: Odoo 19.0 | 日期: 2026 年 3 月
Odoo 19 的前端架构深度依赖于其自研的 OWL(Odoo Web Library) 框架。OWL 是一个现代化的声明式组件系统,其设计理念在很大程度上受到了 Vue 和 React 的启发。随着 Odoo 版本的演进,旧版的 Widget 系统正逐步被原生 JavaScript 类和 OWL 组件全面替代。Odoo 官方明确建议:所有新开发工作都应尽可能使用 OWL 完成 1。
本指南将深入探讨 Odoo 19 前端开发中的五大核心主题:生命周期与 Hooks、注册表系统(Registries)、服务系统(Services)、代码补丁机制(Patching) 以及 SCSS 继承体系,旨在为开发者提供全面、专业的技术参考。
第一章 生命周期与 Hooks
在 OWL 组件的开发中,生命周期钩子(Lifecycle Hooks)赋予了开发者在组件不同阶段执行特定逻辑的能力。通过合理使用这些钩子,可以精准控制组件的初始化、数据加载、DOM 操作以及资源清理 2。
1.1 核心生命周期钩子
OWL 组件的生命周期由多个关键阶段组成,每个阶段都有对应的钩子函数供开发者调用。以下是 Odoo 19 中最常用的生命周期钩子及其应用场景 3:
| 钩子函数 | 执行时机 | 典型应用场景与注意事项 |
|---|---|---|
setup() |
组件实例化后立即执行 | 组件的基石。用于初始化状态、引入服务以及注册其他生命周期钩子。此时 DOM 尚未生成。继承时必须调用 super.setup()。 |
onWillStart() |
首次渲染前执行 | 支持异步操作,阻塞渲染直到完成。常用于 RPC 请求或加载配置。 |
onMounted() |
组件挂载到 DOM 后执行 | 所有 HTML 元素均可访问。适用于初始化第三方库、设置焦点或绑定事件监听器。 |
onWillUpdateProps() |
接收新 Props 前执行 | 允许在重新渲染前对属性变化做出响应,避免旧数据残留。 |
onPatched() |
组件更新并重新渲染后执行 | 可用于重新计算元素尺寸或刷新依赖 DOM 的第三方插件。 |
onWillUnmount() |
组件从 DOM 移除前执行 | 清除定时器、取消事件订阅或销毁外部库实例,防止内存泄漏。 |
完整生命周期示例:以下代码展示了一个综合使用多个生命周期钩子的 OWL 组件:
/** @odoo-module **/
import { Component, useState, onWillStart, onMounted, onWillUnmount } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class SalesDashboard extends Component {
static template = "my_module.SalesDashboard";
setup() {
// 1. 初始化响应式状态
this.state = useState({
orders: [],
isLoading: true,
});
// 2. 引入 ORM 服务
this.orm = useService("orm");
// 3. 注册生命周期钩子
onWillStart(async () => {
// 首次渲染前从后端加载数据
const orders = await this.orm.searchRead(
"sale.order",
[["state", "=", "sale"]],
["name", "amount_total", "partner_id"]
);
this.state.orders = orders;
this.state.isLoading = false;
});
onMounted(() => {
// DOM 就绪后初始化图表库
console.log("Dashboard mounted, DOM is ready.");
this._initChart();
});
onWillUnmount(() => {
// 组件销毁前清理资源
if (this.chartInstance) {
this.chartInstance.destroy();
}
console.log("Dashboard unmounted, resources cleaned.");
});
}
_initChart() {
// 初始化第三方图表(如 Chart.js)
const canvas = document.querySelector(".sales-chart");
if (canvas) {
this.chartInstance = new Chart(canvas, { /* ... */ });
}
}
}
1.2 Odoo 专用 Hooks
除了标准的生命周期钩子,Odoo 框架还提供了一系列实用的自定义 Hooks,用于解决特定的业务需求 4。
| Hook 名称 | 所在模块路径 | 功能描述 |
|---|---|---|
useAssets |
@web/core/assets |
按需懒加载静态资源,优化初始加载速度。 |
useAutofocus |
@web/core/utils/hooks |
自动聚焦 t-ref="autofocus" 元素。 |
useBus |
@web/core/utils/hooks |
简化事件总线订阅,组件卸载时自动清理。 |
usePager |
@web/search/pager_hook |
渲染和管理视图控制面板的分页器。 |
usePosition |
@web/core/position_hook |
将元素相对于目标进行定位,支持自动更新。 |
useSpellCheck |
@web/core/utils/hooks |
为输入框激活拼写检查,失焦时移除样式。 |
useService |
@web/core/utils/hooks |
在组件中引入注册表中定义的服务。 |
useBus 实战示例:监听全局事件总线上的自定义事件:
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { useBus } from "@web/core/utils/hooks";
export class NotificationListener extends Component {
static template = "my_module.NotificationListener";
setup() {
// 订阅全局事件总线,组件卸载时自动取消
useBus(this.env.bus, "CUSTOM_ORDER_CREATED", (event) => {
console.log("New order created:", event.detail);
this._refreshData();
});
}
_refreshData() {
// 刷新当前视图数据
}
}
usePosition 实战示例:创建一个相对定位的弹出菜单:
/** @odoo-module **/
import { Component, useRef, xml } from "@odoo/owl";
import { usePosition } from "@web/core/position_hook";
export class DropdownMenu extends Component {
static template = xml`
<button t-ref="toggler" t-on-click="toggle">Options</button>
<div t-ref="popper" t-if="state.isOpen" class="dropdown-content">
<t t-slot="default"/>
</div>
`;
setup() {
const toggler = useRef("toggler");
usePosition(
() => toggler.el,
{
popper: "popper",
position: "bottom-start",
margin: 4,
onPositioned: (el, { direction }) => {
el.classList.toggle("drop-up", direction === "top");
},
}
);
}
}
第二章 注册表系统 (Registries)
注册表(Registries)是 Odoo 前端框架扩展性的核心机制。它本质上是一个有序的键值对映射字典,用于存储特定类型的对象(如组件、服务、格式化函数等)。当框架需要某个定义时,会直接在相应的注册表中进行查找。正如官方文档所述:
Registries are (ordered) key/value maps. They are the main web client extension points: many features provided by the Odoo javascript framework simply look up into a registry whenever it needs a definition for some object. Customizing the web client is then simply done by adding specific values in the correct registry. 5
2.1 Registry API 基础
开发者可以通过 @web/core/registry 导入全局 registry 对象,然后使用 category() 方法访问不同的子注册表。以下是 Registry 类的核心 API:
| 方法 | 参数 | 说明 |
|---|---|---|
add(key, value, options) |
key: 字符串键名;value: 任意值;options.force: 是否覆盖;options.sequence: 排序权重 |
插入新条目。支持链式调用。触发 UPDATE 事件。 |
getAll() |
无 | 返回按 sequence 升序排列的所有值列表。 |
get(key, defaultValue) |
key: 键名;defaultValue: 默认值 |
获取指定键的值,不存在时返回默认值或抛出异常。 |
contains(key) |
key: 键名 |
检查键是否存在,返回布尔值。 |
remove(key) |
key: 键名 |
移除指定键的条目。 |
category(name) |
name: 子注册表名称 |
获取或创建一个子注册表。 |
基本使用示例:
import { registry } from "@web/core/registry";
// 获取不同类别的子注册表
const fieldRegistry = registry.category("fields");
const serviceRegistry = registry.category("services");
const viewRegistry = registry.category("views");
// 向字段注册表添加自定义字段组件
fieldRegistry.add("my_custom_widget", MyCustomFieldComponent);
// 使用 sequence 控制排序
registry.category("systray").add("my_addon.clock", {
Component: ClockSystrayItem,
}, { sequence: 25 }); // 数字越小越靠右
2.2 核心内置注册表
Odoo 19 预定义了众多类别的注册表 5,以下是开发中最常接触的几个:
| 注册表类别 | 存储内容说明 |
|---|---|
main_components |
顶层组件。WebClient 通过 MainComponentsContainer 全局渲染这些组件。 |
services |
系统服务。Odoo 启动时自动实例化并激活其中的所有服务。 |
systray |
系统托盘组件。显示在导航栏右上角,sequence 越小越靠右。 |
user_menuitems |
用户菜单项。点击右上角用户名时的下拉菜单选项。 |
fields |
字段组件。表单和列表视图中使用的各类字段控件。 |
views |
视图组件。看板、列表、表单、日历等核心视图的定义。 |
effects |
图形效果。如操作成功时的彩虹人动画。 |
formatters |
值格式化函数。将字段值转换为可显示的字符串。 |
parsers |
值解析函数。将用户输入的字符串解析为有效值。 |
注册 Systray 组件示例:
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
export class TaskCounterSystray extends Component {
static template = "my_module.TaskCounterSystray";
setup() {
this.state = useState({ count: 0 });
this.orm = useService("orm");
this._loadCount();
}
async _loadCount() {
this.state.count = await this.orm.searchCount(
"project.task",
[["user_ids", "in", [this.env.services.user.userId]]]
);
}
}
// 注册到 systray 注册表
registry.category("systray").add("my_module.TaskCounter", {
Component: TaskCounterSystray,
}, { sequence: 30 });
注册用户菜单项示例:
/** @odoo-module **/
import { registry } from "@web/core/registry";
registry.category("user_menuitems").add("my_module.settings", (env) => {
return {
description: env._t("My Custom Settings"),
callback: () => {
env.services.action.doAction({
type: "ir.actions.act_window",
res_model: "res.config.settings",
views: [[false, "form"]],
});
},
sequence: 45,
};
});
第三章 服务系统 (Services)
服务(Services)是提供特定功能的长期运行的代码块,是 Odoo 前端依赖注入(DI)系统的基础。与组件不同,服务没有 UI 界面,主要负责处理后台逻辑、数据通信或全局状态管理。官方建议:
Most code that is not a component should be packaged in a service, in particular if it performs some side effect. This is very useful for testing purposes: tests can choose which services are active, so there are less chance for unwanted side effects interfering with the code being tested. 6
3.1 服务的定义与注册
一个标准的服务需要实现特定的接口规范,主要包含 dependencies(依赖声明)和 start(启动函数)两个部分 6。
自定义服务完整示例:以下代码定义了一个用于管理全局计时器的服务:
/** @odoo-module **/
import { registry } from "@web/core/registry";
const timerService = {
dependencies: ["notification"],
start(env, { notification }) {
let timers = {};
let nextId = 1;
function createTimer(name, intervalMs, callback) {
const id = nextId++;
timers[id] = {
name,
intervalId: setInterval(() => {
callback();
notification.add(`Timer "${name}" ticked.`, {
type: "info",
});
}, intervalMs),
};
return id;
}
function clearTimer(id) {
if (timers[id]) {
clearInterval(timers[id].intervalId);
delete timers[id];
}
}
function clearAll() {
for (const id of Object.keys(timers)) {
clearTimer(id);
}
}
return { createTimer, clearTimer, clearAll };
},
};
registry.category("services").add("timerService", timerService);
在组件中使用自定义服务:
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class TimerWidget extends Component {
static template = "my_module.TimerWidget";
setup() {
this.timerService = useService("timerService");
}
startAutoRefresh() {
this.timerId = this.timerService.createTimer(
"auto-refresh",
30000,
() => this._refreshDashboard()
);
}
stopAutoRefresh() {
this.timerService.clearTimer(this.timerId);
}
}
3.2 核心内置服务参考
Odoo 19 提供了丰富的内置服务 6,开发者可以通过 useService("service_name") 轻松调用:
| 服务名称 | 核心功能描述 | 典型用法 |
|---|---|---|
rpc |
与 Odoo 后端进行远程过程调用 | await rpc("/my/controller", { params }) |
orm |
ORM 操作的高级封装 | await this.orm.call("res.partner", "read", [[1]]) |
notification |
显示全局通知消息 | notification.add("Success!", { type: "success" }) |
effect |
显示图形动画效果 | effect.add({ type: "rainbow_man", message: "Done!" }) |
action |
执行 Odoo 动作(打开视图、向导等) | action.doAction("module.action_xml_id") |
cookie |
读写浏览器 Cookies | cookie.setCookie("key", "value", 3600) |
router |
管理浏览器 URL 状态 | router.pushState({ action: 123 }) |
user |
当前用户信息 | user.userId, user.context |
title |
读写窗口标题 | title.setParts({ action: "Sales" }) |
http |
底层 HTTP 请求 | await http.get("/api/data") |
3.3 异步服务与组件生命周期
当服务提供异步 API 时,需要特别注意组件的生命周期问题。如果一个组件在异步调用完成之前被销毁,继续处理返回值可能导致错误。通过在服务定义中设置 async: true 或 async: ["method1", "method2"],可以让框架自动处理这种情况——当调用方组件被销毁时,未完成的异步调用会被安全地忽略。
第四章 代码补丁机制 (Patching)
在进行 Odoo 二次开发时,经常需要修改或扩展核心模块提供的组件或类的行为。由于不应直接修改源码,Odoo 提供了强大的 patch 工具函数来实现原地代码补丁 7。
4.1 Patch 函数 API
patch 函数位于 @web/core/utils/patch 模块中,其签名如下:
import { patch } from "@web/core/utils/patch";
const unpatch = patch(objToPatch, extension);
// 调用 unpatch() 可以撤销本次补丁
| 参数 | 类型 | 说明 |
|---|---|---|
objToPatch |
object |
需要打补丁的目标对象或类的原型 |
extension |
object |
包含需要覆盖或新增的属性/方法的对象 |
| 返回值 | function |
调用后撤销本次补丁的函数 |
4.2 补丁普通对象
对于简单的 JavaScript 对象,可以直接传入对象进行补丁。在补丁方法中使用 super 关键字可以调用原始方法 7:
import { patch } from "@web/core/utils/patch";
const calculator = {
value: 0,
add(n) {
this.value += n;
},
};
patch(calculator, {
add(n) {
console.log(`Adding ${n} to ${this.value}`);
super.add(n); // 调用原始的 add 方法
console.log(`New value: ${this.value}`);
},
});
calculator.add(5);
// 输出: Adding 5 to 0
// 输出: New value: 5
4.3 补丁 JavaScript 类
对于 ES6 类,需要区分静态方法和实例方法。静态方法直接补丁类本身,实例方法则必须补丁类的 prototype 7:
import { patch } from "@web/core/utils/patch";
class SaleOrderLine {
static getDefaultValues() {
return { quantity: 1, discount: 0 };
}
computeTotal() {
return this.quantity * this.unitPrice;
}
}
// 补丁静态方法
patch(SaleOrderLine, {
getDefaultValues() {
const defaults = super.getDefaultValues();
return { ...defaults, discount: 10 }; // 默认折扣改为 10%
},
});
// 补丁实例方法(注意:必须补丁 prototype)
patch(SaleOrderLine.prototype, {
computeTotal() {
const subtotal = super.computeTotal();
return subtotal * (1 - this.discount / 100); // 加入折扣计算
},
});
4.4 补丁 OWL 组件
补丁 OWL 组件是 Odoo 二次开发中最常见的场景。由于 JavaScript 原生机制决定了 constructor 无法被补丁,OWL 组件的最佳实践是将所有初始化逻辑放在 setup() 方法中 7:
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { FormController } from "@web/views/form/form_controller";
import { useService } from "@web/core/utils/hooks";
// 补丁 FormController 的 setup 方法
patch(FormController.prototype, {
setup() {
super.setup(...arguments); // 必须保留原始逻辑
// 添加自定义的通知服务
this.notification = useService("notification");
// 扩展保存行为
const originalOnSave = this.onRecordSaved?.bind(this);
this.onRecordSaved = async (record) => {
if (originalOnSave) {
await originalOnSave(record);
}
this.notification.add("Record saved successfully!", {
type: "success",
sticky: false,
});
};
},
});
4.5 补丁的高级技巧
使用工厂函数避免 extension 复用问题:由于 super 的绑定机制,同一个 extension 对象不能被多次用于不同的补丁操作 7。解决方案是使用工厂函数:
function createLoggingExtension(label) {
return {
process() {
console.log(`[${label}] Before process`);
super.process();
console.log(`[${label}] After process`);
},
};
}
// 每次调用都生成新的 extension 对象
patch(serviceA, createLoggingExtension("ServiceA"));
patch(serviceB, createLoggingExtension("ServiceB"));
补丁 Getter/Setter:patch 同样支持对属性的 getter 和 setter 进行补丁:
patch(myObject, {
get displayName() {
return `[Custom] ${super.displayName}`;
},
set displayName(value) {
super.displayName = value.trim();
},
});
第五章 SCSS 继承与样式管理
Odoo 的前端资产(Assets)管理系统非常庞大,其 SCSS 继承体系旨在兼顾模块化、主题定制(如暗黑模式)以及社区版与企业版的差异化设计 8。
5.1 资产类型与 Bundles
Odoo 19 的前端资产分为三种类型 9:代码(.js 文件)、样式(.css 或 .scss 文件)和模板(.xml 文件)。这些资产通过 Bundles(资产包)进行组织管理,在模块的 __manifest__.py 中声明。
| Bundle 名称 | 用途说明 |
|---|---|
web.assets_common |
通用资产,被 Web Client、网站和 POS 共享。包含底层框架代码。 |
web.assets_backend |
Web Client(后端管理界面)专用的代码和样式。 |
web.assets_frontend |
公共网站专用的资产,包括电商、门户、论坛等。 |
web.assets_unit_tests |
JavaScript 单元测试代码。 |
在 __manifest__.py 中声明资产:
{
'name': 'My Custom Module',
'assets': {
'web.assets_backend': [
# 追加文件(默认操作)
'my_module/static/src/js/**/*',
'my_module/static/src/scss/**/*',
'my_module/static/src/xml/**/*',
# 在指定文件之前插入
('before', 'web/static/src/scss/primary_variables.scss',
'my_module/static/src/scss/my_variables.scss'),
# 替换文件
('replace', 'web/static/src/views/list/list_view.js',
'my_module/static/src/views/list/custom_list_view.js'),
# 移除文件
('remove', 'web/static/src/legacy/some_old_widget.js'),
# 包含子 Bundle
('include', 'my_module._assets_shared'),
],
},
}
5.2 !default 指令的妙用
在复杂的 SCSS 编译环境中,直接重写变量往往会导致不可预期的级联错误。Odoo 强烈推荐使用 SCSS 的 !default 指令 8。
当一个变量使用 !default 声明时,编译器只会在该变量尚未被定义的情况下为其赋值。由于 Odoo 严格控制了资产包的加载顺序,这使得上层模块可以优先定义变量,从而优雅地覆盖底层库的默认设置。
正确的变量覆盖方式:
// my_module/static/src/scss/primary_variables.scss
// 此文件通过 (before, ...) 指令在 Bootstrap 变量之前加载
$o-brand-primary: #7C4DFF !default; // 自定义品牌主色
$o-navbar-height: 52px !default; // 自定义导航栏高度
$border-radius: 0.5rem !default; // 覆盖 Bootstrap 默认圆角
// 错误示例 —— 不要这样做!
$o-brand-primary: #7C4DFF; // 缺少 !default,可能导致级联错误
5.3 SCSS 编译顺序与架构
理解 Odoo 的 SCSS 编译顺序对于定制 UI 至关重要 8。系统按照以下层级自上而下编译:
↓ [编译开始]
│
↓ web.dark_mode_variables
│ ├─ 基础色彩变量
│ └─ 组件变量
│
↓ web._assets_primary_variables
│ ├─ 企业版主题变量
│ ├─ 企业版组件变量
│ ├─ 社区版主题变量
│ └─ 社区版组件变量
│
↓ web._assets_bootstrap
│ └─ Bootstrap 框架(吸收上层自定义变量)
│
↓ web.assets_backend / web.assets_frontend
│ ├─ CSS 变量定义
│ └─ CSS 变量上下文适配
│
● [屏幕上的视觉结果]
5.4 实战:自定义主题模块
以下是一个完整的自定义主题模块示例,展示如何正确利用 SCSS 继承体系:
__manifest__.py:
{
'name': 'Custom Theme',
'depends': ['web'],
'assets': {
'web._assets_primary_variables': [
('before', 'web/static/src/scss/primary_variables.scss',
'custom_theme/static/src/scss/primary_variables.scss'),
],
'web.assets_backend': [
'custom_theme/static/src/scss/backend_overrides.scss',
],
},
}
primary_variables.scss:
// 品牌色彩体系
$o-brand-primary: #1B5E20 !default;
$o-brand-secondary: #4CAF50 !default;
// 布局参数
$o-navbar-height: 48px !default;
$o-horizontal-padding: 24px !default;
// 字体
$o-font-family-sans-serif: 'Inter', 'Noto Sans SC', sans-serif !default;
backend_overrides.scss:
// 利用已定义的变量进行组件级样式定制
.o_main_navbar {
background: linear-gradient(135deg, $o-brand-primary, darken($o-brand-primary, 10%));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.o_form_view {
.o_form_sheet {
border-radius: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
}
附录 A:WebClient 架构概览
Odoo 19 的 WebClient 是一个 OWL 单页应用(SPA),其核心模板结构如下 1:
<t t-name="web.WebClient">
<body class="o_web_client">
<NavBar/>
<ActionContainer/>
<MainComponentsContainer/>
</body>
</t>
其中 ActionContainer 负责渲染当前活动的动作控制器(如表单视图或看板视图),MainComponentsContainer 则渲染所有注册在 main_components 注册表中的全局组件。
Environment 对象:每个 OWL 组件都可以通过 this.env 访问共享的环境对象 1:
| 键名 | 值说明 |
|---|---|
bus |
全局事件总线,用于协调跨组件通信 |
services |
所有已部署的服务实例 |
debug |
调试模式标识(非空字符串表示已启用) |
_t |
国际化翻译函数 |
isSmall |
是否处于移动端模式(屏幕宽度 ≤ 767px) |
附录 B:常见开发模式速查
| 开发需求 | 推荐方案 |
|---|---|
| 添加全局顶层组件 | 注册到 main_components 注册表 |
| 添加系统托盘图标 | 注册到 systray 注册表 |
| 自定义字段控件 | 注册到 fields 注册表 |
| 修改已有组件行为 | 使用 patch 补丁其 prototype |
| 添加后台全局逻辑 | 定义并注册自定义 Service |
| 覆盖 Bootstrap 变量 | 在 _assets_primary_variables 中用 !default 声明 |
| 添加自定义 CSS | 追加到 web.assets_backend |
| 监听全局事件 | 使用 useBus(this.env.bus, ...) |
| 懒加载大型资源 | 使用 useAssets Hook |