Vue源码解析—动手实现简化版MVVM

引言

相信只要去面试 Vue,都会被用到 vue的双向数据绑定,你如果只说个 mvvm就是视图模型模型视图,只要数据改变视图也会同步更新,那可能达不到面试官想要的那个层次。甚至可以说这一点就让面试官觉得你知识了解的还不够,只是粗略地明白双向绑定这个概念。

本博客旨在通过一个简化版的代码来对 mvvm 理解更加深刻,如若存在问题,欢迎评论提出,谢谢您!

最后,希望你给一个点赞或 star :star:,谢谢您的支持!

实现源码传送门

同时,也会收录在小狮子前端笔记仓库里 ✿✿ヽ(°▽°)ノ✿

小狮子前端の学习整理笔记 Front-end-learning-to-organize-notes

实现效果:

几种实现双向绑定的做法

目前几种主流的 mvc(vm)框架都实现了单向数据绑定,即用数据操作视图,数据更新,视图同步更新。而双向数据绑定无非就是在单向绑定的基础上给可输入元素(如 inputtextarea等)添加了 change(input) 事件,来动态修改 modelview,这样就能用视图来操作数据了,即视图更新,数据同步更新。

实现数据绑定的做法大致有如下几种:

发布者-订阅者模式(backbone.js)
脏值检查(angular.js)将旧值和新值进行比对,如果有变化的话,就会更新视图,最简单的方式就是通过 setInterval()定时轮询检测数据变动。
数据劫持(vue.js)

发布者-订阅者模式:一般通过 subpub 的方式实现数据和视图的绑定监听,更新数据方式通常做法是 vm.set('property', value)

但上述方式对比现在来说满足不了我们需要了,我们更希望通过 vm.property = value 这种方式更新数据,同时自动更新视图,于是有了下面两种方式:

脏值检测angular.js 是通过脏值检测的方式比对数据是否变更,来决定是否更新视图,最简单的方式就是通过 setInterval()定时轮询检测数据变动。当然,它只在指定的事件触发时才进入脏值检测,大致如下:

  • DOM事件,譬如用户输入文本,点击按钮等。(ng-click)
  • XHR响应事件($http
  • 浏览器 Location 变更事件($location
  • Timer 事件($timeout, $interval

数据劫持vue.js 则是采用数据劫持结合发布者-订阅者模式的方式,通过 object.defineProperty() 来劫持各个属性的 settergetter,在数据变动时发布消息给订阅者,触发相应的监听回调。

实现 mvvm 的双向绑定

要实现 mvvm 的双向绑定,就必须要实现以下几点:

  • 实现一个指令解析器 Compile,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数
  • 实现一个数据监听器Observer,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者
  • 实现一个Watcher,作为连接ObserverCompile的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图
  • mvvm入口函数,整合以上三者

整合流程图如下图所示:

实现指令解析器 Compile

compile 主要做的事情是解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图,如下图所示:

因为遍历解析的过程有多次操作 dom 节点,为提高性能和效率,会先将 vue 实例根节点的 el 转换成文档碎片fragment进行解析编译操作,解析完成,再将fragment添加回原来的真实dom节点中。

html 页面引入我们重新写的 myVue.js

1
<script src="./myVue.js"></script>

创建 myVue

创建一个 myVue 类,构造函数如下所示,将页面的挂载 el、数据 data、操作集 options 进行保存。

1
2
3
4
5
6
7
8
9
10
11
12
13
class myVue{
constructor(options){
this.$el = options.el
this.$data = options.data
this.$options = options
if(this.$el){
// 1.实现数据观察者(省略...)
// 2.实现指令解析器
new Compile(this.$el,this)
}
// console.log(this)
}
}

实现 Compile

具体实现步骤:

  • 判断当前挂载是否为元素节点,不是的话就得寻找 query
  • 获取文档碎片对象,放入内存中来操作我们的 dom节点,目的是减少页面的回流和重绘
  • 最后,将编译后的模板添加到根元素
1
2
3
4
5
6
7
8
9
10
11
12
class Compile{
constructor(el,vm){
// 判断是否为元素节点,如果不是就query
this.el = this.isElementNode(el) ? el : document.querySelector(el)
this.vm = vm
// 1、获取文档碎片对象,放入内存中,会减少页面的回流和重绘
const fragment = this.node2Fragment(this.el)
// 2、编译模板
this.compile(fragment)
// 3、追加子元素到根元素
this.el.appendChild(fragment)
}

判断是否为元素节点,直接判断nodeType是否为1即可

1
2
3
isElementNode(node){
return node.nodeType === 1
}

通过 document.createDocumentFragment() 创建文档碎片对象,通过 el.firstChild 是否还存在来判断,然后将 dom 节点添加到文档碎片对象中,最后 return

1
2
3
4
5
6
7
8
9
node2Fragment(el){
// 创建文档碎片对象
const fragment = document.createDocumentFragment()
let firstChild
while(firstChild = el.firstChild){
fragment.appendChild(firstChild)
}
return fragment
}

编译模板

解析模板时,会获取得到所有的子节点,此时分两种情况,即元素节点和文本节点。如果当前节点还存在子节点,则需要通过递归操作来遍历其子节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
compile(fragment){
// 1、获取所有子节点
const childNodes = fragment.childNodes;
[...childNodes].forEach(child=>{
// console.log(child)
// 如果是元素节点,则编译元素节点
if(this.isElementNode(child)){
// console.log('元素节点',child)
this.compileElement(child)
}else{
// 其它为文本节点,编译文本节点
// console.log('文本节点',child)
this.compileText(child)
}
if(child.childNodes && child.childNodes.length){
this.compile(child)
}
})
}

编译元素节点(遇见设计模式)

节点 node 上有一个 attributes 属性,来获取当前节点的所有属性,通过是否以 v- 开头来判断当前属性名称是否为一个指令。如果是一个指令的话,还需进行分类编译,用数据来驱动视图。更新数据完毕后,再通过 removeAttribute 事件来删除指令上标签的属性。

如果是非指令的话,例如事件 @click="sayHi",仅需通过指令 v-on 来实现即可。

对于不同的指令,我们最好进行一下封装,这里就巧妙运用了 策略模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
compileElement(node){
const attributes = node.attributes;
[...attributes].forEach(attr=>{
// console.log(attr)
const {name,value} = attr;
// console.log(name,value)
// 判断当前name值是否为一个指令,通过是否以 'v-' 开头来判断
if(this.isDirective(name)){
// console.log(name.split('-'))
const [,directive] = name.split('-') // text html model on:click
// console.log(directive)
const [dirName,eventName] = directive.split(':') // text html model on
// 更新数据 数据驱动视图
complieUtil[dirName](node,value,this.vm,eventName)
// 删除指令上标签上的属性
node.removeAttribute('v-' + directive)
}else if(this.isEventName(name)){ // @click="sayHi"
let [,eventName] = name.split('@')
complieUtil['on'](node,value,this.vm,eventName)
}
})
}

判断当前 attrName 是否为一个指令,仅需判断是否以 v- 开头

1
2
3
isDirective(attrName){
return attrName.startsWith('v-')
}

判断当前 attrName 是否为一个事件,就看是否以'@'开头的事件绑定

1
2
3
isEventName(attrName){
return attrName.startsWith('@')
}

指令处理集合

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
const complieUtil = {
getVal(expr,vm){
return expr.split('.').reduce((data,currentVal)=>{
// console.log(currentVal)
return data[currentVal]
},vm.$data)
},
text(node,expr,vm){
let value;
// 元素节点
if(expr.indexOf('{{') !== -1){
value = expr.replace(/\{\{(.+?)\}\}/g, (...args)=>{
return this.getVal(args[1],vm);
})
}else{ // 文本节点
value = this.getVal(expr,vm)
}
this.updater.textUpdater(node,value)
},
html(node,expr,vm){
const value = this.getVal(expr,vm)
this.updater.htmlUpdater(node,value)
},
model(node,expr,vm){
const value = this.getVal(expr,vm)
this.updater.modelUpdater(node,value)
},
on(node,expr,vm,eventName){
let fn = vm.$options.methods && vm.$options.methods[expr]
// 1、让fn通过bind函数指向原来的vm 2、默认冒泡
node.addEventListener(eventName,fn.bind(vm),false)
},
bind(node,expr,vm,attrName){

},
// 更新的函数
updater:{
textUpdater(node,value){
node.textContent = value
},
htmlUpdater(node,value){
node.innerHTML = value
},
modelUpdater(node,value){
node.value = value
}
}
}

实现数据监听器 Observer

利用 Obeject.defineProperty() 来监听属性变动,那么将需要 observe 的数据对象进行递归遍历,包括子属性对象的属性,都加上 settergetter 。这样的话,给这个对象的某个值赋值,就会触发 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
class Observer{
constructor(data){
this.observe(data)
}
observe(data){
if(data && typeof data === 'object'){
// console.log(Object.keys(data))
// 进行数据劫持
Object.keys(data).forEach(key=>{
this.defineReactive(data,key,data[key])
})
}
}
defineReactive(obj,key,value){
// 递归遍历
this.observe(value)
Object.defineProperty(obj,key,{
enumerable: true,
configurable: false,
get(){
// 订阅数据变化时,往Dep中添加观察者,进行依赖收集
return value
},
// 通过箭头函数改变this指向到class Observer
set:(newVal)=>{
this.observe(newVal)
if(newVal !== value){
value = newVal
}
}
})
}
}

data 示例如下:

1
2
3
4
5
6
7
8
9
data: {
person:{
name: 'Chocolate',
age: 20,
hobby: '写代码'
},
msg: '超逸の技术博客',
htmlStr: '<h3>欢迎一起学习~</h3>'
},

实现 watcher 去更新视图


Watcher 订阅者作为 ObserverCompile 之间通信的桥梁,主要做的事情是:

  • 在自身实例化时往属性订阅器( dep )里面添加自己
  • 自身必须有一个 update()方法
  • 待属性变动dep.notify()通知时,能调用自身的 update() 方法,并触发 Compile 中绑定的回调。

Watcher 订阅者

实例化 Watcher 的时候,调用 getOldVal() 方法,来获取旧值。通过 Dep.target = watcherInstance(this) 标记订阅者是当前 watcher实例(即指向自己)。

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
class Watcher{
constructor(vm,expr,cb){
this.vm = vm
this.expr = expr
this.cb = cb
// 先将旧值进行保存
this.oldVal = this.getOldVal()
}
getOldVal(){
// 将当前订阅者指向自己
Dep.target = this
// 获取旧值
const oldVal = complieUtil.getVal(this.expr,this.vm)
// 添加完毕,重置
Dep.target = null
return oldVal
}
// 比较新值与旧值,如果有变化就更新视图
update(){
const newVal = complieUtil.getVal(this.expr,this.vm)
// 如果新旧值不相等,则将新值callback
if(newVal !== this.oldVal){
this.cb(newVal)
}
}
}

强行触发属性定义的 get 方法,get 方法执行的时候,就会在属性的订阅器 dep 添加当前watcher 实例,从而在属性值有变化的时候,watcherInstance(this) 就能收到更新通知。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 上文省略...
defineReactive(obj,key,value){
// 递归遍历
this.observe(value)
const dep = new Dep()
Object.defineProperty(obj,key,{
enumerable: true,
configurable: false,
get(){
// 订阅数据属性时,往Dep中添加观察者,进行依赖收集
Dep.target && dep.addSub(Dep.target)
return value
},
// 通过箭头函数改变this指向到class Observer
set:(newVal)=>{
this.observe(newVal)
if(newVal !== value){
value = newVal
// 如果新旧值不同,则告诉Dep通知变化
dep.notify()
}
}
})
}

订阅器 dep

主要做两件事情:

  • 收集订阅者
  • 通知订阅者更新
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Dep{
constructor(){
this.subs = []
}
// 收集观察者
addSub(watcher){
this.subs.push(watcher)
}
// 通知观察者去更新
notify(){
console.log('观察者',this.subs);
this.subs.forEach(watcher => watcher.update())
}
}

修改我们原本的 Compile.js 文件

做完上述事情后,此时,当我们修改某个数据时,数据已经发生了变化,但是视图没有更新。那我们在什么时候来添加绑定 watcher 呢?请继续看下图

也就是说,当我们订阅数据变化时,来绑定更新函数,从而让 watcher 去更新视图。此时我们修改我们原本的 Compile.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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
// 指令处理集合
const complieUtil = {
getVal(expr,vm){
return expr.split('.').reduce((data,currentVal)=>{
// console.log(currentVal)
return data[currentVal]
},vm.$data)
},
// 获取新值 对{{a}}--{{b}} 这种格式进行处理
getContentVal(expr,vm){
return expr.replace(/\{\{(.+?)\}\}/g, (...args)=>{
// console.log(args[1]);
return this.getVal(args[1],vm);
})
},
text(node,expr,vm){
let value;
if(expr.indexOf('{{') !== -1){
value = expr.replace(/\{\{(.+?)\}\}/g, (...args)=>{
// 绑定watcher从而更新视图
new Watcher(vm,args[1],()=>{
this.updater.textUpdater(node,this.getContentVal(expr,vm))
// console.log(expr);
})
return this.getVal(args[1],vm);
})
}else{ // 也可能是v-text='obj.name' v-text='msg'
value = this.getVal(expr,vm)
}
this.updater.textUpdater(node,value)
},
html(node,expr,vm){
const value = this.getVal(expr,vm)
new Watcher(vm,expr,(newVal)=>{
this.updater.htmlUpdater(node,newVal)
})
this.updater.htmlUpdater(node,value)
},
model(node,expr,vm){
const value = this.getVal(expr,vm)
// 订阅数据变化时 绑定更新函数 更新视图的变化
// 数据==>视图
new Watcher(vm,expr,(newVal)=>{
this.updater.modelUpdater(node,newVal)
})
this.updater.modelUpdater(node,value)
},
on(node,expr,vm,eventName){
let fn = vm.$options.methods && vm.$options.methods[expr]
// 1、让fn通过bind函数指向原来的vm 2、默认冒泡
node.addEventListener(eventName,fn.bind(vm),false)
},
bind(node,expr,vm,attrName){
let attrVal = this.getVal(expr,vm)
this.updater.attrUpdater(node,attrName,attrVal)
},
// 更新的函数
updater:{
textUpdater(node,value){
node.textContent = value
},
htmlUpdater(node,value){
node.innerHTML = value
},
modelUpdater(node,value){
node.value = value
},
attrUpdater(node, attrName, attrVal){
node.setAttribute(attrName,attrVal)
}
}
}
class Compile{
constructor(el,vm){
// 判断是否为元素节点,如果不是就query
this.el = this.isElementNode(el) ? el : document.querySelector(el)
this.vm = vm
// 1、获取文档碎片对象,放入内存中,会减少页面的回流和重绘
const fragment = this.node2Fragment(this.el)
// 2、编译模板
this.compile(fragment)
// 3、追加子元素到根元素
this.el.appendChild(fragment)
}
// 判断是否为元素节点,直接判断nodeType是否为1即可
isElementNode(node){
return node.nodeType === 1
}
node2Fragment(el){
// 创建文档碎片对象
const fragment = document.createDocumentFragment()
let firstChild
while(firstChild = el.firstChild){
fragment.appendChild(firstChild)
}
return fragment
}
compile(fragment){
// 1、获取所有子节点
const childNodes = fragment.childNodes;
[...childNodes].forEach(child=>{
// console.log(child)
// 如果是元素节点,则编译元素节点
if(this.isElementNode(child)){
// console.log('元素节点',child)
this.compileElement(child)
}else{
// 其它为文本节点,编译文本节点
// console.log('文本节点',child)
this.compileText(child)
}
if(child.childNodes && child.childNodes.length){
this.compile(child)
}
})
}
// 编译元素节点
compileElement(node){
const attributes = node.attributes;
[...attributes].forEach(attr=>{
// console.log(attr)
const {name,value} = attr;
// console.log(name,value)
// 判断当前name值是否为一个指令,通过是否以 'v-' 开头来判断
if(this.isDirective(name)){
// console.log(name.split('-'))
const [,directive] = name.split('-') // text html model on:click
// console.log(directive)
const [dirName,eventName] = directive.split(':') // text html model on
// 更新数据 数据驱动视图
complieUtil[dirName](node,value,this.vm,eventName)
// 删除指令上标签上的属性
node.removeAttribute('v-' + directive)
}else if(this.isEventName(name)){ // @click="sayHi"
let [,eventName] = name.split('@')
complieUtil['on'](node,value,this.vm,eventName)
}
})
}
// 编译文本节点
compileText(node){
// {{}} v-text
// console.log(node.textContent)
const content = node.textContent
if(/\{\{(.+?)\}\}/.test(content)){
// console.log(content)
complieUtil['text'](node,content,this.vm)
}
}
isDirective(attrName){
return attrName.startsWith('v-')
}
// 判断当前attrName是否为一个事件,以'@'开头的事件绑定
isEventName(attrName){
return attrName.startsWith('@')
}
}

此时,我们就能通过数据变化来驱动视图了,例如更改我们的年龄 age 从原来的 20 设置为 22,如下图所示,发现数据更改, watcher 去更新了视图。

知识再梳理

有了之前的代码与流程图结合,我想对于 Vue源码分析应该更加了解了,那么我们再次来梳理一下我们学习的知识点。依旧是结合下面流程图:

最开始,我们实现了 Compile解析指令,找到 {{xxx}}、指令、事件、绑定等等,然后再初始化视图。但此时还有一件事情没做,就是当数据发生变化的时候,在更新数据之前,我们还要订阅数据变化,绑定更新函数,此时就需要加入订阅者Watcher了。当订阅者观察到数据变化时,就会触发Updater来更新视图。

当然,创建 Watcher的前提时要进行数据劫持来监听所有属性,所以创建了 Observer.js 文件。在 get方法中,需要给 Dep 通知变化,此时就需要将 Dep 的依赖收集关联起来,并且添加订阅者 Watcher(这个 WatcherComplie 订阅数据变化,绑定更新函数时就已经创建了的)。此时 Dep 订阅器里就有很多个 Watcher 了,有多少个属性就对应有多少个 Watcher


那么,我们举一个简单例子来走一下上述流程图:

假设原本 data 数据中有一个 a:1,此时我们进行更新为 a:10,由于早已经对我们的数据进行了数据劫持并且监听了所有属性,此时就会触发 set 方法,在 set方法里就会通知 Dep 订阅器发生了变化,然后就会通知相关 Watcher 触发 update 函数来更新视图。而这些订阅者 WatcherComplie 订阅数据变化,绑定更新函数时就已经创建了。

视图->数据

上述,我们基本完成了数据驱动视图,现在我们来完成一下通过视图的变化来更新数据,真正实现双向数据绑定的效果。

在我们 complieUtil 指令处理集合中的 model 模块,给我们当前节点绑定一个 input 事件即可。我们可以通过 e.target.value 来获取当前 input 输入框的值。然后比对一下旧值和新值是否相同,如果不同的话,就得需要更新,调用 setVal 方法(具体见下文代码)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
model(node,expr,vm){
let value = this.getVal(expr,vm)
// 订阅数据变化时 绑定更新函数 更新视图的变化
// 数据==>视图
new Watcher(vm,expr,(newVal)=>{
this.updater.modelUpdater(node,newVal)
})
// 视图==》数据
node.addEventListener('input',(e)=>{
var newValue = e.target.value
if(value == newValue) return
// 设置值
this.setVal(expr,vm,newValue)
value = newValue
})
this.updater.modelUpdater(node,value)
},

setValgetVal 两者没有多大区别,只是 set 时多了一个 inputVal。它们都是找到最底层 key 值,然后更新 value 值。

1
2
3
4
5
6
7
8
9
10
11
getVal(expr,vm){
return expr.split('.').reduce((data,currentVal)=>{
// console.log(currentVal)
return data[currentVal]
},vm.$data)
},
setVal(expr,vm,inputVal){
return expr.split('.').reduce((data,currentVal)=>{
data[currentVal] = inputVal
},vm.$data)
},

更新 bug:在上文,对于 v-text指令处,我们遗漏了绑定 Watcher 步骤,现在进行补充。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
text(node,expr,vm){
let value;
if(expr.indexOf('{{') !== -1){
value = expr.replace(/\{\{(.+?)\}\}/g, (...args)=>{
// 绑定watcher从而更新视图
new Watcher(vm,args[1],()=>{
this.updater.textUpdater(node,this.getContentVal(expr,vm))
// console.log(expr);
})
return this.getVal(args[1],vm);
})
}else{ // 也可能是v-text='obj.name' v-text='msg'
value = this.getVal(expr,vm)
// 绑定watcher从而更新视图
new Watcher(vm,expr,(newVal)=>{
this.updater.textUpdater(node,newVal)
// console.log(expr);
})
}
this.updater.textUpdater(node,value)
},

最终,当我们更改 input 输入框中的值时,发现其他节点也跟着修改,这代表我们的数据进行了修改,相关订阅者触发了 update 方法,双向绑定功能实现!

实现 proxy

我们在使用 vue 的时候,通常可以直接 vm.msg 来获取数据,这是因为 vue 源码内部做了一层代理.也就是说把数据获取操作 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
class myVue{
constructor(options){
this.$el = options.el
this.$data = options.data
this.$options = options
if(this.$el){
// 1.实现数据观察者
new Observer(this.$data)
// 2.实现指令解析器
new Compile(this.$el,this)
// 3.实现proxy代理
this.proxyData(this.$data)
}
// console.log(this)
}
proxyData(data){
for(const key in data){
Object.defineProperty(this,key,{
get(){
return data[key]
},
set(newVal){
data[key] = newVal
}
})
}
}
}

我们简单测试一下,例如我们给 button 绑定一个 sayHi() 事件,通过设置 proxy 做了一层代理后,我们不需要像后面那样通过 this.$data.person.name来更改我们的数据,而直接可以通过 this.person.name 来获取我们的数据。

1
2
3
4
5
6
7
methods: {
sayHi() {
this.person.name = '超逸'
//this.$data.person.name = 'Chaoyi'
console.log(this)
}
}

大厂面试题

请阐述一下你对 MVVM 响应式的理解

vue.js 则是采用数据劫持结合发布者-订阅者模式的方式,通过 Object.defineProperty()来劫持各个属性的gettersetter,在数据变动时发布消息给订阅者,触发相应的监听回调。

MVVM作为数据绑定的入口,整合ObserverCompileWatcher 三者,通过Observer来监听自己的model数据变化,通过Compile来解析编译模板指令,最终利用Watcher搭起ObserverCompile之间的通信桥梁,达到数据变化 -> 视图更新;视图交互变化(input) -> 数据model变更的双向绑定效果。

最开始,我们实现了 Compile解析指令,找到 {{xxx}}、指令、事件、绑定等等,然后再初始化视图。但此时还有一件事情没做,就是当数据发生变化的时候,在更新数据之前,我们还要订阅数据变化,绑定更新函数,此时就需要加入订阅者Watcher了。当订阅者观察到数据变化时,就会触发Updater来更新视图。

当然,创建 Watcher的前提时要进行数据劫持来监听所有属性,所以创建了 Observer.js 文件。在 get方法中,需要给 Dep 通知变化,此时就需要将 Dep 的依赖收集关联起来,并且添加订阅者 Watcher(这个 WatcherComplie 订阅数据变化,绑定更新函数时就已经创建了的)。此时 Dep 订阅器里就有很多个 Watcher 了,有多少个属性就对应有多少个 Watcher


那么,我们举一个简单例子来走一下上述流程图:

假设原本 data 数据中有一个 a:1,此时我们进行更新为 a:10,由于早已经对我们的数据进行了数据劫持并且监听了所有属性,此时就会触发 set 方法,在 set方法里就会通知 Dep 订阅器发生了变化,然后就会通知相关 Watcher 触发 update 函数来更新视图。而这些订阅者 WatcherComplie 订阅数据变化,绑定更新函数时就已经创建了。

总结与答疑

总算是把这篇长文写完了,字数也是达到将近 1w8。通过学习 Vue MVVM源码,对于 Vue 双向数据绑定这一块理解也更加深刻了。当然,本文书写的代码还算是比较简单,也参考了大佬的博客与代码,同时,也存在不足并且小部分功能没有实现,相较于源码来说还是有很多可优化和可重构的地方,那么也欢迎小伙伴们来 PR。一起来动手实现 mvvm

本篇博客参考文献
笑马哥:Vue的MVVM实现原理
github:mvvm
视频学习:Vue源码解析