响应式原理
响应式是Vue的核心特性之一,数据驱动视图,我们修改数据视图随之响应更新,其原理是:
⚡️ 通过数据劫持结合发布和-订阅者模式的方式,通过拦截对数据的操作,在数据变动时发 布消息给订阅者,触发相应的监听回调。
发布者-订阅者模式
简单地说,发布者-订阅者模式的流程就是:监听器监听数据状态变化, 一旦数据发生变化,则会通知对应的订阅者,让订阅者执行对应的业务逻辑 。
首先要对数据进行劫持监听,所以我们需要设置一个监听器Observer,用来监听所有属性。如果属性发生变化了,就需要告诉订阅者Watcher看是否需要更新。因为订阅者是有很多个,所以我们需要有一个消息订阅器Dep来专门收集这些订阅者,然后在监听器Observer和订阅者Watcher之间进行统一管理。接着,我们还需要有一个指令解析器Compile,对每个节点元素进行扫描和解析,将相关指令对应初始化成一个订阅者Watcher,并替换模板数据或者绑定相应的函数,此时当订阅者Watcher接收到相应属性的变化,就会执行对应的更新函数,从而更新视图
- 实现一个监听器 Observer ,用来劫持并监听所有属性,如果属性发生变化,就通知订阅者;
- 实现一个订阅器 Dep,用来收集订阅者,对监听器 Observer 和 订阅者 Watcher 进行统一管理;
- 实现一个订阅者 Watcher,可以收到属性的变化通知并执行相应的方法,从而更新视图;
- 实现一个解析器 Compile,可以解析每个节点的相关指令,对模板数据和订阅器进行初始化。
1. 监听器 Observer
监听器 Observer 的实现,主要是指让数据对象变得"可观测",即每次数据读或写时,我们能感知到数据被读取了或数据被改写了。要使数据变得"可观测"。
-
Vue2.x是借助
Object.defineProperty()
实现的通过
Object.defineProperty
对data上的数据递归地进行getter
和setter
操作。也就是对属性的读取、修改进行拦截(数据劫持)js// 循环遍历数据对象的每个属性 function observable(obj) { if (!obj || typeof obj !== 'object') { return; } let keys = Object.keys(obj); keys.forEach((key) => { defineReactive(obj, key, obj[key]) }) return obj; } // 将对象的属性用 Object.defineProperty() 进行设置 function defineReactive(obj, key, val) { enumerable: true, configurable: true, Object.defineProperty(obj, key, { //拦截get,当我们访问data.key时会被这个方法拦截到 get() { //在这里收集依赖 console.log(`${key}属性被读取了...`); return val; }, //拦截set,当我们为data.key赋值时会被这个方法拦截到 set(newVal) { //当数据变更时,通知依赖项变更UI console.log(`${key}属性被修改了...`); val = newVal; } }) }
我们通过
Object.defineProperty
为对象obj添加属性,可以设置对象属性的getter和setter函数。之后我们每次通过点语法获取属性都会执行这里的getter函数,在这个函数中我们会把调用此属性的依赖收集到一个集合中 ;而在我们给属性赋值(修改属性)时,会触发这里定义的setter函数,在次函数中会去通知集合中的依赖更新,做到数据变更驱动视图变更。 -
Vue3.x是借助
Proxy
实现的通过
Proxy
对象创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。Proxy的监听是深层次的,监听整个对象,而不是某个属性。Proxy相比Object.defineProperty在处理数组和新增属性的响应式处理上更加方便。
jslet nObj=new Proxy(obj,{ //拦截get,当我们访问nObj.key时会被这个方法拦截到 get: function (target, propKey, receiver) { console.log(`getting ${propKey}!`); return Reflect.get(target, propKey, receiver); }, //拦截set,当我们为nObj.key赋值时会被这个方法拦截到 set: function (target, propKey, value, receiver) { console.log(`setting ${propKey}!`); return Reflect.set(target, propKey, value, receiver); } })
.
2. 订阅器 Dep
发布 ---订阅设计模式
发布-订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态改变时,所有依赖于它的对象都将得到通知。
举例说明:
小明最近看上了一套房子,到了售楼处之后才被告知,该楼盘的房子早已售罄。好在售楼 MM 告诉小明,不久后还有一些尾盘推出,开发商正在办理相关手续,手续办好后便可以购买。
但到底是什么时候,目前还没有人能够知道。于是小明把电话号码留在了售楼处。售楼 MM 答应他,只要新楼盘一推出就马上发信息通知小明。
除了小明,还有小红、小强、小龙也是一样,他们的电话号码都被记在售楼处的花名册上,新楼盘推出的时候,售楼 MM会翻开花名册,遍历上面的电话号码,依次发送一条短信来通知他们。
这就是发布-订阅模式在现实中的例子。
发布---订阅模式的优点:
-
发布-订阅模式广泛应用于异步编程中,这是一种替代传递回调函数的方案,比如,我们可以订阅 ajax 请求的 error 、succ 等事件。在异步编程中使用发布-订阅模式, 我们就无需过多关注对象在异步运行期间的内部状态,而只需要订阅感兴趣的事件发生点。
-
发布-订阅模式可以取代对象之间硬编码的通知机制,一个对象不用再显式地调用另外一个对象的某个接口。发布-订阅模式让两个对象松耦合地联系在一起,虽然不太清楚彼此的细节,但这不影响它们之间相互通信。当有新的订阅者出现时,发布者的代码不需要任何修改;同样发布者需要改变时,也不会影响到之前的订阅者。只要之前约定的事件名没有变化,就 可以自由地改变它们。
订阅器 Dep 实现:
完成了数据的'可观测',即我们知道了数据在什么时候被读或写了,那么我们就可以在数据被读或写的时候通知那些依赖该数据的视图更新了。为了方便,我们需要先将所有依赖收集起来,一旦数据发生变化,就统一通知更新。
其实,这就是上面所说的"发布订阅者"模式:数据变化为"发布者",依赖对象为"订阅者"(数据变化时,所有依赖的对象都会得到通知)。
现在,我们需要创建一个依赖收集容器,也就是消息订阅器 Dep,用来容纳所有的"订阅者"。
消息订阅器 Dep 主要负责收集订阅者,然后当数据变化的时候后执行对应订阅者的更新函数。
创建消息订阅器 Dep:
js
function Dep () {
this.subs = [];
}
Dep.prototype = {
addSub: function(sub) {
this.subs.push(sub);
},
notify: function() {
this.subs.forEach(function(sub) {
sub.update();
});
}
};
Dep.target = null;
有了订阅器,我们再将上面的 defineReactive
函数进行改造一下,向其植入订阅器:
js
defineReactive: function(data, key, val) {
var dep = new Dep();
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function getter () {
if (Dep.target) {
dep.addSub(Dep.target);
}
return val;
},
set: function setter (newVal) {
if (newVal === val) {
return;
}
val = newVal;
dep.notify();
}
});
}
上面代码中有一个静态属性 Dep.target
,这是一个全局唯一 的Watcher
,因为在同一时间只能有一个全局的 Watcher
被计算,另外它的自身属性 subs
也是 Watcher
的数组。
.
3. 订阅者 Watcher
订阅者Watcher
在初始化的时候需要将自己添加进订阅器 Dep
中,那该如何添加呢?我们已经知道监听器Observer
是在 get 函数执行了添加订阅者Wather
的操作的,所以我们只要在订阅者 Watcher
初始化的时候触发对应的 get 函数去执行添加订阅者操作即可,那要如何触发 get 的函数,只要获取对应的属性值就可以触发了,核心原因就是因为我们使用了 Object.defineProperty( )
进行数据监听。这里还有一个细节点需要处理,我们只要在订阅者 Watcher
初始化的时候才需要添加订阅者,所以需要做一个判断操作,因此可以在订阅器上做一下手脚:在 Dep.target
上缓存下订阅者,添加成功后再将其去掉就可以了。订阅者 Watcher 的实现如下:
js
function Watcher(vm, exp, cb) {
this.vm = vm; // 一个 Vue 的实例对象
this.exp = exp; // 是 node 节点的 v-model 等指令的属性值 或者插值符号中的属性。如 v-model="name",exp 就是name
this.cb = cb;// 是 Watcher 绑定的更新函数
this.value = this.get(); // 将自己添加到订阅器的操作
}
Watcher.prototype = {
update: function() {
this.run();
},
run: function() {
var value = this.vm.data[this.exp];
var oldVal = this.value;
if (value !== oldVal) {
this.value = value;
this.cb.call(this.vm, value, oldVal);
}
},
get: function() {
Dep.target = this; // 将自己赋值为全局的订阅者 全局变量 订阅者 赋值
var value = this.vm.data[this.exp] // 强制执行监听器里的get函数
Dep.target = null; // 全局变量 订阅者 释放
return value;
}
};
4. 解析器 Compile
解析器 Compile 关键逻辑代码分析
通过监听器 Observer 订阅器 Dep 和订阅者 Watcher 的实现,其实就已经实现了一个双向数据绑定的例子,但是整个过程都没有去解析 dom 节点,而是直接固定某个节点进行替换数据的,所以接下来需要实现一个解析器 Compile 来做解析和绑定工作。解析器 Compile 实现步骤:
- 解析模板指令,并替换模板数据,初始化视图;
- 将模板指令对应的节点绑定对应的更新函数,初始化相应的订阅器;
我们下面对 '{{变量}}' 这种形式的指令处理的关键代码进行分析,感受解析器 Compile 的处理逻辑,关键代码如下:
js
compileText: function(node, exp) {
var self = this;
var initText = this.vm[exp]; // 获取属性值
this.updateText(node, initText); // dom 更新节点文本值
// 将这个指令初始化为一个订阅者,后续 exp 改变时,就会触发这个更新回调,从而更新视图
new Watcher(this.vm, exp, function (value) {
self.updateText(node, value);
});
}
双向数据绑定
双向数据绑定通常是指我们使用的v-model
指令的实现,是Vue的一个特性,也可以说是一个input事件
和value的语法糖
。 Vue通过v-model
指令为组件添加上input事件
处理和value属性
的赋值。
html
<template>
<input v-model='localValue'/>
</template>
上述的组件就相当于如下代码
html
<template>
<!-- 这里添加了input时间的监听和value的属性绑定 -->
<input @input='onInput' :value='localValue' />
<span>{{localValue}}</span>
</template>
<script>
export default{
data(){
return {
localValue:'',
}
},
methods:{
onInput(v){
//在input事件的处理函数中更新value的绑定值
this.localValue=v.target.value;
console.log(this.localValue)
}
}
}
</script>
因此当我们修改input输入框中的值时,我们通过v-model绑定的值也会同步修改,基于上述原理,我们可以很容易的实现一个数据双向绑定的组件。