第三十二章 OWL 框架

篇别:第八篇 前端开发

本章学习目标

  • 理解 Odoo 19 前端以 OWL(Odoo Web Library) 为核心的组件模型,并建立与旧 Widget 体系的迁移意识。
  • 熟练使用 生命周期钩子响应式状态Odoo 专用 Hooks 完成数据加载、Bus 订阅与定位类交互。
  • 掌握 注册表(Registries)服务(Services)patch 三类主要扩展手段,并能阅读 __manifest__.py 中的 assetsSCSS 继承 配置。
  • 能将本章内容与 官方 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 自定义 OWL 组件在表单/客户端动作中挂载效果

32.1.4 本节练习

  1. 实操:实现仅展示 props.title 的组件,并在父模板中传入字符串。
  2. 简答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 组件挂载后 Network 中出现 orm/searchRead 请求

32.2.4 本节练习

  1. 简答onWillStartonMounted 哪个更适合发 RPC?为什么?
  2. 改错:在 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 官方文档 Hooks 章节或源码 `hooks.js` 片段

32.3.5 本节练习

  1. 实操:点击按钮 useService("action").doAction 打开任意 ir.actions.act_window
  2. 简答useBus 与直接 env.bus.addEventListener 相比的优势?

32.4 注册表系统(Registries)

32.4.1 知识要点

注册表是 有序键值映射,是 WebClient 的 主要扩展点:框架在需要某类实现时到对应 category 查找。官方表述大意:定制客户端 ≈ 在正确的 registry 里 add 合适的值。

常用 APIimport { 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 调试器中展开 `odoo.__DEBUG__` 或 registry 相关对象(视版本而定)

32.4.5 本节练习

  1. 实操:列出 registry.category("fields").getAll() 中任意 5 个 key(浏览器控制台,注意安全环境)。
  2. 简答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 callsearchRead 等 ORM 封装。
notification 全局通知条。
effect 彩虹人等。
action doAction 打开窗口/客户端动作。
cookie Cookie 读写。
router URL 状态。
user 当前用户与 context。
title 窗口标题片段。
http 通用 HTTP。

32.5.4 截图占位

图 32-5 Application 标签页中 service 相关请求或断点停在 start()

32.5.5 本节练习

  1. 实操:编写 my_counter service,提供 inc() / value(),在组件中显示计数。
  2. 简答:服务与「模块级单例 JS 对象」相比,测试上优势是什么?

32.6 代码补丁机制(Patching)

32.6.1 知识要点

不修改官方源码的前提下,用 patch@web/core/utils/patch)在原对象或 prototype 上叠加方法/属性。返回 unpatch 函数可撤销(多用于测试)。

目标 补丁位置
普通对象 对象本身
类静态方法 构造函数
类实例方法 Class.prototype
OWL 组件 prototype.setup(避免试图 patch constructor

注意:同一 extension 对象 不可复用于多次 patchsuper 绑定限制);需 工厂函数 每次返回新对象。

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 官方文档 Patching code 页面

32.6.4 本节练习

  1. 简答:升级 Odoo 小版本后 patch 失效,如何定位?
  2. 实操:为你本模块的 patch 加 // ODOO_VERSION: 19.0 注释并在 README 记录依赖视图/方法名。

32.7 SCSS 继承与资产管理

32.7.1 知识要点

前端资源分 JS / CSS|SCSS / QWeb XML,通过 __manifest__.pyassets → 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 bundleCSS 变量与组件样式。具体文件名以 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 编译后的 backend CSS 片段(含 CSS 变量)

32.7.7 本节练习

  1. 实操:仅改品牌主色,观察导航栏与按钮主色联动。
  2. 简答:为何主题变量文件通常用 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 父子组件在 DevTools Components 树中的边界

32.8.4 本节练习

  1. 实操:将某一 表单区块 拆成 子组件,通过 props 传入 只读字段保存回调
  2. 简答:何时 Bus 优于 props 钻透
  3. 判断:子组件应直接调用 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

本章综合练习

  1. 实现:Systray 显示当前用户未读消息数(演示数据即可),点击打开 Discuss。
  2. 综合:用 Service 封装 ORM 轮询 + Bus 通知列表刷新。
  3. 迁移:列出你模块中仍基于 legacy widget 的一处,并给出 OWL 化步骤提纲。
  4. 组件通信:用 子组件 + props + 回调 实现 「内联备注编辑」,禁止使用 全局 Bus 传备注正文。
  5. 阅读:对照 Owl components — Odoo 19.0 核对 static propsslots 在本项目中的可用写法。

参考文献

  1. Framework overview — Odoo 19.0
  2. Owl components — Odoo 19.0
  3. Hooks — Odoo 19.0
  4. Registries — Odoo 19.0
  5. Services — Odoo 19.0
  6. Patching code — Odoo 19.0
  7. SCSS inheritance — Odoo 19.0
  8. Assets — Odoo 19.0