Vue双向绑定的原理

双向绑定原理及简单实现

Vue是采用数据劫持结合发布者-订阅者模式的方式,通过new Proxy()来劫持各个属性的setter,getter,在数据变动时发布消息给订阅者,触发相应的监听回调。

Vue3.x与Vue2.x的区别仅是数据劫持的方式由Object.defineProperty更改为Proxy代理,其他代码不变
Vue最核心的功能有两个,一是响应式的数据绑定系统,二是组件系统。本文仅探究双向绑定是怎样实现的。以及实现一个简化版的**Vue-lite**

一、访问器属性

访问器属性是对象中的一种特殊属性,它不能直接在对象中设置,而必须通过defineProperty()方法单独定义。

1
2
3
4
5
6
7
8
9
10
11
12
const obj = {};
Object.defineProperty(obj, 'hello', {
set: newVal => {
console.log('set方法被调用了');
console.log('newVal='+newVal);
},
get: () => {
console.log('get方法被调用了');
}
});
obj.hello = 'xxx'; // 会触发访问器属性中的set方法 参数是xxx
obj.hello // 会触发访问器属性中的get方法

其中**get(),set()**方法就是实现双向绑定的关键

二、极简双向绑定的实现

html部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue-lite</title>
</head>
<body>
<input type="text" id="inputs" />
<p id="tips"></p>
<button id="btn">设置新值</button>
<script src="./js/Vue-lite.js"></script>
</body>
</html>

Vue-lite.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const inputs = document.querySelector('#inputs');
const tips = document.querySelector('#tips');
const btn = document.querySelector('#btn');
const obj = {};
Object.defineProperty(obj, 'hello', {
set: newVal => {
inputs.value = newVal;
tips.innerHTML = newVal;
},
get: () => {
console.log('get方法被调用了');
return '123'; // 可以对值进行修改
}
});
obj.hello = 'xxx'; // 会触发访问器属性中的set方法 参数是xxx
console.log(obj.hello); // 会触发访问器属性中的get方法
inputs.addEventListener('keyup', e=>{
obj.hello = e.target.value;
});
btn.addEventListener('click', e=>{
obj.hello = '233'; // set数据
});
// 实现了 model => view 以及 view => model 的双向绑定。
// 以上就是 Vue2.x 实现双向绑定的基本原理。

此例实现的效果是:随文本框输入文字的变化,span 中会同步显示相同的文字内容;在js或控制台显式的修改 obj.hello 的值,视图会相应更新。这样就实现了 model => view 以及 view => model 的双向绑定,就是Vue实现双向绑定的最基本原理。

三、细节优化

上述示例仅仅是为了说明原理。我们最终要实现的是:

1
2
3
4
5
6
7
<div id="app">
<div>
<span>姓名: </span>
<input type="text" name="name" v-mode="name" />
</div>
<p>您输入的name是: {{name}}</p>
</div>
1
2
3
4
5
6
const vm = new Vue({
el: '#app',
data: {
name: 'rexhang'
}
});

首先做下需要实现功能点:

  1. 输入框以及文本节点与 data 中的数据绑定
  2. 输入框内容变化时,data 中的数据同步变化。即 view => model 的变化
  3. data 中的数据变化时,文本节点的内容同步变化。即 model => view 的变化

html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">
<input type="text" name="name" v-model="name" />
<br />
{{name}}
</div>
<script src="./js/Vue-lite.js"></script>
<script>
new Vue({
el: '#app',
data: {
name: 'rexhang'
}
});
</script>

Vue-lite.js:

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
131
132
133
134
135
function nodeToFragment(node, vm) {
// createDocumentFragment()方法,是用来创建一个虚拟的节点对象,或者说,是用来创建文档碎片节点。它可以包含各种类型的节点,在创建之初是空的。
let virtualDOM = document.createDocumentFragment();
let child;
while( child = node.firstChild){
compile(child, vm)
virtualDOM.appendChild(child);
}
return virtualDOM;
}

function compile (node, vm){
const reg = /\{\{(.*)\}\}/;
console.log(node);
console.log(node.nodeType);
if (node.nodeType === 1){
// 节点类型是元素
const attr = node.attributes;
for(let i = 0; i < attr.length; i++){
console.log(attr[i].nodeName);

if(attr[i].nodeName === 'v-model'){
// 提取v-model的value
const name = attr[i].nodeValue;

node.addEventListener('input', e=>{
vm[name] = e.target.value;
});
// 将data的值赋给该node
// node.value = vm.data[name];
node.value = vm[name];
node.removeAttribute('v-model');
}
}
}
if (node.nodeType === 3){
// 节点类型是text
if(reg.test(node.nodeValue)){
// 正则来获取匹配到的字符串{{name}} => name
let name = node.nodeValue.match(reg)[1].trim();
// 将data的值赋给该node
// node.nodeValue = vm.data[name];
// node.nodeValue = vm[name];
console.log(vm, node, name);
new Watcher(vm, node, name);

}
}
}

function defineReactive(obj, key, val){
const dep = new Dep();
Object.defineProperty(obj, key, {
set: newVal => {
if(newVal === val) return;
val = newVal;
console.log(val);
dep.notify();
},
get: () => {
if(Dep.target) dep.addSub(Dep.target);
return val;
}
})
}

function observe (obj, vm) {
Object.keys(obj).forEach(key=>{
defineReactive(vm, key, obj[key]);
});
}


const dep = new Dep();

const pub = {
publish: ()=>{
dep.notify();
}
}

const sub1 = { update: function () { console.log(1); } }
const sub2 = { update: function () { console.log(2); } }
const sub3 = { update: function () { console.log(3); } }

function Dep () {
// this.subs = [sub1, sub2, sub3];
this.subs = [];
}

// Dep.prototype.notify = function(){
// this.subs.forEach(sub=>{
// sub.update();
// })
// }

Dep.prototype.addSub = function(sub){
this.subs.push(sub)
}

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

pub.publish();

function Watcher(vm, node, name){
Dep.target = this;
this.name = name;
this.node = node;
this.vm = vm;
this.update();
Dep.target = null;
}

Watcher.prototype = {
update: function(){
this.get();
this.node.nodeValue = this.value;
},
get: function(){
this.value = this.vm[this.name];
}
}

function Vue(opt){
this.data = opt.data;
observe(this.data, this);
const rootDom = opt.el;
const dom = nodeToFragment(document.querySelector(rootDom), this);
// 编译完成,将dom返回到app中
document.querySelector(rootDom).appendChild(dom);
}

最终实现效果:
效果图显示失败

四、总结

  • Object.defineProperty 来进行数据中转(劫持),从而实现事件的发布和后续触发订阅者的监听来实现数据绑定
  • 实现一个监听器 observe 用来劫持并监听所有属性,如有变动,就通知订阅者
  • 实现一个订阅者 Watcher 每个Watcher都绑定一个更新函数,可以把收到的属性变化通知并执行相应的函数,更新视图
  • 实现一个解析器 compile 可以循环解析全部节点获取相关指令,初始化数据,初始化订阅

原文参考 Vue3.x双向绑定原理的实现参考