【Odoo】OWL教程:TodoApp.XS

序说科技 2022年10月08日 468次浏览

上海序说科技,专注于基于Odoo项目实施,实现企业数智化,助力企业成长。
老韩头的开发日常,博客园分享(2022年前博文)
欢迎转载,请注明出处!

🦉 OWL教程:TodoApp 🦉

本教程,我们将常见一个简单的Todo应用。
这个应用应满足如下功能:

  • 允许用户自行增删任务
  • 任务可被标记为完成
  • 任务可按照完成情况进行过滤

我们将通过本教程学习Owl的相关概念,如components, store,以及如何组织一个应用。

目录

  1. 项目准备阶段
  2. 添加第一个组件
  3. 展示任务列表
  4. 布局:基本css样式
  5. Task子组件
  6. 添加任务(Part 1)
  7. 添加任务(Part 2))
  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 将写我们的核心代码:

(function () {
  console.log("hello owl", owl.__info__.version);
})();

此处我们将代码写在了自执行函数里面,可避免将代码暴露给全局。

最后, 我们可以在Owl的github仓库下载最新版本的owl.js (当然我们也可以使用 owl.min.js)。
这里要注意一点,我们需要下载owl.iife.js 或者 owl.iife.min.js,这两个文件是已编译过的可在浏览器中直接使用的版本,并把文件重命名为 owl.js (owl.cjs.js文件是由其他的工具打包的)。

现在一切就绪,让我们通过浏览器打开index.html,此刻我们可以看到页面标题是Owl Todo App,但是页面目前是空的,并且在F12的调试页面下可以看到打印出hello owl 2.x.y.

2. 添加第一个组件

一个Owl应用是由components组成,其中只有一个根组件。通常我们将Root作为根组件。现在让我们用如下代码替换 app.js的内容:

const { Component, mount, xml } = owl;

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

mount(Root, document.body);

现在,重载页面我们将看到页面上有todo app字样。

代码很简单,我们定义了一个包含简单模板的组件,并将其挂载到页面的body对象中。

知识点 1:在大项目中,我们通常将代码分割在多个文件中。其中包含一个主文件,用于初始化应用;多个子目录,包含相应的组件。

知识点 2:本教程中使用了static语法。目前并非所有的浏览器都支持该语法。大多数真实的项目在投产前,都会编译以适配浏览器。但是在本项目中,我们需要手动的将static 做个调整:

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

知识点 3:通过 xml helper写内联代码很简单,但是由于没有语法高亮的复制,很有可能会忽略一些语法错误。对于使用VS Code的朋友,可以安装 Comment tagged template插件,并在内联代码前标注属于哪种语言的代码即可:

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

知识点 4:稍微大一点的应用通常会转义模板。但是内敛代码此刻就比较尴尬了,我们需要通过额外的插件将内联代码摘出来,然后在替换掉转移的内容。

3. 展示任务列表

现在基本框架已经有了,下面我们将着手task对象:

  • id:整型,任务的UUID。 由于任务是由用户创建的,我们无法保证任务的唯一性。因此我们会为每一个任务生成一个UUID。
  • text: 字符串型,用于标识任务内容。
  • isCompleted: 布尔型,用于标识任务是否完成。

现在让我们为 Root 组件添加内联模板:

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 loop 循环任务列表,用以渲染任务内容。注意,我们使用 id 作为循环的 t-key。此处由两个样式task-list and task,将在后续代码中体现。

最后,我们使用了t-att-checked 属性:
前缀为 t-att 的属性,Owl将根据表达式的内容动态加载属性值。

4. 布局:基本css样式

现在我们在app.css添加一些样式:

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

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

让我们为完成的任务添加明显的标识,以区分重要性。在任务中添加动态属性,如下:

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

5. Task子组件

现在是时候将将任务拆分出来作为 Task 组件了.

Task 组件可用于展示任务信息,但是它并不包含任务的状态信息。但是我们可以通过prop属性获取其状态信息。也就是说,虽然状态信息归属于Root根组件,却可以被Task组件访问

因此我们重构代码如下:

// -------------------------------------------------------------------------
// 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子组件;
  • 在我们定义子组件的时候,需确保子组件继承自components 组件,此刻Owl将获取一份该组件的引用;
  • Task 组件的props关键字: 这意味着每个任务都会有一个task属性.如果没有的话,Owl将会报错error
  • 最后,为了触发props验证,我们需要将Owl的mode 设置为 dev(通过mount 函数实现)。但在生产环境下,为了确保性能,需去除该配置。

6. 添加任务(Part 1)

在前面我们使用的是自行定义的任务列表,下面让我们添加自定义任务的代码。首先第一步是在 Root 组件中添加输入框,由于输入框并不属于任务列表,因此我们需要调整 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;
}

我们已添加了输入框,并可在console中打印任务信息。但是在我们刷新页面的时候,输入框并未聚焦。

因此我们可在Root组件准备完成后,使用onMounted钩子驱动代码。我们还需要一个输入框的标识符,并通过 useRef 钩子的t-ref指令获取对象:

<input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/>
// on top of file:
const { Component, mount, xml, useRef, onMounted } = owl;
// in App
setup() {
    const inputRef = useRef("add-input");
    onMounted(() => inputRef.el.focus());
}

这是在Owl中常见的一种处理方式:当我们想要触发某些在整个组件生命周期的动作时,我们可以通过在setup函数中使用生命周期钩子实现。
在上述代码中,我们获取了 inputRef的引用,并在 onMounted 钩子中执行动作达成了聚焦输入框的目的。

7. 添加任务 (part 2)

下面,我们将开始完成用户自定义任务的部分。

我们需要添加一个UUID id,让我们在Root根组件中添加了nextId 变量,并移除模拟数据:

nextId = 1;
tasks = [];

Now, the addTask method can be implemented:

addTask(ev) {
    // 13 is keycode for 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);
        }
    }
}

当我们在按回车后,页面并未发生变化。这是由于Owl并不知道我们的操作,因此我们可使用useState 钩子让代码更具交互性:

// on top of the file
const { Component, mount, xml, useRef, onMounted, useState } = owl;

// replace the task definition in Root with the following:
tasks = useState([]);

8. 标识任务状态

由于我们并未将isCompleted 联动起来,因此当我们将任务标记为完成时,文字的opacity并未变化。

现在,任务的展示使用Task组件完成的,但是它并不包含具体任务完成情况。

此处我们需要借助前面提到的props来实现在Task类中改变input的值:

<input type="checkbox" t-att-checked="props.task.isCompleted" t-on-click="toggleTask"/>
toggleTask() {
  this.props.task.isCompleted = !this.props.task.isCompleted;
}

9. 删除任务

删除任务是在任务自身实例操作的,但是同样需要在Root根组件中任务列表中有所体现。我们借助props实现。

首先我们需要更新Task 模板, css and 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;
}
static props = ["task", "onDelete"];

deleteTask() {
    this.props.onDelete(this.props.task);
}

现在我们在Root组件中实现了任务对象的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后缀:将回调函数绑定到根组件。

10. 使用存储

目前我们的UI和业务逻辑的代码都是继承在应用中的。虽然Owl并未提供更抽象的方式去管理业务逻辑,但是我们可以通过反射 (useState and reactive)的方式实现。

现在,让我们来实现存储功能部分。由于我们将剥离所有与任务相关的代码,这算是目前我们接触较大的反射了。app.js 如下:

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

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

// -------------------------------------------------------------------------
// TaskList
// -------------------------------------------------------------------------
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/* 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/* 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 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. 浏览器本地存储中保存任务

目前我们已完成主体功能,但是当我们刷新页面或者关闭浏览器后,我们先前的任务列表消失了,这是因为我们的任务列表目前是存储在内存中的。为了避免这个问题,我们可以将任务列表存储在浏览器的本地存储中。

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;
}

reactive 函数将会 在目标值变化的时候调用回调函数。此刻,我们需要先手动调用下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;
}

13. 最后优化

到这,我们的Todo功能完成了,下面我们将对应用进行细节的打磨。

  1. 当用户鼠标悬停在任务上时增加一个样式的变化:
.task:hover {
  background-color: #def0ff;
}
  1. 通过点击任务文本,可触发任务状态的变化:
<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>
  1. 标识已完成任务:
.task.done label {
  text-decoration: line-through;
}

成品

TodoApp完成了。撒花…
我们有效的组织了模板、业务代码,并融合在下面的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;
}