了解MVVM及Vue实现原理,手把手带你撸源码。
MVVM及Vue实现原理
Github源码地址:https://github.com/wyj2443573...
mvvm 双向数据绑定
数据影响视图,视图影响数据
angular 脏值检测 vue数据劫持+发布订阅模式
vue 不兼容低版本 用的是Object.defineProperty
下面涉及涵盖的知识点
1. Object.defineProperty
因为vue底层是基于Object.defineProperty实现的,所以对于这方面不懂的自己先学习。
Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。
语法Object.defineProperty(obj, prop, descriptor)
基本用法
var o = {}; o.a = 1; // 等同于 : Object.defineProperty(o, "a", { value : 1, writable : true, configurable : true, enumerable : true }); // 另一方面, Object.defineProperty(o, "a", { value : 1 }); // 等同于 : Object.defineProperty(o, "a", { value : 1, writable : false, configurable : false, enumerable : false });
let o={} Object.defineProperty(o,'a',{ get(){ //获取o.a的值时,会调用get方法 return 'hello'; }, set(value){ //o.a='s' 赋值的时候触发set方法 console.log(value) } }) o.a='s'//'s' o.a //'hello'
2.数据劫持Observe
vue基本格式
//html <div id="app"> {{a}} </div> <script> let vue = new Vue({ el:'#app', data:{ a:1 } }) </script>
模仿vue的格式
vue 中有
- $options : 存在属性 data、el、components 等等
- _data : Vue实例参数中data对象
接下来构建基本页面
- index.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> <script src="mvvm.js"></script> </head> <body> <div id="app"> {{a}} </div> <script> let vue = new Vue({ el:'#app', data:{ a:1 } }) </script> </body> </html>
- mvvm.js
function Vue(options={}){ this.$options=options; //将所有属性挂载在$options; //this._data let data=this._data=this.$options.data; //观察data对象,将每一项做一个数据劫持;就是将data中每一项用Object.defineProperty定义新属性并返回这个对象。 observe(data); } function Observe(data) { //这里写的是主要逻辑 for(let key in data){ //把data属性通过object.defineProperty的方式 定义属性 let val=data[key]; Object.defineProperty(data,key,{ enumerable:true, //可以枚举 get(){ return val; //仅仅是将以前的 a:1的方式 转换换位defineProperty的方式 }, set(newValue){ //更改值的时候触发 if(newValue===val){ //如果设置的值跟之前的值一样则什么也不做 return; } val=newValue; //将新值赋给val,那么get获取val的时候,获取的就是newValue; } }) } } //观察对象给对象增加ObjectDefinedProperty function observe(data){ return new Observe(data); }
此时在控制台打印 vue,发现已经在vue._data中映射了正确的数据。
接下来操作: vue._data.a=30
如果此时data中a是一个复杂的对象类型,如下
<div id="app"> {{a.a}} </div> <script> let vue = new Vue({ el:'#app', data:{ a:{a:1} //此时a赋值为对象 } }) </script>
则此时打印输出vue._data,只有第一层添加上了defineProperty,第二层的a无法劫持
那么我们要递归 深层添加defineProperty
另外递归的时候注意添加退出条件,当value不是对象的时候退出。
代码添加如下
function Observe(data) { //这里写的是主要逻辑 for(let key in data){ //把data属性通过object.defineProperty的方式 定义属性 let val=data[key]; observe(val); //递归 劫持 Object.defineProperty(data,key,{ enumerable:true, //可以枚举 get(){ return val; //仅仅是将以前的 a:1的方式 装换位defineProperty的方式 }, set(newValue){ //更改值的时候触发 if(newValue===val){ //如果设置的值跟之前的值一样则什么也不做 return; } val=newValue; //将新值赋给val,那么get获取val的时候,获取的就是newValue; } }) } } function observe(data){ if(typeof data!='object'){ //如果非对象,则退出遍历递归 return; } return new Observe(data); }
此时内层的a也同样得到劫持
如果我们给a设置新值的时候vue._data.a={b:3} ,会发现内层b并没有被数据劫持。那么在赋新值的时候,也应该通过defineProperty去定义。
在set中用defineProperty定义新属性
set(newValue){ //更改值的时候触发 if(newValue===val){ //如果设置的值跟之前的值一样则什么也不做 return; } val=newValue; //将新值赋给val,那么get获取val的时候,获取的就是newValue; observe(newValue) }
分析到这里,我们已经能够实现了深度的数据观察
3.数据代理
上面的代码如果想要访问a的属性需要通过 vue._data.a 这样的写法获得,这种写法过于繁琐。我们接下来改善一下:用vue.a的方式直接访问到a(用vue 代理 vue._data
)
-- 1.首先用this代理this._data; 让数据中的每一项都用defineProperty代理。
function Vue(options={}){ this.$options=options; //将所有属性挂载在$options; //this._data let data=this._data=this.$options.data; //观察data对象,将每一项做一个数据劫持;就是将data中每一项用Object.defineProperty定义新属性并返回这个对象。 observe(data); for(let key in data){ Object.defineProperty(this,key,{ //this 代理this._data; enumerable:true, get(){ return this._data[key]; //相当于 this.a={a:1} }, set(newVal){ } }) } }
到这一步我们已经实现了数据代理的初级版,vue.a 可以直接获取值而非vue._data.a
-- 2.get方法很容易理解,这里较为重点
的是在set中设置。首先思考:如果直接设置this.a={name:1} ,this.a 与this._data.a 它们的值同步改变吗?
set(newVal){ this[key]=newVal; //?可以这样做吗?我们来实践下 }
很显然两者是不能够同步改变的。
方法实现
function Vue(options={}){ this.$options=options; //将所有属性挂载在$options; //this._data let data=this._data=this.$options.data; //观察data对象,将每一项做一个数据劫持;就是将data中每一项用Object.defineProperty定义新属性并返回这个对象。 observe(data); //this 代理this._data; for(let key in data){ Object.defineProperty(this,key,{ enumerable:true, get(){ return this._data[key]; //相当于 this.a={a:1} }, set(newVal){ //如果直接更改this[key]='XXX',那么this._data[key]的值是不会被同步改变的。 // 我们可以通过给this._data[key]=value赋值,从而调取Observe方法中的set,赋予this._data[key]新值。 // get(){return this._data[key]},获取到的值即是调取Observe方法中get方法return的值 // 也就是根源上的改变是this._data[key];这样不管是this._data[key]还是this[key]随便哪一个被赋予新值,两者都是同步变化的 this._data[key]=newVal; } }) } }
下面来分析一下思路
1.我们可以在set中设置this._data[key]=newValue,如果此时vue.a={name:1}它调取是Observe方法中的set,赋予this._data[key]新值。
设置值的时候相当于走的是这一步set(newValue){ //更改值的时候触发 if(newValue===val){ //如果设置的值跟之前的值一样则什么也不做 return; } val=newValue; //将新值赋给val,那么get获取val的时候,获取的就是newValue; observe(newValue) }
2.如果此时我们获取vue.a 的值,即通过get方法获取return this._data[key],得到的就是最新值
这里解释说明一下
学到这里我们应该了解到:
- vue特点不能新增不存在的属性 因为不存在的属性没有get 和 set,它就不会监控数据的变化
- 深度响应: 因为每次赋予一个新对象时会给这个新对象增加数据劫持。
4.编译模板Compile
这一步我们要做的目的是将目标元素内{{xx}} 花括号中的xx替换成对应的值。
第一步、代码实现如下
function Vue(options={}){ /*代码承接上面*/ new Compile(options.el,this) //实例化Compile } function Compile(el){ //el表示替换哪个元素范围内的模板 let replacePart=document.querySelector(el); let fragment=document.createDocumentFragment(); while(child = replacePart.firstChild){ //将app中的内容移至内存中 fragment.appendChild(child); } replace() //我们在此要做的是通过replace方法,将代码片段中的{{a.a}}的a.a替换为data中对应的值。 replacePart.appendChild(fragment); } function replace(){ }
第二步、replace方法先找到所有要替换的地方,代码如下:
<div id="app"> {{A}} <h1>{{a.a}}</h1> <h1>{{b}}</h1> <h1>{{c}} <h3>{{d}}</h3></h1> </div> <script> let vue = new Vue({ el:'#app', data:{ A:'是A', a:{a:1}, b:'是b', c:'是c', d:'是d' } }) </script>
function Compile(el,vm){ //el代表替换的范围 let replacePart=document.querySelector(el); let fragment=document.createDocumentFragment(); while(child = replacePart.firstChild){ //将app中的内容移至内存中 fragment.appendChild(child); } replace(fragment) //我们在此要做的是通过replace方法,将代码片段中的{{a.a}}的a.a替换为data中对应的值。 replacePart.appendChild(fragment); function replace(fragment){ Array.from(fragment.childNodes).forEach(function(node){ let text=node.textContent; let reg=/\{\{(.*)\}\}/; if(node.nodeType===3&& reg.test(text)){ //nodeType:3 文本节点 console.log(RegExp.$1); // A、 a.a 、b 等等 } if(node.childNodes){ replace(node) //如果当前node存在子节点,递归找到所有需要替换的地方 } }) } }
这一步我们能够找到所有要替换的目标了
第三步、replace方法中 用对应值替换掉需要替换掉的地方,代码如下:
function replace(fragment){ Array.from(fragment.childNodes).forEach(function(node){ let text=node.textContent; let reg=/\{\{(.*)\}\}/; if(node.nodeType===3&®.test(text)){ //nodeType:3 文本节点 console.log(RegExp.$1); let arr=RegExp.$1.split('.') // [A] [a,a] [b] ... let val=vm; //val:{a:{a:1}} arr.forEach(function(k){ val=val[k] //举例 第一次循环 val=val.a val赋值后val:{a:1} ;第二次循环 val=val.a val赋值后为1 }) node.textContent=text.replace(reg,val) } if(node.childNodes){ replace(node) //如果当前node存在子节点,递归替换 } }) }
替换结果如下
5.发布订阅模式
不明白发布订阅模式的朋友先去学习
参考链接:https://segmentfault.com/a/11...
- 代码一:
//发布订阅模式 先订阅 再发布 // 订阅就是往事件池里面扔函数 发布的就是事件池中的函数依次执行 //我们假设subs中每个方法中都有update属性, function Dep(){ this.subs=[] } Dep.prototype.addSub=function(sub){ //订阅 this.subs.push(sub) } Dep.prototype.notify=function(){ this.subs.forEach(sub=>{ sub.update(); }) } function Watcher(fn){ //watch是一个类 通过这个类创建的实例都拥有update方法 this.fn=fn } Watcher.prototype.update=function(){ this.fn(); } let watcher=new Watcher(function(){console.log(1)}); //监听函数 let dep=new Dep(); dep.addSub(watcher); //将watcher放在数组中 dep.addSub(watcher); dep.addSub(watcher); dep.notify() // 数组关系
- 代码二(然后我们将代码二的这个发布订阅的模板放到我们的mvvm.js最下面)
function Dep(){ this.subs=[] } Dep.prototype.addSub=function(sub){ //订阅 this.subs.push(sub) } Dep.prototype.notify=function(){ this.subs.forEach(sub=>{ sub.update(); }) } function Watcher(fn){ this.fn=fn } Watcher.prototype.update=function(){ this.fn(); }
6.连接视图与数据
那么我们接下来的目的是:当数据变化的时候,我们需要重新刷新视图,将页面中的{{a.a}}双括号中的a.a也能够被实时的替换掉。这就用到了上面介绍的发布订阅模式。方法:我们得先将
node.textContent=text.replace(reg,val)
订阅一下,当数据发生变化的时候,执行node.textContent=text.replace(reg,val)
此操作。
我们先写要订阅的事件
这里思考当数据变化的时候会产生新的值,我们需要用newValue替换原有的值。要想取到新的值,我们需要用到当前实例vm与正则的捕获到的RegExp.$1,从而获取类似this.a.a的最新值。
new Watcher(vm,RegExp.$1,function(newValue){ //订阅的事件 函数里需要接受新的值 node.textContent=text.replace(reg,newValue); });
此时Watcher类也应该改动下,不懂没关系,可以顺着看下面的解析。
function Watcher(vm,exp,fn){ this.fn=fn; this.vm=vm; this.exp=exp; //我们要将fn添加到订阅中 Dep.target=this; console.log(Dep.target) let val=vm; let arr=exp.split('.'); arr.forEach(function(k){ val=val[k]; }) Dep.target=null; }
Dep.target为
下面我们来分析代码这个逻辑相对复杂,不明白的话多看几遍
上述代码中,我们可以看到执行了这一步
let val=vm; let arr=exp.split('.'); arr.forEach(function(k){ val=val[k]; //获取this.a.a的时候就会触发get方法 })
这一步遍历 arr 取val[k]的时候相当于获取this.a,会触发了默认的get方法。也就是触发这里的get方法
在get方法中我们需要订阅事件:
上述代码中,取值的时候会调取get方法,Dep.target值是存在的,此时将Dep.target放到我们的事件池中。
当我们set的时候,触发事件池中的事件
此时update的方法我们得更改下,赋予新值
Watcher.prototype.update=function(){ let val=this.vm; let arr=this.exp.split('.'); arr.forEach(function(k){ //this.a.a val=val[k]; }) this.fn(val); //newValue }
此时我们就能得到我们想要的结果了,当数据发生改变时,视图也会更新。
7.双向数据绑定的实现
思路: 先找到v-model这个属性,取对应的属性值s,然后用vue.s 替换input中value值
在Compile方法中写逻辑
打印结果如下
此时已经能够将对应的s值赋值给输入框
接下来我们需要做的是,每次更改输入框中的值的时候,对应的s也会跟着改变
操作结果如下
8.实现computed
官网上有两种computed的基础用法
用法一
var vm = new Vue({ el: '#example', data: { message: 'Hello' }, computed: { // 一个 computed 属性的 getter 函数 reversedMessage: function () { // `this` 指向 vm 实例 return this.message.split('').reverse().join('') } } })
用法二
computed: { fullName: { // getter 函数 get: function () { return this.firstName + ' ' + this.lastName }, // setter 函数 set: function (newValue) { var names = newValue.split(' ') this.firstName = names[0] this.lastName = names[names.length - 1] } } }
思路: computed实现相对来说比较简单,只是把数据挂在vm上
1.html页面如下
<div id="app"> <input type="text" v-model="s"> <h2>s的值:{{s}}</h2> <h2>computed的值:{{hello}}</h2> {{A}} <h1>{{a.a}}</h1> <h1>{{b}}</h1> <h1>{{c}} <h3>{{d}}</h3></h1> </div> <script> let vue = new Vue({ el:'#app', data:{ s:1, A:'是A', a:{a:1}, b:'是b', c:'是c', d:'是d' }, //computed可以缓存 只是把数据挂在vm上 computed:{ hello(){ return this.s+this.c } } }) </script>
2.将hello属性挂载在当前实例上,先初始化computed
initComputed 函数实现
function initComputed(){ let vm=this; let computed=this.$options.computed; //Object.keys [name:'tom',age:2]=>[name,age] Object.keys(computed).forEach(function(key){ Object.defineProperty(vm,key,{ //computed[key] get:typeof computed[key]==='function'? computed[key]:computed[key].get, set(){ } }) }) }
分析以上代码,我们能得知this.hello的变化 只依赖于this.s 和 this.c 。当s和c发生变化时,自动会触发视图更新,获取this.hello得到的也就是最新值。
++++++++++++到此已经完成了分享,如有相关的问题和建议还望不吝提出++++++++++++