浅探VUE的MVVM模式实现
栏目: JavaScript · 发布时间: 6年前
内容简介:腾讯DeepOcean原创文章:MVVM的设计思想:关注Model(数据)的变化,让MVVM框架去自动更新DOM的状态,比较主流的实现有:angular的(脏值检测)vue的(数据劫持->发布订阅模式)我们重点了解vue(数据劫持->发布订阅模式)的实现方式,让我们从操作DOM的繁琐操作中解脱出来我们先简单看一下这个方法它也是用来实现我们数据劫持(数据监听)的关键方法,我们知道vue框架是不兼容IE6~8低版本的,主要是因为它的用到了ES5中的这个Object.defineProperty的方法,而且这个
腾讯DeepOcean原创文章: dopro.io/vue-mvvm-re…
1、MVVM模式
MVVM的设计思想:关注Model(数据)的变化,让MVVM框架去自动更新DOM的状态,比较主流的实现有:angular的(脏值检测)vue的(数据劫持->发布订阅模式)我们重点了解vue(数据劫持->发布订阅模式)的实现方式,让我们从操作DOM的繁琐操作中解脱出来
2、核心方法 Object.defineProperty 的理解
我们先简单看一下这个方法它也是用来实现我们数据劫持(数据监听)的关键方法,我们知道vue框架是不兼容IE6~8低版本的,主要是因为它的用到了ES5中的这个Object.defineProperty的方法,而且这个方法暂时没有很好的降级方案。
var a = {}; Object.defineProperty(a, 'b', { value: 123, // 设置的属性值 writable: false, // 是否只读 enumerable: false, // 是否可枚举 configurable: false // }); console.log(a.b); //123复制代码
方法使用很简单,它接受三个参数,而且都是 必填 的
- 第一个参数:目标对象
- 第二个参数:需要定义的属性或方法的名字。
- 第三个参数:目标属性所拥有的特性
前两个参数比较好理解,主要看第三个参数它是一个对象,看看有哪些属性定义
- value:属性的值。
- writable:如果为 false ,属性的值就不能被重写,只能为只读了。
- enumerable:是否可枚举,默认是false不可枚举的(通常设置为true)
- configurable:总开关,一旦为false,就不能再设置其他的( value , writable , enumerable)
- get(): 函数,获取属性值时执行的方法 (不可以和writable、value属性共存)
- set(): 函数,设置属性值时执行的方法 (不可以和writable、value属性共存)
// 常用定义 var obj = {}; Object.defineProperty(obj, 'school', { enumerable: true, get: function() { // 获取属性值时会调用get方法 }, set: function(newVal) { // 设置属性值时会调用set方法 return newVal } });复制代码
我们通过这个Object.defineProperty这个方法,可以实现对定义的引用数据类型的实现监听,被方法监听后的对象,里面定义的值发生被获取和设置操作的时候,会分别触发Object.defineProperty里面参数三的get和set方法。
3、数据劫持
在了解完了Object.defineProperty方法后,我们现在要通过它来实现我们的数据劫持功能
Obaerve
的方法,看如下代码
function MyVue(options = {}) { // 将所有的属性挂载到$options身上 this.$options = options; // 获取到data数据(Model) var data = this._data = this.$options.data; // 劫持数据 observe(data) } // 给需要观察的对象都添加 Object.defineProperty 的监听 function Observe(data) { for (let key in data) { let val = data[key]; // 递归 =》来实现深层的数据监听 observe(val) Object.defineProperty(data, key, { enumerable: true, get() { return val }, set(newval) { if (val === newval) { //设置的值是否和以前是一样的,如果是就什么都不做 return } val = newval // 这里要把新设置的值也在添加一次数据劫持来实现深度响应, observe(newval); } }) } } function observe(data) { // 这里做一下数据类型的判断,只有引用数据类型才去做数据劫持 if (typeof data != 'object') return return new Observe(data) }复制代码
1)以上代码做了这些事情,先定义了初始换构造函数 MyVue 我们通过它来获取到我们传进来的数据data和我们定义的DOM节点范围,然后把data传进定好的数据劫持方法 observe
2) Observe 实现了对数据的监听整体逻辑,这里有个细节点,没有直接用构造函数Observe去劫持我们的数据,而是写多了一个observe的小方法用来new Observe,并且在里面做了引用数据类型的判断。这样做的目的是为了方便递归来实现数据结构的深层监听,因为我们的data结构肯定是复杂多样的,例如下面代码
// 这里数据结构嵌套很多,要实现深层的数据监听采用了递归的实现方式 data: { a: {b:2} , c:{q:12,k:{name:'binhemori'}} , f:'mvvvm',o:[12,5,9,8]}复制代码
3)这里还需要注意的是我们在set方法里面有再一次把设置的新值,执行了一遍observe方法,是为了实现深度响应, 因为在赋值的时候可能会赋值一个引用数据类型的值 ,我们知道vue有个特点,是不能新增不存在的属性和不能存在属性没有get和set方法的,如果赋值进来的新属性值是引用数据类型,就会把我们原先执行过数据劫持方法的对象地址给替换掉,而新对象是没有经过数据劫持的就是没有get和set的方法,所以我们在设置新值的时候需要在重新给他执行一遍observe数据劫持,确保开发者不管怎样去设置值的时候都能被劫持到
说了这么多,我们来使用一下看看有没有实现对数据的劫持(数据监听)吧
<div id="app"> <div> <div>这里的数据1======<span style="color: red;">{{a.b}}</span></div> <div>这里是数据2======<span style="color: green;">{{c}}</span></div> </div> <input type="text" v-model="a.b" value=""> </div> <!-- 引入自己定义的mvvm模块 --> <script src="./mvvm.js"></script> 复制代码
<script type="text/javascript"> var myvue = new MyVue({ el: '#app', data: { a: { b: '你好' }, c: 12, o: [12, 5, 9, 8] } })
</script>复制代码
可以看到对我们所定义的data中的数据都已经有了get和set方法了,到这里我们对data中数据的变化都是可以监听的到了
4、数据代理
数据代理,我们用过vue的都知道,在实际使用中是能直接通过实例+属性(vm.a)直接获取到数据的,而我们上面的代码要获取到数据还需要这样myvue._data.a这样来获取到数据,中间多了一个 _data 的环节,这样使用起来不是很方便的,下面我们来实现让我们的实例this来代理( _data)数据,从而实现 myvue.a 这样的操作可以直接获取到数据
function MyVue(options = {}) { // 将所有的属性挂载到$options身上 this.$options = options; // 获取到data数据(Model) var data = this._data = this.$options.data; observe(data); // this 就代理数据 this._data for (const key in data) { Object.defineProperty(this, key, { enumerable: true, get() { // this.a 这里取值的时候 实际上是去_data中的值 return this._data[key] }, set(newVal) { // 设置值的时候其实也是去改this._data.a的值 this._data[key] = newVal } }) } }复制代码
以上代码实现了我们的数据代理,就是在构建实例的时候,把data中的数据遍历一次出来,依次加到我们this上,加的过程中也不要忘记添加Object.defineProperty,只要是数据我们都需要添加监听。如下图我们已经实现了对数据的代理
5、编译模板(Compile)
我们已经完成对数据劫持也实现了this对数据的代理,那么接下来要做的就是怎样把数据编译到我们的DOM节点上面,也就是让视图层(view)要展示我们的数据了
// 将数据和节点挂载在一起 function Compile(el, vm) { // el表示替换的范围 vm.$el = document.querySelector(el); // 这里注意我们没有去直接操作DOM,而是把这个步骤移到内存中来操作,这里的操作是不会引发DOM节点的回流 let fragment = document.createDocumentFragment(); // 文档碎片 let child; while (child = vm.$el.firstChild) { // 将app的内容移入内存中 fragment.appendChild(child); } replace(fragment) function replace(fragment) { Array.from(fragment.childNodes).forEach(function (node) { //循环每一层 let text = node.textContent; let reg = /\{\{(.*)\}\}/g; // 这里做了判断只有文本节点才去匹配,而且还要带{{***}}的字符串 if (node.nodeType === 3 && reg.test(text)) { // 把匹配到的内容拆分成数组 let arr = RegExp.$1.split('.'); let val = vm; // 这里对我们匹配到的定义数组,会依次去遍历它,来实现对实例的深度赋值 arr.forEach(function (k) { // this.a.b this.c val = val[k] }) // 用字符串的replace方法替换掉我们获取到的数据val node.textContent = text.replace(/\{\{(.*)\}\}/, val) } // 这里做了判断,如果有子节点的话 使用递归 if (node.childNodes) { replace(node) } }) } // 最后把编译完的DOM加入到app元素中 vm.$el.appendChild(fragment) }复制代码
以上代码实现我们对数据的编译 Compile 如下图,可以看到我们把获取到el下面所有的子节点都存储到了文档碎片 fragment 中暂时存储了起来(放到内存中),因为这里要去频繁的操作DOM和查找DOM,所以移到内存中操作
- 1)先用while循环,先把 el 中所有的子节点都添加到文档碎片中 fragment.appendChild(child);
- 2)然后我们通过 replace 方法去遍历文档中所有的子节点,将他们文本节点中(node.nodeType = 3)带有{{}} 语法中的内容都获取到,把匹配到的值拆分成数组,然后遍历依次去data中查找获取,遍历的节点如果有子节点的话继续使用replace方法直到反回undefined
- 3)获取到数据后,用replace方法替换掉文本中{{}}的整块内容,然后在放回el元素中vm.$el.appendChild(fragment),
6、关联视图(view)与数据(model)
在成功的将我们的数据绑定到了DOM节点之后,要实现我们的视图层(view)跟数据层(model)的关联,现在实际上还没有关联起来,因为无法通过改数据值来引发视图的变化,实现这步之前先聊一下JS中比较常用的设计模式 发布订阅模式 也是vue实现双向数据绑定的很关键的一步
发布订阅模式(又称观察者模式)
我们先简单手动实现一个(就是一个数组关系)
// 发布订阅 function Dep() { this.subs = [] } // 订阅 Dep.prototype.addSub = function (sub) { this.subs.push(sub) } // 通知 Dep.prototype.notify = function (sub) { this.subs.forEach(item => item.update()) } // watcher是一个类,通过这个类创建的函数都会有update的方法 function Watcher(fn) { this.fn = fn; } Watcher.prototype.update = function () { this.fn() }复制代码
这里用Dep方法来实现订阅和通知,在这个类中有addSub(添加)和notify(通知)两个方法,我们把将要做的事情(函数)通过addSub添加进数组里,等时机一到就notify通知里面所有的方法执行
大家会发现为什么要另外定义一个创建函数的方法watcher,而不是直接把方法扔到addSub中好,这样不是多此一举嘛?其实这样做的有它的目的,其中一个好处就是我们通过watcher创建的函数都会有一个update执行的方法可以方便我们调用。而另外一个用处我下面会讲到,先把它运用起来吧
function replace(fragment) { Array.from(fragment.childNodes).forEach(function (node) { let text = node.textContent; let reg = /\{\{(.*)\}\}/g; if (node.nodeType === 3 && reg.test(text)) { let arr = RegExp.$1.split('.'); let val = vm; arr.forEach(function (k) { val = val[k] }) // 在这里运用了Watcher函数来新增要操作的事情 new Watcher(vm, RegExp.$1, function (newVal) { node.textContent = text.replace(/\{\{(.*)\}\}/, newVal) }) 复制代码
node.textContent = text.replace(/{{(.*)}}/, val) }
if (node.childNodes) { replace(node) } }) }复制代码
可以看到我们把定义函数的方法watcher加到了replace方法里面,但是这里的watcher更刚写编写的多了两个形参 vm、RegExp.$1 ,而且写法也新增了一些内容,因为当new Watcher的时候会引发发生几个操作,来看代码:
// vm做数据代理的地方 function MyVue(options = {}) { this.$options = options; var data = this._data = this.$options.data; observe(data); for (const key in data) { Object.defineProperty(this, key, { enumerable: true, get() { return this._data[key] }, set(newVal) { this._data[key] = newVal } }) } } // 数据劫持函数 function Observe(data) { let dep = new Dep(); for (let key in data) { let val = data[key]; observe(val) Object.defineProperty(data, key, { enumerable: true, get() { /* 获取值的时候 Dep.target 对于着 watcher的实例,把他创建的实例加到订阅队列中 */ Dep.target && dep.addSub(Dep.target); return val }, set(newval) { if (val === newval) { return } val = newval; observe(newval); // 设置值的时候让所有的watcher.update方法执行即可触发所有数据更新 dep.notify() } }) } } function Watcher(vm, exp, fn) { this.fn = fn; // 这里我们新增了一些内容,用来可以获取对于的数据 this.vm = vm; this.exp = exp; Dep.target = this let val = vm; let arr = exp.split('.'); /* 执行这一步的时候操作的是vm.a, 而这一步操作其实就是操作的vm._data.a的操作, 会触发this代理的数据和_data上面的数据 */ arr.forEach(function (k) { val = val[k] }) Dep.target = null; } // 这里是设置值操作 Watcher.prototype.update = function () { let val = this.vm; let arr = this.exp.split('.'); arr.forEach(function (k) { val = val[k] }) this.fn(val) //这里面要传一个新值 }复制代码
这里开始会有点绕, 一定要理解好操作数据的时候会触发的那个实例上面数据的get和set,操作的是那个数据这个思维
1)首先看在Watcher构造函数中新增了一些私有属性分别代表:
- Dep.target = this (在构造函数Dep.target临时存储着watcher的当前实例)
- this.vm = vm (vm = myvue实例)
- this.exp = exp (exp = 匹配的查找的对象”a.b”是字符串类型的值)
我们存储这些属性后,接下来就要去获取用exp匹配的字符串里面对于数据也就是
vm.a.b
,但是此时的exp是个字符串,你不能直接这样取值vm[a.b]这是错误的语法,所以要循环去取到对于的值
arr.forEach(function (k) { // arr = [a,b] val = val[k] })复制代码
- 第一次循环的时候是vm[a] = {b:12},取到了a这个对象,然后在赋值回去就是把当前的val变成了a这个对象
- 第二次循环的时候val已经变成了 a对象,此时的k变成了b,val就变成了:a[b] = 12
get() { // 走到这里的时候 Dep.target 已经存储了 watcher的当前实例实例,把他创建的实例加到订阅队列中 Dep.target && dep.addSub(Dep.target); return val }, // 把要做的更新视图层的操作方法用Watcher定义好,里面已经定义好了要操作的对象 new Watcher(vm, RegExp.$1, function (newVal) { node.textContent = text.replace(/\{\{(.*)\}\}/, newVal) }) 复制代码
Watcher.prototype.update = function () {
let val = this.vm;
let arr = this.exp.split('.'); arr.forEach(function (k) { val = val[k] }) this.fn(val) // 把对于的新值传递到方法里面
}复制代码
这里因为加多了一层 vm.a 这样的数据代理,所以逻辑有点绕,记住这句话就好理解 操作 vm.a 代理数据上面值的时候,其实就是操作的vm._data中的数据 所以会触发两个地方的get和set方法,好说这么多,我们来看是否实现数据变动触发视图层的变化吧
这里就实现了数据的变更触发视图层的更新操作了
7、input双向数据绑定的实现
最后一步就来实现视图层的变更触发数据结构的变更操作,上面我们已经把视图与数据关联最核心的代码讲解了,剩下视图变更触发数据变更就比较好实现了
<div id="app"> <div> <div>这里的数据1======<span style="color: red;">{{a.b}}</span></div> <div>这里是数据2======<span style="color: green;">{{c}}</span></div> </div> <input type="text" v-model="a.b" value=""> </div> <!-- 引入自己定义的mvvm模块 --> <script src="./mvvm.js"></script> <script type="text/javascript"> var myvue = new MyVue({ el: '#app', data: { a: { b: '你好' }, c: 12, o: [12, 5, 9, 8] } }) </script>复制代码
// 获取所有元素节点 if (node.nodeType === 1) { let nodeAttr = node.attributes Array.from(nodeAttr).forEach(function (attr) { let name = attr.name; // v-model="a.b" let exp = attr.value; // a.b 复制代码
if (name.indexOf('v-') >= 0) {
let val = vm;
let arr = exp.split('.'); arr.forEach(function (n) { val = val[n] })
// 这个还好处理,取到对应的值设置给input.value就好 node.value = val; }
// 这里也要定义一个Watcher,因为数据变更的时候也要变更带有v-model属性名的值 new Watcher(vm, exp, function (newVal) { node.value = newVal })
// 这里是视图变化的时候,变更数据结构上面的值 node.addEventListener('input', function (e) {
let newVal = e.target.value
if (name.indexOf('v-') >= 0) {
let val = vm;
let arr = exp.split('.'); arr.forEach(function (k,index) { if (typeof val[k] === 'object') { val = val[k] } else{
if (index === arr.length-1) { val[k] = newVal } } }) } }) }) }复制代码
上面代码对数据变更触发视图层变更的逻辑更上一节一样即可,主要是 node.addEventListener('input') 这里设置数据的问题,其实原理跟第六节关联视图(view)与数据(model)的逻辑一样,有一定需要注意的是这边加了一个引用数据类型的判断,不然他的循环会到最底层的数据类型值(也就是基础数据类型) 1)这里判断到取到的不是对象数据类型,不做替换操作 (val = val[k]) 2)判断是不是已经最后一个层级了index === arr.length-1,如果是的话直接把input中的值赋值进当前数据中即可
arr.forEach(function (k,index) { if (typeof val[k] === 'object') { // 如果有嵌套的话就继续查找 val = val[k] } else{ if (index === arr.length-1) { // 查找到最后一个后直接赋值 val[k] = newVal } } })复制代码
以上是整个mvvm双向数据绑定的简单实现原理,内容有些哪里解释不通顺的地方或有更好的意见欢迎留言:)
欢迎关注"腾讯DeepOcean"微信公众号,每周为你推送前端、人工智能、SEO/ASO等领域相关的原创优质技术文章:
看小编搬运这么辛苦,关注一个呗:)
以上所述就是小编给大家介绍的《浅探VUE的MVVM模式实现》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- 使用函数式实现观察者模式模式
- 设计模式之发布订阅模式(2) Redis实现发布订阅模式
- 设计模式:创建型模式之单例模式的五种实现
- 实践:使用Spring 原生注解来快速实现 策略模式 + 工厂模式
- 如何实现Builder模式
- 策略模式-Golang实现
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。