简单易懂的MVVM实现之Watcher(一)

MVVM 大家都不陌生了,它把前端开发者用复杂的 dom 操作中彻底解放了出来,从当年让人惊为天人的 Angularjs,到今天前端必备技能 React 和 Vue,始终贯彻着数据驱动UI这一模式,好处之多无需赘言。想要实现这一点有很多途径,我自己也尝试着做了一套,实现了一些基本功能。

准备工作

首先要知道需要实现的功能,参考主流的框架大概如下:

  • 挂载到页面中的某个节点
  • 数据 - 和模板的数据进行双向绑定
  • 计算属性 - 额外的数据,通过转换数据再显示在模板上
  • 模板
  • 生命周期
  • 回调方法

然后定义一套仿vue的数据结构模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
new MVVM({
el: document.getElementById('root'),
data: {
name: '',
age: '',
},

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

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

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

访问器

想要对数据的访问进行监听,就需要通过Object.defineProperty文档)这个方法,它提供了让我们可以拦截属性赋值和访问的途径,让我们可以在属性改变时做一些额外的操作。

下面将属性的声明进行封装,通过一个额外的cb回调属性对数据的改动进行监听。

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
class Token {
private value: any;

constructor(config: { obj: any; key: string; value: any; cb: Callback }) {
const scope = this;
const { key, value, obj, cb } = config;

this.value = value;

Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveSetter() {
return scope.value;
},
set: function reactiveGetter(value) {
const oldValue = scope.value;
scope.value = value;
if (oldValue !== value) {
cb(value, oldValue);
}
},
});
}
}

封装的额外好处是可以将上一次的值缓存,这样一来在值改变时,可以和上一次的值作比较,从而避免触发无用的回调。

上面我们可以监听单个数据的变化了,但是还远远不够。

对象处理

我们定义的data是对象,而且字段也可能是对象,我们不仅需要监听data上的属性变化,还需要进一步监听嵌套的对象的属性变化,而且还需要处理对象结构变化的情况。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
new MVVM({
data: {
userInfo: null,
},

inited() {
setTimeout(() => {
this.userInfo = {
name: 'yqz',
age: 26,
};

this.userInfo.age = 22;
});
},
});

上面的代码中,初始化时 userInfonull,然后拉取详情后,userInfo 赋值为对象,赋值过后我们也需要同步的监听userInfo.nameuserInfo.age的变化,不然 this.userInfo.age 执行时,就无法触发值的更改事件。

观察者(Watcher)

Watcher 本质上是一个事件的订阅发布中心,它有着以下功能

  • 将目标的属性以及嵌套的对象的数据转换成访问器访问。
  • 订阅来自模板的属性更改事件
  • 代理属性值更改的回调,并派发属性的更改事件

同时有以下约定

  • 订阅的事件名是属性的访问路径(例如 this.userInfo.name = ‘xx’ 触发的事件名是 userInfo.name)
  • 如果没有传入事件名,则是全局监听

实现

首先我们定义一个构造函数,接受owner(指当前的 mvvm 实例)和data( 定义的数据),

1
2
3
4
5
6
7
8
9
10
class Watcher {
owner: any;
listeners: {
[path: string]: CallbackWithPath[];
} = {};
constructor(owner: any, data: any) {
mergeDescriptor(owner, this.traverseData(data));
this.owner = owner;
}
}

traverseData 用于遍历一个对象,并返回一个属性名相同,但是属性全部变成访问器的形式,并监听了属性更改,然后通过mergeDescriptor方法将对象的数据的访问器全部拷贝至了owner

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
traverseData(data: any, path = '') {
const result = {};
Object.keys(data).forEach(key => {
const fullPath = (path ? path + '.' : '') + key;

new Token({
obj: result,
key,
value: isPlainObject(data[key])
? this.traverseData(data[key], fullPath)
: data[key],
cb: (newVal, oldValue) => {
this.handleValueChange(fullPath, newVal, oldValue);
},
});
});
return result;
}

handleValueChange接受了 3 个参数,后面 2 个比较好理解,而fullPath是指值从this的访问路径。

在触发观察者中的回调之前,我们先检查数据是否是plainObject(简单对象),如果是则继续遍历属性生成访问器。这样一来,给userInfo赋值后,后面的userInfo.name也能触发对应的事件。

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
handleValueChange(fullPath: string, newValue: any, oldValue: any) {
let parent = this.owner;
const pathArr = fullPath.split('.');
if (pathArr.length >= 2) {
parent = new Function(
'data',
`return data.${pathArr.slice(0, pathArr.length - 1).join('.')}`
)(this.owner);
}

const key: string = pathArr.pop()!;

if (isPlainObject(newValue)) {
new Token({
obj: parent,
key,
value: this.traverseData(newValue, fullPath),
cb: (_newValue, _oldValue) => {
this.handleValueChange(fullPath, _newValue, _oldValue);
},
});
}

this.trigger(fullPath, newValue, oldValue);
}

接下来就是通过trigger触发事件了,需要注意的是,如果更改了userInfo,那么监听的userInfo.name以及userInfo.age事件也应该触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
trigger(path: string, newValue: any, oldValue: any) {
if (!path) {
path = GLOABL_KEY;
}

if (!this.listeners[path]) {
this.listeners[path] = [];
}

this.listeners[path].forEach(cb => cb(newValue, oldValue, path));

// 改变了对象,那么子级也应该收到通知
Object.keys(this.listeners).forEach(key => {
if (key !== path && key.startsWith(path)) {
const k = key.replace(path + '.', '');
const oldV = getValue(oldValue, k);
const newV = getValue(newValue, k);
this.listeners[key].forEach(cb => cb(newV, oldV, key));
}
});

// 全局事件也需要触发
(this.listeners[GLOABL_KEY] || []).forEach(cb => cb(newValue, oldValue, path));
}

试一试

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 Watcher(owner, {
a: 10,
b: {
c: 12,
},
});

watcher.addListener('b', (p1, p2) => {
console.log('b changed', p1, p2);
});

watcher.addListener('b.c', (p1, p2) => {
console.log('b.c changed', p1, p2);
});

owner.b = { c: 15 };
owner.b.c = 'wahahaha';

运行结果为

下一步

现在已经可以通过Watcher对数据更改进行监听,后续我们将通过模板监听对应的数据,然后实现驱动UI

源码