OWL 框架简介

OWL是什么

从odoo 14.0开始,Odoo官方又一次更换了前端技术,推出了新一代的前端框架OWL(Odoo Web Libary)。OWL从odoo独立出来,成为了一个独立的前端框架。其特点是包含一个组件声明系统、基于钩子的响应系统、默认的并发支持和一个存储的前端路由。

为什么是OWL?

为什么odoo没有使用现有的成熟的前端框架比如React和Vue,而是要重新独立开发一套新的框架。odoo官方给出的一个长篇的理由解释。大概有如下几个理由:

  • odoo官方不想将前端技术依赖于外界的某个特定公司的产品,想要完全拥有技术的掌控权。
  • 尽管函数式编程是一种趋势,但是odoo并不想放弃一些务实的特性,比如继承。
  • React和Vue的现有的社区库,不能满足odoo动态编译延迟计算的需求。
  • odoo基于Xml布局,替换React和Vue的话要重写编译模块,费时费力且对开发者不够友好。
  • JIT编译和响应的需要
  • React和Vue的并发特性不满足Odoo前端的需求。

简而言之,就是odoo官方觉得市面上的产品没有可以直接拿来使用的,不如自己开发一套。

一个简单的例子

接下来,我们看一个简单的例子,这个例子将在页面中画一个按钮,单击按钮实现计数器自动加1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const { Component, useState } = owl;
const { xml } = owl.tags;

class Counter extends Component {
static template = xml`
<button t-on-click="state.value++">
Click Me! [<t t-esc="state.value"/>]
</button>`;

state = useState({ value: 0 });
}

class App extends Component {
static template = xml`
<div>
<span>Hello Owl</span>
<Counter />
</div>`;

static components = { Counter };
}

const app = new App();
app.mount(document.body);

首先从owl取Compent、useState和xml组件,然后定义一个Counter组件,使用内置模板,然后定义一个App,加载Counter组件,最后挂载到页面中。

实现的效果如下:

1.jpg

这样我们就完成了一个简单的OWL应用。

基础教程

接下来,我们来写一个学习应用todoApp。我们创建一个文件夹todoApp,里边包含一个index.html,app.css,app.js:

1
2
3
4
5
todoapp/
index.html
app.css
app.js
owl.js

然后编写index.html:

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>OWL Todo App</title>
<link rel="stylesheet" href="app.css" />
<script src="https://github.com/odoo/owl/releases/download/v1.0.11/owl.min.js"></script>
<script src="app.js"></script>
</head>
<body></body>
</html>

app.js:

1
2
3
(function(){
console.log("Hello OWL", owl.__info__.version);
})();

刷新页面,将可以从console中看到owl的版本号。

添加第一个组件

Owl应用是由组件组成的,每个应用只能有一个根组件,我们来定义一个App组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const {Component} = owl;
const {xml} = owl.tags;
const {whenReady} = owl.utils;

class App extends Component {
static template = xml`<div>todo App</div>`;
}

function setup(){
const app = new App();
app.mount(document.body);
}

whenReady(setup);

显示一些任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class App 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.title"/></span>
</div>
</t>
</div>`;

tasks = [
{
id: 1,
title: "buy milk",
isCompleted: true,
},
{
id: 2,
title: "clean house",
isCompleted: false,
},
];
}

这里用到了qweb的技术,不熟的同学可以去查看相关资料。

添加一下样式

1
2
3
4
5
6
7
8
9
10
11
.task-list {
width: 300px;
margin: 50px auto;
background: aliceblue;
padding: 10px;
}

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

这里将完成的任务设置一下透明度,以显示区别:

1
<div class="task" t-att-class="task.isCompleted ? 'done' : ''">

对应的样式:

1
2
3
.task.done {
opacity: 0.7;
}

将任务抽象为子组件

任务应该抽离出来成为一个单独的子组件,拥有闭合的行为和外观。

Task组件表现为一个任务,但是它不能拥有任务的状态,状态仍然为App组件所拥有。因此,Task可以通过prop来获取数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// -------------------------------------------------------------------------
// Task Component
// -------------------------------------------------------------------------
const TASK_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.title"/></span>
</div>`;

class Task extends Component {
static template = TASK_TEMPLATE;
static props = ["task"];
}

// -------------------------------------------------------------------------
// App Component
// -------------------------------------------------------------------------
const APP_TEMPLATE = xml /* xml */`
<div class="task-list">
<t t-foreach="tasks" t-as="task" t-key="task.id">
<Task task="task"/>
</t>
</div>`;

class App extends Component {
static template = APP_TEMPLATE;
static components = { Task };

tasks = [
{
id: 1,
title: "buy milk",
isCompleted: true,
},
{
id: 2,
title: "clean house",
isCompleted: false,
},
];
}

// -------------------------------------------------------------------------
// Setup code
// -------------------------------------------------------------------------
function setup() {
owl.config.mode = "dev";
const app = new App();
app.mount(document.body);
}

whenReady(setup);
  • 当我们定义一个子组件时,我们需要将它添加到components中。
  • Task中有一个props属性,它仅用于验证目的。
  • 仅当Owl的状态为dev时,验证机制才会生效。生产记得将dev去掉,否则会影响性能。

添加任务1

目前我们还是硬编码的任务,现在,我们将把任务的添加权限交还给用户:

1
2
3
4
5
6
7
8
<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>
1
2
3
4
5
6
7
8
9
10
11
addTask(ev) {
// 13 is keycode for ENTER
if (ev.keyCode === 13) {
const title = ev.target.value.trim();
ev.target.value = "";
console.log('adding task', title);
// todo
}
}

tasks=[]

t-on-keyup绑定了按键事件,当用户按下enter键时,将任务名称输出到控制台中。
然后我们给input添加一个自动的焦点:

1
2
3
4
5
6

inputRef = useRef("add-input");

mounted(){
this.inputRef.el.focus();
}

添加任务2

目前为止,我们仅仅添加了控制台的输出,并没有将任务真正添加到页面中,接下来,我们将把任务真正地插入到列表中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

class App extends Component {
static template = APP_TEMPLATE;
static components = { Task };

nextId = 1;
tasks = [];

addTask(ev) {
// 13 is keycode for ENTER
if (ev.keyCode === 13) {
const title = ev.target.value.trim();
ev.target.value = "";
console.log('adding task', title);

if (title) {
const newTask = {
id: this.nextId++,
title: title,
isCompleted: false
};
this.tasks.push(newTask);
console.log(this.tasks);
}
}
}

inputRef = useRef("add-input");

mounted() {
this.inputRef.el.focus();
}

// tasks = []
}

运行后你会发现,任务并没有如我们期望的那样显示在界面中,但是打开console你会发现,代码如期地运行了。这里的问题在于owl不知道何时应该重新渲染页面,我们可以使用useState这个钩子来使tasks变得更reactive。

1
2
3
4
5
// on top of the file
const { useRef, useState } = owl.hooks;

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

改变任务状态

虽然我们可以在界面上添加任务并显示出来了,但是我们又发现了新问题:勾选了任务,并没有改变透明度。原因是没有改变任务的isCompleted属性。

这里有个问题,任务是由Task组件显示出来的,但是它并不是控件状态的所有者,我们需要与它的父控件App交互,我们可以在Task中触发事件,并在App中进行监听。

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

input控件中添加对单击事件的监听。

1
2
3
toggleTask(ev) {
this.trigger('toggle-task', { id: this.props.task.id });
}

将task中的触发事件继续向上传递,在App中

1
<div class="task-list" t-on-toggle-task='toggleTask'>
1
2
3
4
toggleTask(ev) {
const task = this.tasks.find(t => t.id == ev.detail.id);
task.isCompleted = !task.isCompleted;
}

这样就实现了完成的任务透明度变浅的效果。

添加删除按钮

接下来,我们添加一个删除按钮:

1
2
3
4
5
<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.title"/></span>
<span class="delete" t-on-click="deleteTask">🗑</span>
</div>

添加按钮删除事件:

1
2
3
deleteTask() {
this.trigger('delete-task', {id: this.props.task.id});
}

在App中监听delete-task事件:

1
<div class="task-list" t-on-toggle-task="toggleTask" t-on-delete-task="deleteTask">
1
2
3
4
deleteTask(ev) {
const index = this.tasks.findIndex(t => t.id === ev.detail.id);
this.tasks.splice(index, 1);
}

Store

Owl Store是Owl应用状态的管理中心,由React Redux and VueX开发。

使用Store重构的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
const { Component, Store } = owl;
const { xml } = owl.tags;
const { whenReady } = owl.utils;
const { useRef, useDispatch, useStore } = owl.hooks;

//
// Store
//
const actions = {
addTask({ state }, title) {
title = title.trim();
if (title) {
const task = {
id: state.nextId++,
title: title,
isCompleted: false
};
state.tasks.push(task);
}
},

toggleTask({ state }, id) {
const task = state.tasks.find((t) => t.id === id);
task.isCompleted = !task.isCompleted;
},

deleteTask({ state }, id) {
const index = state.tasks.findIndex((t) => t.id === id);
state.tasks.splice(index, 1)
}
};

const initialState = {
nextId: 1,
tasks: [],
};



// -------------------------------------------------------------------------
// Task Component
// -------------------------------------------------------------------------
const TASK_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="dispatch('toggleTask', props.task.id)"/>
<span><t t-esc="props.task.title"/></span>
<span class="delete" t-on-click="dispatch('deleteTask', props.task.id)">🗑</span>
</div>`;

class Task extends Component {
static template = TASK_TEMPLATE;
static props = ["task"];
dispatch = useDispatch();
}

// -------------------------------------------------------------------------
// App Component
// -------------------------------------------------------------------------
const APP_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="tasks" t-as="task" t-key="task.id">
<Task task="task"/>
</t>
</div>
</div>`;

class App extends Component {
static template = APP_TEMPLATE;
static components = { Task };

inputRef = useRef("add-input");
tasks = useStore((state) => state.tasks);
dispatch = useDispatch();

mounted() {
this.inputRef.el.focus();
}

addTask(ev) {
if (ev.keyCode == 13) {
this.dispatch("addTask", ev.target.value);
ev.target.value = ""
}
}
}

// -------------------------------------------------------------------------
// Setup code
// -------------------------------------------------------------------------
function setup() {
owl.config.mode = "dev";
const store = new Store({ actions, state: initialState });
App.env.store = store;
const app = new App();
app.mount(document.body);
}

whenReady(setup);

本地化存储任务

目前,我们的任务管理应用可以正常地增删,只是如果一旦刷新浏览器,数据就没了。接下来,我们将任务进行本地化存储:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

function makeStore() {
const localState = window.localStorage.getItem("todoApp");
const state = localState ? JSON.parse(localState) : initialState;
const store = new Store({ state, actions });
store.on("update", null, () => {
localStorage.setItem("todoApp", JSON.stringify(store.state));
})
return store;
}

function setup() {
owl.config.mode = "dev";
// const store = new Store({ actions, state: initialState });
// App.env.store = store;
App.env.store = makeStore();
const app = new App();
app.mount(document.body);
}

过滤任务

最后,我们希望给这个这个应用增加一个过滤系统,可以根据任务的状态进行过滤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// on top of file, readd useState:
const { useRef, useDispatch, useState, useStore } = owl.hooks;

// in App:
filter = useState({value: "all"})

get displayedTasks() {
switch (this.filter.value) {
case "active": return this.tasks.filter(t => !t.isCompleted);
case "completed": return this.tasks.filter(t => t.isCompleted);
case "all": return this.tasks;
}
}

setFilter(filter) {
this.filter.value = filter;
}

在页面中显示过滤按钮:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<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="tasks.length">
<div class="task-counter">
<t t-esc="displayedTasks.length"/>
<t t-if="displayedTasks.length lt tasks.length">
/ <t t-esc="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="setFilter(f)"
t-esc="f"/>
</div>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.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;
}

至此,我们就使用OWL完成了一个简单应用的编写。

你的支持我的动力