第 1 章:Owl 组件¶
本章介绍 Owl 框架,这是一个为 Odoo 量身定制的组件系统。OWL 的主要构建块是 组件 和 模板。
在 Owl 中,用户界面的每个部分都由一个组件管理:组件持有逻辑,并定义用于渲染用户界面的模板。在实践中,组件表现为一个继承 Component 类的小型 JavaScript 类。
首先,您需要一个运行中的 Odoo 服务器并设置好开发环境。在开始练习之前,请确保您已按照 教程简介 中描述的所有步骤进行操作。
在本章中,我们使用 awesome_owl 插件,它提供了一个只包含 Owl 和少量其他文件的简化环境。目标是学习 Owl 本身,而不依赖 Odoo 网页客户端代码。
本章每个练习的答案都托管在 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方法。
Tip
浏览器下载的 Odoo JavaScript 文件是经过压缩的。出于调试目的,文件未经压缩时更便于使用。切换到 带资源的调试模式,使文件不被压缩。
这个练习展示了 Owl 的一个重要特性:响应式系统。useState 函数将值包装在代理(proxy)中,使 Owl 能够追踪哪个组件需要状态对象的哪个部分,从而在值发生更改时更新组件。试着移除 useState 函数,看看会发生什么。
2. 将 Counter 提取为子组件¶
目前,计数器的逻辑位于 Playground 组件中,但它不可复用。让我们看看如何从中创建一个 子组件:
将计数器代码从
Playground组件中提取到一个新的Counter组件中。您可以先在同一个文件中完成,但完成后,请更新代码,将
Counter移到它自己的文件夹和文件中,并从./counter/counter相对导入。确保模板位于同名的独立文件中。在
Playground组件的模板中使用<Counter/>,为您的练习场添加两个计数器。
Tip
按照惯例,大多数组件的代码、模板和 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 处理的字符串则不然。
Note
t-esc 指令仍然可以在 Owl 模板中使用。它比 t-out 稍快一些。
5. Props 验证¶
Card 组件有一个隐式 API。它期望在其 props 中接收两个字符串:title 和 content。让我们把这个 API 变得更明确。我们可以添加一个 props 定义,让 Owl 在 开发模式 下执行验证步骤。您可以在 App 配置 中激活开发模式(但在 awesome_owl playground 中它默认是激活的)。
为每个组件进行 props 验证是一个良好的实践。
为
Card组件添加 props 验证。在 playground 模板中将
titleprop 重命名为其他名称,然后在浏览器开发者工具的 Console 选项卡中检查您是否能看到一个错误。
6. 两个 Counter 的总和¶
我们在前一个练习中看到,props 可用于将信息从父组件传递给子组件。现在,让我们看看如何朝相反的方向传递信息:在本练习中,我们想要显示两个 Counter 组件,并在它们下方显示其值的总和。因此,只要 Counter 的值发生变化,父组件(Playground)就需要被通知。
这可以通过使用 回调 prop 来实现:即一个本身是函数、供回调使用的 prop。子组件可以选择用任意参数调用该函数。在我们的例子中,我们将简单地添加一个可选的 onChange prop,每当 Counter 组件递增时就会调用它。
为
Counter组件添加 prop 验证:它应接受一个可选的onChange函数 prop。更新
Counter组件,使其在递增时调用onChangeprop(如果存在)。修改
Playground组件,维护一个初始值为 2 的本地状态值(sum),并在其模板中显示它在
Playground中实现一个incrementSum方法将该方法的引用作为 prop 传递给两个(或更多!)子
Counter组件。
Important
回调 prop 有一个细微之处:它们通常应以 .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 验证。
Tip
由于 TodoList 和 TodoItem 组件耦合得如此紧密,将它们放在同一个文件夹中是合理的。
Note
t-foreach 指令在 Owl 中与 QWeb 的 python 实现并不完全相同:它需要一个唯一的 t-key 值,以便 Owl 能够正确地对每个元素进行协调。
8. 使用动态属性¶
目前,TodoItem 组件并没有在视觉上显示 todo 是否已完成。让我们通过使用 动态属性 来实现这一点。
如果待办事项已完成,则在
TodoItem根元素上添加 Bootstrap 类text-muted和text-decoration-line-through。修改硬编码的
this.todos值,以检查它是否正确显示。
尽管该指令名为 t-att (即 attribute),它也可以用于设置 class 值(以及 html 属性,例如输入框的 value)。
Tip
Owl 允许您将静态 class 值与动态值组合在一起。下面的示例将按预期工作:
<div class="a" t-att-class="someExpression"/>
9. 添加待办事项¶
到目前为止,我们列表中的待办事项都是硬编码的。让我们允许用户向列表中添加待办事项,使其更加实用。
移除
TodoList组件中的硬编码值:this.todos = useState([]);
在任务列表上方添加一个输入框,其占位符为 Enter a new task。
在
keyup事件上添加一个名为addTodo的 事件处理器。实现
addTodo,检查是否按下了回车键(ev.keyCode === 13),如果是,则创建一个新的待办事项,其描述为输入框的当前内容,并清空输入框的所有内容。请确保待办事项具有唯一的 id。它可以只是一个每添加一个待办事项就递增的计数器。
加分项:如果输入框为空,则不做任何操作。
更多内容
理论:组件生命周期与钩子¶
到目前为止,我们已经见过一个钩子函数的例子:useState。钩子 是一种 钩入 组件内部机制的特殊函数。以 useState 为例,它会生成一个与当前组件关联的代理对象。这就是钩子函数必须在 setup 方法中调用的原因,且不能再晚!
Owl 组件会经历许多阶段:它可以被实例化、渲染、挂载、更新、分离、销毁……这就是 组件生命周期。上图展示了组件生命中最重要的事件(钩子以紫色显示)。大致来说,组件先被创建,然后被更新(可能多次),最后被销毁。
Owl 提供了多种内置的 钩子函数。所有这些函数都必须在 setup 函数中调用。例如,如果您想在组件被挂载时执行某些代码,可以使用 onMounted 钩子:
setup() {
onMounted(() => {
// do something here
});
}
Tip
所有钩子函数都以 use 或 on 开头。例如:useState 或 onMounted。
10. 聚焦输入框¶
让我们看看如何使用 t-ref 和 useRef 访问 DOM。其主要思路是,您需要在组件模板中用 t-ref 标记目标元素:
<div t-ref="some_name">hello</div>
然后您可以在 JS 中通过 useRef 钩子 访问它。然而,仔细想想会有一个问题:组件对应的实际 html 元素在组件被创建时并不存在,只有在组件被挂载后才存在。但钩子必须在 setup 方法中调用。因此,useRef 返回一个包含 el (即 element)键的对象,该键只在组件被挂载后才有定义。
setup() {
this.myRef = useRef('some_name');
onMounted(() => {
console.log(this.myRef.el);
});
}
聚焦前一个练习中的
input。这应该从TodoList组件中完成(注意输入框 html 元素上有一个focus方法)。加分项:将代码提取到一个专门的 钩子
useAutofocus中,放在新的awesome_owl/utils.js文件里。
Tip
ref 通常以 Ref 作为后缀,以便明显看出它们是特殊的对象:
this.inputRef = useRef('input');
11. 切换待办事项状态¶
现在,让我们添加一个新功能:将待办事项标记为已完成。这实际上比想象的更棘手。状态的所有者与显示它的组件并不是同一个。因此,TodoItem 组件需要通知其父组件待办事项状态需要切换。一种经典的做法是添加一个 回调 prop toggleState。
在任务的 id 前添加一个带有
type="checkbox"属性的输入框,当状态isCompleted为 true 时该输入框必须被勾选。Tip
如果
t-att指令计算的值为假值,Owl 不会创建该属性。为
TodoItem添加一个回调 proptoggleState。在
TodoItem组件的输入框上添加一个change事件处理器,并确保它以待办事项 id 为参数调用toggleState函数。让它跑起来!
12. 删除待办事项¶
最后一笔是让用户能够删除待办事项。
在
TodoItem中添加一个新的回调 propremoveTodo。在
TodoItem组件的模板中插入<span class="fa fa-remove"/>。每当用户点击它时,应调用
removeTodo方法。让它跑起来!
Tip
如果您使用数组来存储待办事项列表,可以使用 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组件。(加分项)添加 prop 验证。
更多内容
14. 最小化卡片内容¶
最后,让我们为 Card 组件添加一个功能,使其更有趣:我们想要一个按钮来切换其内容的显示或隐藏
为
Card组件添加一个状态,用于跟踪它是展开(默认)还是收起在模板中添加
t-if以条件渲染内容在头部添加一个按钮,并修改代码以便在点击按钮时切换该状态