上海序说科技,专注于基于Odoo项目实施,实现企业数智化,助力企业成长。
老韩头的开发日常,博客园分享(2022年前博文)
欢迎转载,请注明出处!
🦉 OWL教程:TodoApp 🦉
本教程,我们将常见一个简单的Todo应用。
这个应用应满足如下功能:
- 允许用户自行增删任务
- 任务可被标记为完成
- 任务可按照完成情况进行过滤
我们将通过本教程学习Owl的相关概念,如components, store
,以及如何组织一个应用。
目录
- 项目准备阶段
- 添加第一个组件
- 展示任务列表
- 布局:基本css样式
- Task子组件
- 添加任务(Part 1)
- 添加任务(Part 2))
- 标识任务状态
- 删除任务
- 使用存储
- 浏览器本地存储中保存任务
- 筛选任务
- 最后优化
- 成品
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功能完成了,下面我们将对应用进行细节的打磨。
- 当用户鼠标悬停在任务上时增加一个样式的变化:
.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;
}
成品
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;
}