【Vue基础】--变化检测机制
目录
- 变化检测机制
- object的变化检测
- 什么是变化检测
- 如何追踪变化
- 如何收集依赖
- 依赖收集在哪里
- 依赖是谁
- 什么是Watcher
- 递归检测所有key
- 关于Object的问题
- Array的变化检测
- 拦截器
- 使用拦截器覆盖Array原型
- 将拦截器方法挂载到数组的属性上
- 如何收集依赖
- 依赖列表存在哪儿
- 收集依赖
- 在拦截器中获取Observer实例
- 向数组的依赖发送通知
- 检测数组中元素的变化
- 检测数组新增元素的变化
- 变化检测相关API的实现原理
- vm.$watch
- watch的内部原理
- deep参数的实现原理
- vm.$set
- Array的处理
- vm.$delete
变化检测机制
Vue.js最独特的特性之一是响应式系统。数据模型只是普通的JS对象。而当你修改数据时,视图会进行更新。这使得状态管理非常简单和直接。
object的变化检测
什么是变化检测
从状态生成DOM,再输出到用户界面显示的流程叫作渲染。应用在运行时会不断地进行重新渲染。其中,响应式系统赋予框架重新渲染的能力,其重要组成部分就是变化检测机制。
变化检测的作用是检测数据的变化。当数据变化时,会通知视图进行相应的更新。Vue.js的渲染过程是声明式的,通过模板来描述状态与DOM之间的映射关系。
那么,应用在运行时需要不停地重新渲染。如何确定状态中发生了什么变化?
变化检测就是用来解决这个问题的,它有两种类型:一种是“推”(push),另一种是“拉”(pull)。
Angular和React中的变化检测都属于“拉”,就是说当状态发生变化时,会发送一个信号给框架,框架收到信号,会进行一个暴力比对来找出哪些DOM节点需要重新渲染。这在Angular是脏检查机制,在React中使用虚拟DOM。
而Vue.js的变化检测属于“推”。当状态发生变化了,Vue.js立刻就知道了,且在一定程度上知道哪些状态变化了。因此,它知道的信息更多,也就可以进行更细粒度的更新。
所谓更细粒度的更新,就是:如果有一个状态绑定了好多个依赖,每个依赖表示一个具体的DOM节点,当状态发生变化时,向这个状态的所有依赖发送通知,让它们进行DOM更新操作。
但是这也有一定的代价,粒度越细,依赖追踪在内存上的开销就会越大。因此,Vue.js 2.0引入了虚拟DOM,将粒度调整为中等粒度,即一个状态绑定的依赖不再是具体的DOM节点,而是一个组件。这样,状态变化后,会通知到组件,组件内部再使用虚拟DOM进行比对更新。这样大大降低了依赖数量,降低了依赖追踪所消耗的内存。
如何追踪变化
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}})
}
函数defineReactive用来对Object.defineProperty进行封装,每当从data的key中读取数据时,get被触发;往data的key中设置数据时,set函数被触发。
如何收集依赖
Vue.js 2.0中,模板使用数据等同于组件使用数据,所以当数据发生变化时,会将通知发送到组件,然后组件内部再通过虚拟DOM重新渲染。
一句话:在getter中收集依赖,在setter中触发依赖。
依赖收集在哪里
要在getter中收集依赖,那么要把依赖收集到哪里?
首先我们想到的是每个key都有一个数组,用来存储当前key的依赖。假设依赖是一个函数,保存在window.target上,改造defineReactive函数如下:
function defineReactive(data, key, val) {let dep = [];// 新增Object.defineProperty(data, key, {enumerable: true,configurable: true,get: function() {dep.push(window.target);// 新增return val},set:function(newVal) {if (val === newVal) {return}// 新增for (let i = 0; i < dep.length; i++>) {dep[i](newVal, val)}val = newVal}})
}
这里新增数组dep,用来存储被收集的依赖。然后set被触发时,循环dep来触发收集到的依赖。
但是这样写有点耦合,我们可以把依赖收集的代码封装成一个Dep类来管理依赖。使用这个类,我们可以收集依赖、删除依赖、向依赖发送通知等。
export default class Dep {constructor() {this.subs = [];}addSub(sub) {this.subs.push(sub);}removeSub(sub) {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++) {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();}})
}
依赖是谁
上面我们收集的依赖是window.target。我们究竟要收集谁?收集谁,换句话说,就是当属性发生变化后,通知谁。
我们要通知用到数据的地方,而使用这个数据的地方有很多,而且类型还不一样,既有可能是模板,也有可能是用户写的一个watch,这时,需要抽象出一个能集中处理这些情况的类。然后,我们在依赖收集阶段只收集这个封装好的类的实例进来,通知也只通知它一个。接着,它再负责通知其他地方。
收集谁?就是Watcher。
什么是Watcher
Watcher是一个中介的角色,数据发生变化时通知它,然后它再通知其他地方。
经典例子:
// keypath
vm.$watch('a.b.c', function(newVal, oldVal) {// todo
})
这段代码表示当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 = cbthis.value = this.get()}get() {window.target = thislet value = this.getter.call(this.vm, this.vm)window.target = undefinedreturn value}update() {const oldValue = this.valuethis.value = this.get()this.cb.call(this.vm, this.value, oldValue)}
}// 解析简单路径:先将keypath用.分割成数组,然后循环数组一层一层去读数据,最后拿到的obj就是keypath中想要读的数据
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[segments[i]]}return obj}
}
上面代码可以把自己主动添加到data.a.b.c的Dep中去。
因为在get方法中先把window.target设置成了this,即当前watcher实例,然后再读一下data.a.b.c的值,这样肯定会触发getter。
触发了getter,就会触发收集依赖的逻辑。收集依赖,会从window.target中读取一个依赖并添加到Dep中。
只要先在window.target赋一个this,再读一下值,去触发getter,就可以把this主动添加到keypath的Dep中。
依赖注入到Dep中后,每当data.a.b.c的值发生变化时,就会让依赖列表中所有的依赖循环触发update方法,也就是Watcher中的update方法。而update方法会执行参数中的回调函数,将value和oldValue传到参数中
不管用户执行的vm.$watch(‘a.b.c’, function(newVal, oldVal) {}),还是模板中用到的data,都是通过Watcher来通知自己是否需要发生变化。
递归检测所有key
上面的代码只能检测数据中的某一个属性,如果希望把数据中的所有属性(包括子属性)都检测到,我们要封装一个Observer类。这个类的作用是将一个数据内的所有属性(包括子属性)都转换成getter/setter的形式,然后去跟踪它们的变化:
// Observer类会附加到没一个被检测的object上。
// 一旦被附加上,Observer会将object的所有属性转换为getter、setter的形式
// 来收集属性的依赖,并且当属性发生变化时会通知这些依赖
export class Observer {constructor(value) {this.value = valueif (!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 = newValdep.notify()}})
}
上面定义了Observer类,用来将一个正常的object转换成被检测的object。只要将一个object传到Observer中,这个object就会变成响应式的object。
关于Object的问题
前面介绍了Object的变化检测原理,数据的变化是通过getter/setter来跟踪的。但是这种跟踪方式,有些语法中即使数据变化了,也跟踪不到。
// 新增属性
var vm = new Vue({el: '#el',template: '#demo-template',methods: {action() {this.obj.name = 'xxx'}},data: {obj: {}}
})
// 删除属性
var vm = new Vue({el: '#el',template: '#demo-template',methods: {action() {delete this.obj.name}},data: {obj: {name: 'xxx'}}
})
在action方法中,在obj上新增了name属性或删除了name属性,Vue.js无法检测到这个变化,所以不会向依赖发送通知。
Vue.js通过Object.defineProperty来将对象的key转换成getter/setter,但getter/setter只能追踪一个数据是否被修改,无法追踪新增和删除属性。这也没有办法,因为ES6之前,JavaScript没有提供元编程的能力,无法检测到一个新属性被添加到对象中,也无法检测到一个属性从对象中删除了。
为了解决这个问题,Vue.js提供了两个API–vm.set和vm.set和vm.set和vm.delete。下面我们会详细介绍。
Array的变化检测
上面介绍了Object的检测方式。现在介绍Array的检测方式。
Array的检测方式和Object的不同,比如:this.list.push(1),使用push方法向list中新增数字1.
Object是通过getter/setter的形式来侦测变化,但是使用push方法来改变数组,并不会触发getter/setter。
我们可以通过Array原型上的方法来改变数组的内容,Object那种通过getter/setter的实现方式行不通。

拦截器
拦截器就是一个和Array.prototype一样的Object,里面包含的属性一模一样,只不过这个Object中某些可以改变数组自身内容的方法是我们处理过的。
const arrayProto = Array.prototypeexport const arrayMethods = Object.create(arrayProto)['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function(method) {// 缓存原始方法const original = arrayProto[method]Object.defineProperty(arrayMethods, method, {value: function mutator(...args) {return original.apply(this, args)},enumerable: false,writable: true,configurable: true,})
})
上面代码,创建了变量arrayMethods,它继承自Array.prototype,具备其所有功能。后面,我们要使用arrayMethods去覆盖Array.prototype。
接下来,在arrayMethods上使用Object.definProperty方法将那些可以改变数组自身内容的方法进行封装。
当使用push方法时,其实调用的是arrayMethods.push,而arrayMethods.push是函数mutator。
最后,在mutator中执行original(它是原生Array.prototype上的方法,例如Array.prototype.push)来做他应该做的事,比如push的功能。
使用拦截器覆盖Array原型
有了拦截器,要让它生效,就需要用它去覆盖 Array.prototype。但是我们又不能直接覆盖,这样会污染全局的Array。我们希望拦截器操作只针对那些被检测了变化的数据生效,就是说希望拦截器只覆盖那些响应式数据的原型。
要将一个数据转换为响应式的,需要通过Observer,所以我们只需要在Observer中使用拦截器覆盖那些即将被转换为响应式Array类型数据的原型就好了:
export class Observer {constructor(value) {this.value = valueif (Array.isArray(value)) {value.__proto__ = arrayMethods // 新增,将拦截器赋值给value.__proto__,这样可以巧妙地实现覆盖value原型的功能} else {this.walk(value)}}
}

__proto__其实是Object.getPrototypeOf和Object.setPrototypeOf的早期实现,使用ES6的Object.setPrototypeOf来代替__proto__完全可以实现同样的效果。
将拦截器方法挂载到数组的属性上
import { arrayMethods } from "./array";// __proto__ 是否可用
const hasProto = '__proto__' in {}
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)export class Observer {constructor(value) {this.value = valueif (Array.isArray(value)) {const augment = hasProto ? protoAugment : copyAugmentaugment(value, arrayMethods, arrayKeys)} else {this.walk(value)}}...
}function protoAugment(target, src, keys) {target.__proto__ = src
}function copyAugment(target, src, keys) {for (let i = 0, l = keys.length; i < l; i++>) {const key = keys[i]def(target, key, src[key])}
}
在上面的代码中,新增了 hasProto 来判断当前浏览器是否支持__proto__。还新增了copyAugment函数,用来将已经加工了拦截操作的原型方法直接添加到value的属性中。
还使用hasProto判断浏览器是否支持__proto__: 如果支持,则使用protoAugment函数来覆盖原型;如果不支持,则调用copyAugment函数将拦截器中的方法挂载到value上。
如何收集依赖
Array的依赖和Object一样,也在defineReactive中收集:
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.append()// 收集Array的依赖return val},set: function(newVal) {if (val === newVal) {return}dep.notify()val = newVal}})
}
Array在getter中收集依赖,在拦截器中触发依赖。
依赖列表存在哪儿
export class Observer {constructor(value) {this.value = valuethis.dep = new Dep() // 新增depif (Array.isArray(value)) {const augment = hasProto ? protoAugment : copyAugmentaugment(valu, arrayMethods, arrayKeys)} else {thi.walk(value)}}...
}
为什么数组的dep(依赖)要保存在Observer实例上呢?
上面我们介绍了数据在getter中收集依赖,在拦截器中触发依赖,所以这个依赖保存的位置很关键,它必须在getter和拦截器中都可以访问到。
我们将依赖保存在Observer实例中,是因为在getter中可以访问Observer实例,同时在Array拦截器中也可以访问Observer实例。
收集依赖
把Dep实例保存在Observer的属性上之后,我们可以在getter中像下面这样访问并收集依赖:
function defineReactive(data, key, val) {let childOb = observe(val) // 修改let dep = new Dep()Object.defineProperty(data, key, {enumerable: true,configurable: true,get: function() {dep.depend()// 新增if (childOb) {childOb.dep.depend()}return val},set: function(newVal) {if (val === newVal) {return}dep.notify()val = newVal}})
}/*** 尝试为value创建一个Observer实例,* 如果创建成功 , 直接返回新创建的Observer实例。* 如果value已经存在一个Observer实例, 则直接返回它*/
export function observe(value, asRootData) {if (!isObject(value)) {return}let obif (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {ob = value.__ob__} else {ob = new Observer(value)}return ob
}
上面代码中,新增了函数observe,尝试创建一个Observer实例。如果value已经是响应式数据,不需要再创建Observer实例。
在defineReactive函数中调用了observe,它把val当作参数传入并拿到一个返回值,那就是Observer实例。
defineReactive函数中的val有可能会是一个数组。通过observe我们得到了数组的Observer实例(childOb),最后通过childOb的dep执行depend方法来收集依赖。
通过这种方式,就可以实现在getter中将依赖收集到Observer实例的dep中。
在拦截器中获取Observer实例
如何在拦截器中访问Observer实例?
因为Array拦截器是对原型的一种封装,所以可以在拦截器中访问到this(当前正在被操作的数组)。
dep保存在Observer中,所以需要在this上读到Observer的实例:
function def(obj, key, val, enumerable) {Object.defineProperty(obj, key {value: val,enumerable: !!enumerable,writable: true,configurable: true,})
}export class Observer {constructor(value) {this.value = valuethis.dep = new Dep()def(value, '__ob__', this) // 新增if (Array.isArray(value)) {const augment = hasProto ? protoAugment :copyAugmentaugment(value, arrayMethods, arrayKeys)} else {this.walk(value)}}...
}
上面代码中,Observer新增了一段逻辑,在value上新增一个不可枚举的属性__ob__,这个属性的值就是当前Observer的实例。
这样我们就可以通过数组数据的__ob__属性拿到Observer实例,然后拿到__ob__上的dep。
当然,__ob__的作用不仅是为了在拦截器中访问Observer实例,还可以用来标记当前value是否已经被Observer转换成了响应式数据。
所有被检测了变化的数据身上都会有一个__ob__属性来表示是响应式的。如果value是响应式的,则直接返回__ob__;如果不是,则使用new Observer来将数据转换为响应式数据。
当value身上标记了__ob__,就可以通过 value.ob 来访问Observer实例。如果是Array拦截器,因为拦截器是原型方法,可以直接通过this.ob 来访问Observer实例:
;['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function(method) {// 缓存原始方法const original = arrayProto[method]Object.defineProperty(arrayMethods, method, {value: function mutator(...args) {const ob = this.__ob__ // 新增,通过this.__ob__获取Observer实例return original.apply(this, args)},enumerable: false,writable: true,configurable: true,})
})
向数组的依赖发送通知
检测到数组发生变化时,需要向依赖发送通知。首先要能访问到依赖。
前面已经讨论过如何在拦截器中访问Observer实例,我们只需要在Observer实例中拿到dep属性,然后直接发送通知就可以:
;['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function(method) {// 缓存原始方法const original = arrayProto[method]def(arrayMethods, method, function mutator(...args) {const result = original.apply(this, args)const ob = this.__ob__ob.dep.notify() // 向依赖发送消息return result})
})
检测数组中元素的变化
前面讨论过Observer,其作用是将object的所有属性转换为getter/setter的形式来检测变化。现在Observer类既能处理Object类型的数据,也能处理Array类型的数据。
export class Observer {constructor(value) {this.value = valuedef(value, '__ob__', this)// 新增if (Array.isArray(value)) {this.observeArray(value)} else {this.walk(value)}}
}// 检测Array中的每一项
observeArray(items) {for (let i = 0, l = items.length; i < l; i++) {observe(items[i])}
}
检测数组新增元素的变化
数组中新增内容也需要转换成响应式来检测变化,否则会出现修改数据时无法触发消息等问题。只要能获取新增元素并使用Observer来检测就行。
想获取新增元素,需要在拦截器中对数组方法的类型进行判断。如果操作数组的方法是push、unshift、splice,就把参数中新增的元素拿过来,用Observer来检测:
;['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function(method) {// 缓存原始方法const original = arrayProto[method]def(arrayMethods, method, function mutator(...args) {const result = original.apply(this, args)const ob = this.__ob__let insertedswitch (method) {case 'push':case 'unshift':inserted = argsbreakcase 'splice':inserted = args.slice(2)break}ob.dep.notify()return result})
})
通过switch对method判断,从args中将新增元素取出来,暂存在inserted中,然后使用Observer把inserted中的元素转换为响应式的:
;['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function(method) {// 缓存原始方法const original = arrayProto[method]def(arrayMethods, method, function mutator(...args) {const result = original.apply(this, args)const ob = this.__ob__let insertedswitch (method) {case 'push':case 'unshift':inserted = argsbreakcase 'splice':inserted = args.slice(2)break}// 新增 使用ob.observeArray检测新增元素的变化if (inserted) {ob.observeArray(inserted)}ob.dep.notify()return result})
})
变化检测相关API的实现原理
vm.$watch
用于观察一个表达式或computed函数在Vue.js实例上的变化。回调函数调用时,会从参数得到新数据和旧数据。表达式只接受以点分隔的路径:
vm.$watch('a.b.c', function(newVal, oldVal) {// todo
})// vm.$watch返回一个取消观察函数,用来停止触发回调
var unwatch = vm.$watch('a', (newVal, oldVal) => {})
// 取消观察
unwatch()// deep: 为了发现对象内部值得变化,可以在选项参数中指定deep: true
vm.$watch('someObject',callback, {deep: true
})
vm.someObject.nestedValue = 123
// 回调函数会被触发// immediate: 将立即以表达式的当前值触发回调,可以在选项参数中指定immediate: true
vm.$watch('a',callback, {immediate: true
})
// 立即以 'a' 的当前值触发回调
watch的内部原理
vm.watch其实是对Watcher的一种封装。通过Watcher完全可以实现vm.watch其实是对Watcher的一种封装。通过Watcher完全可以实现vm.watch其实是对Watcher的一种封装。通过Watcher完全可以实现vm.watch的功能,但是vm.watch中的参数deep和immediate是Watcher中没有的。下面看看vm.watch中的参数deep和immediate是Watcher中没有的。下面看看vm.watch中的参数deep和immediate是Watcher中没有的。下面看看vm.watch是怎么实现的:
// 执行new Watcher来实现vm.$watch的基本功能
Vue.prototype.$watch = function(expOrFn, cb, options) {const vm = thisoptions = options || {}const watcher = new Watcher(vm, expOrFn, cb, options)if (options.immediate) {cb.call(vm, watcher.value)}return function unwatchFn() {watcher.teardown()}
}
有个细节需要注意,expOrFn是支持函数的。这里需要对Watcher进行修改:
export default class Watcher {constructor(vm, expOrFn, cb) {this.vm = vm// expOrFn 参数支持函数if (typeof expOrFn === 'function') {this.getter = expOrFn} else {this.getter = parsePath(expOrFn)}this.cb = cbthis.value = this.get()}
}
当expOrFn是函数时,它不只可以动态返回数据,其中读取的所有数据也都会被Watcher观察。当expOrFn是字符串类型的keypath时,Watcher会读取这个keypath所指向的数据并观察这个数据的变化。 而当expOrFn是函数时, Watcher会同时观察expOrFn函数中读取的所有Vue.js实例上的响应式数据。
如果函数从Vue.js实例上读取了两个数据,那么Watcher会同时观察这两个数据的变化,当其中任意一个发生变化时,Watcher都会得到通知。
Vue.js中的计算属性(Computed)的实现原理与expOrFn支持函数有很大的关系
执行new Watcher后,会判断用户是否使用了immediate参数,如果使用了,则立即执行一次cb。
最后,返回一个函数unwatchFn,它的作用是取消观察数据。
当用户执行这个函数时,实际上是执行了watcher.teardown()来取消观察数据,其本质是把watcher实例从当前正在观察的状态的依赖列表中移除。
前面介绍Watcher时并没有介绍teardown方法,现在要在Watcher中添加该方法来实现unwatch的功能。
首先,需要在Watcher中记录自己都订阅了谁,也就是watcher实例被收集进了哪些Dep里。然后当Watcher不想继续订阅这些Dep时,循环自己记录的订阅列表来通知它们(Dep)将自己从它们(Dep)的依赖列表中移除掉。
下面把收集依赖的代码做下改动,现在Watcher中添加addDep方法,作用是在Watcher中记录自己都订阅过哪些Dep:
export default class Watcher {constructor(vm, expOrFn, cb) {this.vm = vmthis.deps = [] // 新增this.depIds = new Set() // 新增this.getter = parsePath(expOrFn)this.cb = cbthis.value = this.get()}...addDep(dep) {const id = dep.idif (!this.depIds.has(id)) {this.depIds.add(id)this.deps.push(dep)dep.addSub(this)}}
}
上面代码中,使用deplds来判断如果当前Watcher已经订阅了该Dep, 则不会重复订阅。前面,我们介绍过Watcher读取value时,会触发收集依赖的逻辑。 当依赖发生变化时,会通知Watcher重新读取最新的数据。如果没有这个判断,就会发现每当数据发生了变化,Watcher都会读取最新的数据。而读数据就会再次收集依赖,这就会导致Dep中的依赖有重复。这样当数据发生变化时,会同时通知多个Watcher。为了避免这个问题,只有第一次触发getter的时候才会收集依赖。
然后,执行this.deplds.add来记录当前Watcher已经订阅了这个Dep。执行this.deps.push(dep)记录自己都订阅了哪些Dep。
最后, 触发dep.addSub(this)来将自己订阅到Dep中。
在Watcher中新增addDep方法后,Dep中收集依赖的逻辑做下修改:
let uid = 0 // 新增export default class Dep {constructor(vm, expOrFn, cb) {this.id = uid++ // 新增this.subs = []}...depend() {if (window.target) {// this.addSub(window.target) 废弃window.target.addDep(this) // 新增}}
}
Dep会记录数据发生变化时,需要通知哪些Watcher, 而Watcher中也同样记录了自己会被哪些Dep通知。
Watcher和Dep是多对多的关系。为什么?个Dep吗?如果Watcher中的expOrFn参数是一个表达式,那么肯定只收集一个Dep,并且大部分都是这样。但有例外,expOrFn可以是一个函数,此时如果该函数中使用了多个数据,这是Watcher就要收集多个Dep了:
this.$watch(function() {return this.name + this.age
}, function(newValue, oldValue) {console.log(newValue, oldValue)
})
在上面例子中,我们的表达式是一个函数,并且在函数中访问了name和age两个数据,这种情况下Watcher内部会收集两个Dep一name的Dep和age的Dep, 同时这两个Dep中也会收集Watcher, 这导致age和name中的任意一个数据发生变化时,Watcher都会收到通知。
我们在Watcher中记录自己都订阅了哪些Dep之后,就可以在Watcher中新增teardown方法来通知这些订阅的Dep, 让它们把自己从依赖列表中移除掉:
teardown() {let i = this.deps.lengthwhile (i--) {this.deps[i].removeSub(this)}
}removeSub(sub) {const index = this.subs.indexOf(sub)if (index > -1) {return this.subs.splice(inde, 1)}
}
deep参数的实现原理
要实现deep的功能,除了要触发当前这个被监听数据的收集依赖的逻辑之外,还要把当前监听的这个值在内的所有子值都触发一遍收集依赖逻辑。这就可以实现当前这个依赖的所有子数据发生变化时,通知当前Watcher了。
export default class Watcher {constructor(vm, expOrFn, cb, options) {this.vm = vm// 新增if (options) {this.deep = !!options.deep} else {this.deep = false}this.deps = []this.depIds = new Set()this.getter = parsePath(expOrFn)this.cb = cbthis.value = this.get()}...get() {window.target = thislet value = this.getter.call(vm, vm)// 新增if (this.deep) {traverse(value)}window.target = undefinedreturn value}
}
如果用户使用了deep参数,则在window.target=undefined之前调用traverse来处理deep的逻辑。
这里需要强调的是,一定要在window.target=undefined之前去触发子值的收集依赖逻辑,这样才能保证子集收集的依赖是当前这个Watcher。如果在window.target=undefined之后去触发收集依赖的逻辑,那么其实当前的Watcher并不会被收集到子值的依赖列表中,也就无法实现deep的功能。
接下来,递归value的所有子值来触发他们收集依赖的功能:
const seenObjects = new Set()export function traverse(val) {_traverse(val, seenObjects)seenObjects.clear()
}function _traverse(val, seen) {let i, keysconst isA = Array.isArray(val)if ((!isA && !isObject(val)) || Object.isFrozen(val)) {return}if (val.__ob__) {const depId = val.__ob__.dep.idif (seen.has(depId)) {return}seen.add(depId)}if (isA) {i = val.lengthwhile(i--) {_traverse(val[i], seen)}} else {keys = Object.keys(val)i = keys.lengthwhile(i--) {_traverse(val[keys[i]], seen)}}
}
vm.$set
在object上设置一个属性,如果object是响应式的,Vue.js会保证属性被创建后也是响应式的,并且触发视图更新。
该方法主要用来避开Vue.js不能检测属性被添加的限制。可以为object新增属性,然后将这个新增属性转换为响应式的。
注意:target不能是Vue.js实例或者实例的根数据对象
var vm = new Vue({el: '#el',template: '#demo-template',methods: {action() {this.obj.name = 'xxx'}},data: {obj: {}}
})import { set } from '../observer/index'
Vue.prototype.$set = setexport function set (target, key, val) {// todo
}
Array的处理
上面,创建了set方法并且规定它接收3个参数,这3个参数与vm.$setAPI规定的需要传递的参数一致。如果target是数组:
export function set (target, key, val) {if (Array.isArray(target) && isValidArrayIndex(key)) {target.length = Math.max(target.length, key)target.splice(key, 1, val)return val}// key已经存在于target中if (key in target && !(key in Object.prototye)) {target[key] = valreturn val}// 处理新增的属性const ob = target.__ob__if (target._isVue || (ob && ob.vmCount)) {process.env.NODE_ENV !== 'production' && warn('Avoid adding reactive properties to a Vue instance or its root $data'+'at runtime - declare it upfront in the data option.' )return val}if (!ob) {target[key] = valreturn val}defineReactive(ob.value, key, val)ob.dep.notify()return val
}
上面代码中,target是数组并且key是一个有效的索引值,就先设置length属性。如果传递的索引值大于当前数组的length, 就需要让target的length等于索引值。
-
通过splice方法把val设置到target中的指定位置(参数中提供的索引值的位置)。splice方法把val设置到target中的时候,数组拦截器会侦测到target发生了变化,并且会自动帮助我们把这个新增的val转换成响应式的。
-
如果key已经在target中,其实这个key已经被侦测了变化。这种情况属于修改数据,直接用key和val改数据就好了。修改数据的动作会被Vue.js检测到,数据发生变化后,会自动向依赖发送通知。
-
在target上新增key,我们首先获取target的__ob__属性。接下来,处理"target不能是Vue.js实例或Vue.js实例的根数据对象” 的情况。
如果target身上没有__ob__属性,说明它并不是响应式的,并不需要做什么特殊处理,只需要通过key和val在target上设置就行。
如果前面的所有判断条件都不满足,那么说明用户是在响应式数据上新增了一个属性,使用defineReactive将新增属性转换成getter/setter的形式即可。
vm.$delete
vm.$delete的作用是删除数据中的某个属性。因为Vue.js的变化检测是使用Object.defineProperty实现的,如果数据使用delete关键字删除,无法发现数据发生了变化。为了解决这个问题,使用vm.$delete来删除数据中的某个属性。
vm.$delete实现原理,就是删除属性后向依赖发消息:
// 先在Vue.js的原型上挂载$.delete方法
import { del } from '../observer/index'
Vue.prototype.$delete = delexport function del(target, key) {// 处理数组的情况if (Array.isArray(target) && isValidArrayIndex(key)) {target.splice(key, 1)return}const ob = target.__ob__if (target._isVue || (ob && ob.vmCount)) {process.env.NODE_ENV !== 'production' && warn('Avoid adding reactive properties to a Vue instance or its root $data'+'- just set it to null.' )return}// 如果key不是target自身属性,终止程序执行if (!hasOwn(target, key)) {return}delete target[key]// 如果ob不存在,终止程序执行if (!ob) {return}ob.dep.notify()
}
