| name | huo15-owl-dev |
| version | 1.0.0 |
| slug | owl-dev |
| description | OWL (Odoo Web Library) 开发技能 — Odoo 前端组件开发框架,基于 QWeb 模板、响应式状态管理、OWL 组件生命周期 |
| aliases | ["OWL组件开发","Odoo前端","OWL","odoo web library","OWL dev"] |
SKILL.md — huo15-owl-dev
触发条件
用户提到以下内容时激活本技能:
- OWL 组件开发
- Odoo 前端 / Web 开发
- QWeb 模板
- OWL 组件生命周期
- Odoo JS/Web 组件
odoo.define / owl.component
- Odoo 15+ 前端架构
OWL 核心概念
什么是 OWL?
OWL(Odoo Web Library)是 Odoo 15+ 引入的新一代前端框架,用于替代旧的 web.old(backbone-based)架构。它基于:
- QWeb 模板(XML)
- 响应式状态管理(PoP/Props)
- TypeScript(原生支持)
OWL vs 旧架构
| 特性 | OWL(新) | web.old(旧) |
|---|
| 模板 | QWeb XML | QWeb XML |
| 状态 | useState / useRefs | 不存在 |
| 组件 | 类 + 装饰器 | Widget 基类 |
| 生命周期 | PoP (Props on Props) | 不存在 |
| TypeScript | 原生支持 | 不支持 |
| 响应式 | 自动追踪依赖 | 手动绑定 |
| 性能 | 虚拟DOM + 懒加载 | 完整重渲染 |
OWL 组件结构
最小组件模板
import { Component, useState, useEnv } from "@odoo/owl";
export class MyComponent extends Component {
static template = "my_module.MyComponent";
state = useState({
value: 0,
items: [],
loading: false,
});
onWillMount() {
this.loadData();
}
async loadData() {
this.state.loading = true;
try {
const result = await this.rpc("/my_module/data");
this.state.items = result;
} finally {
this.state.loading = false;
}
}
increment() {
this.state.value++;
}
onClick() {
this.trigger("custom-event", { payload: this.state.value });
}
}
目录结构
my_module/
├── static/
│ └── src/
│ ├── js/
│ │ ├── components/
│ │ │ ├── my_component/
│ │ │ │ ├── my_component.xml # QWeb 模板
│ │ │ │ └── my_component.ts # 组件逻辑
│ │ │ └── other_component/
│ │ │ ├── other_component.xml
│ │ │ └── other_component.ts
│ │ └── pages/
│ │ └── my_page.xml
│ └── css/
│ └── my_component.scss
├── views/
│ └── templates.xml # Web 入口
└── __manifest__.py
组件注册与挂载
__manifest__.py 配置
{
'name': '我的模块',
'depends': ['web'],
'assets': {
'web.assets_backend': [
('include', 'my_module.static.src.js.components.my_component.my_component_xml'),
'my_module/static/src/js/components/my_component/my_component.ts',
'my_module/static/src/css/my_component.scss',
],
},
}
XML 入口(views/templates.xml)
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets_backend" name="my_module assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<link rel="stylesheet" href="/my_module/static/src/css/my_component.scss"/>
<script type="module" src="/my_module/static/src/js/components/my_component/my_component.es.js"/>
</xpath>
</template>
</odoo>
注册到 OwlComponentStore
import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";
export class MyComponent extends Component {
static template = "my_module.MyComponent";
}
registry.category("components").add("my_component", MyComponent);
在另一个组件中使用
<-- parent.xml -->
<MyComponent t-component="'my_component'" />
Props(属性传递)
定义 Props 接口
import { Component } from "@odoo/owl";
import { Props } from "@odoo/owl";
interface MyProps {
title: string;
count: number;
onUpdate: (value: number) => void;
}
export class MyComponent extends Component<Props & MyProps> {
static template = "my_module.MyComponent";
static props = {
title: { type: String, optional: true },
count: { type: Number },
onUpdate: { type: Function },
};
updateCount(newCount: number) {
this.props.onUpdate(newCount);
}
}
Props 类型
static props = {
name: String,
active: Boolean,
count: Number,
items: Array,
}
static props = {
title: { type: String, optional: true },
}
static props = {
config: { type: Object },
}
static props = {
onChange: Function,
onSubmit: { type: Function, optional: true },
}
static props = {
items: {
type: Array,
validate: (items) => items.every(i => typeof i === 'object'),
},
}
状态管理(State)
useState
import { useState } from "@odoo/owl";
export class MyComponent extends Component {
state = useState({
count: 0,
name: "",
active: false,
user: {
name: "张三",
age: 30,
},
items: [],
loading: false,
error: null,
});
}
状态更新
this.state.count = 5;
this.state.user.name = "李四";
this.state.items.push({ id: 1, name: "item" });
this.state.items = [...this.state.items, newItem];
this.state.items.filter(i => i.id !== deleteId);
this.state = {
...this.state,
count: this.state.count + 1,
loading: false,
};
生命周期(Lifecycle)
完整生命周期钩子
export class MyComponent extends Component {
async onWillMount() {
}
onWillRender() {}
onMounted() {
const el = this.el;
}
onRendered() {}
onWillUpdateProps(nextProps) {
}
onWillPatch() {}
onPatched() {
}
onWillUnmount() {
}
onUnmounted() {}
}
生命周期图
组件创建 → onWillMount → 渲染 → onMounted
↓
props/state 变化 → onWillUpdateProps → onWillPatch → patch → onPatched
↓
组件卸载 → onWillUnmount → onUnmounted
QWeb 模板
基础语法
<templates>
<t t-name="my_module.MyComponent">
<div class="my-component">
<h1 t-esc="props.title"/>
<span t-if="state.loading">加载中...</span>
<ul>
<li t-foreach="state.items" t-as="item" t-key="item.id">
<span t-esc="item.name"/>
</li>
</ul>
<button t-on-click="increment">点击</button>
<button t-on-click.self="doSomething">仅自身</button>
<SubComponent t-component="'sub_component'" t-props="subProps"/>
</div>
</t>
</templates>
常用指令
| 指令 | 用法 | 说明 |
|---|
t-esc | <span t-esc="value"/> | 转义文本 |
t-raw | <span t-raw="html"/> | 原始 HTML |
t-if | <div t-if="state.show"/> | 条件渲染 |
t-else | <div t-elif="state.show2"/> | 否则 |
t-foreach | <li t-foreach="arr" t-as="i"/> | 循环 |
t-key | t-key="item.id" | 循环 key |
t-on-* | t-on-click="handler" | 事件绑定 |
t-model | t-model="state.inputValue" | 双向绑定 |
t-ref | <div t-ref="myDiv"/> | DOM 引用 |
t-set | <t t-set="val" t-value="123"/> | 设置变量 |
t-call | <t t-call="other_template"/> | 引用模板 |
t-translation | t-translation="on" | 翻译标记 |
事件绑定
<button t-on-click="onClick">点击</button>
<button t-on-click.stop="onClick">阻止冒泡</button>
<a href="#" t-on-click.prevent="onLink">阻止默认</a>
<button t-on-click.stop.prevent="onClick">组合</button>
<button t-on-click="(ev) => onClick(42, ev)">传参</button>
<button t-on-click="() => onClick(42)">箭头函数</button>
<button t-on-click="onClick">访问事件: ev.target</button>
双向绑定(t-model)
<input type="text" t-model="state.inputValue"/>
<textarea t-model="state.description"/>
<input type="checkbox" t-model="state.checked"/>
<select t-model="state.selectedId">
<option value="">请选择</option>
<option t-foreach="state.options"
t-as="opt"
t-att-value="opt.id"
t-esc="opt.name"/>
</select>
Hooks(钩子)
内置 Hooks
import {
useState,
useRef,
useEffect,
useEnv,
useDispatch,
useSelectors,
} from "@odoo/owl";
useRef(DOM 引用)
import { useRef } from "@odoo/owl";
export class MyComponent extends Component {
myInput = useRef("myInput");
onMounted() {
this.myInput.el.focus();
}
getValue() {
return this.myInput.el.value;
}
}
<input t-ref="myInput" type="text"/>
useEffect(副作用)
import { useEffect } from "@odoo/owl";
export class MyComponent extends Component {
state = useState({ data: null });
setup() {
useEffect(
() => console.log("count changed:", this.state.count),
() => [this.state.count]
);
useEffect(
() => {
const timer = setInterval(() => this.tick(), 1000);
return () => clearInterval(timer);
},
() => []
);
}
}
useEnv(获取环境)
import { useEnv } from "@odoo/owl";
export class MyComponent extends Component {
setup() {
const env = useEnv();
const user = env.session.user;
const db = env.session.db;
}
}
Service(服务)
使用 ActionService
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
export class MyComponent extends Component {
setup() {
this.actionService = useService("action");
this.ormService = useService("orm");
}
async doAction() {
await this.actionService.doAction("my_module.action_wizard");
}
async searchData() {
const result = await this.ormService.call(
"my.model",
"search_read",
[[['active', '=', true]], ['name', 'id']],
{ context: { ... } }
);
return result;
}
}
常用服务
this.actionService.doAction({
type: "ir.actions.act_window",
res_model: "my.model",
view_mode: "form",
views: [[false, "form"]],
target: "new",
});
useService("notification").notify("标题", "内容", "success");
useService("dialog").add(ConfirmDialog, {
title: "确认删除",
body: "确定要删除吗?",
confirmLabel: "删除",
cancelLabel: "取消",
confirmCallback: () => this.doDelete(),
});
const result = await useService("rpc").call("/my_route", params);
自定义 Service
import { registry } from "@web/core/registry";
export const myService = {
start(env) {
return {
async getData(id) {
return await env.services.orm.call(
"my.model", "read", [id]
);
},
};
},
};
registry.category("services").add("myService", myService);
路由(Router)
配置路由
'assets': {
'web.assets_backend': [
'my_module/static/src/js/routes.js',
],
},
定义路由
import { registry } from "@web/core/registry";
import { route } from "@web/core/router/route";
registry.category("main").add("my_module.dashboard",
route("/my_module/dashboard", MyComponent, {
load: async (env) => {
return await env.services.orm.call("my.model", "get_data", []);
},
})
);
国际化(i18n)
模板中使用翻译
<span>这是中文文本</span>
<span t-esc="env._t('Hello World')"/>
JS 中使用翻译
import { useEnv } from "@odoo/owl";
export class MyComponent extends Component {
setup() {
const env = useEnv();
const msg = env._t("Hello");
const formatted = env._t("You have %s items", [count]);
}
}
样式(SCSS/CSS)
组件样式
.my-component {
padding: 16px;
background: #fff;
border-radius: 4px;
&__header {
font-size: 18px;
font-weight: 600;
}
&__list {
list-style: none;
&-item {
padding: 8px;
&--active {
background: #f0f0f0;
}
}
}
&--loading {
opacity: 0.6;
pointer-events: none;
}
}
CSS 变量
.o_my_component {
--my-color: #{$primary};
--my-spacing: 16px;
padding: var(--my-spacing);
color: var(--my-color);
}
测试(Testing)
组件测试
import { describe, expect, test } from "@odoo/owl";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { MyComponent } from "./my_component";
describe("MyComponent", () => {
test("renders correctly", async () => {
const env = await makeTestEnv();
const wrapper = new MyComponent(null, { env });
await wrapper.mount(fixture);
expect(wrapper.el.textContent).toContain("标题");
});
test("increments counter on click", async () => {
const env = await makeTestEnv();
const comp = new MyComponent(null, { env });
comp.state.count = 0;
await comp.mount(fixture);
await comp.onClick();
expect(comp.state.count).toBe(1);
});
});
常用测试工具
import { makeTestEnv, makeFakeCookieService } from "@web/../tests/helpers/mock_env";
import { dom } from "@web/../tests/helpers/dom";
await mockServer.mockModel("my.model", {
search_read: () => [{ id: 1, name: "Test" }],
});
调试技巧
开发工具
- 浏览器扩展:安装
Odoo Web Developer 浏览器插件
- 日志:
console.log 替换为 console.debug 减少干扰
- QWeb 错误:Odoo 会显示模板解析错误位置
常见错误
| 错误 | 原因 | 解决 |
|---|
| Template not found | __manifest__.py 未注册 assets | 检查 assets_backend 配置 |
| Component not rendering | t-component 名称错误 | 确认 registry.category("components").add() 名称 |
| State not updating | 直接修改对象而非赋值 | this.state.items.push() 不触发更新,需替换 |
| Event not working | t-on-click 语法错误 | 检查是否少了括号 t-on-click="handler" |
| Props undefined | 子组件 Props 未定义 | 检查 static props 定义 |
热重载
最佳实践
代码组织
- 一个组件一个目录:
components/my_comp/
- 模板文件名匹配:
my_comp.xml + my_comp.ts
- 组件名 PascalCase:
MyComponent
- 方法名 camelCase:
onClick, getData
性能优化
state = useState({ count: 0, items: [] });
class MyComponent extends Component {
onClick = (id) => {
};
}
<li t-foreach="items" t-as="item" t-key="item.id">
安全建议
- 用户输入转义:使用
t-esc 而非 t-raw
- SQL 注入:永远通过 ORM 操作数据
- XSS:避免使用
t-raw 处理用户输入
快速参考
常用导入
import { Component, useState, useRef, useEffect, useEnv, useService } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { ormService } from "@web/core/orm_service";
import { actionService } from "@web/core/action_service";
组件装饰器
@registerComponent("my_component")
export class MyComponent extends Component {
}
Props 验证
static props = {
title: String,
count: Number,
subtitle: { type: String, optional: true },
onChange: Function,
config: { type: Object },
items: Array,
}
本技能基于 OWL v2.x(Odoo 16+),适配辉火云企业套件前端开发环境。