使用proxy实现一个双向绑定

前言

上一篇文章说了ES6中的Proxy,现在就来利用proxy一步步实现一个模拟vue的双向绑定。

目录

如何实现

  • 在学习vue的时候,vue是通过劫持数据的变化,监听到数据变化时改变前端视图。
  • 那么要实现双向绑定,必然需要一个监听数据的方法。如文章标题所示,这里使用的proxy实现数据的监听。
  • 当监听到数据变化时,需要一个watcher响应并调用更新数据的compile方法去更新前端视图。
  • 在vue中v-model作为绑定的入口。当我们监听到前端input输入信息并绑定了数据项的时候,需要先告知watcher,由watcher改变监听器的数据。
  • 大概的双向绑定的原理为:

1.实现一个observer(数据监听器)

利用proxy实现一个数据监听器很简单,因为proxy是监听整个对象的变化的,所以可以这样写:

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
class VM {
constructor(options, elementId) {
this.data = options.data || {}; // 监听的数据对象
this.el = document.querySelector(elementId);
this.init(); // 初始化
}

// 初始化
init() {
this.observer();
}

// 监听数据变化方法
observer() {
const handler = {
get: (target, propkey) => {
console.log(`监听到${propkey}被取啦,值为:${target[propkey]}`);
return target[propkey];
},
set: (target, propkey, value) => {
if(target[propkey] !== value){
console.log(`监听到${propkey}变化啦,值变为:${value}`);
}
return true;
}
};
this.data = new Proxy(this.data, handler);
}
}

// 测试一下
const vm = new VM({
data: {
name: 'defaultName',
test: 'defaultTest',
},
}, '#app');

vm.data.name = 'changeName'; // 监听到name变化啦,值变为:changeName
vm.data.test = 'changeTest'; // 监听到test变化啦,值变为:changeTest

vm.data.name; // 监听到name被取啦,值为:changeName
vm.data.test; // 监听到test被取啦,值为:changeTest

这样,数据监听器已经基本实现了,但是现在这样只能监听到数据的变化,不能改变前端的视图信息。现在需要实现一个更改前端信息的方法,在VM类中添加方法changeElementData

1
2
3
4
5

// 改变前端数据
changeElementData(value) {
this.el.innerHTML = value;
}

在监听到数据变化时调用changeElementData改变前端数据,handlerset方法中调用方法

1
2
3
4
set(target, propkey, value) {
this.changeElementData(value);
return true;
}

在init中设置一个定时器更改数据

1
2
3
4
5
6
7
8
init() {
this.observer();

setTimeout(() => {
console.log('change data !!');
this.data.name = 'hello world';
}, 1000)
}

已经可以看到监听到的信息改变到前端了,但是!

这样写死的绑定数据显然是没有意义,现在实现的逻辑大概如下面的图

2.实现数据动态更新到前端

上面实现了一个简单的数据绑定展示,但是只能绑定一个指定的节点去改变此节点的数据绑定。这样显然是不能满足的,我们知道vue中是以{{key}}这样的形式去绑定展示的数据的,而且vue中是监听指定的节点的所有子节点的。因此对象中需要在VIEWOBSERVER之间添加一个监听层WATCHER。当监听到数据发生变化时,通过WATCHER去改变VIEW,如图:

根据这个流程,下一步我们需要做的是:

  1. 监听整个绑定的element的所有节点并匹配所有节点中的所有{{text}}模板
  2. 监听到数据变化时,告知watcher需要数据改变了,替换前端模板

在VM类的构造器中添加三个参数

1
2
3
4
5
constructor() {
this.fragment = null; // 文档片段
this.matchModuleReg = new RegExp('\{\{\s*.*?\s*\}\}', 'gi'); // 匹配所有{{}}模版
this.nodeArr = []; // 所有带有模板的前端结点
}

新建一个方法遍历el中的所有节点,并存放到fragment

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 创建一个文档片段
*/
createDocumentFragment() {
let documentFragment = document.createDocumentFragment();
let child = this.el.firstChild;
// 循环向文档片段添加节点
while (child) {
documentFragment.appendChild(child);
child = this.el.firstChild;
}
this.fragment = documentFragment;
}

匹配{{}}的数据并替换模版

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

/**
* 匹配模板
* @param { string } key 触发更新的key
* @param { documentElement } fragment 结点
*/
matchElementModule(key, fragment) {
const childNodes = fragment || this.fragment.childNodes;
[].slice.call(childNodes).forEach((node) => {
if (node.nodeType === 3 && this.matchModuleReg.test(node.textContent)) {
node.defaultContent = node.textContent; // 将初始化的前端内容保存到节点的defaultContent中
this.changeData(node);
this.nodeArr.push(node); // 保存带有模板的结点
}

// 递归遍历子节点
if(node.childNodes && node.childNodes.length) {
this.matchElementModule(key, node.childNodes);
}
})
}

/**
* 改变视图数据
* @param { documentElement } node
*/
changeData(node) {
const matchArr = node.defaultContent.match(this.matchModuleReg); // 获取所有需要匹配的模板
let tmpStr = node.defaultContent;
for(const key of matchArr) {
tmpStr = tmpStr.replace(key, this.data[key.replace(/\{\{|\}\}|\s*/g, '')] || '');
}
node.textContent = tmpStr;
}

实现watcher,数据变化是触发此watcher更新前端

1
2
3
4
5
watcher(key) {
for(const node of this.nodeArr) {
this.changeData(node);
}
}

initproxyset方法中执行新增的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
init() {
this.observer();
this.createDocumentFragment(); // 将绑定的节点都放入文档片段中
for (const key of Object.keys(this.data)) {
this.matchElementModule(key);
}
this.el.appendChild(this.fragment); // 将初始化的数据输出到前端
}

set: () => {
if(target[propkey] !== value) {
target[propkey] = value;
this.watcher(propkey);
}
return true;
}

测试一下:

3.实现数据双向绑定

现在我们的程序已经可以通过改变data动态地改变前端的展示了,接下来需要实现的是一个类似VUEv-model绑定input的方法,通过input输入动态地将输入的信息输出到对应的前端模板上。大概的流程图如下:

一个简单的实现流程大概如下:

  1. 获取所有带有v-model的input结点
  2. 监听输入的信息并设置到对应的data中

在constructor中添加

1
2
3
4
constructor() {
this.modelObj = {};
}

在VM类中新增方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 绑定 y-model
bindModelData(key, node) {
if (this.data[key]) {
node.addEventListener('input', (e) => {
this.data[key] = e.target.value;
}, false);
}
}

// 设置 y-model 值
setModelData(key, node) {
node.value = this.data[key];
}

// 检查y-model属性
checkAttribute(node) {
return node.getAttribute('y-model');
}

watcher中执行setModelData方法,matchElementModule中执行bindModelData方法。
修改后的matchElementModulewatcher方法如下

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
matchElementModule(key, fragment) {
const childNodes = fragment || this.fragment.childNodes;
[].slice.call(childNodes).forEach((node) => {

// 监听所有带有y-model的结点
if (node.getAttribute && this.checkAttribute(node)) {
const tmpAttribute = this.checkAttribute(node);
if(!this.modelObj[tmpAttribute]) {
this.modelObj[tmpAttribute] = [];
};
this.modelObj[tmpAttribute].push(node);
this.setModelData(tmpAttribute, node);
this.bindModelData(tmpAttribute, node);
}

// 保存所有带有{{}}模版的结点
if (node.nodeType === 3 && this.matchModuleReg.test(node.textContent)) {
node.defaultContent = node.textContent; // 将初始化的前端内容保存到节点的defaultContent中
this.changeData(node);
this.nodeArr.push(node); // 保存带有模板的结点
}

// 递归遍历子节点
if(node.childNodes && node.childNodes.length) {
this.matchElementModule(key, node.childNodes);
}
})
}

watcher(key) {
if (this.modelObj[key]) {
this.modelObj[key].forEach(node => {
this.setModelData(key, node);
})
}
for(const node of this.nodeArr) {
this.changeData(node);
}
}

来看一下是否已经成功绑定了,写一下测试代码:

成功!!

最终的代码如下:

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
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
class VM {
constructor(options, elementId) {
this.data = options.data || {}; // 监听的数据对象
this.el = document.querySelector(elementId);
this.fragment = null; // 文档片段
this.matchModuleReg = new RegExp('\{\{\s*.*?\s*\}\}', 'gi'); // 匹配所有{{}}模版
this.nodeArr = []; // 所有带有模板的前端结点
this.modelObj = {}; // 绑定y-model的对象
this.init(); // 初始化
}

// 初始化
init() {
this.observer();
this.createDocumentFragment();
for (const key of Object.keys(this.data)) {
this.matchElementModule(key);
}
this.el.appendChild(this.fragment);
}

// 监听数据变化方法
observer() {
const handler = {
get: (target, propkey) => {
return target[propkey];
},
set: (target, propkey, value) => {
if(target[propkey] !== value) {
target[propkey] = value;
this.watcher(propkey);
}
return true;
}
};
this.data = new Proxy(this.data, handler);
}

/**
* 创建一个文档片段
*/
createDocumentFragment() {
let documentFragment = document.createDocumentFragment();
let child = this.el.firstChild;
// 循环向文档片段添加节点
while (child) {
documentFragment.appendChild(child);

child = this.el.firstChild;
}
this.fragment = documentFragment;
}

/**
* 匹配模板
* @param { string } key 触发更新的key
* @param { documentElement } fragment 结点
*/
matchElementModule(key, fragment) {
const childNodes = fragment || this.fragment.childNodes;
[].slice.call(childNodes).forEach((node) => {

// 监听所有带有y-model的结点
if (node.getAttribute && this.checkAttribute(node)) {
const tmpAttribute = this.checkAttribute(node);
if(!this.modelObj[tmpAttribute]) {
this.modelObj[tmpAttribute] = [];
};
this.modelObj[tmpAttribute].push(node);
this.setModelData(tmpAttribute, node);
this.bindModelData(tmpAttribute, node);
}

// 保存所有带有{{}}模版的结点
if (node.nodeType === 3 && this.matchModuleReg.test(node.textContent)) {
node.defaultContent = node.textContent; // 将初始化的前端内容保存到节点的defaultContent中
this.changeData(node);
this.nodeArr.push(node); // 保存带有模板的结点
}

// 递归遍历子节点
if(node.childNodes && node.childNodes.length) {
this.matchElementModule(key, node.childNodes);
}
})
}

/**
* 改变视图数据
* @param { documentElement } node
*/
changeData(node) {
const matchArr = node.defaultContent.match(this.matchModuleReg); // 获取所有需要匹配的模板
let tmpStr = node.defaultContent;
for(const key of matchArr) {
tmpStr = tmpStr.replace(key, this.data[key.replace(/\{\{|\}\}|\s*/g, '')] || '');
}
node.textContent = tmpStr;
}

watcher(key) {
if (this.modelObj[key]) {
this.modelObj[key].forEach(node => {
this.setModelData(key, node);
})
}
for(const node of this.nodeArr) {
this.changeData(node);
}
}

// 绑定 y-model
bindModelData(key, node) {
if (this.data[key]) {
node.addEventListener('input', (e) => {
this.data[key] = e.target.value;
}, false);
}
}

// 设置 y-model 值
setModelData(key, node) {
node.value = this.data[key];
}

// 检查y-model属性
checkAttribute(node) {
return node.getAttribute('y-model');
}
}

最后

本节我们使用Proxy,从监听器开始,到观察者一步步实现了一个模仿VUE的双向绑定,代码中也许会有很多写的不严谨的地方,如发现错误麻烦大佬们指出~~