深入MVVM原理

asynchronous

水平有限,暂时还只会用Vue。斗胆用MVVM原理分析一下Vue。

什么是双向绑定

之前的一篇废话中介绍过发布订阅者模式,而Vue中也正是采用了 发布订阅者模式 + 数据劫持的方式实现双向绑定。而在这其中Object.defineProperty起到了至关重要的作用,用法就不多说了。当设置value和获取value时会调用settergetter。PS:听说还有人要为它申请专利…?

大致思路

  1. 数据监听器 Observer,对数据对象的所有属性进行监听,如果有变动则获取到并通知订阅者。

  2. 指令解析器 Compile,对元素节点的指令进行扫描和解析,根据指令模板替换数据,绑定相应的回调函数。

  3. 订阅者Watcher,关联以上两者,订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,更新视图。

  4. 入口函数mvvm,整合以上。

结合内容来看,就是这个样子的:
图是盗的

Observer

数据劫持

假设你已经知道了Object.defineProperty()可以用来监听属性变动,那么将数据对象,包括其子属性对象的属性,进行递归遍历,只要给对象的某个值赋值,就会触发setter,监听到数据的变化。

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

let data = {
name: 'Corbusier'
}

Observer(data)
data.name = 'Gaudi'

function Observer(data) {
if(!data || typeof data !== 'object') {
return
}
/* 递归遍历到子属性对象 */
Object.keys(data).forEach( key => {
defineReactive(data, key, data[key])
function defineReactive(data,key,val) {
Observer(val) // 监听子属性
Object.defineProperty(data,key, {
enumerable: true, // 可枚举
configurable: false, // 不可再定义
get: function() {
return val
},
set: function(newVal) {
console.log(`检测到值变化,${val} ===> ${newVal}`)
val = newVal
}
})
}
})
}

// 检测到值变化,Corbusier ===> Gaudi

MVVM构造函数

MVVM构造函数作为一个构造器使用,模仿Vue构造器的功能使用。

1
2
3
4
5
6
7
8
9

function MVVM(options = {}) {
/* 挂载属性到参数上,同时赋值给_data */
this.$options = options
let data = this._data = this.$options.data

/* 数据劫持 */
Observer(data)
}

这样写会有一个问题,监听数据对象为options.data,更新视图时,必须通过let vm = new MVVM({data: {name: 'Corbusier'}}) vm._data.name = 'Gaudí' 的方式来改变。而我们期望的方式是:let vm = new MVVM({data: {name: 'Corbusier'}}) vm.name = 'Gaudí'

所以需要给MVVM实例添加一个属性代理的方法,当访问vm属性时代理访问vm._data属性,改造一下该构造函数:

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
function MVVM(options = {}) {
/* 挂载属性到参数上,同时赋值给_data */
this.$options = options
let data = this._data = this.$options.data
let me = this

/* 数据代理 */
Object.keys(data).forEach(key => {
me._proxy(key)
})

/* 数据劫持 */
Observer(data)
}

MVVM.prototype = {
_proxy: function(key) {
let me = this
Object.defineProperty(me, key, {
enumerable: true,
configurable: false,
get: function() {
return me._data[key]
},
set: function(newVal) {
me._data[key] = newVal
}
})
}
}

运用Object.defineProperty()方法来劫持MVVM实例对象的属性的读写权,在读写MVVM实例时转为读写vm._data的属性值。

Compile

现在来到了编译解析阶段。主要的任务是解析模板指令,将模板中的变量替换为数据,初始化渲染为View层,每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据变动则通知更新View。

遍历解析过程多次操作DOM节点,为了提高效率、性能,先将el节点转换为文档碎片fragment进行解析编译,完成后其放回原真实DOM节点中。

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
function MVVM(options = {}) {
/* 挂载属性到参数上,同时赋值给_data */
this.$options = options
let data = this._data = this.$options.data
let me = this

/* 数据代理 */
Object.keys(data).forEach(key => {
me._proxy(key)
})

/* 数据劫持 */
Observer(data)

/* 编译 */
new Complie(options.el, this)
}

function Complie(el, vm){
vm.$el = document.querySelector(el)
let child,fragment;
// 创建文档碎片节点
let fragment = document.createDocumentFragment()

// 将el节点中的内容存入
while(child = vm.$el.firstChild) {
fragment.appendChild(child)
}

function Replace(frag) {
Array.from(frag.childNodes).forEach(node => {
let text = node.textContent
// 正则匹配 Moustache expression
//let reg = /\{\{(.*?)\}\}/g

if(node.nodeType === 3 && reg.test(text)) {
let arr = RegExp.$1.split('.')
let val = vm
/*
取元素节点中的匹配元素遍历
赋值为MVVM节点中绑定的元属性值
*/
arr.forEach(key => {
val = val[key]
})

/* 将元素节点的内容替换为MVVM实例属性值 */
node.textContent = text.replace(reg, val).trim()
}

// 递归子节点
if(node.childNodes && node.childNodes.length) {
Replace(node)
}
})
}

Replace(fragment) // 替换节点中的内容
vm.$el.appendChild(fragment) // 放回真实节点
}

现在我们得到的是一个可以编译,但不能在View层展示数据内容的Compile编译器,先别着急,继续看下去,稍后给出解决方案。

Watcher

前面介绍过订阅者,Watcher就是一个订阅者,作为ObserverCompile之间的关联。仍然拿设计模式之发布-订阅者模式中的栗子做说明。

你需要发布结婚举行婚礼的消息,这时候打开通讯录,挨个给朋友打电话。这样的过程简化一下就是:

  1. MVVM实例化对象数组中添加订阅者。(在通讯录中的联系人名单)

  2. 订阅者添加update方法。(保存联系人对应的电话)

  3. 订阅者添加监听属性,属性变动时调用自身的update()方法,并触发Compile中绑定的回调函数。(打电话挨个通知联系人)

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
function Dep() {
this.subs = []
}

Dep.prototype = {
addSubs(sub) {
this.subs.push(sub)
},
notify() {
this.subs.forEach(sub => sub.update())
}
}

function Watcher(fn) {
this.fn = fn
}

Watcher.prototype.update = function() {
this.fn()
}

let watcher = new Watcher( () => console.log('I'm a Watcher'))
let dep = new Dep()
dep.addSub(watcher)

// 触发通知,执行update上绑定的方法
dep.notify() // I'm a Watcher

在之前实现Compile的过程中发现数据并没有更新到View,现在我们可以解决这个问题了。

更新视图

再一次通过这张图来理解这其中的逻辑,Watcher需要做的是:将CompileObserver联系起来。

  1. 作为订阅者,WatcherDep数组中添加对数据的订阅,当数据变化时通知订阅者,Dep本身作为ObserverWatcher的中间人,类似于之前所说的通讯录的功能。数据变化时,订阅者Watcher可以直接获取到该变化。

  2. 另一方面,Compile也需要知晓数据的变化,及时完成绑定其中的回调函数的执行,达到更新View视图视图的目的。所以Compile同样也要订阅数据的变化。当数据变化时调用update方法,并触发Compile中的回调函数。

针对1):在数据劫持Observer中添加Dep通知数据变化,发生变化时通知Watcher,重写一下Watcher构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Watcher(vm, exp, fn) {
this.fn = fn
this.vm = vm
this.exp = exp

/* 添加全局属性 */
Dep.target = this
let arr = exp.split('.')
let val = vm

arr.forEach(key => {
val = val[key]
})

Dep.target = null
}

当获取值的时候,会调用definePropertyget方法。给Dep定义一个全局target属性,暂存Watcher,添加完之后移除:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Observer(data) {
// ...
Object.keys(data).forEach(key => {
defineReactive(data, key, data[key])
function defineReactive(data, key, val) {
Observer(val)
let dep = new Dep()
Object.defineProperty(data, key, {
// ...
get: function() {
Dep.target && dep.addSub(Dep.target)
return val
},
set: function() {
//...
}
})
}
})
}

而在设置值时,需要经由Observerset方法 ==> Dep中的notify通知订阅者Watcher ==> Watcher中的update方法,达到更新视图的目的。

当需要notify通知时,数据已经发生了变化,此时需要获取新值,并将Compile里的替换逻辑Replace去修改{{}}中的内容。将这三个方法一并修改:

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
// 接以上
set: function(newVal) {
if( val === newVal ) return
val = newVal
dep.notify()
}

function Replace(frag) {
// 省略
node.textContent = text.replace(reg, val).trim()

// 监听变化
new Watcher(vm, RegExp.$1, newVal => {
node.textContent = text.replace(reg, newVal).trim()
})
}

Watcher.prototype.update = function() {
// 重新获取新值
let arr = this.exp.split('.')
let val = this.vm
arr.forEach(key => {
val = val[key]
})

/* 新值newVal替换{% raw %}{{}}{% endraw %}表达式中的内容 */
this.fn(val)
}

现在数据的变化可以反应在视图上了,不信?在控制台输入mvvm.a = 'xx'试试。是不是突然有变化了?

当然,现在还差一点。数据的双向绑定。

实现双向绑定

比如:在结构中有input表单,需要做双向绑定。那么需要遍历的是文档中的元素节点。

从节点中找到v-model表达式,将其初始值与MVVM实例中的data属性值绑定。同时键盘输入绑定事件,新值赋给MVVM实例中的数据值,然后订阅者Watcher订阅它的数据变化,及时作出更新,完成双向绑定。

听上去很简单,let’s do it!

1
2
<div>{{input}}</div>
<input v-model="input">
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
function Replace(frag) {
Array.from(frag.childNodes).forEach(node => {
//...nodeType === 3 && reg.test(text)
if(node.nodeType === 1) {
let nodeArr = node.attributes
Array.from(nodeArr).forEach(attr => {
let value = attr.value
let name = attr.name

if(name.includes('v-')) {
// 数据初始化
node.value = vm[value]
}

/**
绑定事件为MVVM中的data数据赋值
当设置MVVM实例的值时,调用set函数
再调用notify,通知订阅者的update更新函数
完成视图更新
**/
node.addEventListener('input', ev => {
let newVal = ev.target.value
vm[value] = newVal
})

/**
将数据添加到订阅者中监听变化
**/
new Watcher(vm, value, newVal => {
node.value = newVal
})
})
}
})
}

后续

到这里基本已经结束了,但是发现一个问题:当文档元素中有相邻的moustache expression时,后一个键值并不能被正确编译。例如:

1
<div>{{input}} - {{div}}</div>

改写Compile中的Replace逻辑,主要是用了数组的迭代方法,将实例上的所有属性赋值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Replace(frag) {
Array.from(frag.childNodes).forEach(node => {
// 省略...
if(node.nodeType === 3 && reg.test(text)) {
function replaceText() {
node.textContent = text.replace((matched, value) => {
new Watcher(vm, value , replaceText)
return value.split('.').reduce((val, key)=>{
return val = val[key]
},vm)
})
}
replaceText()
}
})
}

总结

MVVM主要包含了:1). Observer利用setget对数据进行劫持 2). Compile对指令的解析,视图更新回调函数的绑定 3). Watcher订阅数据的变化,当数据变化时,及时的调用其update方法,并触发Compile上的回调函数,及时更新视图,实现数据与视图的同步。