原文地址:https://github.com/odoo/owl/blob/master/README.md#documentation
在这篇教程里,我们将创建一个非常简单的TodoList 应用,该app应该满足下面的需求:
这个工程师探索和学习一些Owl重要概念的非常好的机会,比如组件,存储以及怎么组织一个应用.
这篇教程,我们将创建一个非常简单的工程,只有一些静态文件没有额外的工具. 第一步创建下列文件结构:
todoapp/
index.html
app.css
app.js
owl.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
一个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.
一个大的工程,我们将会把代码分别写在多个不同的文件里,组件在不同的子目录中,一个main文件初始化整个应用, 然而,这是一个非常小的项目,我们让它尽可能简单.
这篇教程使用了静态的类属性语法,并不是左右的浏览器都支持这种写法. 大多数真实的工程都会对代码进行编译,所以这不是一个问题. 但是对于这篇教程来说,你如果想让代码运行在任一浏览器上,你需要将用static关键字进行的赋值改成这样:
class App extends Compents {}
App.template=xml`todo app`;
使用xml助手写内联模板是好的,但是没有语法高亮, 这很容易出现语法错误的xml, 一些编辑器支持语法高亮,比如 vscode有一个插件 “Comment tagged template”, 如果安装了它,它会正正确的显示标记模板.
对于大型应用程序来说,使用内联模板会稍微增加一些困难,因为我们需要额外的工具来提取代码中的xml,并将其替换为翻译后的值。
现在基础工作已经做好了,是时候考虑任务了.为了实现我们的需求,我们将通过一个对象数组来记录任务,它包含下列关键字:
现在我们已经确定了数据格式,让我们给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, 我们将在下一小节使用他们.
到目前为止,我们的任务列表看上去相当难看, 让我们在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
}
注意: 这里我们看到了动态属性的另外一种用法.
现在很清楚了,我们需要一个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模式会稍微慢一点,因为它要做一些额外的检测和校验.
我们依然在使用一个硬编码的任务列表,真的是时候让用户自己来添加任务了. 第一步是添加一个input到Root组件,但是这个input要在task list外面,所以我们需要调整Root的模板,js以及css.
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指令.
// 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元素获得焦点.
前一章节,我们做了所有事情除了真的添加任务. 现在让我们实现它.
我们需要一个方法来生成唯一的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([]);
现在它可以如预期工作了.
如果你尝试标记一条任务为已完成,你会注意到任务内容的透明度并没有发生变化,这是因为没有代码去修改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;
}
让我们现在增加删除任务的功能. 这根之前的功能是不同的: 删除任务必须在任务自身做,但是实际的操作需要在任务列表. 所以,我们需要跟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组件.
看一下代码,很明显,所有处理任务的代码都分散在应用程序的各个地方。此外,它还混合了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});
重构后的代码: 将数据相关的逻辑从组件中抽取出来.
现在,我们的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方法来初始化确保我们能观测到现在所有的值.
我们几乎完成了,我们可以增加,更新,删除任务,唯一漏掉的特性是根据任务状态来显示任务.
我们需要在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}"
我们的任务列表的所有特性都完成了,不过我们依然可以增加额外的一些细节来提高用户体验.
1 当用户鼠标滑过任务时,增加一个视觉反馈
.task:hover {
background-color: #def0ff;
}
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 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;
}