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 得到的也就是最新值。
++++++++++++ 到此已经完成了分享, 如有相关的问题和建议还望不吝提出 ++++++++++++