简单易懂的MVVM实现之数据驱动模板(三)

通过前面实现的WatcherCompiler,我们已经能够监听数据的改动以及解析模板语法。现在就让我们将他们结合起来,实现数据驱动模板。

结构

根节点

绝大多数的 MVVM 框架都会指定挂载的根节点,框架只会对根节点内部进行操作,作用不言而喻。

数据

在一个 mvvm(ViewModel) 作用域中,需要定义一组用于驱动 ui 的数据data。由于我们在Watcher中是通过Object.defineProperty来监听数据更改的,也就是说data的字段是必须事先声明好的。

假设data有一下结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const owner: any = {};
const watcher = new Wathcer(
{},
{
name: '',
age: '',
}
);

watcher.addListener('name', () => {
console.log(1);
});

watcher.addListener('sex', () => {
console.log(2);
});

owner.name = 'xxx'; // log 1
owner.sex = 'xxx'; // 不会log 2

上面的例子中,因为sex并未在初始时data中定义,所以修改sex不会触发事件。

计算属性

在模板上进行复杂的计算并不是明智的原则。比如接口返回的数据gener是有1|2|0三个值,我们要在模板上将其中文显示。

1
<div>{{ gender===1?'男':(gender ===2?'女':'未知')}}</div>

看着都头疼,所以我们提供计算属性来完成此类工作。

1
2
3
4
5
6
7
8
9
{
genderText(){
switch(this.gender){
case 1: return '男';
case 2: return '女';
}
return '未知';
}
}
1
<div>{{ genderText }}</div>

这样是不是清爽多了呢?

数据监听

通过watcher可以很方便的监听数据的改动,但是这里我们可以提供一个更方便的 api。

如需要监听formData.name改动进行关键字匹配搜索时

1
2
3
4
5
{
'formData.name': function(value, oldValue){
this.search(value);
}
}

生命周期

mvvm 有自己的声明周期,createddestroyed

方法和事件回调

1
2
3
4
5
6
7
8
{
async loadData() {
//
},
handleClick() {
console.log('clicked');
},
}

汇总

将上面的结构汇总可以得到一个借鉴(山寨)vue 的 MVVM 模型对象结构。

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
new MVVM({
el: document.getElementById('root'),
data: {
name: '',
age: '',
},

computed: {
ageText() {
return this.age + '岁';
},
},

watch: {
name: function() {
// ...
},
},

created() {
console.log('created');
},

destroyed() {
console.log('destroyed');
},

methods: {
async loadData() {
//
},
handleClick() {
console.log('clicked');
},
},
});

实现

Constructor

首先实现构造函数,构造函数接受一个参数,类型为上面定义的数据结构。

MVVM类需要实现IOwner接口,这样WatcherComplier内部才能够正常运行。

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
interface MVVMConfig {
el: HTMLElement;
data: any;
created?: () => void;
destroyed?: () => void;
watch?: { [key: string]: (newValue: any, oldValue: any) => void };
computed: any;
methods: any;
}

class MVVM implements IOwner {
$el: HTMLElement;
$watcher: Watcher;
$complier: Compiler;
[key: string]: any;

private config: MVVMConfig;
constructor(config: MVVMConfig) {
this.$el = config.el;
this.$watcher = new Watcher(this, config.data);
this.$complier = new Compiler(this);
this.config = config;

this.initMethods();
this.initComputed();
this.initWatch();
this.$complier.init();
this.config.created && this.config.created.call(this);
}
// ...
}

除了初始化WatcherCompiler,构造函数还依次做了以下几个步骤

initMethods

初始化方法和回调函数,仅仅是将传入的methods方法代理到this上执行而已。

1
2
3
4
Object.keys(this.config.methods || {}).forEach(key => {
// @ts-ignore
this[key] = this.config.methods[key].bind(this);
});

initComputed

这里有一个优化点,如果按这种的写法,我们无法得知计算属性内部监听了哪些属性,只能全局监听。也就是说任何数据的改动都会触发计算属性的执行。

1
2
3
ageText() {
return this.age + '岁';
},

所以我们对写法进行一些改造,显式声明依赖项,这样一来仅在age发生变化时才执行计算属性。

1
2
3
4
5
{
ageText:['age', function() {
return this.age + '岁';
}],
}

计算属性实现并不复杂,只是通过了Watcher对数据的变化进行了监听,并执行计算属性函数得到返回值,并将键值作为事件名触发Watcher的变化,这样在模板上监听了这个键值的元素就能实时更新了。

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
private initComputed() {
const computed = this.config.computed || {};
const computedKeys = Object.keys(computed);

computedKeys.forEach(ckey => {
// 全局监听
if (typeof computed[ckey] === 'function') {
const cb = () => {
this[ckey] = computed[ckey].call(this);
this.$watcher.trigger(ckey, this[ckey], '');
};
this.$watcher.addListener('', cb);
} else if (Array.isArray(computed[ckey])) {
// 读取依赖并监听
const value = [...computed[ckey]];
const fn = value.pop();
value.forEach(path => {
const cb = () => {
this[ckey] = fn.call(this);
this.$watcher.trigger(ckey, this[ckey], '');
};
this.$watcher.addListener(path, cb);
cb();
});
}
});
}

initWatch

初始化观察方法,实际上是Watcher的一个语法糖,非常简单。

1
2
3
4
5
6
7
8
9
private initWatch() {
const watch = this.config.watch || {};
const watchKeys = Object.keys(watch);
watchKeys.forEach(key => {
this.$watcher.addListener(key, (n, o, key) => {
watch[key].call(this, n, o);
});
});
}

初始化Compiler

因为模板需要的很多属性需要在初始化后才能得出,所以Compiler的初始化放到最后执行。

created

最后执行声明周期created,这个时候 dom 元素已经挂载,模板绑定已经处理完毕。

试一试

我们实现一个简单的count试一试。

你可以打开demo中的 count.html 查看效果

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
</head>
<body>
<div id="app">
<span>点击了:{{ count }}次</span>
<button @click="add(1)">add</button>
<button @click="add(4)">add4</button>
</div>

<script src="./dist/main.js"></script>

<script>
new MVVM({
el: document.getElementById('app'),
data: {
count: 0,
},
methods: {
add(e, count) {
this.count += count;
},
},
});
</script>
</body>
</html>

下一步

目前我们的初级目标已经达成了,但是功能上还很简单,下一篇我们将实现前面提到过的指令(directive),用来增强模板的功能。

源码