第三十二章 OWL 框架
篇别:第八篇 前端开发
本章学习目标
- 理解 Odoo 19 前端以 OWL(Odoo Web Library) 为核心的组件模型,并建立与旧 Widget 体系的迁移意识。
- 熟练使用 生命周期钩子、响应式状态 与 Odoo 专用 Hooks 完成数据加载、Bus 订阅与定位类交互。
- 掌握 注册表(Registries)、服务(Services)、patch 三类主要扩展手段,并能阅读
__manifest__.py中的 assets 与 SCSS 继承 配置。 - 能将本章内容与 官方 Frontend 文档(Framework overview、OWL、Hooks、Registries、Services、Patching、Assets、SCSS inheritance)对照查阅。
导读:OWL 在 Odoo 19 中的地位
Odoo 19 的前端深度依赖自研的 OWL 框架:声明式组件、响应式更新,设计理念上接近主流前端框架的常见模式。官方建议 新功能优先用 OWL 实现;旧版 Widget 体系正被 原生 JavaScript 类 + OWL 组件 替代。
本章在结构上对应《Odoo 19 前端 OWL 框架开发指南》的五大主题:生命周期与 Hooks、注册表、服务、补丁(Patching)、SCSS 与资产,并补充 WebClient 架构速览 与 开发模式速查表。
权威细节请以 Odoo 19.0 前端文档 为准;第三方博客仅作辅助理解。
32.1 OWL 核心概念与响应式基础
32.1.1 知识要点
- 组件类 + 静态模板(
static template = "module.Name"或xml模板字符串)。 setup():初始化状态、注册子 Hook、引入useService;继承时须super.setup()(若父类有 setup)。useState:组件内响应式对象;useRef:DOM 或可变引用。- 禁止在渲染函数中产生副作用;异步与 DOM 操作放在合适生命周期中。
32.1.2 案例:最小组件骨架
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
export class HelloOwl extends Component {
static template = "my_module.HelloOwl";
setup() {
this.state = useState({ title: "OWL" });
}
}
32.1.3 截图占位

32.1.4 本节练习
- 实操:实现仅展示
props.title的组件,并在父模板中传入字符串。 - 简答:
useState返回的对象为何不能直接整体替换为普通对象?
32.2 生命周期钩子
32.2.1 知识要点
OWL 通过生命周期钩子把 初始化、异步数据、DOM、卸载清理 分阶段处理。下表为 Odoo 19 开发中最常用钩子及其典型用途(具体行为以当前 OWL 版本为准):
| 钩子 | 执行时机 | 典型用途与注意 |
|---|---|---|
setup() |
实例化后、首次渲染前 | 注册 useState、其他 Hook、服务;无 DOM。子类 须 super.setup()。 |
onWillStart() |
首次渲染前 | 可 async,会阻塞首屏直至完成;适合 RPC、读配置。 |
onMounted() |
挂载到 DOM 后 | 访问真实 DOM、第三方库初始化、聚焦。 |
onWillUpdateProps() |
即将接收新 props | 对比新旧 props,避免陈旧状态。 |
onPatched() |
每次更新渲染完成后 | 依赖 DOM 几何尺寸、非 OWL 插件刷新。 |
onWillUnmount() |
卸载前 | 清定时器、退订、销毁外部实例,防泄漏。 |
32.2.2 案例:综合生命周期(仪表盘加载 + 图表 + 清理)
/** @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() {
this.state = useState({
orders: [],
isLoading: true,
});
this.orm = useService("orm");
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(() => {
this._initChart();
});
onWillUnmount(() => {
if (this.chartInstance) {
this.chartInstance.destroy();
}
});
}
_initChart() {
const canvas = this.el?.querySelector(".sales-chart");
if (canvas && window.Chart) {
this.chartInstance = new window.Chart(canvas, { /* ... */ });
}
}
}
32.2.3 截图占位

32.2.4 本节练习
- 简答:
onWillStart与onMounted哪个更适合发 RPC?为什么? - 改错:在
setup()里直接document.querySelector取表单节点会有什么问题?
参考答案提示:1. onWillStart 阻塞首屏前完成数据;onMounted 适合依赖 DOM 的调用。2. 此时组件 DOM 可能尚未存在。
32.3 Odoo 专用 Hooks
32.3.1 知识要点
除标准 OWL 钩子外,@web 提供大量 业务向 Hook(路径随版本微调,以源码与文档为准):
| Hook | 典型路径 | 功能摘要 |
|---|---|---|
useAssets |
@web/core/assets |
懒加载静态资源,减小首包。 |
useAutofocus |
@web/core/utils/hooks |
自动聚焦 t-ref="autofocus"。 |
useBus |
@web/core/utils/hooks |
订阅 env.bus,卸载时自动清理。 |
usePager |
@web/search/pager_hook |
分页器与搜索面板协同。 |
usePosition |
@web/core/position_hook |
Popper 类定位,随目标更新。 |
useSpellCheck |
@web/core/utils/hooks |
输入框拼写检查样式控制。 |
useService |
@web/core/utils/hooks |
获取已注册 Service 实例。 |
32.3.2 案例: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", (ev) => {
console.log("New order:", ev.detail);
this._refreshData();
});
}
_refreshData() {
// 刷新本地状态或触发 props 更新
}
}
32.3.3 案例:usePosition 下拉层(配合 useState / useRef)
/** @odoo-module **/
import { Component, useRef, useState, 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() {
this.state = useState({ isOpen: false });
const togglerRef = useRef("toggler");
usePosition(
() => togglerRef.el,
{
popper: "popper",
position: "bottom-start",
margin: 4,
onPositioned: (el, { direction }) => {
el.classList.toggle("drop-up", direction === "top");
},
}
);
}
toggle() {
this.state.isOpen = !this.state.isOpen;
}
}
32.3.4 截图占位

32.3.5 本节练习
- 实操:点击按钮
useService("action").doAction打开任意ir.actions.act_window。 - 简答:
useBus与直接env.bus.addEventListener相比的优势?
32.4 注册表系统(Registries)
32.4.1 知识要点
注册表是 有序键值映射,是 WebClient 的 主要扩展点:框架在需要某类实现时到对应 category 查找。官方表述大意:定制客户端 ≈ 在正确的 registry 里 add 合适的值。
常用 API(import { registry } from "@web/core/registry"):
| 方法 | 说明 |
|---|---|
category(name) |
获取或创建子注册表。 |
add(key, value, options) |
sequence 控制顺序;force 覆盖。 |
get(key) / getAll() / contains(key) / remove(key) |
查询与维护。 |
32.4.2 案例:字段、systray、用户菜单
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);
registry.category("systray").add("my_addon.clock", {
Component: ClockSystrayItem,
}, { sequence: 25 });
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]]]
);
}
}
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) => ({
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,
}));
32.4.3 内置注册表(节选)
| category | 用途 |
|---|---|
main_components |
顶层全局组件容器渲染。 |
services |
服务定义,启动时实例化。 |
systray |
导航栏右侧托盘。 |
user_menuitems |
用户下拉菜单项。 |
fields |
列表/表单字段组件。 |
views |
各类型视图控制器。 |
effects |
彩虹人等反馈动效。 |
formatters / parsers |
显示格式化与输入解析。 |
32.4.4 截图占位

32.4.5 本节练习
- 实操:列出
registry.category("fields").getAll()中任意 5 个 key(浏览器控制台,注意安全环境)。 - 简答:
sequence数值大小与显示顺序的关系(systray 等)请以你当前版本实测并记录。
32.5 服务系统(Services)
32.5.1 知识要点
Service 是长生命周期、无 UI 的能力单元,构成前端的 依赖注入 核心。官方建议:非组件且带副作用的逻辑 优先做成 service,便于测试替换实现。
服务对象通常包含:
dependencies:其他 service 名称列表。start(env, deps):返回对外 API(对象或函数)。- 异步相关:部分版本支持
async/async: ['method'],在调用组件销毁后忽略悬挂 Promise(以文档为准)。
32.5.2 案例:自定义计时器服务
/** @odoo-module **/
import { registry } from "@web/core/registry";
const timerService = {
dependencies: ["notification"],
start(env, { notification }) {
const timers = {};
let nextId = 1;
function createTimer(name, intervalMs, callback) {
const id = nextId++;
timers[id] = {
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() {
Object.keys(timers).forEach(clearTimer);
}
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()
);
}
}
32.5.3 内置服务参考(节选)
| 名称 | 用途 |
|---|---|
rpc |
调用后端路由。 |
orm |
call、searchRead 等 ORM 封装。 |
notification |
全局通知条。 |
effect |
彩虹人等。 |
action |
doAction 打开窗口/客户端动作。 |
cookie |
Cookie 读写。 |
router |
URL 状态。 |
user |
当前用户与 context。 |
title |
窗口标题片段。 |
http |
通用 HTTP。 |
32.5.4 截图占位

32.5.5 本节练习
- 实操:编写
my_counterservice,提供inc()/value(),在组件中显示计数。 - 简答:服务与「模块级单例 JS 对象」相比,测试上优势是什么?
32.6 代码补丁机制(Patching)
32.6.1 知识要点
不修改官方源码的前提下,用 patch(@web/core/utils/patch)在原对象或 prototype 上叠加方法/属性。返回 unpatch 函数可撤销(多用于测试)。
| 目标 | 补丁位置 |
|---|---|
| 普通对象 | 对象本身 |
| 类静态方法 | 构造函数 |
| 类实例方法 | Class.prototype |
| OWL 组件 | prototype.setup 等(避免试图 patch constructor) |
注意:同一 extension 对象 不可复用于多次 patch(super 绑定限制);需 工厂函数 每次返回新对象。
32.6.2 案例:对象、类、OWL 组件
import { patch } from "@web/core/utils/patch";
patch(SomeClass.prototype, {
computeTotal() {
const subtotal = super.computeTotal();
return subtotal * (1 - this.discount / 100);
},
});
补丁 FormController(示意,API 以当前版本为准):
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { FormController } from "@web/views/form/form_controller";
import { useService } from "@web/core/utils/hooks";
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" });
};
},
});
工厂函数避免 extension 复用:
function createLoggingExtension(label) {
return {
process() {
console.log(`[${label}] Before`);
super.process();
console.log(`[${label}] After`);
},
};
}
patch(serviceA, createLoggingExtension("A"));
patch(serviceB, createLoggingExtension("B"));
32.6.3 截图占位

32.6.4 本节练习
- 简答:升级 Odoo 小版本后 patch 失效,如何定位?
- 实操:为你本模块的 patch 加
// ODOO_VERSION: 19.0注释并在 README 记录依赖视图/方法名。
32.7 SCSS 继承与资产管理
32.7.1 知识要点
前端资源分 JS / CSS|SCSS / QWeb XML,通过 __manifest__.py → assets → bundle 组织。常用 bundle:
| Bundle | 用途 |
|---|---|
web.assets_common |
WebClient、网站、POS 等共享底层。 |
web.assets_backend |
后台界面。 |
web.assets_frontend |
网站/电商等前台。 |
web.assets_unit_tests |
单元测试。 |
清单中可使用的操作元组(示意):before / replace / remove / include 等,精确控制顺序与替换。
32.7.2 案例:__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'),
('include', 'my_module._assets_shared'),
],
},
}
32.7.3 !default 与变量覆盖
在 web._assets_primary_variables 等链路中,优先使用 !default:仅当变量尚未赋值时生效,避免破坏 Odoo 控制的编译顺序。
$o-brand-primary: #7C4DFF !default;
$o-navbar-height: 52px !default;
错误示例:$o-brand-primary: #7C4DFF;(缺少 !default 可能导致重复定义或顺序问题)。
32.7.4 SCSS 编译层级(示意)
自上而下大致为:暗色变量 → primary variables(企业/社区主题) → Bootstrap 吸收变量 → backend/frontend bundle → CSS 变量与组件样式。具体文件名以 19.0 源码为准。
32.7.5 案例:主题模块最小结构
__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-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%));
}
32.7.6 截图占位

32.7.7 本节练习
- 实操:仅改品牌主色,观察导航栏与按钮主色联动。
- 简答:为何主题变量文件通常用
before插到primary_variables.scss之前?
32.8 组件通信:Props、子组件与事件(对应白皮书「组件通信」主题)
本节与 32.1~32.3 衔接:在掌握 状态与生命周期 后,把 父子边界 画清楚,避免「全局 service 滥用」或「巨型单组件」。
32.8.1 知识要点
- Props:父 → 子 单向数据流;在 Odoo 中常通过
static props = { ... }(或文档推荐写法)声明 类型与可选性,利于 静态检查与可读性。 - 子组件:
static components = { Child: ChildComponent }在模板中以<Child .../>使用;子组件 不要 反向写父 state,应t-on-事件名通知父组件。 - 回调:父用
onSomeChange一类 prop 传入函数(或 OWL 约定的事件名),子this.props.onSomeChange(payload);保持 payload 小而可序列化(避免传整个 env)。 - Bus:跨层、跨子树 的 松耦合 通信用
useBus(this.env.bus, ...);父子间 优先 props + 事件,便于 单测。 - 与 Registry 区别:字段组件、systray 等 接入点 用 Registry;单次表单单据内的 UI 拆分 用 子组件。
32.8.2 案例:父列表 + 子卡片
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
class LoanCard extends Component {
static template = "my_library.LoanCard";
static props = { title: String, onReturn: Function };
onClickReturn() {
this.props.onReturn(this.props.title);
}
}
export class LoanBoard extends Component {
static template = "my_library.LoanBoard";
static components = { LoanCard };
setup() {
this.state = useState({ items: [{ title: "书 A" }] });
}
_onReturn(title) {
// 调 orm 或更新 state
this.state.items = this.state.items.filter((i) => i.title !== title);
}
}
模板示意(XML):t-foreach 渲染 <LoanCard title="..." onReturn.bind="_onReturn"/>(具体 t-props 语法以 OWL 版本为准)。
32.8.3 截图占位

32.8.4 本节练习
- 实操:将某一 表单区块 拆成 子组件,通过 props 传入 只读字段 与 保存回调。
- 简答:何时 Bus 优于 props 钻透?
- 判断:子组件应直接调用
useService("orm")修改任意业务数据,无需通知父组件。( )
参考答案提示:2. 跨 Action、跨注册表组件、与 DOM 无关的全局广播。3. 错;多数业务仍宜 由父或 service 统一协调。
附录(本章):WebClient 架构速览
Odoo 19 WebClient 可理解为 OWL SPA,核心模板结构(示意):
<t t-name="web.WebClient">
<body class="o_web_client">
<NavBar/>
<ActionContainer/>
<MainComponentsContainer/>
</body>
</t>
ActionContainer:当前动作对应的视图控制器。MainComponentsContainer:渲染main_components注册表中的全局组件。
env 常用键(以文档为准):
| 键 | 含义 |
|---|---|
bus |
全局事件总线 |
services |
已启动服务 |
debug |
调试模式标记 |
_t |
翻译函数 |
isSmall |
窄屏/移动布局判断 |
附录(本章):常见开发模式速查
| 需求 | 推荐 |
|---|---|
| 全局顶层组件 | main_components |
| 系统托盘 | systray |
| 自定义字段 UI | fields |
| 改已有组件行为 | patch(...prototype...) |
| 全局副作用逻辑 | 自定义 Service |
| 覆盖 Bootstrap/Odoo 变量 | web._assets_primary_variables + !default |
| 追加样式 | web.assets_backend 等 |
| 监听全局事件 | useBus(this.env.bus, ...) |
| 懒加载大包 | useAssets |
本章综合练习
- 实现:Systray 显示当前用户未读消息数(演示数据即可),点击打开 Discuss。
- 综合:用 Service 封装 ORM 轮询 + Bus 通知列表刷新。
- 迁移:列出你模块中仍基于 legacy widget 的一处,并给出 OWL 化步骤提纲。
- 组件通信:用 子组件 + props + 回调 实现 「内联备注编辑」,禁止使用 全局 Bus 传备注正文。
- 阅读:对照 Owl components — Odoo 19.0 核对
static props与slots在本项目中的可用写法。
参考文献
- Framework overview — Odoo 19.0
- Owl components — Odoo 19.0
- Hooks — Odoo 19.0
- Registries — Odoo 19.0
- Services — Odoo 19.0
- Patching code — Odoo 19.0
- SCSS inheritance — Odoo 19.0
- Assets — Odoo 19.0