所谓数据响应式就是建立响应式数据
与依赖
(调用了响应式数据的操作)之间的关系,当响应式数据发生变化时,可以通知那些使用了这些响应式数据的依赖操作进行相关更新操作,可以是DOM更新,也可以是执行一些回调函数。从Vue2到Vue3都使用了响应式,那么它们之间有什么区别?
Object.defineProperty()
实现的。Proxy
实现的。那么它们之间有什么区别?为什么Vue3会选择Proxy替代defineProperty?我们先看看下面两个例子:
defineReactive(data,key,val){
Object.defineProperty(data,key,{
enumerable:true,
configurable:true,
get:function(){
console.log(`对象属性:${key}访问defineReactive的get!`)
return val;
},
set:function(newVal){
if(val===newVal){
return;
}
val = newVal;
console.log(`对象属性:${key}访问defineReactive的set!`)
}
})
}
let obj = {};
this.defineReactive(obj,'name','sapper');
// 修改obj的name属性
obj.name = '工兵';
console.log('obj',obj.name);
// 为obj添加age属性
obj.age = 12;
console.log('obj',obj);
console.log('obj.age',obj.age);
// 为obj添加数组属性
obj.hobby = ['游戏', '原神'];
obj.hobby[0] = '王者';
console.log('obj.hobby',obj.hobby);
// 为obj添加对象属性
obj.student = {school:'大学'};
obj.student.school = '学院';
console.log('obj.student.school',obj.student.school);
从上图可以看出使用defineProperty定义了包含name属性的对象obj,然后添加age属性、添加hobby属性(数组)、添加student属性并分别访问,都没有触发obj对象中的get、set方法。也就是说defineProperty定义对象不能监听添加额外属性或修改额外添加的属性的变化
,我们再看看这样一个例子:
let obj = {};
// 初始化就添加hobby
this.defineReactive(obj,'hobby',['游戏', '原神']);
// 改变数组下标0的值
obj.hobby[0] = '王者';
console.log('obj.hobby',obj.hobby);
假如我们一开始就为obj添加hobby属性,我们发现修改数组下标0的值,并没有触发obj里的set方法,也就是说defineProperty定义对象不能监听根据自身数组下标修改数组元素的变化
,注意地,如果是直接用defineProperty定义数组元素是可以监听的,但是对于数组比较大的时候就很牺牲性能,尤神考虑到性能就没有使用这种方法。那么我们继续看一下Proxy代理的对象例子:
// proxy实现
let targetProxy = {name:'sapper'};
let objProxy = new Proxy(targetProxy,{
get(target,key){
console.log(`对象属性:${key}访问Proxy的get!`)
return target[key];
},
set(target,key,newVal){
if(target[key]===newVal){
return;
}
console.log(`对象属性:${key}访问Proxy的set!`)
target[key]=newVal;
return target[key];
}
})
// 修改objProxy的name属性
objProxy.name = '工兵';
console.log('objProxy.name',objProxy.name);
// 为objProxy添加age属性
objProxy.age = 12;
console.log('objProxy.age',objProxy.age);
// 为objProxy添加hobby属性
objProxy.hobby = ['游戏', '原神'];
objProxy.hobby[0] = '王者';
console.log('objProxy.hobby',objProxy.hobby);
// 为objProxy添加对象属性
objProxy.student = {school:'大学'};
objProxy.student.school = '学院';
console.log('objProxy.student.school',objProxy.student.school);
从上图是不是发现了Proxy与defineProperty的明显区别之处了,Proxy能支持对象添加或修改触发get、set方法,不管对象内部有什么属性。所以
Object.defineProperty()**:defineProperty定义对象不能监听**添加额外属性或修改额外添加的属性的变化;defineProperty定义对象不能监听根据自身数组下标修改数组元素的变化。我们看看Vue里的用法例子:
data() {
return {
name: 'sapper',
student: {
name: 'sapper',
hobby: ['原神', '天涯明月刀'],
},
};
},
methods: {
deleteName() {
delete this.student.name;
console.log('删除了name', this.student);
},
addItem() {
this.student.age = 21;
console.log('添加了this.student的属性', this.student);
},
updateArr() {
this.student.hobby[0] = '王者';
console.log('更新了this.student的hobby', this.student);
},
}
从图中确实可以修改data里的属性,但是不能及时渲染,所以Vue2提供了两个属性方法解决了这个问题:Vue.$set
和Vue.$delete
。
注意不能直接this._ data.age这样去添加age属性,也是不支持的。
this.$delete(this.student, 'name');// 删除student对象属性name
this.$set(this.student, 'age', '21');// 添加student对象属性age
this.$set(this.student.hobby, 0, '王者');// 更新student对象属性hobby数组
Proxy:解决了上面两个弊端,proxy可以实现:
可以直接监听对象而非对象属性,可以监听对象添加额外属性的变化;
const user = {name:'张三'}
const obj = new Proxy(user,{
get:function (target,key){
console.log("get run");
return target[key];
},
set:function (target,key,val){
console.log("set run");
target[key]=val;
return true;
}
})
obj.age = 22;
console.log(obj); // 监听对象添加额外属性打印set run!
const obj = new Proxy([2,1],{
get:function (target,key){
console.log("get run");
return target[key];
},
set:function (target,key,val){
console.log("set run");
target[key]=val;
return true;
}
})
obj[0] = 3;
console.log(obj); // 监听到了数组元素的变化打印set run!
Proxy 返回的是一个新对象,而 Object.defineProperty 只能遍历对象属性直接修改。
支持多达13 种拦截方法,不限于 apply、ownKeys、deleteProperty、has 等等是Object.defineProperty 不具备的。
总的来说,Vue3响应式使用Proxy解决了Vue2的响应式的诟病,从原理上说,它们所做的事情都是一样的,依赖收集和依赖更新。
这里基于Vue2.6.14版本进行分析
Vue2响应式:通过Object.defineProperty()对每个属性进行监听,当对属性进行读取的时候就会触发getter,对属性修改的时候就会触发setter。首先我们都知道Vue实例中有data属性定义响应式数据,它是一个对象。我们看看下面例子的data:
data(){
return {
name: 'Sapper',
hobby: ['游戏', '原神'],
obj: {
name: '张三',
student: {
major: '软件工程',
class: '1班',
}
}
}
}
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
从上图我们可以看到,data中的每一个属性都会带 __ob__
属性,它是一个Observer对象,其实Vue2中响应式的关键就是这个对象,在data中的每一个属性都会带get、set方法,而Vue源码中其实把get、set分别定义为reactiveGetter、reactiveSetter,这些东西怎么添加进去的。Vue2又是怎么数据变化同时实时渲染页面?先看看下面的图:
Observer
,监视者类,监视数据的变化,在数据变化时告诉通知者,在这个类中将数据的所有属性用Object.defineProperty
重新定义一遍,绑定了存取器(getter/setter)
Dep
,通知者类,通知订阅者更新视图,因为一个数据可能被多处使用,所以一个通知者会存储多位订阅者
Watcher
,订阅者类,用于存储数据变化后要执行的更新函数,调用更新函数可以使用新的数据更新视图总结:数据变化时,监视者能够立即捕获到变化,同时告诉通知者,通知者在通知数据相关的订阅者执行更新函数,获取新的数据
(1)给data属性创建Observer实例:通过初注册响应式函数initState中调用了initData函数实现为data创建Observer实例。函数如下:
function initData(vm: Component) {
// 获取组件中声明的data属性
let data: any = vm.$options.data
// 对new Vue实例下声明、组件中声明两种情况的处理
data = vm._data = isFunction(data) ? getData(data, vm) : data || {}
...
// observe data
const ob = observe(data) // 为data属性创建Observer实例
ob && ob.vmCount++
}
(2)通过Observer实例把data中所有属性转换成getter/setter形式来实现响应性:对data属性分为两种情况处理:对象属性处理
(defineReactive实现)和数组属性处理
。数组怎么处理(后面再详细说明)
注意地,由于Vue实例的data永远都是一个对象,所以data里面包含的数组类型只有对象属性、数组属性。
(3) 在getter收集依赖,在setter中触发依赖:当读取data中的数据时,会在get方法中收集依赖,当修改data中的数据时,会在set方法中通知依赖更新。defineReactive方法中主要是做四件事情:创建Dep实例
、给对象属性添加get/set方法
、收集依赖
、通知依赖更新
。
从上面我们知道了dep.depend()实现了依赖收集,dep.notify()实现了通知依赖更新,那么Dep类究竟做了什么?我们先看看下面的图:
创建订阅者Watcher,保存更新函数与数据表达式,并将此订阅者存入一个全局变量中(表示进入依赖收集阶段),然后执行这个更新函数
执行函数会时会根据表达式访问数据,触发了监视者的绑定在其身上的 getter,getter 从全局变量获取订阅者,存入其绑定的通知者中
每个数据访问结束时,表示本轮的依赖收集完成,清除全局变量中的订阅者
然后针对模板中用到的每一个响应式数据,都会重复以上的过程。对于多层嵌套的数据,也是转换成字符串一层层访问,监视者会为一路上所有数据的通知者添加本轮的订阅者
从图中我们得明确一点,谁使用了变化的数据
,也就是说哪个依赖使用了变化的数据,其实就是Dep.taget,它就是我们需要收集的依赖,是一个Watcher实例对象,其实Watcher对象有点类似watch监听器,我们先看一个例子:
vm.$watch('a.b.c',function(newVal,oldVal)){....}
怎么监听多层嵌套的对象,其实就是通过.分割为对象,循环数组一层层去读数据,最后一后拿到的就是想要对的数据。
export function parsePath (path){
const segment = path.split('.');
return function(obj){
...
for(let i=0;i<segment.length;i++){
if(!obj) return;
obj = obj[segment[i]]
}
return obj
}
}
当嵌套对象a.b.c属性发生变化时,就会触发第二个参数中的函数。也就是说a.b.c就是变化的数据,当它的值发生变化时,通知Watcher,接着Watcher触发第二个参数执行回调函数。我们看看Watcher类源码,是不是发现了cb其实就与watch的第二参数有异曲同工之妙。
export default class Watcher implements DepTarget {
vm?: Component | null
cb: Function
deps: Array<Dep>
...
constructor(vm: Component | null,expOrFn: string | (() => any),cb: Function,...) {
...
this.getter = parsePath(expOrFn)// 解析嵌套对象
...
}
get() { // 读取数据
...
return value
}
addDep(dep: Dep) {
...
dep.addSub(this)//添加依赖
...
}
cleanupDeps() {// 删除依赖
...
dep.removeSub(this)
...
}
update() {// 通知依赖更新
this.run()
...
}
run() {
...
this.cb.call(this.vm, value, oldValue)
}
...
depend() { // 收集依赖
let i = this.deps.length
while (i--) {
this.deps[i].depend()
}
}
...
}
html代码
<div id="app">
{{ obj.message }}
div>
js代码
let data = {
obj: {
message: 'Hello Vue!',
},
}
new Vue({
el: '#app',
data,
})
setTimeout(() => {
data.obj = {
message: 'Obj have changed!',
}
}, 1000)
setTimeout(() => {
data.obj.message = 'Message have changed!'
}, 2000)
(1)Vue 拿到了 data
这个对象,创建一个监视者,绑定到对象的 __ob__
属性上
(2)创建监视者会将data对象身上的所有属性用 Object.defineProperty
重新一遍,在定义的同时,为每一个数据创建它的通知者。通知者会在闭包环境中创建,只有该数据的存取器能够访问到。
(3)如果对象的值中还有对象,会递归上面的过程
(4)处理完 data
后,将其所有属性映射到 Vue
实例身上(允许vm.xxx直接访问)
数据监视完毕后,Vue 会解析模板
模板解析的内容较为复杂,这一过程会创建虚拟节点 vnode
,匹配到 {{ }}
v-
等响应式写法,会根据当前节点的类型、响应式语法等建立该节点的更新函数 patch
,将数据与视图绑定
本文主要是帮助理解响应式更新的逻辑,模板解析语法并不是本文的重点,所以在之后的讲解与实现中使用的还是 DOM 节点
Vue 会根据 el: '#app'
配置项获取根 DOM 元素,遍历其所有子节点
发现html文本节点的内容是 {{ obj.message }}
,检测到特定的响应式写法,建立更新函数并提取数据表达式(字符串 'obj.message'
)
因为要操作的是只是文本节点的内容,所以更新函数较为简单(下方代码)
const patch = (value) => {
node.textContent = value
}
创建订阅者,保存更新函数与数据表达式,并将此订阅者存入一个全局变量中(表示进入依赖收集阶段),然后执行这个更新函数
执行函数会时会根据表达式访问数据,触发了监视者的绑定在其身上的 getter,getter 从全局变量获取订阅者,存入其绑定的通知者中
每个数据访问结束时,表示本轮的依赖收集完成,清除全局变量中的订阅者
然后针对模板中用到的每一个响应式数据,都会重复以上的过程。对于多层嵌套的数据,也是转换成字符串一层层访问,监视者会为一路上所有数据的通知者添加本轮的订阅者
模板解析完成时,只创建了一个订阅者但被添加到了四个通知者中(如下图)
以后修改数据就会触发监视者的 setter,setter 就能告诉通知者,通知其内部的订阅者执行更新函数修改视图,实现了数据的响应式
如果修改的数据值是一个对象,会先为其创建监视者,再告诉通知者发布订阅,执行更新函数访问这些数据时,所有子数据的新通知者又存储了之前解析模板时创建的订阅者
还是根据例子来讲解更新流程
data.obj
,是一个对象,原对象的监视者、Dep2、Dep4被移除obj
和 obj.message
,将此订阅者又添加进 Dep2 和 Dep4 中data.obj.message
从最开始的例子,我们了解对象以及嵌套对象的监听,但是Object.defineProperty是用来监听对象指定属性的变化,不支持数组监听,那么数组又是怎么监听?我们上面说了data中的数据被赋予响应性都是在Observer中实现的,那么监听的实现也是在Observer对象中实现的,先对数组的特定方法做自定义处理,为了拦截数组元素通知依赖更新,然后才通过observeArray函数遍历创建Observer实例,主要分为两种情况:
// 源码Observer类中对数组处理的部分代码
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
this.observeArray(value)
}
当浏览器支持__ proto __ 对象:强制赋值当前arrayMethods给target的__ proto __ 对象,直接给当前target数组带上自定义封装的数组方法,从而实现监听数组变化。其实arrayMethods处理后就是下面这样一个对象:
protoAugment(value, arrayMethods)
function protoAugment (target, src: Object) {
target.__proto__ = src
}
当浏览器不支持__ proto __ 对象:遍历数组元素通过defineProperty定义为元素带上自定义封装的原生数组方法,由于自定义数组方法中做了拦截通知依赖更新,从而实现监听数组的变化。
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)
console.log(arrayKeys);// ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
copyAugment(value, arrayMethods, arrayKeys)
function copyAugment (target: Object, src: Object, keys: Array<string>) {
for (let i = 0, l = keys.length; i < l; i++) {
const key = keys[i];
//遍历数组元素,给每一个元素绑上数组的方法
def(target, key, src[key])// 遍历数组元素通过为元素带上
}
}
对数组的Array原生方法做了自定义封装的源码如下,在自定义方法中拦截通知依赖更新。
// 遍历target实现创建Observer实例
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}