【Owl】🦉 OWL教程:待办事项应用 🦉

26 阅读14分钟

在这个教程中,我们将构建一个非常简单的待办事项列表应用程序。应用程序应该满足以下要求:

  • 允许用户创建和删除任务
  • 任务可以标记为已完成
  • 可以按活动/已完成任务进行筛选显示

这个项目将是一个发现和学习一些重要Owl概念的机会,例如组件、存储以及如何组织应用程序。

内容

  1. 设置项目
  2. 添加第一个组件
  3. 显示任务列表
  4. 布局:一些基本的CSS
  5. 将任务提取为子组件
  6. 添加任务(第一部分)
  7. 添加任务(第二部分)
  8. 切换任务
  9. 删除任务
  10. 使用存储
  11. 在本地存储中保存任务
  12. 筛选任务
  13. 最后的润色
  14. 最终代码

1. 设置项目

在这个教程中,我们将做一个非常简单的项目,使用静态文件,没有额外的工具。第一步是创建以下文件结构:

todoapp/
    index.html
    app.css
    app.js
    owl.js

这个应用程序的入口点是index.html文件,它应该包含以下内容:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>OWL Todo App</title>
    <link rel="stylesheet" href="app.css" />
  </head>
  <body>
    <script src="owl.js"></script>
    <script src="app.js"></script>
  </body>
</html>

然后,现在app.css可以保持为空。稍后它将用于给我们的应用程序添加样式。app.js是我们将编写所有代码的地方。目前,让我们只放入以下代码:

// app.js
(function () {
console.log("hello owl", owl.info.version);
})();

owl.__info__.version 2.3.0
注意我们把一切放在一个立即执行的函数里,以避免泄露任何东西到全局作用域。

最后,owl.js应该是从Owl仓库下载的最新版本(如果你喜欢,可以使用owl.min.js)。请注意,您应该下载owl.iife.js或owl.iife.min.js,因为这些文件是为直接在浏览器上运行而构建的,并将它们重命名为owl.js(其他文件如owl.cjs.js是为其他工具捆绑构建的)。

现在,项目应该准备好了。在浏览器中加载index.html文件应该显示一个空页面,标题为Owl待办事项应用,它应该在控制台中记录一条消息,如hello owl 2.x.y。

2. 添加第一个组件

一个Owl应用程序由组件构成,有一个根组件。让我们首先定义一个根组件。用以下代码替换app.js中函数的内容:

// app.js
const { Component, mount, xml } = owl;

// Owl组件
class Root extends Component {
static template = xml`<div>todo app</div>`;
}

mount(Root, document.body);

现在,在浏览器中重新加载页面应该会显示一个消息。

代码非常简单:我们定义了一个带有内联模板的组件,然后将其挂载到文档主体中。

注意1:在更大的项目中,我们会将代码拆分成多个文件,组件在一个子文件夹中,一个主文件将初始化应用程序。然而,这是一个非常小的项目,我们希望保持尽可能简单。

注意2:本教程使用了静态类字段语法。这还没有得到所有浏览器的支持。大多数真实项目将转译他们的代码,所以这不是问题,但是在这个教程中,如果你需要代码在每个浏览器上都能工作,你需要将每个静态关键字转换为对类的赋值:

class App extends Component {}
App.template = xml`<div>todo app</div>`;

注意3:使用xml助手编写内联模板很好,但是没有语法高亮显示,这使得它非常容易有格式错误的xml。一些编辑器支持这种情况的语法高亮显示。例如,VS Code有一个插件Comment tagged template,如果安装了,将正确显示标记模板:

static template = xml /* xml */<div>todo app</div>;

注意4:大型应用程序可能希望能够翻译模板。使用内联模板使其稍微困难,因为我们需要额外的工具来从代码中提取xml,并用翻译值替换它。

3. 显示任务列表

现在基础已经完成,是时候开始考虑任务了。为了实现我们的需求,我们将以以下键的对象数组的形式跟踪任务:

  • id:一个数字。拥有一种唯一标识任务的方式非常有用。由于标题是用户创建/编辑的,它不能保证是唯一的。因此,我们将为每个任务生成一个唯一的id号。
  • text:一个字符串,用来解释任务是关于什么的。
  • isCompleted:一个布尔值,用来跟踪任务的状态。
    现在我们已经决定了状态的内部格式,让我们向App组件添加一些演示数据和一个模板:

class Root extends Component {
  static template = xml/* xml */ `
    <div class="task-list">
        <t t-foreach="tasks" t-as="task" t-key="task.id">
            <div class="task">
                <input type="checkbox" t-att-checked="task.isCompleted"/>
                <span><t t-esc="task.text"/></span>
            </div>
        </t>
    </div>`;

  tasks = [
    {
      id: 1,
      text: "buy milk",
      isCompleted: true,
    },
    {
      id: 2,
      text: "clean house",
      isCompleted: false,
    },
  ];
}

模板包含一个t-foreach循环,用于遍历任务。它可以从组件中找到任务列表,因为渲染上下文包含组件的属性。注意我们使用每个任务的id作为t-key,这非常常见。有两个CSS类:task-list和task,我们将在下一部分中使用它们。

最后,注意t-att-checked属性的使用:通过t-att前缀的属性使其动态。Owl将评估表达式并将其设置为属性的值。

4. 布局:一些基本的CSS

到目前为止,我们的任务列表看起来相当糟糕。让我们向app.css中添加以下内容:

/* app.css */
.task-list {
  width: 300px;
  margin: 50px auto;
  background: aliceblue;
  padding: 10px;
}

.task {
  font-size: 18px;
  color: #111111;
}

这样好多了。现在,让我们添加一个额外的功能:已完成的任务应该有一些不同的样式,以使其更清楚地表明它们不那么重要。为了做到这一点,我们将在每个任务上添加一个动态CSS类:

<div class="task" t-att-class="task.isCompleted ? 'done' : ''">
.task.done {
  opacity: 0.7;
}

注意,这里我们再次使用了动态属性。

5. 将任务提取为子组件

现在很明显,应该有一个任务组件来封装任务的外观和行为。

这个任务组件将显示一个任务,但它不能拥有任务的状态:数据片段应该只有一个所有者。否则就是自找麻烦。所以,任务组件将作为属性获取其数据。这意味着数据仍然由App组件拥有,但可以被任务组件使用(不修改它)。

由于我们正在移动代码,这是重构代码的好机会:

// -------------------------------------------------------------------------
// Task Component
// -------------------------------------------------------------------------
class Task extends Component {
  static template = xml /* xml */`
    <div class="task" t-att-class="props.task.isCompleted ? 'done' : ''">
      <input type="checkbox" t-att-checked="props.task.isCompleted"/>
      <span><t t-esc="props.task.text"/></span>
    </div>`;
  static props = ["task"];
}

// -------------------------------------------------------------------------
// Root Component
// -------------------------------------------------------------------------
class Root extends Component {
  static template = xml /* xml */`
    <div class="task-list">
      <t t-foreach="tasks" t-as="task" t-key="task.id">
        <Task task="task"/>
      </t>
    </div>`;
    static components = { Task };

    tasks = [
        ...
    ];
}

// -------------------------------------------------------------------------
// Setup
// -------------------------------------------------------------------------
mount(Root, document.body, {dev: true});

这里发生了很多事情:

  • 首先,我们现在有一个子组件Task,在文件顶部定义,
  • 每当我们定义一个子组件时,它需要被添加到其父组件的static components键中,这样Owl才能获得对它的引用,
  • 任务组件有一个props键:这仅用于验证目的。它说每个任务应该恰好被赋予一个名为task的属性。如果不是这样,Owl将抛出错误。这在重构组件时非常有用
  • 最后,要激活属性验证,我们需要将Owl的模式设置为dev。这是在mount函数的最后一个参数中完成的。注意,当应用程序在真实的生产环境中使用时,应该删除它,因为dev模式稍微慢一些,因为有额外的检查和验证。

6. 添加任务(第一部分)

我们仍然使用硬编码的任务列表。现在,是时候给用户一种自己添加任务的方式了。第一步是向Root组件添加一个input。但这个输入将位于任务列表之外,所以我们需要适配Root模板、js和css:

<div class="todo-app">
    <input placeholder="Enter a new task" t-on-keyup="addTask"/>
    <div class="task-list">
        <t t-foreach="tasks" t-as="task" t-key="task.id">
            <Task task="task"/>
        </t>
    </div>
</div>
addTask(ev) {
    // 13 is keycode for ENTER
    if (ev.keyCode === 13) {
        const text = ev.target.value.trim();
        ev.target.value = "";
        console.log('adding task', text);
        // todo
    }
}
.todo-app {
  width: 300px;
  margin: 50px auto;
  background: aliceblue;
  padding: 10px;
}

.todo-app > input {
  display: block;
  margin: auto;
}

.task-list {
  margin-top: 8px;
}

现在我们有一个工作正常的输入,每当用户添加任务时都会在控制台中记录。注意,当您加载页面时,输入没有聚焦。但是添加任务是任务列表的核心功能,所以让我们通过聚焦输入使其尽可能快。

我们需要在Root组件准备好(挂载)时执行代码。让我们使用onMounted钩子来实现。我们还需要使用useRef钩子和t-ref指令来获取input的引用:

<input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/>
// 文件顶部:
const { Component, mount, xml, useRef, onMounted } = owl;
// 在Root Component中
setup() {
    const inputRef = useRef("add-input");
    onMounted(() => inputRef.el.focus());
}

这是一种非常常见的情况:每当我们需要根据组件的生命周期执行一些操作时,我们需要在setup方法中使用其中一个生命周期钩子来完成。在这里,我们首先获取对inputRef的引用,然后在onMounted钩子中,我们简单地聚焦HTML元素。

7. 添加任务(第二部分)

在前一节中,我们做了除了实际创建任务之外的所有事情!所以,让我们现在来做。

我们需要一种生成唯一id数字的方式。为此,我们将在App中简单地添加一个nextId数字。同时,让我们在App中删除演示任务:

nextId = 1;
tasks = [];

现在,可以实现addTask方法了:

addTask(ev) {
    // 13是ENTER的键码
    if (ev.keyCode === 13) {
        const text = ev.target.value.trim();
        ev.target.value = "";
        if (text) {
            const newTask = {
                id: this.nextId++,
                text: text,
                isCompleted: false,
            };
            this.tasks.push(newTask);
        }
    }
}

这几乎可以工作,但如果你测试它,你会注意到当用户按Enter时,永远不会显示新任务。但是,如果你添加一个debugger或console.log语句,你会看到代码实际上按预期运行。问题是Owl不知道它需要重新渲染用户界面。我们可以通过使用useState钩子使任务响应式来解决这个问题:

// 在文件顶部
const { Component, mount, xml, useRef, onMounted, useState } = owl;

// 用以下内容替换App中的任务定义:
tasks = useState([]);

现在它按预期工作了!

8. 切换任务

如果你尝试将任务标记为已完成,你可能已经注意到文本没有在不透明度上发生变化。这是因为没有修改isCompleted标志的代码。

现在,这是一个有趣的情况:任务由任务组件显示,但它不是其状态的所有者,所以理想情况下,它不应该修改它。然而,现在我们将这样做(这将在以后的步骤中改进)。在任务中,将输入更改为:

<input type="checkbox" t-att-checked="props.task.isCompleted" t-on-click="toggleTask"/>

并添加toggleTask方法:

toggleTask() {
  this.props.task.isCompleted = !this.props.task.isCompleted;
}

9. 删除任务

现在,让我们添加删除任务的可能性。这与前面的功能不同:删除任务必须在任务本身上完成,但实际操作需要在任务列表上执行。所以,我们需要将请求传达给根组件。这通常是通过在属性中提供一个回调来完成的。

首先,让我们更新任务模板、CSS和JS:

<div class="task" t-att-class="props.task.isCompleted ? 'done' : ''">
    <input type="checkbox" t-att-checked="props.task.isCompleted" t-on-click="toggleTask"/>
    <span><t t-esc="props.task.text"/></span>
    <span class="delete" t-on-click="deleteTask">🗑</span>
</div>
.task {
  font-size: 18px;
  color: #111111;
  display: grid;
  grid-template-columns: 30px auto 30px;
}

.task > input {
  margin: auto;
}

.delete {
  opacity: 0;
  cursor: pointer;
  text-align: center;
}

.task:hover .delete {
  opacity: 1;
}
deleteTask() {
    this.props.onDelete(this.props.task);
}

现在,我们需要在根组件中为每个任务提供onDelete回调:

  <Task task="task" onDelete.bind="deleteTask"/>
deleteTask(task) {
    const index = this.tasks.findIndex(t => t.id === task.id);
    this.tasks.splice(index, 1);
}

注意,onDelete属性用.bind后缀定义:这是一个特殊的后缀,确保函数回调绑定到组件。

还要注意,我们有两个名为deleteTask的函数。任务组件中的一个只是将工作委托给拥有任务列表的根组件,通过onDelete属性。

10. 使用存储

查看代码,很明显,处理任务的所有代码散落在应用程序的各个角落。此外,它混合了UI代码和业务逻辑代码。Owl没有提供任何高层次的抽象来管理业务逻辑,但使用基本的反应性原语(useState和reactive)很容易做到。

让我们在我们的应用程序中使用它来实现一个中央存储。这是一个相当大的重构(对我们的应用程序),因为它涉及到从组件中提取所有与任务相关的代码。这是app.js文件的新内容:

const { Component, mount, xml, useRef, onMounted, useState, reactive, useEnv } =
  owl;

// -------------------------------------------------------------------------
// Store
// -------------------------------------------------------------------------
function useStore() {
  const env = useEnv();
  return useState(env.store);
}

// -------------------------------------------------------------------------
// TaskList Component
// -------------------------------------------------------------------------
class TaskList {
  nextId = 1;
  tasks = [];

  addTask(text) {
    text = text.trim();
    if (text) {
      const task = {
        id: this.nextId++,
        text: text,
        isCompleted: false,
      };
      this.tasks.push(task);
    }
  }

  toggleTask(task) {
    task.isCompleted = !task.isCompleted;
  }

  deleteTask(task) {
    const index = this.tasks.findIndex((t) => t.id === task.id);
    this.tasks.splice(index, 1);
  }
}

function createTaskStore() {
  return reactive(new TaskList());
}

// -------------------------------------------------------------------------
// Task Component
// -------------------------------------------------------------------------
class Task extends Component {
  static template = xml`
  <div class="task" t-att-class="props.task.isCompleted ? 'done' : ''">
    <input type="checkbox" t-att-checked="props.task.isCompleted" t-on-click="()=>store.toggleTask(props.task)"/>
    <span><t t-esc="props.task.text"/></span>
    <span class="delete" t-on-click="()=>store.deleteTask(props.task)">🗑</span>
  </div>
  `;
  static props = ["task"];

  setup() {
    this.store = useStore();
  }
}

// -------------------------------------------------------------------------
// Root Component
// -------------------------------------------------------------------------
class Root extends Component {
  static template = xml`
  <div class="todo-app">
    <input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input" />
    <div class="task-list">
      <t t-foreach="store.tasks" t-as="task" t-key="task.id">
        <Task task="task" />
      </t>
    </div>
  </div>
  `;
  static components = { Task };

  setup() {
    const inputRef = useRef("add-input");
    onMounted(() => inputRef.el.focus());
    this.store = useStore();
  }

  addTask(ev) {
    // 13 is the keycode for enter
    if (ev.keyCode === 13) {
      this.store.addTask(ev.target.value);
      ev.target.value = "";
    }
  }
}

// -------------------------------------------------------------------------
// Setup
// -------------------------------------------------------------------------
const env = {
  store: createTaskStore(),
};
mount(Root, document.body, { dev: true, env });

11. 在本地存储中保存任务

现在,我们的TodoApp运行得很好,除非用户关闭或刷新浏览器!它真的不方便只保持应用程序的状态在内存中。为了解决这个问题,我们将在本地存储中保存任务。根据我们当前的代码库,这是一个简单的更改:我们需要将任务保存到本地存储并监听任何更改。

class TaskList {
  constructor(tasks) {
    this.tasks = tasks || [];
    const taskIds = this.tasks.map((t) => t.id);
    this.nextId = taskIds.length ? Math.max(...taskIds) + 1 : 1;
  }
  // ...
}

function createTaskStore() {
  const saveTasks = () => localStorage.setItem("todoapp", JSON.stringify(taskStore.tasks));
  const initialTasks = JSON.parse(localStorage.getItem("todoapp") || "[]");
  const taskStore = reactive(new TaskList(initialTasks), saveTasks);
  saveTasks();
  return taskStore;
}

关键是反应函数接受一个回调,每当观察到的值更改时都会调用它。注意我们需要最初调用saveTasks方法,以确保我们观察到所有当前值。

12. 筛选任务

我们快完成了,我们可以添加/更新/删除任务。唯一缺少的功能

是根据它们完成的状态显示任务的可能性。我们需要在根中跟踪筛选器的状态,然后根据它的值过滤可见任务。

class Root extends Component {
  static template = xml /* xml */`
    <div class="todo-app">
      <input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/>
      <div class="task-list">
        <t t-foreach="displayedTasks" t-as="task" t-key="task.id">
          <Task task="task"/>
        </t>
      </div>
      <div class="task-panel" t-if="store.tasks.length">
        <div class="task-counter">
          <t t-esc="displayedTasks.length"/>
          <t t-if="displayedTasks.length lt store.tasks.length">
              / <t t-esc="store.tasks.length"/>
          </t>
          task(s)
        </div>
        <div>
          <span t-foreach="['all', 'active', 'completed']"
            t-as="f" t-key="f"
            t-att-class="{active: filter.value===f}"
            t-on-click="() => this.setFilter(f)"
            t-esc="f"/>
        </div>
      </div>
    </div>`;

  setup() {
    ...
    this.filter = useState({ value: "all" });
  }

  get displayedTasks() {
    const tasks = this.store.tasks;
    switch (this.filter.value) {
      case "active": return tasks.filter(t => !t.isCompleted);
      case "completed": return tasks.filter(t => t.isCompleted);
      case "all": return tasks;
    }
  }

  setFilter(filter) {
    this.filter.value = filter;
  }
}
.task-panel {
  color: #0088ff;
  margin-top: 8px;
  font-size: 14px;
  display: flex;
}

.task-panel .task-counter {
  flex-grow: 1;
}

.task-panel span {
  padding: 5px;
  cursor: pointer;
}

.task-panel span.active {
  font-weight: bold;
}

注意这里我们使用对象语法动态设置筛选器的CSS类。

13. 最后的润色

我们的列表现在已经功能齐全。我们仍然可以添加一些额外的细节来改善用户体验。

当用户悬停在任务上时添加视觉反馈:

.task:hover {
background-color: #def0ff;
}

使任务的文本可点击,以切换其复选框:

<input type="checkbox" t-att-checked="props.task.isCompleted"
    t-att-id="props.task.id"
    t-on-click="() => store.toggleTask(props.task)"/>
<label t-att-for="props.task.id"><t t-esc="props.task.text"/></label>

划掉已完成任务的文本:

.task.done label {
  text-decoration: line-through;
}

14. 最终代码

我们的应用程序现在完成了。它工作正常,UI代码与业务逻辑代码很好地分离,它是可测试的,全部在150行代码以下(包括模板!)。

参考,这是最终代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>OWL Todo App</title>
    <link rel="stylesheet" href="app.css" />
  </head>
  <body>
    <script src="owl.js"></script>
    <script src="app.js"></script>
  </body>
</html>
(function () {
  const { Component, mount, xml, useRef, onMounted, useState, reactive, useEnv } = owl;

  // -------------------------------------------------------------------------
  // Store
  // -------------------------------------------------------------------------
  function useStore() {
    const env = useEnv();
    return useState(env.store);
  }

  // -------------------------------------------------------------------------
  // TaskList
  // -------------------------------------------------------------------------
  class TaskList {
    constructor(tasks) {
      this.tasks = tasks || [];
      const taskIds = this.tasks.map((t) => t.id);
      this.nextId = taskIds.length ? Math.max(...taskIds) + 1 : 1;
    }

    addTask(text) {
      text = text.trim();
      if (text) {
        const task = {
          id: this.nextId++,
          text: text,
          isCompleted: false,
        };
        this.tasks.push(task);
      }
    }

    toggleTask(task) {
      task.isCompleted = !task.isCompleted;
    }

    deleteTask(task) {
      const index = this.tasks.findIndex((t) => t.id === task.id);
      this.tasks.splice(index, 1);
    }
  }

  function createTaskStore() {
    const saveTasks = () => localStorage.setItem("todoapp", JSON.stringify(taskStore.tasks));
    const initialTasks = JSON.parse(localStorage.getItem("todoapp") || "[]");
    const taskStore = reactive(new TaskList(initialTasks), saveTasks);
    saveTasks();
    return taskStore;
  }

  // -------------------------------------------------------------------------
  // Task Component
  // -------------------------------------------------------------------------
  class Task extends Component {
    static template = xml/* xml */ `
      <div class="task" t-att-class="props.task.isCompleted ? 'done' : ''">
        <input type="checkbox"
          t-att-id="props.task.id"
          t-att-checked="props.task.isCompleted"
          t-on-click="() => store.toggleTask(props.task)"/>
        <label t-att-for="props.task.id"><t t-esc="props.task.text"/></label>
        <span class="delete" t-on-click="() => store.deleteTask(props.task)">🗑</span>
      </div>`;

    static props = ["task"];

    setup() {
      this.store = useStore();
    }
  }

  // -------------------------------------------------------------------------
  // Root Component
  // -------------------------------------------------------------------------
  class Root extends Component {
    static template = xml/* xml */ `
      <div class="todo-app">
        <input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/>
        <div class="task-list">
          <t t-foreach="displayedTasks" t-as="task" t-key="task.id">
            <Task task="task"/>
          </t>
        </div>
        <div class="task-panel" t-if="store.tasks.length">
          <div class="task-counter">
            <t t-esc="displayedTasks.length"/>
            <t t-if="displayedTasks.length lt store.tasks.length">
                / <t t-esc="store.tasks.length"/>
            </t>
            task(s)
          </div>
          <div>
            <span t-foreach="['all', 'active', 'completed']"
              t-as="f" t-key="f"
              t-att-class="{active: filter.value===f}"
              t-on-click="() => this.setFilter(f)"
              t-esc="f"/>
          </div>
        </div>
      </div>`;
    static components = { Task };

    setup() {
      const inputRef = useRef("add-input");
      onMounted(() => inputRef.el.focus());
      this.store = useStore();
      this.filter = useState({ value: "all" });
    }

    addTask(ev) {
      // 13 is keycode for ENTER
      if (ev.keyCode === 13) {
        this.store.addTask(ev.target.value);
        ev.target.value = "";
      }
    }

    get displayedTasks() {
      const tasks = this.store.tasks;
      switch (this.filter.value) {
        case "active":
          return tasks.filter((t) => !t.isCompleted);
        case "completed":
          return tasks.filter((t) => t.isCompleted);
        case "all":
          return tasks;
      }
    }

    setFilter(filter) {
      this.filter.value = filter;
    }
  }

  // -------------------------------------------------------------------------
  // Setup
  // -------------------------------------------------------------------------
  const env = { store: createTaskStore() };
  mount(Root, document.body, { dev: true, env });
})();
.todo-app {
  width: 300px;
  margin: 50px auto;
  background: aliceblue;
  padding: 10px;
}

.todo-app > input {
  display: block;
  margin: auto;
}

.task-list {
  margin-top: 8px;
}

.task {
  font-size: 18px;
  color: #111111;
  display: grid;
  grid-template-columns: 30px auto 30px;
}

.task:hover {
  background-color: #def0ff;
}

.task > input {
  margin: auto;
}

.delete {
  opacity: 0;
  cursor: pointer;
  text-align: center;
}

.task:hover .delete {
  opacity: 1;
}

.task.done {
  opacity: 0.7;
}
.task.done label {
  text-decoration: line-through;
}

.task-panel {
  color: #0088ff;
  margin-top: 8px;
  font-size: 14px;
  display: flex;
}

.task-panel .task-counter {
  flex-grow: 1;
}

.task-panel span {
  padding: 5px;
  cursor: pointer;
}

.task-panel span.active {
  font-weight: bold;
}