第 1 章:Owl 组件

本章介绍 Owl framework,一个专为 Odoo 量身定制的组件系统。Owl 的主要构件是 components 和 templates。

在 Owl 中,用户界面的每个部分都由一个组件管理:它们包含逻辑并定义用于渲染用户界面的模板。实际上,一个组件由一个小的 JavaScript 类表示,该类继承自 Component 类。

要开始学习,您需要一个正在运行的 Odoo 服务器和开发环境设置。在进入练习之前,请确保您已遵循本 教程介绍 中描述的所有步骤。

小技巧

如果您使用 Chrome 作为您的网络浏览器,可以安装 Owl Devtools 扩展。此扩展提供了许多功能,帮助您理解和分析任何 Owl 应用程序。

视频:如何使用 DevTools

在本章中,我们使用 awesome_owl 插件,它提供了一个简化的环境,仅包含 Owl 和一些其他文件。目标是学习 Owl 本身,而不依赖 Odoo Web 客户端代码。

本章每个练习的答案都托管在 official Odoo tutorials repository 上。建议先自己尝试解决,不要直接看答案!

示例:一个 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. 显示计数器

../../../_images/counter.png

作为第一个练习,让我们修改位于 awesome_owl/static/src/ 的 Playground 组件,将其转换为计数器。要查看结果,您可以使用浏览器访问 /awesome_owl 路由。

  1. 修改 playground.js 使其像一个计数器,如上面的例子。将类名保持为 Playground。你需要使用 useState hook,以便在被该组件读取的状态对象的任何部分被修改时,组件都能重新渲染。

  2. 在同一个组件中,创建一个 increment 方法。

  3. 修改 playground.xml 中的模板,使其显示你的计数器变量。使用 t-esc 来输出数据。

  4. 在模板中添加一个按钮,并在该按钮上指定 t-on-click 属性,以便每次点击按钮时触发 increment 方法。

小技巧

浏览器下载的 Odoo JavaScript 文件是压缩过的。为了调试方便,未压缩的文件更容易处理。切换到 启用调试模式(带资源) ,这样文件就不会被压缩。

本练习展示了 Owl 的一个重要特性:reactivity system。useState 函数将值包装在一个代理中,以便 Owl 能够跟踪哪个组件需要状态的哪一部分,从而在值被修改时对其进行更新。试着移除 useState 函数,看看会发生什么。

2. 将 Counter 提取为子组件

目前,Playground 组件里只有计数器的逻辑,但它是不可复用的。下面看看如何由它创建一个 sub-component:

  1. 将计数器代码从 Playground 组件提取到一个新的 Counter 组件中。

  2. 您可以先在同一文件中完成,但完成后,更新您的代码,将 Counter 移动到它自己的文件夹和文件中。从 ./counter/counter 相对导入。确保模板在其自己的文件中,且文件名相同。

  3. 在 Playground 组件的模板中使用 <Counter/> ,在您的 Playground 中添加两个计数器。

../../../_images/double_counter.png

小技巧

按照惯例,大多数组件的代码、模板和 CSS 应该与组件具有相同的蛇形命名。例如,如果我们有一个 TodoList 组件,其代码应该在 todo_list.js 、 todo_list.xml 中,如果需要,还应在 todo_list.scss 中

3. 一个简单的 Card 组件

组件是把复杂的用户界面拆分为多个可复用部分的最自然方式。但要让它们真正发挥作用,就必须能在它们之间传递一些信息。下面看看父组件如何通过属性(通常被称为 props)向子组件传递信息。

本练习的目标是创建一个 Card 组件,该组件接受两个属性: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>
  1. 创建一个 Card 组件

  2. 在 Playground 中导入它,并在其模板中显示几个卡片

../../../_images/simple_card.png

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 directive 实现。

然而,显示任意内容作为 HTML 是危险的,因为它可能被用来注入恶意代码。因此,默认情况下,Owl 总是会对字符串进行转义,除非它被明确标记为安全的(使用 markup 函数)。

  1. 更新 Card 以使用 t-out

  2. 更新 Playground 以导入 markup ,并在一些 html 值上使用它

  3. 确保您看到普通字符串总是被转义,而标记为安全的字符串则不会。

注解

t-esc 指令仍然可以在 Owl 模板中使用。它比 t-out 略快。

../../../_images/markup.png

5. 属性验证

Card 组件有一个隐含的 API。它期望在其 props 中接收两个字符串:title 和 content。让我们把这个 API 变得更显式。我们可以添加一个 props 定义,让 Owl 在 dev mode 中执行验证步骤。你可以在 App configuration 中激活 dev mode(不过它在 awesome_owl playground 上默认已启用)。

对每个组件进行属性验证是一种良好的实践。

  1. 为 Card 组件添加 props validation。

  2. 在沙盒模板中将 title 属性重命名为其他名称,然后检查浏览器开发者工具的 控制台 标签页中是否可以看到错误。

6. 两个 Counter 的总和

我们在之前的练习中看到,props 可用于从父组件向子组件传递信息。现在,让我们看看如何反向传递信息:在本练习中,我们希望显示两个 Counter 组件,并在其下方显示它们值的总和。因此,每当某个 Counter 的值发生变化时,父组件( Playground )需要得到通知。

可以通过使用 callback prop 来实现:prop 是一个函数,用于回调。子组件可以选择使用任意参数调用该函数。在我们这里,只需添加一个可选的 onChange prop,每次 Counter 组件递增时就会被调用。

  1. 为 Counter 组件添加属性验证:它应接受一个可选的 onChange 函数属性。

  2. 更新 Counter 组件,使其在每次递增时调用 onChange 属性(如果存在)。

  3. 修改 Playground 组件以维护一个本地状态值( sum ),初始值设为 2,并在模板中显示它。

  4. 在 Playground 中实现一个 incrementSum 方法。

  5. 将该方法作为属性传递给两个(或更多!)子 Counter 组件。

../../../_images/sum_counter.png

重要

回调 prop 有一个细微之处:它们通常应使用 .bind 后缀定义。请参考 documentation。

7. 一个待办事项列表

现在让我们通过创建一个待办事项列表来探索 Owl 的各种功能。我们需要两个组件:一个用于显示 TodoItem 组件列表的 TodoList 组件。待办事项列表的状态应由 TodoList 维护。

在本教程中,一个 todo 是包含三个值的对象:一个 id (数字)、一个 description (字符串)和一个标志 isCompleted (布尔值):

{ id: 3, description: "buy milk", isCompleted: false }
  1. 创建 TodoList 和 TodoItem 组件。

  2. TodoItem 组件应接收一个 todo 作为属性,并在其 div 中显示其 id 和 description 。

  3. 目前,硬编码待办事项列表:

    // in TodoList
    this.todos = useState([{ id: 3, description: "buy milk", isCompleted: false }]);
    
  4. 使用 t-foreach 在每个 TodoItem 中显示每条 todo。

  5. 在沙盒中显示一个 TodoList 。

  6. 为 TodoItem 添加属性验证。

../../../_images/todo_list.png

小技巧

由于 TodoList 和 TodoItem 组件紧密耦合,将它们放在同一个文件夹中是合理的。

注解

t-foreach 指令在 Owl 中与 QWeb Python 实现并不完全相同:它需要一个唯一的 t-key 值,以便 Owl 能够正确地协调每个元素。

8. 使用动态属性

目前,TodoItem 组件还没有视觉上显示 todo 是否已完成。让我们通过 dynamic attributes 来实现这一点。

  1. 如果 TodoItem 已完成,在其根元素上添加 Bootstrap 类 text-muted 和 text-decoration-line-through 。

  2. 更改硬编码的 this.todos 值,检查它是否正确显示。

尽管该指令名为 t-att (表示属性),但它也可以用于设置 class 值(以及 HTML 属性,例如输入框的 value )。

../../../_images/muted_todo.png

小技巧

Owl 允许您将静态类值与动态值结合使用。以下示例将按预期工作:

<div class="a" t-att-class="someExpression"/>

另请参阅:Owl:动态类属性

9. 添加待办事项

到目前为止,我们列表中的待办事项是硬编码的。让我们通过允许用户向列表中添加待办事项来使其更有用。

  1. 移除 TodoList 组件中的硬编码值:

    this.todos = useState([]);
    
  2. 在任务列表上方添加一个输入框,占位符为 输入新任务 。

  3. 在名为 addTodo 的 keyup 事件上添加一个 event handler。

  4. 实现 addTodo ,检查是否按下了回车键( ev.keyCode === 13 ),如果是,则使用输入框的当前内容作为描述创建一个新的待办事项,并清空输入框的所有内容。

  5. 确保每个待办事项都有唯一的 ID。可以使用一个计数器,在每次添加待办事项时递增。

  6. 加分项:如果输入框为空,则不执行任何操作。

../../../_images/create_todo.png

另请参阅

Owl:响应式

理论:组件生命周期与钩子

到目前为止,我们只看到一个 hook 函数的例子:useState。hook 是一个特殊的函数,它会 钩入 组件的内部。以 useState 为例,它会生成一个与当前组件相关联的代理对象。这就是为什么 hook 函数必须在 setup 方法中调用,而不能更晚!

../../../_images/component_lifecycle.svg

一个 Owl 组件会经历许多阶段:它可以被实例化、渲染、挂载、更新、分离、销毁……这就是 component lifecycle。上图展示了组件生命周期中最重要的事件(hook 显示为紫色)。粗略地说,组件先被创建,然后被更新(可能多次),最后被销毁。

Owl 提供了许多内置的 hooks functions。它们都必须被在 setup 函数中调用。例如,如果你想在自己的组件被挂载时执行一些代码,可以使用 onMounted hook:

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 hook 访问它了。但是如果你仔细想想,这里有个问题:组件的实际 html 元素在组件被创建时并不存在。它只有在组件被挂载时才存在。但是 hook 必须在 setup 方法中被调用。所以,useRef 会返回一个对象而不是元素本身。

setup() {
   this.myRef = useRef('some_name');
   onMounted(() => {
      console.log(this.myRef.el);
   });
}
  1. 聚焦上一个练习中的 input。这应该从 TodoList 组件中完成(注意 input html 元素上有一个 focus 方法)。

  2. 加分项:将代码提取到一个名为 useAutofocus 的专门 hook 中,放在新的 awesome_owl/utils.js 文件里。

../../../_images/autofocus.png

小技巧

Refs 通常以 Ref 为后缀,以明确它们是特殊对象:

this.inputRef = useRef('input');

11. 切换待办事项

现在,让我们添加一个新功能:标记某条 todo 为已完成。这其实比想象的更棘手。状态的所有者与显示它的组件不是同一个,所以 TodoItem 组件需要向父组件传达该 todo 状态需要切换。一种经典的做法是添加一个 callback prop。

  1. 在任务 ID 前添加一个带有属性 type="checkbox" 的输入框,如果状态 isCompleted 为 true,则该复选框必须被选中。

    小技巧

    如果使用 t-att 指令计算的属性值为假值,Owl 不会创建该属性。

  2. 为 TodoItem 添加一个回调属性 toggleState 。

  3. 在 TodoItem 组件的输入框上添加一个 change 事件处理器,并确保它使用待办事项 ID 调用 toggleState 函数。

  4. 使其工作!

../../../_images/toggle_todo.png

12. 删除待办事项

最后一步是让用户能够删除待办事项。

  1. 在 TodoItem 中添加一个新的回调属性 removeTodo 。

  2. 在 TodoItem 组件的模板中插入 <span class="fa fa-remove"/> 。

  3. 每当用户点击它时,应调用 removeTodo 方法。

  4. 使其工作!

    小技巧

    如果您使用数组存储待办事项列表,可以使用 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);
}
../../../_images/delete_todo.png

13. 使用插槽的通用 Card

在 之前的练习 中,我们构建了一个简单的 Card 组件。但说实话它相当有限。如果我们想在卡片内显示一些任意内容,比如子组件,该怎么办?嗯,这行不通,因为卡片的内容是用字符串描述的。然而,如果我们可以将内容描述为一段模板,那将会非常方便。

这正是 Owl 的 slot 系统所设计的用途:允许编写通用组件。

让我们修改 Card 组件以使用插槽:

  1. 移除 content 属性。

  2. 使用默认插槽定义主体内容。

  3. 插入一些带有任意内容的卡片,例如 Counter 组件。

  4. (加分项)添加属性验证。

../../../_images/generic_card.png

14. 折叠卡片内容

最后,让我们为 Card 组件添加一个功能,使其更有趣:我们希望有一个按钮来切换其内容(显示或隐藏)。

  1. 为 Card 组件添加一个状态,用于跟踪它是打开(默认)还是关闭。

  2. 在模板中添加 t-if ,以根据条件渲染内容(conditionally render the content)。

  3. 在头部(header)添加一个按钮,并修改代码以在按钮被点击时切换状态(flip the state)。

../../../_images/toggle_card.png