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.htmlapp.cssapp.jsowl.js

这个应用的入口是index.html, 它包含下面的内容:

DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /><title>OWL Todo Apptitle><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`todo app`
}mount(Root,document.body)

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

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

要点1

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

要点2

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

class App extends Compents {}
App.template=xml`todo app`;

要点3

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

要点4

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

3.显示任务列表

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

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

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

class Root extends Component{static template = xml``;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``;static props=['task']}
// -----------------------------------------------------------------
// Root Component
// -----------------------------------------------------------------
class Root extends Component{static template=xml``;static component={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.



addTask(ev) {// 13 is keycode for ENTERif (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指令.


// 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 ENTERif (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

🗑

.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的回调方法.

  
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 */`🗑`;setup(){this.store=useStore()}static props=["task"];
}
// --------------------------------------------------------
// Root Components
// --------------------------------------------------------class Root extends Component {static template = xml/* xml */ ` 
`;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 */`/ task(s)`;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. 让任务的文本可以点击,切换它的复选框


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 Apptitle><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 */ `🗑`;static props = ["task"];setup() {this.store = useStore();}}// -------------------------------------------------------------------------// Root Component// -------------------------------------------------------------------------class Root extends Component {static template = xml/* xml */ `/ task(s)`;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 ENTERif (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;
}


本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!

相关文章

立即
投稿

微信公众账号

微信扫一扫加关注

返回
顶部