稍微学一下Vue的数据响应式(Vue2及Vue3区别)
什么是数据响应式
从一开始使用Vue时,对于之前的jq开发而言,一个很大的区别就是基本不用手动操作dom,data中声明的数据状态改变后会自动重新渲染相关的dom。
换句话说就是Vue自己知道哪个数据状态发生了变化及哪里有用到这个数据需要随之修改。
因此实现数据响应式有两个重点问题:
- 如何知道数据发生了变化?
- 如何知道数据变化后哪里需要修改?
对于第一个问题,如何知道数据发生了变化,Vue3之前使用了ES5的一个APIObject.definePropertyVue3中使用了ES6的Proxy,都是对需要侦测的数据进行变化侦测,添加getter和setter,这样就可以知道数据何时被读取和修改。
第二个问题,如何知道数据变化后哪里需要修改,Vue对于每个数据都收集了与之相关的依赖,这里的依赖其实就是一个对象,保存有该数据的旧值及数据变化后需要执行的函数。每个响应式的数据变化时会遍历通知其对应的每个依赖,依赖收到通知后会判断一下新旧值有没有发生变化,如果变化则执行回调函数响应数据变化(比如修改dom)。
下面详细分别介绍Vue2及Vue3的数据变化侦测及依赖收集。
Vue2
变化侦测
Object的变化侦测
转化响应式数据需要将Vue实例上data属性中定义的数据通过递归将所有属性都转化为getter/setter的形式,Vue中定义了一个Observer类来做这个事情。
functiondef(obj,key,val,enumerable){ Object.defineProperty(obj,key,{ value:val, enumerable:!!enumerable, writable:true, configurable:true }) } classObserver{ constructor(value){ this.value=value; def(value,'__ob__',this); if(!Array.isArray(value)){ this.walk(value); } } walk(obj){ for(const[key,value]ofObject.entries(obj)){ defineReactive(obj,key,value); } } }
直接将一个对象传入newObserver()后就对每项属性都调用defineReactive函数添加变化侦测,下面定义这个函数:
functiondefineReactive(data,key,val){ letchildOb=observe(val); Object.defineProperty(data,key,{ enumerable:true, configurable:true, get:function(){ //读取data[key]时触发 console.log('getter',val); returnval; }, set:function(newVal){ //修改data[key]时触发 console.log('setter',newVal); if(val===newVal){ return; } val=newVal; } }) } functionobserve(value,asRootData){ if(typeofval!=='object'){ return; } letob; if(hasOwn(value,'__ob__')&&value.__ob__instanceofObserver){ ob=value.__ob__; }else{ ob=newObserver(val); } returnob; }
函数中判断如果是对象则递归调用Observer来实现所有属性的变化侦测,根据__ob__属性判断是否已处理过,防止多次重复处理,Observer处理过后会给数据添加这个属性,下面写一个对象试一下:
constpeople={ name:'c', age:12, parents:{ dad:'a', mom:'b' }, mates:['d','e'] }; newObserver(people); people.name;//getterc people.age++;//getter12setter13 people.parents.dad;//getter{}gettera
打印people可以看到所有属性添加了getter/setter方法,读取name属性时打印了people.age++修改age时打印了getter12setter13说明people的属性已经被全部成功代理监听。
Array的变化侦测
可以看到前面Observer中仅对Object类型个数据做了处理,为每个属性添加了getter/setter,处理后如果属性值中有数组,通过属性名+索引的方式(如:this.people.mates[0])获取也是会触发getter的。但是如果通过数组原型方法修改数组的值,如this.people.mates.push('f'),这样是无法通过setter侦测到的,因此,在Observer中需要对Object和Array分别进行单独的处理。
为侦测到数组原型方法的操作,Vue中是通过创建一个拦截器arrayMethods,并将拦截器重新挂载到数组的原型对象上。
下面是拦截器的定义:
constArrayProto=Array.prototype; constarrayMethods=Object.create(ArrayProto); ;[ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ].forEach(method=>{ constoriginal=ArrayProto[method]; Object.defineProperty(arrayMethods,method,{ value:functionmutator(...args){ console.log('mutator:',this,args); returnoriginal.apply(this,args); }, enumerable:false, writable:true, configurable:true }) })
这里arrayMethods继承了Array的原型对象Array.prototype,并给它添加了pushpopshiftunshiftsplicesortreverse这些方法,因为数组是可以通过这些方法进行修改的。添加的pushpop...方法中重新调用original(缓存的数组原型方法),这样就不会影响数组本身的操作。
最后给Observer中添加数组的修改:直接将拦截器挂载到数组原型对象上
classObserver{ constructor(value){ this.value=value; def(value,'__ob__',this); if(Array.isArray(value)){ value.__proto__=arrayMethods; }else{ this.walk(value); } } walk(obj){ for(const[key,value]ofObject.entries(obj)){ defineReactive(obj,key,value); } } }
再来验证一下:
constpeople={ name:'c', age:12, parents:{ dad:'a', mom:'b' }, mates:['d','e'] }; newObserver(people); people.mates[0];//getter(2)["d","e"] people.mates.push('f');//mutator:(2)["d","e"]["f"]
现在数组的修改也能被侦测到了。
依赖收集
目前已经可以对Object及Array数据的变化进行截获,那么开始考虑一开始提到的Vue响应式数据的第二个问题:如何知道数据变化后哪里需要修改?
最开始已经说过,Vue中每个数据都需要收集与之相关的依赖,用来表示该数据变化时需要进行的操作行为。
通过数据的变化侦测我们可以知道数据何时被读取或修改,因此可以在数据读取时收集依赖,修改时通知依赖更新,这样就可以实现数据响应式了。
依赖收集在哪
为每个数据都创建一个收集依赖的对象dep,对外暴露depend(收集依赖)、notify(通知依赖更新)的两个方法,内部维护了一个数组用来保存该数据的每项依赖。
对于Object,可以在getter中收集,setter中通知更新,对defineReactive函数修改如下:
functiondefineReactive(data,key,val){ letchildOb=observe(val); //处理每个响应式数据时都创建一个对象用来收集依赖 letdep=newDep(); Object.defineProperty(data,key,{ enumerable:true, configurable:true, get:function(){ //收集依赖 dep.depend(); returnval; }, set:function(newVal){ if(val===newVal){ return; } val=newVal; //通知依赖更新 dep.notify(); } }) }
上面代码中依赖是收集在一个Dep实例对象上的,下面看一下Dep这个类。
classDep{ constructor(){ this.subs=[]; } addSub(sub){ this.subs.push(sub); } removeSub(sub){ if(this.subs.length){ constindex=this.subs.indexOf(sub); this.subs.splice(index,1); } } depend(){ if(window.target){ this.addSub(window.target); } } notify(){ constsubs=this.subs.slice(); for(leti=0;iDep的每个实例都有一个保存依赖的数组subs,收集依赖时是从全局的一个变量上获取到并插入subs,通知依赖时就遍历所有subs成员并调用其update方法。
Object的依赖收集和触发都是在defineProperty中进行的,因此Dep实例定义在defineReactive函数中就可以让getter和setter都拿到。
而对于Array来说,依赖可以在getter中收集,但触发却是在拦截器中,为了保证getter和拦截器中都能访问到Dep实例,Vue中给Observer实例上添加了dep属性。
classObserver{ constructor(value){ this.value=value; this.dep=newDep(); def(value,'__ob__',this); if(Array.isArray(value)){ value.__proto__=arrayMethods; }else{ this.walk(value); } } walk(obj){ for(const[key,value]ofObject.entries(obj)){ defineReactive(obj,key,value); } } }Observer在处理数据响应式时也将自身实例添加到了数据的__ob__属性上,因此在getter和拦截器中都能通过响应式数据本身的 __ob__.dep拿到其对应的依赖。修改defineReactive和拦截器如下:
functiondefineReactive(data,key,val){ letchildOb=observe(val); letdep=newDep(); Object.defineProperty(data,key,{ enumerable:true, configurable:true, get:function(){ dep.depend(); //给Observer实例上的dep属性收集依赖 if(childOb){ childOb.dep.depend(); } returnval; }, ... }) } ;[ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ].forEach(method=>{ constoriginal=ArrayProto[method]; def(arrayMethods,method,(...args)=>{ constresult=original.apply(this,args); constob=this.__ob__; ob.dep.notify(); returnresult; }) })依赖长什么样
现在已经知道了依赖保存在每个响应式数据对应的Dep实例中的subs中,通过上面Dep的代码可以知道,收集的依赖是一个全局对象,且该对象对外暴露了一个update方法,记录了数据变化时需要进行的更新操作(如修改dom或Vue的Watch)。
首先这个依赖对象的功能主要有两点:
- 需要主动将自己收集到对应响应式数据的Dep实例中;
- 保存数据变化时要进行的操作并在update方法中调用;
其实就是一个中介角色,Vue中起名为Watcher。
classWatcher{ constructor(vm,expOrFn,cb){ this.vm=vm; //保存通过表达式获取数据的方法 this.getter=parsePath(expOrFn); this.cb=cb; this.value=this.get(); } get(){ //将自身Watcher实例挂到全局对象上 window.target=this; //获取表达式对应的数据 //会自动触发该数据的getter //getter中收集依赖时从全局对象上拿到这个Watcher实例 letvalue=this.getter.call(this.vm,this.vm); window.target=undefined; returnvalue; } update(){ constoldValue=this.value; this.value=this.get(); //将旧值与新值传递给回调函数 this.cb.call(this.vm,this.value,oldValue); } }对于第一点,主动将自己收集到Dep实例中,Watcher中设计的非常巧妙,在get中将自身Watcher实例挂到全局对象上,然后通过获取数据触发getter来实现依赖收集。
第二点实现很简单,只需要将构造函数参数中的回调函数保存并在update方法中调用即可。
构造函数中的parsePath方法就是从Vue实例的data上通过表达式获取数据,比如表达式为"user.name"则需要解析该字符串然后获取data.user.name数据。
总结
- 数据先通过调用newObserver()为每项属性添加变化侦测,并创建一个Dep实例用来保存相关依赖。在读取属性值时保存依赖,修改属性值时通知依赖;
- Dep实例的subs属性为一个数组,保存依赖是向数组中添加,通知依赖时遍历数组一次调用依赖的update方法;
- 依赖是一个Watcher实例,保存了数据变化时需要进行的操作,并将实例自身放到全局的一个位置,然后读取数据触发数据的getter,getter中从全局指定的位置获取到该Watcher实例并收集在Dep实例中。
以上就是Vue2中的响应式原理,在Observer处理完后,外界只需要通过创建Watcher传入需要监听的数据及数据变化时的响应回调函数即可。
Vue3
Vue3中每个功能单独为一个模块,并可以单独打包使用,本文仅简单讨论Vue3中与数据响应式相关的Reactive模块,了解其内部原理,与Vue2相比又有何不同。
因为该模块可以单独使用,先来看一下这个模块的用法示例:
vue3demo