第 1 章:Owl 组件¶
本章介绍 Owl 框架,这是一个为 Odoo 量身定制的组件系统。OWL 的主要构建块是 组件 和 模板。
在 Owl 中,用户界面的每个部分都由一个组件管理:组件持有逻辑,并定义用于渲染用户界面的模板。在实践中,组件由一个继承 Component 类的小型 JavaScript 类表示。
开始之前,你需要一个运行中的 Odoo 服务器和已配置好的开发环境。在进入练习之前,请确保你已完成本 教程简介 中描述的所有步骤。
本章我们使用 awesome_owl 插件模块,它提供了一个简化的环境,其中只包含 Owl 和少量其他文件。目标是学习 Owl 本身,而不依赖 Odoo Web 客户端的代码。
本章每个练习的解答都托管在 Odoo 官方教程仓库 上。建议先自己尝试解答,不要先看答案!
示例:一个 Counter 组件¶
首先,让我们看一个简单的例子。下面展示的 Counter 组件会维护一个内部数值,显示它,并在用户点击按钮时更新它。
import { Component, useState } from "@odoo/owl";
export class Counter extends Component {
static template = "my_module.Counter";
setup() {
this.state = useState({ value: 0 });
}
increment() {
this.state.value++;
}
}
Counter 组件指定了表示其 html 的模板名称。该模板使用 QWeb 语言以 XML 编写:
<templates xml:space="preserve">
<t t-name="my_module.Counter">
<p>Counter: <t t-esc="state.value"/></p>
<button class="btn btn-primary" t-on-click="increment">Increment</button>
</t>
</templates>
1. 显示计数器¶
作为第一个练习,让我们修改位于 awesome_owl/static/src/ 的 Playground 组件,将其变成一个计数器。要查看结果,你可以在浏览器中访问 /awesome_owl 路由。
修改
playground.js,使其像上面的示例一样充当计数器。类名仍保留Playground。你需要使用 useState 钩子,以便当该组件读取过的状态对象的任何部分被修改时,组件都会重新渲染。在同一个组件中,创建一个
increment方法。修改
playground.xml中的模板,使其显示你的计数器变量。使用 t-esc 来输出数据。在模板中添加一个按钮,并在该按钮上指定 t-on-click 属性,以便在点击按钮时触发
increment方法。
小技巧
浏览器下载的 Odoo JavaScript 文件是压缩过的。出于调试目的,文件未压缩时会更加方便。请切换到 带资源的调试模式,以便文件不被压缩。
本练习展示了 Owl 的一个重要特性:响应式系统。useState 函数将值包装在一个代理中,这样 Owl 就能跟踪哪个组件需要状态的哪个部分,从而在值发生变化时进行更新。试着移除 useState 函数,看看会发生什么。
2. 将 Counter 提取为子组件¶
目前,计数器逻辑位于 Playground 组件中,但它无法复用。让我们看看如何从中创建一个 子组件:
将计数器代码从
Playground组件中提取到一个新的Counter组件中。你可以先在同一个文件中完成,但完成后,请更新代码,将
Counter移到它自己的文件夹和文件中。使用./counter/counter进行相对导入。确保模板位于它自己的文件中,且名称相同。在
Playground组件的模板中使用<Counter/>,以在你的 playground 中添加两个计数器。
小技巧
按照惯例,大多数组件的代码、模板和 css 应使用与组件相同的蛇形命名(snake-case)。例如,如果我们有一个 TodoList 组件,其代码应位于 todo_list.js、todo_list.xml 中,如有必要,还应位于 todo_list.scss 中
3. 一个简单的 Card 组件¶
组件确实是将复杂的用户界面拆分为多个可复用部分的最自然方式。但要真正发挥它们的作用,还需要能够在组件之间传递一些信息。让我们看看父组件如何通过属性(最常见的叫法是 props)向子组件提供信息。
本练习的目标是创建一个 Card 组件,它接收两个 props:title 和 content。例如,下面展示了它的使用方式:
<Card title="'my title'" content="'some content'"/>
上面的示例应使用 Bootstrap 生成类似下面的 html:
<div class="card d-inline-block m-2" style="width: 18rem;">
<div class="card-body">
<h5 class="card-title">my title</h5>
<p class="card-text">
some content
</p>
</div>
</div>
创建一个
Card组件在
Playground中导入它,并在其模板中显示几张卡片
4. 使用 markup 显示 html¶
如果你在上一练习中使用了 t-esc,那么你可能已经注意到 Owl 会自动转义其内容。例如,如果你尝试像这样显示一些 html:<Card title="'my title'" content="this.html"/> 并使用 this.html = "<div>some content</div>"",那么最终输出只会将 html 作为字符串显示。
在这种情况下,由于 Card 组件可用于显示任何类型的内容,因此允许用户显示一些 html 是合理的。这通过 t-out 指令 来实现。
然而,将任意内容作为 html 显示是危险的,它可能被用来注入恶意代码,因此默认情况下,Owl 总是会转义字符串,除非该字符串已通过 markup 函数显式标记为安全。
更新
Card以使用t-out更新
Playground,导入markup,并在一些 html 值上使用它确保你看到普通字符串总是会被转义,而经过 markup 处理的字符串则不会。
注解
t-esc 指令仍然可以在 Owl 模板中使用。它比 t-out 稍快一些。
5. Props 校验¶
Card 组件有一个隐式 API。它期望在 props 中接收两个字符串:title 和 content。让我们把这个 API 变得更明确。我们可以添加一个 props 定义,让 Owl 在 dev 模式 下执行校验步骤。你可以在 App 配置 中启用 dev 模式(不过在 awesome_owl playground 中它默认已启用)。
为每个组件都进行 props 校验是一个好做法。
为
Card组件添加 props 校验。在 playground 模板中将
titleprops 重命名为其他名称,然后在浏览器开发者工具的 Console 标签页中检查,确认你能看到错误。
6. 两个 Counter 之和¶
在前面的练习中我们看到,props 可用于从父组件向子组件传递信息。现在,让我们看看如何反方向传递信息:在本练习中,我们要显示两个 Counter 组件,并在它们下方显示其值之和。因此,每当其中一个 Counter 的值发生变化时,父组件(Playground)都需要被告知。
这可以通过使用 回调 prop 来实现:prop 就是用于被回调的函数。子组件可以选择用任意参数调用该函数。在我们的例子中,我们只需添加一个可选的 onChange prop,每当 Counter 组件被递增时就会调用它。
为
Counter组件添加 prop 校验:它应接受一个可选的onChange函数 prop。更新
Counter组件,使其在每次递增时调用onChangeprop(如果存在)。修改
Playground组件,维护一个本地状态值(sum),初始值为 2,并在其模板中显示它在
Playground中实现一个incrementSum方法将该方法作为 prop 传给两个(或更多!)子
Counter组件。
重要
回调 props 有一个微妙之处:它们通常应使用 .bind 后缀来定义。请参阅 文档。
7. 待办事项列表¶
现在让我们通过创建一个待办事项列表来了解 Owl 的各种特性。我们需要两个组件:一个 TodoList 组件,用于显示 TodoItem 组件的列表。待办事项列表是一个应由 TodoList 维护的状态。
在本教程中,todo 是一个包含三个值的对象:一个数字类型的 id、一个字符串类型的 description 以及一个布尔标志 isCompleted:
{ id: 3, description: "buy milk", isCompleted: false }
创建一个
TodoList组件和一个TodoItem组件。TodoItem组件应接收一个todo作为 prop,并在一个div中显示其id和description。目前,先硬编码待办事项列表:
// in TodoList this.todos = useState([{ id: 3, description: "buy milk", isCompleted: false }]);
使用 t-foreach 在
TodoItem中显示每个待办事项。在 playground 中显示一个
TodoList。为
TodoItem添加 props 校验。
小技巧
由于 TodoList 和 TodoItem 组件紧密耦合,将它们放在同一个文件夹中是合理的。
注解
Owl 中的 t-foreach 指令与 QWeb Python 实现并不完全相同:它需要一个唯一的 t-key 值,以便 Owl 能够正确地协调每个元素。
8. 使用动态属性¶
目前,TodoItem 组件还没有在视觉上显示 todo 是否已完成。让我们通过使用 动态属性 来实现这一点。
如果已完成,请在
TodoItem根元素上添加 Bootstrap 类text-muted和text-decoration-line-through。更改硬编码的
this.todos值,以检查它是否正确显示。
尽管该指令的名称 t-att 源自 attribute,但它也可用于设置 class 值(以及诸如 input 的 value 之类的 html 属性)。
小技巧
Owl 允许你将静态 class 值与动态值组合使用。下面的示例会按预期工作:
<div class="a" t-att-class="someExpression"/>
另请参阅:Owl:动态 class 属性
9. 添加待办事项¶
到目前为止,我们列表中的待办事项都是硬编码的。让我们允许用户向列表中添加待办事项,使其更有用。
删除
TodoList组件中的硬编码值:this.todos = useState([]);
在任务列表上方添加一个输入框,占位符为 输入新任务。
为
keyup事件添加一个名为addTodo的 事件处理器。实现
addTodo,检查是否按下了回车键(ev.keyCode === 13),如果是,则使用输入框的当前内容作为描述创建一个新的待办事项,并清空输入框中的所有内容。确保每个待办事项都有唯一的 id。它可以只是一个每添加一个待办事项就递增的计数器。
加分项:如果输入为空,则不做任何操作。
更多内容
理论:组件生命周期与钩子¶
到目前为止,我们已看到一个钩子函数的例子:useState。钩子 是一种 深入 组件内部的特殊函数。对于 useState 而言,它会生成一个与当前组件关联的代理对象。这就是为什么钩子函数必须在 setup 方法中调用,而不能更晚!
Owl 组件会经历许多阶段:它可以被实例化、渲染、挂载、更新、分离、销毁……这就是 组件生命周期。上图展示了一个组件生命周期中最重要的几个事件(钩子以紫色显示)。大致来说,组件先被创建,然后被更新(可能多次),最后被销毁。
Owl 提供了多种内置的 钩子函数。它们都必须在 setup 函数中调用。例如,如果你希望在组件挂载时执行一些代码,可以使用 onMounted 钩子:
setup() {
onMounted(() => {
// do something here
});
}
小技巧
所有钩子函数都以 use 或 on 开头。例如:useState 或 onMounted。
10. 聚焦输入框¶
让我们看看如何通过 t-ref 和 useRef 访问 DOM。其主要思路是:你需要在组件模板中使用 t-ref 标记目标元素:
<div t-ref="some_name">hello</div>
然后你可以在 JS 中通过 useRef 钩子 访问它。然而,仔细想想会有一个问题:组件创建时,其实际的 html 元素并不存在。它只在组件挂载后才存在。但钩子必须在 setup 方法中调用。因此,useRef 返回一个对象,其中包含一个表示 element 的 el 键,该键仅在组件挂载后才定义。
setup() {
this.myRef = useRef('some_name');
onMounted(() => {
console.log(this.myRef.el);
});
}
聚焦上一练习中的
input。这应该由TodoList组件完成(注意,input html 元素上有一个focus方法)。加分项:将代码提取到一个专门的 钩子
useAutofocus中,放在新文件awesome_owl/utils.js中。
小技巧
Refs 通常以 Ref 作为后缀,以明确它们是一些特殊对象:
this.inputRef = useRef('input');
11. 切换待办事项状态¶
现在,让我们添加一个新功能:将待办事项标记为已完成。这实际上比想象中更棘手。状态的所有者与显示它的组件并不是同一个。因此,TodoItem 组件需要告知其父组件:待办事项的状态需要切换。一种经典的做法是添加一个 回调 prop toggleState。
在任务 id 之前添加一个带有属性
type="checkbox"的输入框,当状态isCompleted为 true 时必须勾选它。小技巧
如果
t-att指令计算出的结果为假值,Owl 不会创建该属性。为
TodoItem添加回调 propstoggleState。在
TodoItem组件的输入框上添加change事件处理器,并确保它以该待办事项的 id 调用toggleState函数。让它工作起来!
12. 删除待办事项¶
最后一步是让用户能够删除待办事项。
在
TodoItem中添加一个新的回调 propremoveTodo。在
TodoItem组件的模板中插入<span class="fa fa-remove"/>。每当用户点击它时,都应调用
removeTodo方法。让它工作起来!
小技巧
如果你使用数组来存储待办事项列表,可以使用 JavaScript 的
splice函数从中删除一个待办事项。
// find the index of the element to delete
const index = list.findIndex((elem) => elem.id === elemId);
if (index >= 0) {
// remove the element at index from list
list.splice(index, 1);
}
13. 带插槽的通用 Card¶
在 前面的练习 中,我们构建了一个简单的 Card 组件。但说实话,它相当有限。如果我们想在卡片内显示一些任意内容,比如一个子组件,该怎么办呢?这样做是不行的,因为卡片的内容是由字符串描述的。不过,如果能把内容描述为一段模板,那将非常方便。
这正是 Owl 的 插槽 系统所设计的用途:允许编写通用组件。
让我们修改 Card 组件以使用插槽:
移除
contentprop。使用默认插槽来定义主体。
插入几张包含任意内容的卡片,例如一个
Counter组件。(加分项)添加 props 校验。
更多内容
14. 最小化卡片内容¶
最后,让我们给 Card 组件添加一个功能,使其更有趣:我们希望有一个按钮来切换其内容(显示或隐藏)
为
Card组件添加一个状态,用于跟踪它是否处于打开状态(默认)在模板中添加
t-if,以有条件地渲染内容在头部添加一个按钮,并修改代码,以便在点击按钮时翻转状态