在这个教程中,我们将构建一个非常简单的待办事项列表应用程序。应用程序应该满足以下要求:
- 允许用户创建和删除任务
- 任务可以标记为已完成
- 可以按活动/已完成任务进行筛选显示
这个项目将是一个发现和学习一些重要Owl概念的机会,例如组件、存储以及如何组织应用程序。
内容
- 设置项目
- 添加第一个组件
- 显示任务列表
- 布局:一些基本的CSS
- 将任务提取为子组件
- 添加任务(第一部分)
- 添加任务(第二部分)
- 切换任务
- 删除任务
- 使用存储
- 在本地存储中保存任务
- 筛选任务
- 最后的润色
- 最终代码
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;
}