根据观察者模式学习结果实现一个数据双向绑定功能

在上一篇学习的时候对观察者模式和发布-订阅者模式进行了更深层次的学习;这里通过一个 input 模拟VUE双向绑定(正好学习)的功能来加深对观察者模式的记忆,这个例子只对 v-model 指令及显示文本的模板语法进行处理来进行学习(Vue中有很多其它指令等,不做处理)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<div id="app">
<input v-model="test">
\{\{test\}\}
</div>

<script src="*****/observer.js"></script>
<script src="*****/dep.js"></script>
<script src="*****/watcher.js"></script>
<script src="*****/compile.js"></script>
<script src="*****/mvvm.js"></script>

<script>
const vm = new MVVM({
el: 'app',
data() {
return {
test: 'mvvm数据双向绑定demo'
}
}
});
</script>

注意:下文中 \{\{\}\} 这个符号标识的是 Mustache 语法

思路

input => 数据:给input加一个事件,当变化时让其绑定的数据及时变化
数据 => input:通过 defineProperty 设置 get 和 set 来进行数据劫持,触发视图的更新

代码实现

给所有数据都用 defineProperty 设置 get,set

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
function observer(data) {
if (typeof data !== 'object') return;

Object.keys(data).forEach(key => {
defineReactive(data, key, data[key]);
});
}

function defineReactive(data, key, val) {
observer(val);
let dep = new Dep();
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
// 添加观察者到目标(Subject)中
Target && dep.add(Target);
return val;
},
set(newVal) {
val = newVal;
// 当数据变化时,通知观察者更新所有数据
dep.notify();
}
});
}

这样,对所有的数据都进行了劫持,只要数据有修改那么在 set 中都能够监听到。

MVVM 构造函数

调用构造函数 MVVM 的时候,需要把 data 里面的所有数据的键值都绑定上 get 和 set;然后再编译模板

1
2
3
4
5
6
7
8
9
10
11
12
13
class MVVM {
constructor(options) {
this._options = options;
// 取出创建实例时传入参数值data
let data = this._data = options.data();
// 将所有参数值data键值绑定上 get/set
observer(data);
// 查找出创建实例时传入的参数值el,dom结构id
let dom = document.getElementById(options.el);
// 将模板进行编译
new Compile(dom, this);
}
}

模板编译

既然在构造函数 MVVM 创建实例的时调用了 Compile 函数对模板进行编译。那么模板编译到底是干什么?
其实模板编译就是遍历节点,寻找具有 v-model 属性的元素节点,以及 \{\{\}\} 文本模板语法这种格式的文本节点

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
class Compile {
constructor(el, vm) {
this.el = el;
this._vm = vm;
this._compileElement(el);
}

/**
* 遍历所有节点
**/
_compileElement(el) {
let childs = el.childNodes;
// Array.from 从一个类似数组或可迭代的对象中创建一个新的数组实例并返回
Array.from(childs).forEach(node => {
// 仍然有子节点,继续遍历
if (node.childNodes && node.childNodes.length) {
_compileElement(node);
} else {
// 直接编译
_compile(node);
}
});
}

// 编译没有子节点的节点
// 又分当前节点和文本节点
_compile(node) {
if (node.nodeType === 3) {
// 文本节点
let reg = /\{\{(.*)\}\}/;
let text = node.textContent;
if (reg.test(test)) {
// 检测出模板语法\{\{\}\},该干点儿什么
new Watcher(this._vm, key, val => {
node.textContent = val;
});
}
} else if (node.nodeType === 1) {
// 元素节点
let nodeAttr = node.attributes;
Array.from(nodeAttr).forEach(attr => {
if (attr.nodeName === 'v-model') {
// 检测到了元素节点的属性 v-model,该干点儿什么
node.value = this._vm[attr.nodeValue];
node.addEventListener('input', () => {
this._vm[attr.nodeValue] = node.value;
});
new Watcher(this._vm, attr.nodeValue, val => {
node.value = val;
});
}
});
}
}
}

这里查到了对应的属性 v-model,只要一个数据变化,跟它所关联的 dom 元素都需要更新;
答案出来了,已经是上一篇学习的观察者模式了,观察者(observer)会被添加到目标(Subject)中,目标一通知,所有的观察者都会更新。所以在检测到属性 v-model\{\{\}\} 后需要创建一个观察者,添加到目标中去。

实现观察者

在观察者里面需要实现一个 update 方法对监测到数据改动时的更新

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
let uId = 0;
class Watcher {
constructor(vm, exp, cb) {
this._cb = cb;
this._vm = vm;
// exp 是传递的键值
this._exp = exp;
this._uid = uId;
// 没添加一次watcher都分配一个id,并且增加1,防止重复添加
uId++;
Target = this;
// 通过传递的键值获取当前data值
// 在对 data 值进行获取时会触发用 defineProperty 给所有数据属性添加的 get 方法
this._value = vm[exp];
// 在前面这一句中触发 get 后即删除当前 Target 值
Target = null;
}

updata() {
// 取出当前data值,为什么这里可以直接用 this._vm[键值] 取值?
// 而不需要用 this._vm.data[键值]?
let value = this._vm[_exp];
if (value !== this._value) {
this._value = value;
this._cb.call(this.vm, value);
}
}
}

实现目标(Subject)

观察者是被添加到目标上的,所以得有一个目标构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Dep {
constructor() {
this.subs = [];
}

add(watcher) {
this.subs.push(watcher);
}

notify() {
this.subs.forEach(sub => {
sub.update();
});
}
}

通过前面的线索整理最后结果

  • 首先,在给 key 值添加 get,set 的时候都要创建一个 Dep(目标)。
  • 再次是编译模板时,遇到 v-model 或者 \{\{\}\} 就要创建一个观察者添加到 Dep,同时将 data 里面的值赋给 当前节点,并且 input 还需要绑定一个 input 事件,输入时改变对象里面的值。
  • 在观察者中准备一个全局变量,利用 key 值的 get 属性将它添加到 watcher。
  • 最后,将所有思路整理成 最终代码