# 双向数据绑定

双向数据绑定其实就是模型(Model)改变后同步到视图中(View),视图(View)中的数据改变时同步到模型(Model)中。确保视图与模型中的数据一致。

# 常用的实现方式

  1. 发布者-订阅模式(backbone.js) 利用发布订阅设计模式,更新数据方法一般都是函数调用。例如:vm.set('property', value)

  2. 脏值检查(angular.js) 通过脏值检测的方式比对数据是否有变更,来决定是否更新视图。最简单的就是setInterval()轮询检查数据。不过一般都是通过指定事件触发才进行数据检查。例如:DOM 事件(输入框 input 回调)、XHR 响应事件...等等

  3. 数据劫持(vue.js) 采用数据劫持加上发布订阅模式。例如:通过Object.defineProperty()劫持各个属性的settergetter,数据更新时同通知订阅者,调用更新视图函数。

# 数据劫持实现方法

先附上一个简易版流程图。 流程图

  1. Observer:发布者,使用Object.defineProperty()进行数据劫持,数据有变换时通知Dep
  2. Compile:解析 HTML 模板,将自定义指令转化为对于的视图更新函数Watcher,同时初始化视图。
  3. Watcher:订阅者,模板解析后的视图View更新函数。
  4. Dep:一个消息队列,会通知多个订阅者Watcher,因为模型上的一个属性会被视图的很多地方同时绑定,也就是一对多。

ObserverCompileWatcherDep实现后组合就是MVVM了。

这里采用 ES5 的方式进行实现,后面会提供一个 ES6 版本和 Proxy 版本。

WARNING

如果不了解Object.defineProperty(),建议去查阅一下,这是一个 ES5 的内置方法。

# 类的基本实现

先实现ObserverCompileWatcherDep的基本逻辑。

Observer类的基本实现,简单拦截一个对象中的所有数据。

function Observer(obj) {
   var _this = this
   Object.keys(obj).forEach(function (key) {
      var val = obj[key]
      // 深度劫持,对象中的对象
      // 注意是new Observer(),否则Oberver内部的this是指向window
      if (typeof val === 'object') new Observer(val)
      _this.defineProp(obj, key, val)
   })
}

// 拦截单个数据
Observer.prototype.defineProp = function(obj, key, val) {
   Object.defineProperty(obj, key, {
      enumerable: true, // 可枚举,可以for in
      configurable: true, // 描述符可以改变,也可以在对象中删除该属性
      get: function() {
         return val
      },
      set: function(v) {
         console.log('劫持一下')
         val = v
      }
   })
}
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

Compile类的基本实现,对HTML模板进行解析,根据不同的指令处理不同的逻辑。

// [el] HTML元素
function Compile(el) {
   // 创建文档碎片,提高DOM操作效率
   var fragment = document.createDocumentFragment(el)
   var child
   while (child = el.firstChild) {
      // 加入后,会移除之前的节点
      fragment.appendChild(child)
   }
   this.decode(fragment)
   // 添加的不是fragment本身,而是其子孙节点
   el.appendChild(fragment)
}

// 解析元素节点和文本节点
// [node] 元素节点
Compile.prototype.decode = function(node) {
   var _this = this
   // {{}} v-text v-model
   // 使用childNodes,不使用children
   Array.prototype.forEach.call(node.childNodes, function(child) {
      switch (child.nodeType) {
      case 1:
         // 元素节点 <p v-text="x">...<p>
         _this.decodeElement(node)
         // 子节点也需要解析,递归
         _this.decode(node)
         break;
      case 3:
         // 文本节点 {{ content }}
         _this.decodeText(node)
         break;
      }
   })
}

// 解析元素节点的属性 v-text v-model
Compile.prototype.decodeElement = function(node) {
   // TODO:实现逻辑
}

// 解析文本节点的内容 {{ content }}
Compile.prototype.decodeText = function(node) {
   // TODO:实现逻辑
}
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

Watcher类的基本实现,一个订阅者。

// [prop] 对应的属性
// [callback] 某个节点的视图更新函数
function Watcher(prop, callback) {
   this.prop = prop
   this.callback = callback // 保存视图更新函数
}

// 视图更新
Watcher.prototype.update = function() {
   this.callback && this.callback()
}
1
2
3
4
5
6
7
8
9
10
11

Dep类的基本实现,一个队列,可以增加订阅者和通知所有订阅者。

function Dep() {
   // [watcher1, watcher2...]
   this.queue = []
}

// 新增订阅者
// [subject] 一个Watcher实例
Dep.prototype.add = function(subject) {
   this.queue.push(subject)
}

// 通知所有订阅者 -> 调用一组视图更新函数
Dep.prototype.notify = function() {
   this.queue.forEach(function(subject) {
      subject.update()
   })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 类的完善

经常会使用到val = data.prop1.prop2data.prop1.prop2 = val,为了方便,就写两个公共函数。

// [obj] 对象
// [stringProp] 属性字符串
function GetData(obj, stringProp) {
   var props = stringProp.split('.')
   while(props.length > 0) {
      obj = obj[props.shift()]
   }
   return obj
}

// [obj] 对象
// [stringProp] 属性字符串
// [value] 值
function SetData(obj, stringProp, value) {
   var props = stringProp.split('.')
   while(props.length > 1) {
      obj = obj[props.shift()]
   }
   obj[props[0]] = value
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

Observer类的完善,一个属性对应一个队列,{{ content }}属性改变就通知队列中所有的订阅者。因为视图很多地方会绑定同一个属性。这样一来就可以通知绑定该数据的具体地方了。例如:<input v-model="content"/><p>{\{ content }}</p>



















 


















// [obj] 需要监听的对象
function Observer(obj) {
   var _this = this
   Object.keys(obj).forEach(function (key) {
      var val = obj[key]
      // 深度劫持,对象中的对象
      if (typeof val === 'object') Observer(val)
      _this.defineProp(obj, key, obj[val])
   })
}

// 拦截单个数据
// [obj] 对象
// [key] 需要监听的属性
// [val] 该属性的值
Observer.prototype.defineProp = function(obj, key, val) {
   // 一个属性,一个队列
   // 因为视图很多地方会绑定同一个属性,例如:<input v-model="content"><p>{{ content }}
   // TODO:需要思考,如何把对应的Watcher实例加进去
   var dep = new Dep()
   Object.defineProperty(obj, key, {
      enumerable: true, // 可枚举,可以for in
      configurable: true, // 描述符可以改变,也可以在对象中删除该属性
      get: function() {
         return val
      },
      set: function(v) {
         if (v !== val) {
            val = v
            // 数据更新了,通知dep
            // dep通知watcher,调用视图更新函数
            dep.notify()
         }
      }
   })
}
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

Watcher类的完善。









 

















// [data] 数据模型
// [prop] 对应的属性
// [callback] 某个节点的视图更新函数
function Watcher(data, prop, callback) {
   this.data = data
   this.prop = prop
   this.callback = callback // 保存视图更新函数
   this.value = this.getVal()
   // TODO:需要思考,如何将该实例添加到对应的队列dep中
}

Watcher.prototype.getVal = function() {
   return GetData(this.data, this.prop)
}

// 视图更新
Watcher.prototype.update = function() {
   // 对比一下新旧值,判断是否更新
   var newVal = this.getVal()
   if (this.callback && this.value !== newVal) {
      // 顺便更新一下内部的value
      this.value = newVal
      this.callback()
   }
}
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

Compile类的完善。

  1. 需要解析元素节点<p v-text="x"></p>,初始化内容,同时删除属性v-text。还需要创建一个订阅者,订阅该属性,更新视图。
  2. 需要解析元素节点<input v-model="x" />,初始化内容,同时删除属性v-model,监听input事件更新数据模型。还需要创建一个订阅者,订阅该属性,更新视图。
  3. 需要解析文本节点{{ content }},初始化内容。还需要创建一个订阅者,订阅该属性,更新视图。
// [el] HTML元素
// [data] 模型数据
function Compile(el, data) {
   // 模型数据
   this.data = data

   // 创建文档碎片,提高DOM操作效率
   var fragment = document.createDocumentFragment()
   var child
   while (child = el.firstChild) {
      // 加入后,会移出之前的节点
      fragment.appendChild(child)
   }
   // 进行编译
   this.decode(fragment)
   // 添加的不是fragment本身,而是其子孙节点
   el.appendChild(fragment)
}

// 解析元素节点和文本节点
// [node] 元素节点
Compile.prototype.decode = function(node) {
   var _this = this
   // {{}} v-text v-model
   // 使用childNodes,不使用children
   Array.prototype.forEach.call(node.childNodes, function(child) {
      switch (child.nodeType) {
      case 1:
         // 元素节点 <p v-text="x">...<p>
         _this.decodeElement(child)
         // 子节点也需要解析,递归
         _this.decode(child)
         break;
      default:
         // 文本节点 {{ content }}
         _this.decodeText(child)
         break;
      }
   })
}

// 解析元素节点的属性 v-text v-model
// [node] 元素节点
Compile.prototype.decodeElement = function(node) {
   var _this = this
   Array.prototype.forEach.call(node.attributes, function(attr) {
      // v-xxx="vvv"
      // v-xxx
      var attrName = attr.name
      // vvv
      var attrValue = attr.value
      // 视图更新函数
      var callback = undefined
      switch(attrName) {
      case 'v-text':
         // 视图初始化
         node.textContent = GetData(_this.data, attrValue)
         // 视图更新函数
         callback = function() {
            node.textContent = GetData(_this.data, attrValue)
         }
         // 在元素上移除 v-text="xx"
         node.removeAttribute('v-text')
         break;
      case 'v-model':
         // 视图初始化
         node.setAttribute('value', GetData(_this.data, attrValue))
         // 视图更新函数
         callback = function() {
            node.setAttribute('value', GetData(_this.data, attrValue))
         }
         // 同时监听input事件,双向绑定
         node.addEventListener('input', function(e) {
            SetData(_this.data, attrValue, e.target.value)
         })
         // 在元素上移除 v-text="xx"
         node.removeAttribute('v-model')
         break;
      }
      
      // 如果视图更新函数不为空,则创建一个订阅者
      if (callback) new Watcher(_this.data, attrValue, callback)
   })
}

// 解析文本节点的内容 xx{{ content1 }}xx{{ context2 }}xx
// [node] 元素节点
Compile.prototype.decodeText = function(node) {
   var _this = this
   // 是否初始化
   var initial = false
   // 原始模板数据
   var templete = node.textContent
   // 视图更新函数
   var callback = function() {
      // 替换文本内容
      node.textContent = templete.replace(/{{[^{}]+}}/g, function(t) {
         var prop = t.match(/[^{}\s]+/)[0]

         // 如果第一次初始化,需要创建一个订阅者
         if (!initial) new Watcher(_this.data, prop, callback)

         return GetData(_this.data, prop)
      })
      initial = true
   }
   // 调用一下,初始化模板,创建订阅者
   callback()
}
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

Dep类基本不变,就省略。

# 如何将对应的Watcher加入到对应的Dep中?








 



















 


 

 


Observer.prototype.defineProp = function(obj, key, val) {
   var dep = new Dep()
   Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get: function() {
      // 如果缓存存在,则添加进Dep中。*请结合Watcher中的注释理解。
         if (Dep.Cache) dep.add(Dep.Cache)
         return val
      },
      set: function(v) {
         if (v !== val) {
            val = v
            dep.notify()
         }
      }
   })
}

// ...

function Watcher(data, prop, callback) {
   this.data = data
   this.prop = prop
   this.callback = callback
   
   // 将当前实例,保存起来
   Dep.Cache = this
   // getVal其实就是GetData,也是data.prop1,必然会触发Object.defineProperty中的get方法
   // 在get方法里面做一些文章就可以添加进入dep了。
   this.value = this.getVal()
   // 添加完后,清楚缓存
   Dep.Cache = undefined
}
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

# 完成MVVM

根据上面的流程来,以下方式就可以完成双向数据绑定的类了。

function MVVM(el, data) {
   this.$data = data
   new Observer(data)
   new Compile(el, data)
}
1
2
3
4
5

写个简单的例子测试一下:

<div id="app">
   name: {{ name }}; age: {{ age }}
   <input v-model="name"/>
   <div v-text="address.country"></div>
</div>
1
2
3
4
5
new MVVM(document.getElementById('app'), {
   name: "YXP",
   age: "23",
   address: {
      country: '中国1'
   }
})
1
2
3
4
5
6
7

演示图