要了解Vue中的响应式原理 ,我们首先要知道什么是MVVM及什么是观察者模式,什么是发布-订阅模式。
Vue是对MVVM框架的很好体现,那什么是MVVM呢?顾名思义它其实就是Model-View-ViewModel模式。它是一个软件架构设计模式,Model可以看成是代表数据模型,View代表视图,它负责将数据模型转化成ui进行展示。ViewMode用来连接Model和View,将view需要的数据暴露,处理view层的具体业务逻辑。
MVVM它可以分离视图(View)和模型(Model),其中VM是MVVM的思想核心,它是M和V的调用者,提供了数据的双向绑定。降低代码耦合,利用双向绑定,数据更新后视图自动更新,来自动更新DOM,可以更好的编写测试代码。凡事有利有弊,它的不足之处是bug很难被调试,因为使用了双向数据绑定,从而出现bug时,有可能是view的代码有问题,也有可能是model上的代码出故障,对于大型的图形应用程序来说,视图较多,维护成本偏高。
那什么又是观察者模式?什么又是发布-订阅模式呢?这二者有什么联系和区别?其实观察者模式可以看成定义了对象间的一种一对多的依赖关系,当一个对象的状态发生改变,所有依赖于它的对象都会得到通知,并进行相应的更新。如下图所示,观察者(observer)它可以直接订阅(subscribe)主题(subject),当主题被激活时,会触发(fire event)观察者里的事件。观察者它一般是提供一个更新的接口,用于当被观察者状态发生变化时,能够及时得到通知,从而作出反应,而对于被观察者而言,它是要做到的就是状态发生改变要及时的通知给观察者,保持具体的观察者信息。
其实观察者模式在之前还有一个别名叫发布订阅模式,但随着项目的复杂度,代码可维护性的提高,它们两个虽然大体方向一致,但还是有很多不同的地方,二者并不相等。在js的实践中,我们更多的是用到发布-订阅模式。在现在的发布-订阅模式中,发布者发送的消息不会直接发送给订阅者,而是会通过一个第三方组件中间件来进行联系。发布者和订阅者之间不知道对方的存在,通过中间件来进行联系。能过下图我们可以看到,订阅者(Subscriber)把自己想订阅的事件注册(Subscribe)到中间件(event channel),当发布者(Publisher)发布事件(event)到中间件,由中间件统一进行调度(fire event)订阅者注册到调度中心的处理代码。
它们二者最大的区别在于发布-订阅模式中有一个事件调用中心即中间件。观察者模式由具体目标调度,每个被订阅的目标里面都需要有对观察者的处理,这样做简单高效,但是会造成代码的冗余。可维护性较差。而关于发布-订阅模式它是由调度中心中间件统一进行处理的,订阅者,发布者互不干扰,消除了二者的依赖性,实现了解耦。但发布者,订阅者对彼此之间存在没有感知有时也会带来相应的麻烦。
Vue是MVVM很好的体现。学习vue的过程中我们知道它有以下三要素,响应式,模板引擎及渲染。最独特的一点是响应式,即数据模型更新,视图更新。在这期间我们要了解它是如何知道数据变化,怎样在数据变化的同时在视图上进行体现。
Vue的响应式就是data中的属性被代理到vm上。在js中,侦测数据变化有两种方式,一种是通过Object.defineProperty进行数据劫持,另外一种是通过Proxy进行数据代理,下面我们详细进行分析。
Object.defineProperty与Proxy
Object.defineProperty()方法会直接在一个对象上定义一个新属性或者是修改一个对象的现在属性并返回这个对象。主要是利用Object.defineProperty中的访问器属性get和set方法。当把一个普通对象传入Vue实例作为data选项时,Vue将遍历对象中所有的属性,将其添加上访问器属性。当读取data中的数据时自动调用get方法,当修改data中的数据自动调用set方法。利用的是对象属性中的get/set方法来监听数据的变化。
<script>
//这是要被劫持的对象
let data = {
like: ''
};
let newData = 'eat';
function say(like) {
if (like === 'swam') {
console.log('我最喜欢的活动是游泳');
} else {
console.log('我最喜欢的活动是远足');
}
}
// 只要是调用了data的like属性,那就会触发get函数,调用时获取到的结果就是get函数的返回值
//只是给data的like赋值那么就会触发set函数,形参对应的就是设置的那个值。
Object.keys(data).forEach(key => {
Object.defineProperty(data, key, {
get: function () {
console.log('获取时触发了get');
return newData;
},
set: function (val) {
console.log('设置时触发了set,val是:' + val);
say(val);
// 不能直接进行设置不然会引起死循环,所以要用到第三方变量
// data.like='eat';
}
})
})
console.log(data);
data.like = 'gram';
console.log(data);
</script>
使用Object.defineProperty时,它是将对象的key转换成get/set形式来跟踪变化的,get/set它只能跟踪一个数据是否被修改,不能跟踪属性的新增与删除。这时删除属性我们可以用到vm.$delete实现,新增的话可以使用Vue.set(location,a,1)这样的方法添加响应式属性或者是给这个对象重新赋值data.location={...data.location,a:1}。
它还无法监听到数组的方法,对于数组而言,有以下方法push,pop,shift,unshift,splice,sort,reverse它们是经过了一些内部处理对数组进行重写来保证响应式的。
<script>
let data = [1, 2];
let methods = ['push', 'unshift', 'splice', 'reverse', 'sort', 'shift', 'pop'];
//先保存一下原型上数组原来的方法
let arrayProto = Array.prototype;
//创建一个自己的原型进行重写
let proto = Object.create(arrayProto);
methods.forEach(method => {
proto[method] = function (...args) {
//默认没有播入新的数据
let inserted;
switch (method) {
case 'push':
case 'unshift':
inserted = args
break;
//数组splice方法只有传递3个参数,才有增加的效果
case 'splice':
inserted = args.slice(2);
default:
break;
}
ArrayObserver(inserted);
arrayProto[method].call(this, ...args)
}
})
function ArrayObserver(obj) {
for (let i = 0; i < obj.length; i++) {
let item = obj[i];
//如果是普通值则不被监控,是对象则会被defineReactive
Observer(item);
}
}
function Observer(obj) {
//如果是对象
if (({}).toString.call(obj).match(/ (\w+)]/)[1] == 'Object') {
for (let key in obj) {
defineReactive(obj, key, obj[key]);
}
}
//如果是数组
if (Array.isArray(obj)) {
//实现对数组方法的重写
Object.setPrototypeOf(obj,proto);
ArrayObserver(obj);
}
}
function defineReactive(data, key, value) {
//深度监听
Observer(value);
Object.defineProperty(data, key, {
get() {
return value;
console.log('set');
},
set(newval) {
if (newval !== value) {
Observer(newval);
value = newval;
console.log('get')
}
}
})
}
Observer(data);
function $set(data, key, value) {
defineReactive(data, key, value)
}
data.push(3, 4);
console.log(data);//[1,2,3,4]
</script>
它还不能通过索引来直接设置数据项。Object.defineProperty它只能劫持对象的属性,我们需要对对象进行遍历。如果属性值也是对象时,则需要进行深度的遍历。这样非常麻烦,所以才有了Proxy数据代理。
Proxy它是在目标对象之前就回调了一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,它提供了一种机制,可以对外界的访问进行过滤和改写,我们可以这样认为,Proxy是Object.defineProperty的加强版。
Proxy它可以实现直接的监听对象而不是属性,它还可以直接监听数组的变化。但它的兼容性不是特别好。
<script>
let obj = {
like: 'swam',
age: { age: 20 },
arr: [1, 2, 3, 4]
};
function render() {
console.log('render')
}
let handler = {
get(target, key) {
//判断取的值是否为对象
if (typeof target[key] == 'object' && target[key] !== null) {
return new Proxy(target[key], handler);
}
return Reflect.get(target, key);
},
set(target, key, value) {
if (key === 'length') return true
render();
return Reflect.set(target, key, value)
}
}
let proxy = new Proxy(obj, handler)
proxy.age.name = 'davina' // 支持新增属性
console.log(proxy.age.name) // 模拟视图的更新 "davina"
proxy.arr[0] = '100' //支持数组的内容发生变化
console.log(proxy.arr) //Proxy {0: "100", 1: 2, 2: 3, 3: 4}
</script>
我们可以用Proxy实现一个极简版本的双向绑定。
<input type="text" id="input">
<div class="box"></div>
<script>
const input = document.getElementById('input');
const box = document.querySelector('div');
const obj = {};
const newObj = new Proxy(obj, {
get: function (target, key, receiver) {
return Reflect.get(target, key, receiver);
},
set: function (target, key, value, receiver) {
if (key === 'text') {
input.value = value;
box.innerHTML = value;
}
return Reflect.set(target, key, value, receiver);
},
});
input.addEventListener('keyup', function (e) {
newObj.text = e.target.value;
});
</script>
当数据的属性发生变化时,可以通知那些曾经使用过这个数据的地方数据变化了,那么我们是怎么知道曾经使用过这个数据的地方是哪些地方?我们要怎么进行通知?
这时我们就要收集相应的依赖才能知道哪此地方依赖我们的数据,以及数据更新时进行相应的更新。这时我们就要用到”事件发布订阅模式“。接下来先介绍两外重要的角色-Dep订阅者和Watcher观察者。
订阅者 Dep
Dep是存储依赖的地方,它可以用来收集依赖,删除依赖,向依赖发送消息等等。它的主要作用是用来存放watcher观察者对象的,我们可以把观察者看成是一个中介的角色,数据发生变化时会通知它,然后再由它通知到其它的地方。当需求收集依赖时,我们可以调用addSub方法,当需求派发更新时我们调用notify方法。
//订阅者
class Dep {
constructor() {
//提供一个事件池
this.subs = [];
}
//增加事件的操作,向事件池里放事件
addSub(sub) {
this.subs.push(sub);
}
//通知更新
notify() {
this.subs.forEach(item => {
// 让对应的事件做更新操作
item.update();
})
}
}
Observer监听者
我们需求知道属性值的变化,用Observer来实现监听
/* 监听者*/
function observe(data) {
//简单判断类型
if (typeof data !== 'object') {
return;
}
let keys = Object.keys(data);//key是所有属性名组成的数组
keys.forEach(key => {
defineReactive(data, key, data[key])
})
}
//封装一个defineReactive函数专门调用defineProperty,实现数据劫持
function defineReactive(obj, key, value) {
observe(value);//实现深层劫持
let dep = new Dep;//每一个key都有一个私有变量dep
Object.defineProperty(obj, key, {
get() {
if (Dep.target) {
dep.addSub(Dep.target);//Dep.target就是watcher实例
}
return value;
},
set(newval) {
if (value !== newval) {
value = newval;
observe(value);
dep.notify();
}
}
})
}
watcher观察者
当属性发生变化后,我们要通知所有用到这个数据的地方,在一个项目或者是文件中用到这个数据的地方有很多,而且类型不一定相同,这时就需求我们抽象出一个类中集中的处理这个情况。我们在收集依赖的阶段只是收集这个封装好的类的实例进来,通知也只通知它一个,再由它负责通知其它的地方,这样速度和效率会快很多。依赖收集的目的是将watcher观察者对象存放到当前的闭包中的Dep订阅者的subs下。
/*watcher的简单实现*/
class Watcher {
constructor(obj, key, cb) {
// 将 Dep.target 指向自己
Dep.target = this
this.cb = cb
this.obj = obj
this.key = key
this.value = obj[key]
// 最后将 Dep.target 置空
Dep.target = null
}
update() {
// 获得新值
this.value = this.obj[this.key]
// 我们定义一个 cb 函数,这个函数用来模拟视图更新,调用它即代表更新视图
this.cb(this.value)
}
}
以上就是watcher的简单实现 ,在执行构造函数的时候将Dep.target指向自己,从而使收集到了对应的watcher,在派发更新的时候取出相应的watcher,然后然后执行update。
总结一下就是:所谓的依赖其实就是watcher,收集依赖时,我们要做到在get中收集依赖,在set中触发依赖。先收集依赖,就把用到这个数据的地方先收集起来,放到一个地方,然后属性发生变化时,把之前收集好的依赖循环触发就可以了。当外界通过watcher读取数据时,这就发触发get,从而将watcher添加到依赖中,哪个watcher触发了get,就把哪个watcher放到到Dep中,当数据发生变化时,会循环依赖列表,把所有的watcher都执行一次。
一个完整的双向绑定有以下几点:
1、在new Vue()后利用Proxy或Object.defineProperty方法对对象/对象属性进行"劫持",Vue中的data会通过observe添加上get/set属性,来对数据进行追踪变化,当对象被读取时会执行get函数,而当被赋值时执行set函数。在属性发生变化后通知订阅者
2、解析器Compile解析模板中的指令,收集方法和数据,等待数据变化然后渲染。
3、Watcher接收到的Observe产生的数据变化,并根据Compile提供的指令进行视图渲染,使得数据变化促使视图变化
vue是通过虚拟DOM追踪自己要改变的真实DOM,这里用真实的dom来简单的进行模拟。
<body>
<div id="app">
<h1>我的名字是:{{name}}</h1>
<h2>今年是:{{age}}</h2>
<input type="text" v-model='name'></br>
<input type="text" v-model='age'>
</div>
</body>
<script>
/* 用来数据劫持 */
function observe(data) {
if (typeof data !== 'object') {
return;
}
let keys = Object.keys(data);
keys.forEach(key => {
defineReactive(data, key, data[key])
})
}
//封装一个defineReactive函数专门调用defineProperty,实现数据劫持
function defineReactive(obj, key, value) {
observe(value);
let dep = new Dep;
Object.defineProperty(obj, key, {
get() {
if (Dep.target) {
dep.addSub(Dep.target);
}
return value;
},
set(newval) {
if (value !== newval) {
value = newval;
observe(value);
dep.notify();
}
}
})
}
/* 模板编译 */
//把元素节点转移到文档碎片上,将节点进行编译后再还给节点
function nodeToFragment(node, vm) {
let child;
let fragment = document.createDocumentFragment();
//把node中的每一个子节点,转移到了fragment
while (child = node.firstChild) {
//在移到fragment上先进行编译
compile(child, vm);
fragment.appendChild(child);
}
//又把fragment上所有的节点放到node上
node.appendChild(fragment);
}
function compile(node, vm) {
//判断node的节点类型
if (node.nodeType == 1) {
let attrs = node.attributes;
[...attrs].forEach(item => {
if (/^v\-/.test(item.nodeName)) {
//证明它是v-开头的
let valName = item.nodeValue;//获取"name"这个单词
new Watcher(node, vm, valName);
let val = vm.$data[valName];
node.value = val;
node.addEventListener('input', (e) => {
vm.$data[valName] = e.target.value;
})
}
});
//针对有子节点的元素接着进行编译
[...node.childNodes].forEach(item => {
compile(item, vm);
})
} else {
let str = node.textContent;
node.str = str;
if (/\{\{(.+?)\}\}/.test(str)) {
str = str.replace(/\{\{(.+?)\}\}/g, (a, b) => {
b = b.replace(/^ +| +$/g, '');
new Watcher(node, vm, b);
return vm.$data[b]
})
node.textContent = str
}
}
}
//订阅者
class Dep {
constructor() {
this.subs = [];
}
addSub(sub) {
this.subs.push(sub);
}
notify() {
this.subs.forEach(item => {
item.update();
})
}
}
//观察者
class Watcher {
constructor(node, vm, key) {
Dep.target = this;
this.node = node;
this.vm = vm;
this.key = key;
this.get();
Dep.target = null;
}
//把对应节点里的内容进行更新
update() {
//如果是input更新value值,如果是文本更新textContent
this.get();
if (this.node.nodeType == 1) {
this.node.value = this.value;
} else {
let str = this.node.str;//node.str = str;
str = str.replace(/\{\{(.+?)\}\}/g, (a, b) => {
b = b.trim();
//考虑到在一个文本里有多个小胡子
if (b == this.key) {
return this.value
} else {
return this.vm.$data[b];
}
})
this.node.textContent = str;
}
}
get() {
this.value = this.vm.$data[this.key]
}
}
function Vue(options) {
//$el 存储的是当前元素
this.$el = document.querySelector(options.el);
// $data存储的是data中的属性
this.$data = options.data
observe(this.$data)
nodeToFragment(this.$el, this)
}
let vm = new Vue({
el: '#app',
data: {
name: 'davina',
age: 10,
}
})
</script>