最近在学习Vue
,之前一直对Vue
的双向数据绑定只算是了解。经过这几天的深入学习。对它的原理有了更加深刻的认识。虽然Vue
并没有有完全遵循MVVM模型
, 但是它的设计也是受到了MVVM模型
的启发。所以它也是能实现双向数据绑定的, 那我们来看mvvm模型
的双向数据绑定是什么?其实双向数据绑定实现的效果就是指:模型(model
)javaScript
中定义的对象,它改变了会同步视图(view)。修改视图()view)也会同步修改数据层;
为什么说 Vue
没有完全遵循 MVVM
吗? 是以为Vue
提供了ref
属性, 通过 ref
能够得到dom对象, 通过ref
直接去操作视图,这一点违背了 MVVM
演示如下:
什么是双向数据绑定
双向数据绑定就是 view
层 和 model
层 可以互相影响, view
层改变了, 会同步更新mode
, model
改变了, 会同步更新 view
。
Vue 双向数据绑定的原理
我们知道 vue
是双向数据绑定, 它主要是由三个重要部分构成:
- Model(数据层): 应用中的数据或者业务逻辑
- View(视图层): 应用的展示效果,各类的UI组件
- ViewModel(数据视图层): 框架封装的核心,负责将
View
和 Model
连接起来
ViewModel的理解
它是由两个主要部分组成:
- 监听器(Observer): 对所有的的数据进行监听
- 解析器(Compiler):对每个节点进行扫描和解析,根据指令模板替换内容,以及绑定相应的更新函数
数据绑定
从开发者的角度去看,视图到data
的的改动只需要监听DOM
的变化再同步赋值给JavaScript
变量即可;如:<input>
标签添加change
或者input
监听事件并在事件处理函数中给变量赋值。其实这也是Vue
中v-model
指令做的很重要事情。另外数据到视图关键是监听数据的变化,再去更新相应的DOM
。那么监听数据的变化有哪些方式呢?。
我们知道常见架构模式有MVC
, MVP
,MVVM
模式,目前前端框架基本上都是采用MVVMM
实现双向数据绑定。Vue
也不例外。各个框架实现双向数据板绑定的方法有有所不同,目前大概有以为这三种:
而Vue
采用的是数据劫持和发布订阅者模式相结合的方式来实现双向数据绑定。而数据劫持主要是通过Object.defineProperty
来实现。
Object.defineProperty
关于Object.defineProperty 可以看我之前写的一篇文章: Object.defineProperty。let ,我们主要看它的get
和set
能帮我们实现什么?
我们对一个Javacript
变量进行设置值和获取值:
1 2 3 4 5
| const obj = { labelName: "标签" } obj.labelName = "更新标签名字" obj.labelName;
|
我们对一个Javacript
变量进行设置值和获取值, 除了设置成功和获取成功, 我们是没办法看到其他的变化了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const obj = {} let labelName; Object.defineProperty(obj, "labelName", { get: function() { console.log("获取标签名字"); return labelName; }, set: function(newValue) { console.log("设置新的标签名"); labelName = newValue } }) obj.labelName = "标签"; console.log(obj.labelName);
|
具体的效果如下:
从上面的例子可以看出,我们在访问JavaScript
变量的时候会自动执行get
函数。设置值得时候会自动执行set
函数。
思路整理
根据上面的两点我们知道Vue
是通过数据劫持结合发布订阅模式来实现双向数据绑定,所以实现双向数据绑定就必须实现以下几点:
- 实现一个监听器
Observer
能够对数据对象的所有属性进行监听;Observer
监听器里面使用Object.defineProperty
来监听。当属性有变动拿到最新的值和通知订阅者。
- 实现一个指令解析器
Compile
。对每个元素的节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数。
- 实现一个订阅者
Watcher
,能够订阅并收到每个属性的变动的通知,执行指令绑定的相应的回调函数,从而更新视图;
- 由于
data
的某个key
在⼀个视图中可能出现多次,所以每个key
都需要⼀个管家Dep
来管理多个Watcher
- 实现入口函数,整合以上三者。
上面的流程如下图:
监听器Oberver
监听器的作用是去监听数据的每一个属性,使用上面我们讲的Object.defineProperty
来监听属性的变动。我们需要对Observer
的数据对象进行递归遍历。使得子属性对象的属性都加上get
和 set
函数。这样就能监听到数据的变化了
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
| function Observer(data) { if (!data || typeof data !== "object") { return; } Object.keys(data).forEach((key) => { defineObserver(data, key, data[key]); }) }
function defineObserver(data, key, value) { Observer(data[key]); Object.defineProperty(data, key, { get: function() { return value; }, set: function(newValue) { if (value !== newValue) { console.log("监听到变化了", newValue) value = newValue; } } }) }
|
当我们监听的属性发生变化之后我们需要去通知订阅者Wtcher
去执行更新函数更新视图。这个过程中会有很多的订阅者(一个属性就是一个订阅者Watcher
), 所以我们创建一个容器Dep
去做一个统一的管理。这个容器维护一个数组,用来收集订阅者。当数据变动触发notify
, 然后容器(订阅器)触发订阅者的update
方法。最终的的Observer
如下:
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
| function Observer(data) { if (!data || typeof data !== "object") { return; } Object.keys(data).forEach((key) => { defineObserver(data, key, data[key]); }) }
function defineObserver(data, key, value) { Observer(data[key]); const dep = new Dep(); Object.defineProperty(data, key, { get: function() { if (Dep.target) { dep.addSub(Dep.target) } return value; }, set: function(newValue) { if (value !== newValue) { console.log("监听到变化了", newValue) value = newValue; dep.notify(); } } }) }
function Dep() { this.subs = []; } Dep.prototype = { addSub: function(sub) { this.subs.push(sub); }, notify: function() { this.subs.forEach((sub) => { sub.update(); }) } }; Dep.target = null;
|
大家可能对下面这段代码可能不是很理解:
1 2 3 4 5 6 7
| get: function() { if (Dep.target) { dep.addSub(Dep.target) } return value; }
|
这一段代码的目的是为了后面写的Watcher
. 到哪里我会解释的。
那么到目前为止,就实现了一个Observer
了。已经有监听数据变化和通知订阅者的功能了。
Compile解析器
Compile
主要做的事情就两点:
- 解析模板的指令,将模板中的变量替换成数据,然后初始化渲染页面。
- 对每个指令对应的节点绑定订阅者(添加更新视图的函数uodate)
如下图:
因为在解析DOM
加点的过程中我们会频繁的操作DOM
所以我们利用好文档片段[DocumentFragment]](https://developer.mozilla.org/zh-CN/docs/Web/API/DocumentFragment) 来帮助我们解析`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 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
| function Compile(vm) { this.vm = vm; this.el = vm.$el; this.fragment = null; this.init(); } Compile.prototype = { init: function() { this.fragment = this.nodeFragment(this.el); this.compileNode(this.fragment); this.el.appendChild(this.fragment); }, nodeFragment: function(el) { const fragment = document.createDocumentFragment(); let child = el.firstChild; while(child) { fragment.appendChild(child); child = el.firstChild; } return fragment; }, compileNode: function(fragment) { const childNodes = fragment.childNodes; [...childNodes].forEach(node => { if (this.isElementNode(node)) { this.compile(node); } else { const text = node.textContent;
const reg = /\{\{(.*)\}\}/;
if(reg.test(text)) { const prop = reg.exec(text)[1].trim(); this.compileText(node, prop); } } if (node.childNodes && node.childNodes.length) { this.compileNode(node); } }); }, compile: function(node) { let nodeAttrs = node.attributes; [...nodeAttrs].forEach((attr) => { const name = attr.name; if (this.isDirective(name)) { if (name === "v-model") { const value = attr.value; this.compileModel(node, value) } } }) },
compileModel: function(node, prop) { const val = this.vm.$data[prop]; this.updateModel(node, val);
new Watcher(this.vm, prop, (newValue) => { this.updateModel(node, newValue); })
node.addEventListener('input', (e) => { const newValue = e.target.value; if (newValue === val) { return; } this.vm.$data[prop] = newValue; }) }, compileText: function(node, prop) { const val = this.vm.$data[prop]; this.updateView(node, val); new Watcher(this.vm, prop, (newValue) => { this.updateView(node, newValue); }) },
updateView: function(node, value) { node.textContent = value == 'undefined' ? "" : value; }, updateModel: function(node, value) { node.value = typeof value == 'undefined' ? "" : value; },
isDirective: function(attr) { return attr.indexOf('v-') !== -1; }, isElementNode: function(node) {
return node.nodeType === 1; } }
|
在写Compile
时候对下面这段代码比较疑惑:
1 2 3 4 5 6 7 8 9 10
| nodeFragment: function(el) { const fragment = document.createDocumentFragment(); let child = el.firstChild; while(child) { fragment.appendChild(child); child = el.firstChild; } return fragment; },
|
while
为啥一直是 child = el.firstChild;
呢?那岂不是一直都一个元素节点?,其实不是的,这是因为:
appendChild:Node.appendChild() 方法将一个节点附加到指定父节点的子节点列表的末尾处。如果将被插入的节点已经存在于当前文档的文档树中,那么 appendChild() 只会将它从原先的位置移动到新的位置.
哈哈, 豁然开朗!!!
这里一个Compile
就完成了。
Watcher 订阅者
Watcher
订阅器主要做的事情就两件:
- 在自身实例化的时候网订阅器(
Dep
容器)添加自己。
- 必须有一个
uodate
方法,目的是为了调用每个订阅者的更新视图的函数(即:接收到通知,执行更新函数)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| function Watcher(vm, prop, callback) { this.vm = vm; this.prop = prop; this.callback = callback; this.value = this.get(); } Watcher.prototype = { update: function() { const value = this.vm.$data[this.prop]; const oldValue = this.value; if (value !== oldValue) { this.callback(value); } }, get: function() { Dep.target = this; const value = this.vm.$data[this.prop]; Dep.target = null; return value; } }
|
这里的这段代码很关键:
1
| const value = this.vm.$data[this.prop];
|
注意这里是获取属性
。在实现Observer
监听了所有属性的获取,监听属性的时候我们在get
方法有如下的代码:
1 2 3 4 5 6 7
| get: function() { if (Dep.target) { dep.addSub(Dep.target) } return value; }
|
这里这么写的时候就是跟Watcher
的 const value = this.vm.$data[this.prop];
相呼应的。我们的订阅者就是在这时候添加到容器Dep
里面去的。
实现的入口函数
上面三个重要的点实现完了。最后就只需要一个入口函数来整合了。
1 2 3 4 5 6 7 8
| function MyVue(options) { this.$el = document.querySelector(options.el); this.$data = options.data; new Observer(this.$data); new Compile(this); }
|
我们尝试去修改数据,也完全没问题;
但是有个问题就是我们修改数据时时通过 vm.$data.name
去修改数据,而不是想 Vue 中直接用 vm.name
就可以去修改,那这个是怎么做到的呢?其实很简单,Vue 做了一步数据代理操作。最新代码如下:
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 MyVue(options) { this.$el = document.querySelector(options.el); this.$data = options.data; Object.keys(this.$data).forEach(key => { this.proxyData(key); }); this.init(); } MyVue.prototype = { init: function() { new Observer(this.$data); new Compile(this); }, proxyData: function(key) { Object.defineProperty(this, key, { get: function () { return this.$data[key] }, set: function (value) { this.$data[key] = value; } }); } }
|
实现效果如下:
最终效果
最后实现的相关如下:
如果需要代码的可点击 MyVue。最后一点感触, 原理性的东西看懂了原理,还是需要手写一遍, 其中会发现很多意想不到的情况。收获颇多。