vue中对象的双向数据绑定
1、变化侦测
vue.js会自动通过状态生成DOM,并显示在页面上,这个过程叫渲染。vue.js渲染过程是声明式的,我们通过模板来描述状态与DOM之间的映射关系。通常,在运行时应用内部会不断发生变化,需要不停的渲染,此时需要变化侦测来确定状态中发生了什么变化。
变化侦测一种是“推”,一种是“拉”。Angular和React中的变化侦测都属于“拉”,即当状态发生变化时,它不知道具体哪个状态变了,只知道有状态变化,然后会发送一个信号告诉框架,框架内部收到信号后,会进行一个暴力比对来找出哪些DOM节点需要重新渲染。这在Angular中是脏检查的流程,在React中使用的是虚拟DOM。而Vue.js的变化侦测属于“推”。一个状态绑定着很多依赖,每个依赖表示一个具体的DOM节点,当状态发生变化时,向这个状态的所有依赖发送通知,让它进行DOM更新操作。从VUE2.0开始,引入了虚拟DOM,一个状态所绑定的依赖不再是具体的DOM节点,而是一个组件。这样状态变化后,会通知到组件,组件内部在使用虚拟DOM进行比对,从而大大降低依赖的数量,降低依赖追踪所消耗的内存。
2、Object的变化侦测
侦测对象的变化有两种方法:Object.defineProperty和ES6的proxy,这里只讨论使用Object.defineProperty的情况。
2.1 如何追踪变化?
我们首先使用一个函数defineReactive来对Object.defineProperty进行封装。其作用是定义一个响应式数据,即在函数中进行变化追踪,每当从data的key中读取数据时,get函数被触发;每当往data的key中设置数据时,set函数被触发。
2.2 如何收集依赖?
只是对Object.defineProperty进行封装,其实没有什么用处,真正有用的是收集依赖。我们之所以要观察数据,其目的就是当变量的属性发生变化时,可以通知那些使用到该属性的地方。
总之: 在get中收集依赖,在set中触发依赖。
例如:
<template>
<h1>{{name}}</h1>
</template>
该模板中使用了变量name,所以当它发生变化时,要向使用了它的地方发送通知。所以首先要收集依赖,即把用到变量name的地方收集起来,然后等属性发生变化时,把之前收集好的依赖循环触发一遍就好了。
2.3 依赖收集在哪里?
我们在getter中收集依赖,依赖收集在哪里?假设依赖是一个函数,保存在window.target上,我们可以新增一个dep数组,用来存储被收集的依赖。然后在set被触发时,循环dep以触发收集到的依赖。我们把依赖收集的代码封装成一个Dep类,专门管理依赖。使用这个类,可以收集依赖、删除依赖或者向依赖发送通知。
export default class Dep{ constructor(){ this.subs = [] } addSub(sub){ this.subs.push(sub) } removeSub(){ remove(this.subs, sub) } depend(){ if(window.target){ this.addSub(window.target) } } notify(){ const subs = this.subs.slice() for(let i=0,l=subs.length; i<l;i++){ subs[i].update() } } } function remove(arr, item){ if(arr.length){ const index = arr.indexOf(item) if(index > -1){ return arr.splice(index, 1) } } }
之后再改造下defineReactive:
function defineReactive(data, key, val){ let dep = new Dep() // 修改 Object.defineProperty(data, key, { enumerable: true, configurable: true, get: function(){ dep.depend() // 修改 return val }, set: function(newVal){ if(val === newVal){ return } val = newVal dep.notify() } }) }
2.4 依赖是谁?
在上面的代码中我们收集的依赖是window.target,它到底是什么?
收集谁也就是当属性变化的时候通知谁?我们要通知用到数据的地方,而使用这个数据的地方有很多,而且类型还不一样,既有可能是模板,也有可能是用户写的一个watch,这时需要抽象出一个能集中处理这些情况的类。然后,我们在依赖收集阶段只收集这个封装好的类的实例进来,通知只通知它一个。接着,他在负责通知其他地方,即watcher。
2.5 什么是watcher?
watcher是一个中介角色,数据发生变化时通知它,然后再通知其他地方。
关于watcher,先看下例:
vm.$watch('a.b.c', function(newVal, oldVal){ // 做点什么 })
// 当data.a.b.c的值发生变化时,触发第二个参数中的函数
上面代码表示当data.a.b.c的值发生变化时,触发第二个参数中的函数,怎么实现呢?可以把这个watcher实例添加到data.a.b.c属性的Dep中就可以。然后,当data.a.b.c的值发生变化时,通知watcher。接着Watcher在执行参数中的这个回调函数。代码如下:
export default class Watcher{ constructor(vm, expOrFn, cb){ this.vm = vm // 执行this.getter()就可以读取data.a.b.c的内容 this.getter = parsePath(expOrFn) this.cb = cb this.value = this.get() } get(){ window.target = this
// 修改this指向,并将this.vm作为参数传入getter let value = this.getter.call(this.vm, this.vm) window.target = undefined return value } update(){ const oldValue = this.value this.value = this.get() this.cb.call(this.vm, this.value, oldValue) } } const bailRE = /[^\w.$]/ export function parsePath(path){ if(bailRE.test(path)){ return }
// 先按.来分割成数组,然后一层层遍历读取数据 const segments = path.split('.') return function(obj){ for(let i=0; i<segments.length; i++){ if(!obj) return obj = obj[segements[i]] } return obj } }
这段代码可以将data.a.b.c主动添加到Dep中。首先在get方法中把window.target设置成this,也就是当前的watcher实例,然后再读取data.a.b.c的值,此时触发getter。触发getter就会触发收集依赖,会从window.target中读取一个依赖并添加到Dep中。依赖注入之后,每当data.a.b.c的值发生变化,就会让依赖列表中所有的依赖循环执行update方法,也就是Watcher中的update方法。update方法执行参数中的回调函数将value和oldValue传到参数中。
2.6 递归侦测所有的key
目前为止我们已经实现了变化侦测的功能,但是只能侦测数据中的某一个属性,我们希望把数据中的所有属性都侦测到,所以要封装一个Observer类。这个类的作用就是将一个数据内的所有属性(包括子属性)都侦测到,所以要封装一个Observer类。这个类的作用就是将一个数据内的所有属性(包括子属性)都转换成getter/setter形式,然后追踪他们的变化:
export class Observer{ constructor(value){ this.value = value if(!Array.isArray(value)){ this.walk(value) } } // walk会将每一个属性都转换成getter/setter的形式来侦测变化 // 这个方法只有在数据类型为 Object时被调用 walk(obj){ const keys = Object.keys(obj) 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(val === newVal){ return } val = newVal dep.notify() } }) }
2.7 关于Object的问题
Vue.js通过Object.defineProperty来将对象的key转换成getter/setter的形式来追踪变化,但getter/setter只能追踪一个数据是否被修改,无法追踪新增属性和删除属性,这是因为在ES6之前没有提供元编程的能力,无法侦测到一个新属性被添加到了对象中,也无法侦测到一个属性从对象中删除了。
2.8 总结
下图给出了Data、Observer、Dep和Watcher之间的关系。
Data通过Observer转换成getter/setter的形式来追踪变化。
当外界通过Watcher读取数据时,会触发getter从而将Watcher添加到依赖中。
当数据发生变化时,会触发setter,循环遍历Dep中的依赖(Watcher),Watcher接收到通知后,会向外界发送通知,变化通知到外界后会触发视图更新。