Vue:Object变化侦测
1. 什么是变化侦测
Vue.js会自动检测状态并生成DOM,然后将其输出到页面上,这个过程称为渲染,这个渲染过程是声明式的,我们通过模板来描述状态和DOM之间的映射关系。
而通常情况下,我们的页面是不断在更新状态的,此时页面根据状态来重新渲染,想要检测这个过程,就涉及到了变化侦测。
变化侦测有两种类型:“推”和“拉”。国内三大主流框架中的Angular和React都采用“拉”,Vue采用的是“推”。
“推”的优势是,当状态发生变化,Vue能够立刻知道,并将信息推送到各个相关依赖。因此,当知道信息越多,就可以进行更细粒度的更新。
粒度越细,在依赖上追踪所消耗的内存开销就会越大。Vue引入了虚拟DOM,将粒度调整为中等粒度,则一个状态所绑定的依赖不再是具体的DOM节点,而是组件,组件再通知给DOM,大大降低了依赖数量,降低内存损耗。
2. 追踪变化和收集依赖的实现
2.1 JS中的对象追踪变化
在JS中,检测对象变化的方法主要有两种:Object.defineProperty
和Proxy
。
下面我们来对Object.defineProperty
来进行封装,使之成为响应式的:
function defineReactive(data,key,val){
Object.defineProperty(data,key,{
enumerable: true,
configurable: true,
get: function(){
return val;
},
set: function(newVal){
if(val === newVal){
return ;
}
val = newVal;
}
})
}
2.2 收集依赖
收集依赖,就是把模板中用到目标数据的地方先保存起来,等数据发生变化时,把之前收集的依赖循环触发一遍渲染即可。
用一句话总结,就是getter收集依赖,setter触发依赖。
让我们将收集依赖的代码封装成类,并改进一下上面的封装内容,使之能够收集触发依赖。
class Dep{
constructor(){
this.subs = [];
}
addSub(sub){
this.subs.push(sub);
}
removeSub(sub){
let index = this.subs.indexOf(sub);
if(index > -1){
this.subs = this.subs.splice(index,1);
}
}
depend(){
if(window.target){
this.addSub(window.target);
}
}
notify(){
let subs = this.subs.splice();
for(let i=0;i<this.subs.length;i++){
subs[i].update() // 这里的update方法在后续定义
}
}
}
function defineReactive(data,key,val){
let dependencies = new Dep(); // 依赖
Object.defineProperty(data,key,{
enumerable: true,
configurable: true,
get:function(){
dependencies.depend();
return val;
},
set: function(newVal){
if(newVal === val){
return ;
}
val = newVal;
dependencies.notify();
}
})
}
- 在上面代码中,我们收集的依赖叫做
window.target
,实际上他有一个抽象的名字:Watcher。
2.3 Watcher的角色
Watcher在Vue中扮演中介的角色,当有数据发生变化时通知他,然后就再通知给其他地方。
在Vue中,Watcher的使用方式:
vm.$watch("name",function(newVal,oldVal){
// do something
})
想要实现watcher,只要把watcher实例添加到data.name属性中,将window.target指向它,当数据发生变化后,就可以通知到Watcher,Watcher再执行参数里的回调函数就行了。
class Watcher{
// vm,属性或函数,callback
constructor(vm,expOrFn,cb){
this.vm = vm;
this.getter = parsePath(expOrFn); // 解析字符串路径
this.cb = cb;
this.value = this.get();
}
get(){
window.target = this;
let value = this.getter.call(this.vm,this.vm);
window.target = undefined;
return value;
}
update(){
const oldVal = this.value;
this.value = this.get();
//触发回调函数
this.cb.call(this.vm,this.value,oldVal);
}
}
-
在Watcher的get方法中,我们将window.target指向this,获取到当前依赖的值(如data.name)过程中,出发了getter函数;
-
getter触发后,就会将该依赖的this存入Dep实例中,之后每当data.name发生变化,就会触发Dep的notify方法,循环触发所有依赖的update方法,从而实现“推”的过程。
-
代码中解析字符串路径的方法具体实现如下:
-
const exp = /[^\w.$]/; function parsePath(path){ if(exp.test(path)){ return ; } const segments = path.split('.'); return function(obj){ for(let i=0;i<segments.length;i++){ if(!obj){ return; } obj = obj[segments[i]]; } return obj; } }
-
通过循环一层层去读取数据,最终得到的就是目标数据。
2.4 如何侦测所有的key
在上面的代码中,我们一次只能够侦测到数据中的一个属性,但我们的目的时侦测所有的属性(包括子属性),所以我们可以封装一个类,将数据中的所有属性都变成可以侦测变化的key。
class Observer{
constructor(value){
this.value = value;
// 当值不为数组时,将其转化为可侦测的
if(!Array.isArray(value)){
this.walk(value);
}
}
walk(value){
const keys = Object.keys(value);
for(let i=0;i<keys.length;i++){
defineReactive(obj,keys[i],obj[keys[i]]);
}
}
}
function defineReactive(data,key,val){
if(typeof val === 'object'){
new Observer(val)
}
let dep = new Dep();
Object.defineProperty(data,key,{
enumerable: true,
configurable: true,
get:function(){
dep.depend();
return val;
},
set:function(newVal){
if(newVal === val){
return ;
}
val = newVal;
dep.notify();
}
})
}
2.5 整个变化侦测的流程
首先,data先通过Observer将所有数据转化成可侦测数据;
当外界读取数据时,Watcher会触发getter,将该数据的Watcher收集到Dep(收集依赖);
当数据发生变化时,触发setter,setter会通知Dep,Dep通知Watcher;
Watcher接到通知后,向外界发送通知,外界根据数据的变化进行相应操作。
3. 总结
- 变化侦测就是侦测数据的变化。
- Object可以通过
Object.defineProperty
来将属性转换成可侦测模式。获取数据时触发getter,修改数据时触发setter。 - 通过getter来收集依赖,通过setter来通知依赖数据发生了变化。
- 收集依赖我们需要一个储存依赖的方法,因此封装了一个Dep类,用于添加、删除、通知依赖。
- 所谓依赖就是Watcher,只要Watcher触发了getter,就将其收集到Dep中。当数据发生变化后,会循环依赖列表,逐个通知。
- Watcher的原理是将自己设置到全局唯一对象,然后读取数据,触发了getter,getter会获取全局对象,即当前的Watcher,将其添加到Dep中。Watcher就是通过这样的方式来主动订阅任意数据的变化。
- 为了侦测object中的所有数据的变化,我们封装了Observer类,将object的所有数据(包括子数据)都通过defineReactive转化成可侦测数据。
- 在ES6前,JS没有提供元编程的能力,因此无法追踪对象新增和删除属性的变化。