简单实现仿Vuejs双向绑定

前言

最近看了些关于vue的博客,觉得只懂的用vue的语法糖,却连一点原理都不知道总有点那啥。
所以今天整理了一些关于简单实现Vue双向数据绑定的知识~
参考资料

正文

实现原理基础

Vue实现双向数据绑定的做法是数据劫持,个人的理解是将需要绑定的元素进行封装,修改setget方法,在元素被访问时达到响应绑定的效果。

我们可以先来看一个非常简单的双向绑定实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<div id="app">
<input type="text" id="a">
<sapn id="b"></sapn>
</div>
<script type="text/javascript">
var obj = {};
Object.defineProperty(obj, "bindtext", {
//修改obj对象bindtext属性的set方法
set: function(newVal) {
document.getElementById('a').value = newVal;
document.getElementById('b').innerHTML = newVal;
}
});

document.addEventListener('keyup', function(e) {
//监听键盘操作,修改
obj.bindtext = e.target.value;
});
</script>

这个例子可已做到对输入框与标签的双向数据绑定,但是为了最后做到如同vue进行双向绑定一样方便,我们还需要做一下对上述步骤的分解和重组。

  1. 将输入框及文本节点与data的数据绑定
  2. 输入框内容发生变化时,data中的数据同步变化(view=>model)
  3. data中数据变化时,同样修改绑定的文本节点(model=>view)

绑定数据

DocumentFragment相关

这里需要引入一下DocumentFragment的知识,按照我们的思路,对绑定的页面元素进行封装过程会引入很大的消耗,我们需要一种方法来避免对DOM元素的直接封装操作。
Vue进行编译时,引入DocumentFragment作为代替容器。DocumentFragment可以简单看作一个节点容器,它可以包裹多个子节点,当整个DocumentFragment插入DOM时,只有其子节点被插入DOM。使用DocumentFragment代替DOM的直接处理,可以提高速度和性能。
想详细了解DocumentFragment可以查看DocumentFragment详细说明

封装DOM节点为DocumentFragment的代码如下

1
2
3
4
5
6
7
8
9
10
11
12

function convertNode(node, vm) {
var fragment = document.createDocumentFragment();
var child;
while (child = node.firstChild) {
//do something
fragment.append(child);
}
return fragment;
}
var dom = convertNode(document.getElementById('app'));
document.getElementById('app').appendChild(dom);

上面的convertNode函数会将传入node的子节点进行劫持,经过处理后重新挂载回目标节点。

数据绑定初始化

简单说明一下这部分的具体思路

  1. 首先我们需要一个函数comile负责将目标节点中所有含f-model属性的元素及被双括号包裹的文本内容与对应data绑定。
  2. 对需要绑定的目标节点,其子节点的封装过程由上面的convertNode传递。
  3. 一个fake-Vue对象作为Model层和ViewModel

代码代码

1
2
3
4
<div id="app">
<input type="text" id="a" f-model='text'>
{{text}}
</div>

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
function convertNode(node, fm) {
var fragment = document.createDocumentFragment();
var child;
while (child = node.firstChild) {
compile(child, fm);
fragment.append(child);
}
return fragment;
}

function compile(node, fm) {
var reg = /\{\{(.*)\}\}/;
if (node.nodeType === 1) {
var attr = node.attributes;
for (var i = 0; i < attr.length; i++) {
if (attr[i].nodeName == 'f-model') {
var bindName = attr[i].nodeValue;
node.value = fm.data[bindName];
node.removeAttribute('f-model');
}
}
}
if (node.nodeType === 3) {
if (reg.test(node.nodeValue)) {
var bindName = RegExp.$1.trim();
console.log(RegExp.$1);
node.nodeValue = fm.data[bindName];
}
}
}

function fake-Vue(options) {
this.data = options.data;
var id = options.el;
var dom = convertNode(document.getElementById(id), this);
document.getElementById(id).appendChild(dom);
}

var fm = new fake-Vue({
el: 'app',
data: {
text: 'hello world'
}
});

数据响应

上面的代码就简单实现了将输入框内容及文本节点内容与数据绑定。下一步要做的就是数据对象的响应式
这里第一步我们需要修改被绑定元素的setget方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function observe(obj,fm){
Object.keys(obj).forEach(function(key)){
Object.defindPrpperty(obj,key,{
get:function(){
return obj[key];
}
set:function(newVal){
if(newVal == val)
return;
val = newVal;
console.log(val);
}
});
}
}

然后是修改compile方法,为输入框添加事件监听触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function compile(node, fm) {
var reg = /\{\{(.*)\}\}/;
if (node.nodeType === 1) { //节点类型为元素节点
var attr = node.attributes; //对所有属性进行解析
for (var i = 0; i < attr.length; i++) {
if (attr[i].nodeName == 'f-model') {
//将元素与数据绑定
var bindName = attr[i].nodeValue;
node.addEventListener('input',function(e){
fm.data[bindName] = e.target.value;
node.value = fm.data[bindName];
});
node.removeAttribute('f-model');
}
}
}
if (node.nodeType === 3) { //节点类型为文本节点
if (reg.test(node.nodeValue)) {
var bindName = RegExp.$1.trim();
node.nodeValue = fm.data[bindName];
}
}
}

还需要在fakeVue中添加observe调用

1
2
3
4
5
6
7
function fakeVue(options) {
this.data = options.data;
observe(this.data,this);
var id = options.el;
var dom = convertNode(document.getElementById(id), this);
document.getElementById(id).appendChild(dom);
}

做完上述修改,输入框的内容变化的时候,fm对象中的值也会一起被修改。这样就完成了model层向View层的绑定。

应用观察者模式

这里我们为了完成View层向Model层的绑定,需要引入观察者模式 Observer Pattern,别名也叫订阅/发布模式。
这里简单描述下观察者模式,比较抽象的理解就是观察者模式中包含两种角色:观察者(订阅者)和被观察者(发布者),作为观察者,只要订阅了被观察者的事件,那么当观察者的状态改变时,被观察者需要主动通知观察者,但是观察者后续采取什么动作是与被观察者无关的。
基本的实现思想是被观察者维护一个订阅列表,列表中存放观察者的对象或观察者提供的回调方法供被观察者进行通知。当被观察者的状态改变时,只要循环执行列表即可。
骄傲javascript中的addEventListener就是向目标元素订阅相关的动作,如

1
element.addEventListener('click', callback, false);

理解了观察者模式,我们也就容易实现View层向Model层的绑定。把Model层作为被观察者,而页面元素作为相关的观察者。

实现双向绑定

实现双向绑定的具体步骤是

  1. 在对元素进行编译时,对文本节点包装成Watcher,添加到data相关元素的观察者列表里。Watcher负责更新页面元素。
  2. 监听过程中,为所有data属性生成一个主体对象depdep中包含需要维护的观察者列表,触发Watcher的更新动作,即作为订阅信息的发布者。留下一个全局的Dep对象用于保存目标元素的相关。
  3. 修改observe函数,添加对观察者列表的添加动作。
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
function Dep() {
this.subs = []; //被维护的观察者列表
}
Dep.prototype = {
addSub: function(sub) { //被观察者列表的添加动作
this.subs.push(sub);
},
notify: function() { //对观察者列表的所有观察者触发更新
this.subs.forEach(function(sub) {
sub.update();
});
}
}

function Watcher(fm, node, bindname) {
//将全局Dep.target设置为当前页面元素node
Dep.target = this;
//完成watcher的初始化
this.name = bindname;
this.node = node;
this.fm = fm;

this.update(); //初次绑定时进行更新
Dep.target = null; //保证Dep.target唯一
}

Watcher.prototype = { //对绑定node的更新操作
get: function() {
this.value = this.fm.data[this.name];
},
update: function() {
this.get();
this.node.nodeValue = this.value;
}
}

function observe(obj, fm) {
Object.keys(obj).forEach(function(key) {
var val = obj[key];
var dep = new Dep();
Object.defineProperty(obj, key, {
get: function() {
if (Dep.target) //Dep.target存在,将目标元素添加到当前data属性的观察者列表中
dep.addSub(Dep.target);
return val;
},
set: function(newVal) {
if (newVal == val)
return;
val = newVal;
//data属性被修改,由dep触发view层更新
dep.notify();
console.log(val);
}
});
});
}
function compile(node, fm) {
var reg = /\{\{(.*)\}\}/;
if (node.nodeType === 1) { //节点类型为元素节点
var attr = node.attributes; //对所有属性进行解析
for (var i = 0; i < attr.length; i++) {
if (attr[i].nodeName == 'f-model') {
//将元素与数据绑定
// node.value = fm.data[bindName];
var bindName = attr[i].nodeValue;
node.addEventListener('input', function(e) {
fm.data[bindName] = e.target.value;
});
node.removeAttribute('f-model');
}
}
}
if (node.nodeType === 3) { //节点类型为文本节点
if (reg.test(node.nodeValue)) {
var bindName = RegExp.$1.trim();
// node.nodeValue = fm.data[bindName];
new Watcher(fm, node, bindName); //为该页面元素node生产watcher
}
}
}

可能上面的代码有点难懂,这里贴一张流程图,一图胜千言,应该不难懂的
双向绑定流程图
黑色的线代表初始化观察者列表等等,红色的线是活动过程中,Model层的状态改变向View层更新的过程。

结语

写完啦写完啦
本文的代码和demo扔在了双向数据绑定demo
感谢阅读~

用钱砸我