【JavaScript学习笔记】自己实现双向绑定
参考剖析Vue原理&实现双向绑定MVVM和Vue.js双向绑定的实现原理,自己写一个数据双向绑定。
1、通过Object.defineProperty(obj, prop, descriptor)劫持对象的属性读写,其中obj是要在上面定义属性的对象,prop是要定义或修改的属性名称,descriptor是属性的描述符。描述符中可选get和set键值。get是属性的getter方法,返回属性值;set为setter方法,接受唯一参数,并将该参数的值赋值给属性,get和set的默认值均为undefined。
2、双向绑定的简单实现。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17<input type="input" id="input">
<span id="show"></span>
<script>
var obj = {};
Object.defineProperty(obj, 'txt', {
get: function () {
return obj;
},
set: function (newValue) {
document.getElementById('input').value = newValue;
document.getElementById('show').innerHTML = newValue;
}
});
document.getElementById('input').addEventListener('keyup', function (e) {
obj.txt = e.target.value;
});
</script>
当通过input进行输入时,obj.txt的值会相应更新;当通过控制台改变obj.txt的值时,setter会改变view,从而实现了view=>model,model=>view的双向绑定。不过这种简单绑定不会真正执行obj.txt = e.target.value,obj永远为{},如果在set方法中赋值obj.txt = e.target.value,则会造成无限循环。为解决这个问题,将Object.defineProperty()封装为一个函数,即可在其中保存状态obj.txt,修改如下: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<input type="text" id="input">
<div id="show"></div>
<script>
function defineProperty(obj, attr){
var val;
Object.defineProperty(obj, attr, {
get: function () {
return val;
},
set: function (newValue) {
if (newValue === val){
return;
}
val = newValue;
document.getElementById("input").value = newValue;
document.getElementById("show").innerHTML = newValue;
}
});
}
var obj = {};
defineProperty(obj, "txt");
document.getElementById("input").addEventListener("keyup", function(e){
obj.txt = e.target.value;
})
</script>
上面就是一个最简单的双向绑定,但要实现类似于Vue的功能还需要继续改进。
3、model=>view绑定1
2
3
4<div id='app'>
<input type="text" v-model="input">
{{text}}
</div>
1 | function compile(node, vm){ |
实现功能:通过Vue实例vm的el属性查找作用范围,范围内input节点中有v-model属性时,将input的值改为vm下data对应键的值,当检测到双花括号时,将该位置替换为vm下data对应属性的值。
vm通过new关键字声明,构造函数Vue中将el确定的节点及vm自身传入nodeToFragment函数,之后再将当前子节点替换为nodeToFragment函数返回的DocumentFragment对象。nodeToFragment函数将当前节点的子节点逐个传入编译函数compile,并将编译好的节点添加到DocumentFragment对象返回。编译函数compile()判断当前节点类型,节点为元素时,判断是否有v-model属性,并替换input.value,当节点为文本时,利用正则表达式判断是否含有双花括号,并查找括号内对应的值替换该节点。
存在的问题:1
2
3<div id='app'>
<p>{{text}}</p>
</div>
当双花括号节点不是app的子节点时,无法编译。解决方法是在compile()函数中,当节点为元素节点时,判断其是否有子节点,有则再次调用compile函数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23function compile(node, vm){
if(node.nodeType === 1){
var attr = node.attributes;
for(let i = 0; i<attr.length; i++){
if(attr[i].nodeName === 'v-model'){
let name = attr[i].nodeValue;
node.value = vm[name];
node.removeAttribute('v-model');
}
}
if (child = node.firstChild) {
compile(child, vm);
}
}
if(node.nodeType === 3){
let reg = /\{\{(.*)\}\}/;
if(reg.test(node.nodeValue)){
let name = RegExp.$1;
name = name.trim();
node.nodeValue = vm.data[name];
}
}
}
4、view=>model绑定
依照2中方法,先将vm下data中所有值通过Object.defineProperty()赋值,之后在编译函数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
40function observe(data, vm){
Object.keys(data).forEach(function(key){
Object.defineProperty(vm, key, {
get: function (){
return vm[key];
},
set: function (newValue){
document.getElementById("show").innerHTML = newValue;
document.getElementById("input").value = newValue;
}
});
});
}
function compile(node, vm){
if(node.nodeType === 1){
var attr = node.attributes;
for(let i = 0; i<attr.length; i++){
if(attr[i].nodeName === 'v-model'){
let name = attr[i].nodeValue;
node.addEventListener('keyup', function(e){
vm[name] = e.target.value;
});
node.value = vm.data[name];
node.removeAttribute('v-model');
}
}
if (child = node.firstChild) {
compile(child, vm);
}
}
...
}
function Vue(options){
var id = options.el;
this.data = options.data;
observe(this.data, this);//添加view=>model的绑定
...
}
1 | <div id='app'> |
此时改变input的值时,span也会改变;通过console改变vm.input时,span也会改变。但当在console中输入vm.input时会出现无限循环的情况:
这是由于在getter中会反复调用自身,下面对observe进行改进,将Object.defineProperty从函数中单独取出,构成一个闭包。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21function defineProperty(vm, key, val){
Object.defineProperty(vm, key, {
get: function (){
return val;
},
set: function (newValue){
document.getElementById("show").innerHTML = newValue;
document.getElementById("input").value = newValue;
if(newValue === val){
return;
}
val = newValue;
}
});
}
function observe(data, vm){
Object.keys(data).forEach(function(key){
defineProperty(vm, key, data[key]);
});
}
修改后vm.input的值就能正确改变和返回了。但此时view的变化是通过setter手动添加的,而且只能是元素形式的节点,如果节点是模板字符串则无法动态改变。解决办法是采用订阅/发布模式进行修改,对每个节点绑定一个观察者Watcher,对每个数据绑定一个分发者Dep,数据改变时,调用分发者的通知方法,通知每个观察者进行相应的改变。
5、订阅/发布模式(subscribe&publish)
采用订阅/发布模式对代码进行修改。首先定义观察者Watcher,并在编译函数compile()中对每个节点添加观察着Watcher,当接收到分发者指令时,调用update方法更新视图。接下来定义消息分发者Dep,Dep维护观察者数组,当值发生变化时,通知各观察者调用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
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//第三部分
function Watcher(vm, node, name, nodeType){
Dep.target = this;
this.vm = vm;
this.node = node;
this.name = name;
this.nodeType = nodeType;
this.update();
Dep.target = null;
}
Watcher.prototype = {
update: function(){
this.get();
if (this.nodeType === 'text') {
this.node.nodeValue = this.value;
}
if (this.nodeType === 'input') {
this.node.value = this.value;
}
},
get: function(){
this.value = this.vm[this.name];
}
}
function Dep(){
this.subs = [];
}
Dep.prototype = {
addSub: function(sub){
this.subs.push(sub);
},
notify: function(){
this.subs.forEach(function(sub){
sub.update();
});
}
}
//第二部分
function defineProperty(vm, key, val){
var dep = new Dep();
Object.defineProperty(vm, key, {
get: function (){
if(Dep.target){
dep.addSub(Dep.target);
}
return val;
},
set: function (newValue){
if(newValue === val){
return;
}
val = newValue;
dep.notify();
}
});
}
function observe(data, vm){
//Object.keys(data)返回data的key数组
Object.keys(data).forEach(function(key){
defineProperty(vm, key, data[key]);
});
}
//第一部分
function compile(node, vm){
if(node.nodeType === 1){
var attr = node.attributes;
for(let i = 0; i<attr.length; i++){
if(attr[i].nodeName === 'v-model'){
let name = attr[i].nodeValue;
node.addEventListener('keyup', function(e){
vm[name] = e.target.value;
});
node.value = vm[name];
node.removeAttribute('v-model');
new Watcher(vm, node, name, "input");
}
}
if (child = node.firstChild) {
compile(child, vm);
}
}
if(node.nodeType === 3){
let reg = /\{\{(.*)\}\}/;
if(reg.test(node.nodeValue)){
let name = RegExp.$1;
name = name.trim();
// node.nodeValue = vm.data[name];
new Watcher(vm, node, name, "text");
}
}
}
function nodeToFragment(node, vm){
var flag = document.createDocumentFragment();
var child;
while(child = node.firstChild){
compile(child, vm);
flag.appendChild(child);
}
return flag;
}
function Vue(options){
var id = options.el;
var data = options.data;
observe(data, this);
var dom = nodeToFragment(document.getElementById(id), this);
document.getElementById(id).appendChild(dom);
}
var vm = new Vue({
el: 'app',
data: {
input: 'hello'
}
});