OWL教程0 创建一个todoList App

OWL教程0 创建一个todoList App

原文地址:https://github.com/odoo/owl/blob/master/README.md#documentation

教程: 创建一个todoList App

在这篇教程里,我们将创建一个非常简单的TodoList 应用,该app应该满足下面的需求:

  • 让用户添加和移除任务
  • 任务可以标记为已完成
  • 任务可以根据状态(活跃/已完成)来过滤显示

这个工程师探索和学习一些Owl重要概念的非常好的机会,比如组件,存储以及怎么组织一个应用.

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.js应该是从owl仓库下载的最新版本,当然你也可以使用owl.min.js , 注意,你还应该下载owl.iife.js 因为这些文件是用来在浏览器直接运行的,将它重命名为owl.js.(其他的文件比如owl.cjs.js捆绑了其他工具,可能比较大一些)

现在,工程已经准备好了,在浏览器里加载index.html会显示一个空的页面, 标题是"Owl Todo App",它应该在控制台显示一个信息,比如 hello owl 2.x.y

2.添加第一个组件

一个Owl程序由多个组件组成,只有一个单独的根组件.让我们从定义根组件开始. 用下列代码代替app.js的内容

const {Component,mout,xml} = owl;

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

mount(Root,document.body)

现在,在浏览器中刷新页面会显示一条信息.

代码相当简单,我们通过内联模板定义了一个组件,然后将它挂载到document body.

要点1

一个大的工程,我们将会把代码分别写在多个不同的文件里,组件在不同的子目录中,一个main文件初始化整个应用, 然而,这是一个非常小的项目,我们让它尽可能简单.

要点2

这篇教程使用了静态的类属性语法,并不是左右的浏览器都支持这种写法. 大多数真实的工程都会对代码进行编译,所以这不是一个问题. 但是对于这篇教程来说,你如果想让代码运行在任一浏览器上,你需要将用static关键字进行的赋值改成这样:

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

要点3

使用xml助手写内联模板是好的,但是没有语法高亮, 这很容易出现语法错误的xml, 一些编辑器支持语法高亮,比如 vscode有一个插件 “Comment tagged template”, 如果安装了它,它会正正确的显示标记模板.

要点4

对于大型应用程序来说,使用内联模板会稍微增加一些困难,因为我们需要额外的工具来提取代码中的xml,并将其替换为翻译后的值。

3.显示任务列表

现在基础工作已经做好了,是时候考虑任务了.为了实现我们的需求,我们将通过一个对象数组来记录任务,它包含下列关键字:

  • id: 一个数字,任务的唯一标识.
  • text: 一个字符串, 用于描述任务
  • isCompleted: 布尔类型,记录任务状态

现在我们已经确定了数据格式,让我们给app组件添加一些演示数据和模板.

class Root extends Component{
	static template = 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, 我们将在下一小节使用他们.

4 布局: 基本的CSS

到目前为止,我们的任务列表看上去相当难看, 让我们在app.css中增加下面的代码

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

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

这样好多了,现在,我们来增加额外的特性. 已完成的任务风格让他稍微不同, 让它看上去没有那么重要,要做到这一点,我们需要给每条任务增加一个动态的css class:

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

注意: 这里我们看到了动态属性的另外一种用法.

5.将Task提取为子组件

现在很清楚了,我们需要一个task组件来描述一条任务的外观和行为.

Task组件用来显示一条任务,但是它不拥有任务的状态: 一组数组只有一个拥有者. 否则就是自找麻烦.所以, Task组件通过prop属性来获取它的数据. 这意味着,数据存储在App组件中,但是可以被Task组件使用(不能修改)

由于我们再移动代码,这是重构代码的好机会.

// -----------------------------------------------------------------
// 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" />
			<span><t t-esc="props.task.text" /></span>
		</div>
	`;
	
	static props=['task']

}
// -----------------------------------------------------------------
// Root Component
// -----------------------------------------------------------------
class Root extends Component{
	static template=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=[{
    	id:1,
    	text: "buy milk",
    	isCompleted:true,
    },{
        id:2,
    	text: "clean house",
    	isCompleted:false,
    }]

}
// -----------------------------------------------------------------
// Setup
// -----------------------------------------------------------------
mount(Root, document.body);

这里发生了很多事情:

第一,我们有了一个子组件Task, 在文件的顶部被定义.

第二 无论什么时候我们定义子组件,都需要将它添加到静态属性components中

第三 Task组件有一个props属性, 这只是出于验证的目的,它表明每一Task组件都要给一个名字叫task的属性值,否则,Owl会报错, 这在重构组件的时候会很有用.

最后, 为了激活属性验证,我们需要将Owl的模式设置为"dev", 这是在mount函数的最后一个参数完成的, 注意,在生产环境下应该移除它,因为dev模式会稍微慢一点,因为它要做一些额外的检测和校验.

6 增加任务(part1)

我们依然在使用一个硬编码的任务列表,真的是时候让用户自己来添加任务了. 第一步是添加一个input到Root组件,但是这个input要在task list外面,所以我们需要调整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;
}

我们现在有了一个工作的input框,当我们增加一条任务的时候会在控制台打印出来. 注意,当我们加载页面, input框没有获取焦点, 但是添加任务是任务列表的一个核心特性. 所以,让我们尽可能快的让input框获取焦点.

我们需要在Root组件准备好的时候(mounted)执行一些代码, 让我们使用onMounted钩子,我们也需要引用这个input框, 可以通过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());
}

这是非常常见的场景: 无论什么时候我们需要执行一些动作依赖于组件的生命周期循环,我们需要在setup方法中使用生命周期钩子, 这里,我们第一步获取到inputRef, 然后再onMounted钩子中,我们简单的让html元素获得焦点.

7 添加任务(part 2)

前一章节,我们做了所有事情除了真的添加任务. 现在让我们实现它.

我们需要一个方法来生成唯一的id, 我们在App中增加一个nextId, 同时移除演示数据tasks

nextId = 1;
tasks = [];

现在,addTask方法可以这样实现:

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

这几乎就工作了,但是如果你测试它,你会注意到,你按回车后,新的任务并没有显示出来.但是你添加debugger或者console.log语句, 你会看到,代码确实如期望的运行了. 问题在于Owl没办法知道它需要重新渲染用户界面. 我们可以通过让tasks reactive来解决这个问题,使用useState钩子.

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

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

现在它可以如预期工作了.

8 任务切换

如果你尝试标记一条任务为已完成,你会注意到任务内容的透明度并没有发生变化,这是因为没有代码去修改isCompleted 标志.

现在,这是有趣的解决方案: 任务是通过Task组件显示的,单它却不是数据的拥有者.所以理想情况下,它不应该改变它. 然而,现在,这就是我们要做的(后面会改进它), 在Task组件中,修改input标签:

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

增加 toggleTask 方法: 注意: 一定要添加this关键字

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

9 删除任务

让我们现在增加删除任务的功能. 这根之前的功能是不同的: 删除任务必须在任务自身做,但是实际的操作需要在任务列表. 所以,我们需要跟Root组件通信, 这通常通过提供一个callback函数来实现.

首先,让我们更新Task组件的模板,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;
}
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, 这是一个特殊的后缀用来确保回调函数跟组件做了绑定.

通过测试发现: 如果不加这个后缀,在回调函数里,this是没用的.

另外还要注意,我们有两个函数名字都叫deleteTask, task组件只是将工作委托给Root组件.

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
// --------------------------------------------------------
class TaskList{
  nextId = 1;
  tasks=[];

  addTask(text){
    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 Components
// --------------------------------------------------------

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

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

  static props=["task"];
}
// --------------------------------------------------------
// Root Components
// --------------------------------------------------------

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-todo" type="text" />
        <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 useref= useRef("add-todo");
    onMounted(()=>useref.el.focus())
    this.store = useStore();
  }

  addTask(ev){
     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;
}

关键点是reactive方法, 它有一个回调函数,每当观测值发生变化的时候,回调函数都会执行.

注意: 我们需要调用saveTasks方法来初始化确保我们能观测到现在所有的值.

12 过滤任务

我们几乎完成了,我们可以增加,更新,删除任务,唯一漏掉的特性是根据任务状态来显示任务.

我们需要在Root中保存过滤器的状态,然后根据它的值来显示任务.

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

注意: 这里我们设置过滤器动态的css类使用的是对象语法

t-att-class="{active: filter.value===f}"

13. 最后一击(The Final Touch)

我们的任务列表的所有特性都完成了,不过我们依然可以增加额外的一些细节来提高用户体验.

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>

3 . 改变完成任务的文本的风格

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

最后的话

我的的程序现在完成了,它能很好的工作, UI代码能和商业逻辑代码很好的分离,它可以测试,总共不到150行代码

这里是最后的代码:

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.js

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

app.css

.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;
}
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值