变化侦测
什么是变化侦测?
Vue.js自动通过状态生成DOM,并将其输出到页面,此为渲染。Vue.js的渲染过程是声明式的,我们通过模版来描述状态与DOM之间的映射关系。而当应用内部状态变化时需要重新渲染,而如何确定状态中什么变化了就是变化侦测要解决的问题。
object的变化侦测
如何追踪变化?
两种方式可以追踪变化:Object.defineProperty和ES6的Proxy。
由于ES6在浏览器中的支持度不理想,所以Vue2采用了Object.defineProperty实现,而Vue3则采用了Proxy。
利用Object.defineProperty侦测对象变化可以写出这样的代码:
function defineReactiv (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,value即可。封装好后,每当从data的key中读取数据,get函数被触发;往data的key中设置数据时,set函数被触发。
如何收集依赖?收集在哪里?
在Vue2中,模板使用数据等同于组件使用数据,所以数据变化时,会将通知发送到组件,组件内部再通过虚拟DOM重新渲染。
我们观察依赖的目的是当数据发生变化的时候,通知那些使用了该数据的地方。所以我们需要收集依赖,把用到数据的地方收集起来,等属性发生变化时,将之前收集好的依赖循环触发一遍,也就是在getter中收集依赖,setter中触发依赖。
现在已经知道了要在哪里收集触发依赖,那么要把它收集到哪里?我们创建一个专门帮助管理依赖的类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(windwo.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 () {
if(val === newVal) {
return
}
val = newVal
dep.notify()
}
})
}
什么是Watcher
上边的代码中,我们收集的依赖是window.target,那我们究竟要收集谁呢。收集说的通俗易懂点就是当属性变化时,我们应该通知谁。
我们需要通知用到数据的地方,而用到它的地方可能会很多,有可能是模版,也有可能是watch等等,所以我们需要抽象出一个能集中处理这些情况的类。我们在收集依赖阶段只收集这个封装好的类实例,同样也只通知它自己,由它负责通知其他地方,这就是Watcher。
Watcher是一个中介的角色,数据变化时通知它,他负责通知其他地方。
首先看一下Watcher的经典使用方式:当a.b.c变化时会触发第二个参数中的函数
vm.$watch('a.b.c', function (newVal, oldVal) {
// 操作
})
根据使用方法我们可以首先实现Watcher:
export default class Watcher {
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)
windwo.target = undefined
return value
}
update() {
const oldValue = this.value
this.value = this.get()
this.cb.call(this.vm, this.value, oldValue)
}
}
这段胆码可以把自己主动添加到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中后,每次值变化就会让依赖列表中所有依赖循环触发update方法,执行参数中的回调函数,将value和oldValue传到参数中。
递归侦测所有key
现在已经可以实现变化侦测的功能,但是我们希望把数据中所有属性(含子属性)都侦测到,需要一个Observer类。它的作用是将一个数据内所有属性都转换成getter/setter形式,再去追踪他们的变化
export class Observer {
constructor(value) {
this.value = value
if(!Array.isArray(value)) {
this.walk(value)
}
}
walk() {
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 () {
if(val === newVal) {
return
}
val = newVal
dep.notify()
}
})
}
我们定义Observer类,将正常对象转换成被侦测的对象。然后判断数据类型,只有Object才会调用walk进行转换,最后在defineReactive中新增new Observer(val)来递归子属性。
关于Object的问题
上边我们介绍了Object类型的变化侦测原理,正是因为数据变化是通过getter/setter进行追踪,所以有些语法中即使数据变化,Vue也追踪不到。
比如,向object添加或删除属性:
var vm = new Vue({
el:'#app',
template:'#demo-template',
methods:{
action() {
this.obj.age = '23'
},
dAction() {
delete this.obj.name
}
},
data: {
obj:{
name:'abc'
}
}
})
Object.defineProperty将对象的key转化为getter/setter来追踪变化,但它只能追踪一个数据是否被修改,无法侦测新增和删除。
所以Vue提供两个API:vm.delete
总结
Object可以通过Object.defineProperty将属性转换成getter/setter形式来追踪变化,读取触发getter,修改触发setter。
在getter中对使用了数据的依赖进行收集,当setter触发时通知getter中收集的依赖数据发生变化。
收集依赖需要为依赖找一个储存的地方,所以有了Dep,它用来收集、删除依赖并向依赖发送消息。
依赖就是Watcher,只有Watcher触发的getter才会收集依赖,哪个Watcher触发了getter就把它收集到Dep中,数据发生变化时会循环依赖列表通知所有Watcher。
Watcher的原理是先把自己设置到全局唯一的位置,然后读取数据触发getter,接着getter中就会从唯一的位置读取当前正在读取数据的Watcher,并把它收集到Dep中,这样Watcher可以主动去订阅任一数据的变化。
此外,还创建了Observer类,它是用来将一个object中的所有数据(包括子数据)都转换成响应式的。
Array的变化侦测
Array的侦测为什么与Object不同,因为Object使用getter/setter侦测变化,而数组通常使用push等方法来改变,不会触发getter/setter。
如何追踪变化
这里不讨论ES6的情况,在ES6以前,我们没有能够拦截原型方法(push等)的能力,但我们可以用自定义方法覆盖原生的原型方法。
我们可以用一个拦截器覆盖Array.prototype。之后每当使用原型上的方法操作数组的时候,执行的其实是拦截器中的方法,之后在拦截器内使用原生的原型方法去操作数组。
拦截器
数组原型上的方法有七个,分别是push,pop,shift,unshift,splice,sort和reverse。我们可以实现一个和原型样的Object,里面的属性一模一样,只不过其中改变数组自身内容的方法是重写的。
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
const methods = ['push','pop','shift','unshift','splice','sort','reverse']
methods.forEach((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,具备所有功能,接下来使用Object.defineProperty对改变数组的方法进行封装。所以当我们调用push的时候,实际调用的是arrayMethods.push,而它实际上是mutator函数,在mutator函数中执行original(原生方法)来做应该做的事,我们可以在mutator中做其他的事比如发送变化通知。
使用拦截器覆盖Array原型
我们需要用它覆盖Array.prototype,但又不能直接覆盖,因为这样会污染全局的Array。我们希望拦截只针对那些被侦测了变化的数据生效,也就是希望拦截器只覆盖那些响应式数组的原型。
而数据转成响应式的,需要通过Observer,所以我们只需要在Observer中用拦截器覆盖那些Array类型数据的原型。
export class Observer {
constructor(value) {
this.value = value
if(Array.isArray(value)) {
value._proto_ = arrayMethods
} else {
this.walk(value)
}
}
}
它的作用是将拦截器赋给value.proto,覆盖value原型的功能。
拦截器挂载到数组属性上
部分浏览器可能不支持_proto_,而Vue的做法是如果不能使用_proto_,则直接将arrayMethods身上的方法设置到被侦测的数组上:
const hasProto = '_proto_' in {}
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)
export class Observer {
constructor(value) {
this.value = value
if(Array.isArray(value)) {
const augment = hasProto ? protoAugment : copyAugment
augment(value, arrayMethods, arrayKeys)
} else {
...
}
}
...
}
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])
}
}
如何收集依赖
我们已完成了拦截器,本质上是为了当数组内容变化时能够得到通知Dep中的依赖的能力。而数组也是在getter中收集依赖的。想要读取数组,首先肯定会触发这个数组的名对应属性的getter。而Array的依赖和Object一样,也会在defineReactive中收集:
function defineReactive(data, key, val) {
if(typeof val === 'object') new Observer(val)
let dep = new Dep()
Object.deintProperty(data,key, {
enumerable:true,
configurable:true,
get:function() {
dep.depend()
// 收集数组的依赖
return val
},
set:function(newVal) {
if(val === newVal) return
dep.notify()
val = newVal
}
})
}
Array在getter中收集依赖,在拦截器中触发依赖。
依赖列表存在哪
Array的依赖被存放在Observer中:
export class Observer {
constructor(value) {
this.value = value
this.dep = new Dep() // 新增dep
if(Array.isArray(value)) {
const augment = hasProto ? protoAugment : copyAugment
augment(value, arrayMethods, arrayKeys)
} else {
...
}
}
...
}
我们有个疑问,为什么数组的dep要保存在Observer实例上呢。因为数组在getter中收集依赖,在拦截器中触发依赖,所以依赖保存的位置很关键,需要在getter和拦截器中都能访问到。将Dep实例保存到Observer的属性上以后,我们能够在getter中访问并收集。
function defineReactive(data, key, val) {
let childOb = observer(val)
let dep = new Dep()
Object.defineProerty(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
}
})
}
在defineReactive中调用observe,将val当做参数传进去拿到返回值,就是observer实例。
在拦截器中获取Observer实例
因为Array拦截器是对原型的一种封装,所以在拦截器中可以访问到this。而dep保存在Observer中,所以需要在this上读到Observer实例。
function def (obj, key, val, enumerable) {
Object.defineProperty(obj, key, {
value:val,
enumerable:!!enumerable,
writable:true,
configruable:true
})
}
export class Observer {
constructor(value) {
this.value = value
this.dep = new Dep()
def(value , '__ob__',this)
if(Array.isArray(value)) {
...
} else {
...
}
}
...
}
上述代码在Observer中新增了一段代码,它在value上新增了一个不可枚举的__ob__属性即当前Observer实例。它还可以用来标记当前value是否被Observer转换成响应式数据。
也就是说所有被侦测了的数据身上都会有一个__ob__来表示它们是响应式的,可以通过value.__ob__来访问Observer实例。如果是Array拦截器,因为它是原型方法,所以可以直接通过this.__ob__来访问Observer实例。
['push','pop',……].forEach(function(method) {
const original = arrayProto[method]
Object.defineProperty(arrayMethods,method, {
value:function mutator(...args) {
const ob = this.__ob__
return original.apply(this,args)
},
...
})
})
向数组依赖发送通知
我们只需要在拦截器中访问Observer实例,拿到dep属性,直接发送通知即可。
['push','pop',……].forEach(function(method) {
const original = arrayProto[method]
Object.defineProperty(arrayMethods,method, {
value:function mutator(...args) {
const ob = this.__ob__
ob.dep.notify()
return original.apply(this,args)
},
...
})
})
侦测数组元素变化及新增变化
元素变化
介绍Observer时说过,作用是将object的属性变为getter/setter形式,现在Observer类不仅处理Object类型,还要处理Array类型。所以我们要在Observer中新增一些处理,让它能把Array也变成响应式:
export class Observer {
constructor(value) {
this.value = value
def(value,'__ob__',this)
if(Array.isArray(value)) {
this.observeArray(value)
} else {
this.walk(value)
}
}
...
observeArray(items) {
for(let i = 0,l = items.length;i < l; i++) {
observe(items[i])
}
}
}
新增元素的变化
- 获取新增元素
我们需要在拦截器中队数组方法的类型进行判断,如果是push、unshift和splice(添加元素方法),需要把参数中新增的元素拿过来,用Observer侦测。
['push','pop',……].forEach(function(method) {
const original = arrayProto[method]
def(arrayMethods, method, function mutator(...args) {
const result = original.apply(this, args)
const ob = this.__ob__
let inserted
switch(method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
ob.dep.notify()
return result
})
})
关于数组的问题
对Array的变化侦测是通过拦截原型的方法实现,所以有些操作Vue无法拦截,比如:
this.list[0] = 2
this.list.length = 0
无法侦测数组变化,不会触发re-render或者watch。
总结
Array的追踪方式和Object不同,因为它是通过方法来改变内容,所以通过创建拦截器去覆盖数组的原型方法来追踪变化。
而为了不污染全局的原型方法,我们在Observer中只针对需要侦测变化的数组用__proto__来覆盖原型方法,而ES6之前并不是所有浏览器都支持,所以针对不支持的浏览器,我们循环拦截器,将方法设置到数组身上来拦截Array.prototype上的原型方法。
Array同Object收集依赖的方式相同,均在getter中,但是使用依赖的位置不同,数组要在拦截器中向依赖发送消息,所以依赖不能像Object一样保存在defineReactive中,而是保存在Observer实例上。
在Observer中队每个侦测了变化的数据都标记__ob__,并把this保存在__ob__上。主要有两个作用,一方面为了标记数据是否被侦测变化(保证同一数据只侦测一次),另一方面可以很方便的通过数据取到__ob__,从而拿到Observer上保存的依赖,发送通知。
除了侦测数组自身变化,数组中元素的变化也要侦测,在Observer中判断如果当前被侦测数据是数组,则调用observerArray将数组中每个元素转换成响应式。
除了已有数据的侦测,当使用push等方法新增数据时,新增的数据也要侦测,我们使用当前操作数组的方法判断,如果是push、unshift和splice,则将参数中的新增数据提取出来,对其转换。
变化侦测相关的api实现原理
vm.$watch
用法
vm.$watch(expOfFn, callback, [options])
用于观察一个表达式或computed函数在vue实例上的变化。回调函数调用时会从参数得到新数据和旧数据,表达式只接受以点分隔的路径,例如a.b.c。
options包括deep和immediate,deep为了发现对象内部值的变化,immediate将立即以表达式的当前值触发回调。
vm.$watch('someObject',callback, {
deep:true, // someObject.a修改时触发。
immediate:true // 立即以someObject的当前值触发回调。
})
内部原理
vm.watch的功能,但它的deep和immediate是Watcher没有的。
Vue.property.$watch = function(expOrFn, cb, options) {
const vm = this
options = options || {}
const watcher = new Watcher(vm, expOfFn, cb, options)
if(options.immediate) {
cb.call(vm, watcher)
}
return function unwatchFn() {
watcher.teardown()
}
}
expOrFn是支持函数的,而之前介绍Watcher时没有添加这部分,需要对Watcher简单修改。
export default class Watcher {
constructor(vm, expOrFn, cb) {
this.vm = vm
//修改
if(typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
}
this.cb = cb
this.value = this.get()
}
}
如果expOrFn是函数,将它直接赋给getter,如果不是,则用parsePath读取keypath中的数据,keypath是属性路径,例如a.b.c.d就代表从vm.a.b.c.d中读取数据。
而expOrFn是函数时,不止可以动态返回数据,其中读取的数据也都会被Watcher观察。当expOrFn是字符串类型的keypath时,Watcher会读取它指向的数据并观察数据的变化。而expOrFn是函数时,Watcher会同时观察expOrFn函数中读取的所有Vue实例上的响应式数据。
执行new Watcher后,代码会判断用户是否用了immediate参数,使用了则立即执行一次cb。
最后返回一个函数unwatcheFn,它的作用是取消观察数据。用户执行它时,实际上是执行了watcher.teardown()来取消观察数据,其本质是把watcher实例从当前正在观察的状态的依赖列表中移除。
现在需要实现watcher中的teardown方法,来实现unwatch功能。
首先需要在Watcher中记录自己都订阅了谁,当Watcher不想继续订阅时,循环自己记录的列表来通知他们将自己从他们的依赖列表中移除。
export default class Watcher {
constructor(vm, expOrFn, cb) {
this.vm = vm
this.deps = []
this.depsIds= new Set()
……
}
……
addDep(dep) {
const id = dep.id
if(!this.depIds.has(id)) {
this.depIds.add(id)
this.deps.push(dep)
dep.addSub(this)
}
}
}
上述代码中,用depIds判断当前Watcher是否订阅了Dep,不会发生重复订阅。
接着执行this.depIds.add来记录当前Watcher已经订阅了这个Dep。然后执行this.deps.push(dep)记录自己订阅了那些Dep。最后触发dep.addSub(this)将自己订阅到Dep中。
Watcher中新增了addDep方法后,Dep中收集依赖的逻辑也需要改变:
let uid = 0
export default class Dep {
constructor() {
this.id = uid++
this.subs = []
}
……
depend() {
if(window.target) {
window.target.addDep(this)
}
}
}
Dep会记录数据发生变化的时候,需要通知哪些Watcher,而Watcher中也记录了自己会被哪些Dep通知,是多对多的关系。
在Watcher中记录了自己都订阅了那些Dep后,可以在Watcher中增加teardown方法来通知订阅的Dep,让他们把自己从依赖中移除:
teardown() {
let i = this.deps.length
while(i--) {
this.deps[i].removeSub(this)
}
}
export default class Dep {
……
removeSub(sub) {
const index = this.subs.indexOf(sub)
if(index > -1) {
return this.subs.splice(index, 1)
}
}
}
deep参数实现原理
deep的 作用是监听它及其子对象的数据,其实就是除了要触发当前被监听数据的收集依赖逻辑外,还要把当前监听值在内的所有子值都触发一遍收集依赖逻辑。
export default class Watcher {
constructor(vm, expOrFn, cb, options) {
this.vm = vm
// 新增
if(options) {
this.deep = !!options.deep
} else {
this.deep = false
}
……
}
get() {
window.target = this
let value = this.getter.call(vm, vm)
//新增
if(this.deep) {
traverse(value)
}
windwo.target = undefined
return value
}
}
如果使用了deep参数,则在target = undefined前调用traverse来处理deep的逻辑。
const seenObjects = new Set()
export function traverse(value) {
_traverse(val, seenObjects)
seenObjects.clear()
}
function _traverse(val, seen) {
let i , keys
const isA = Array.isArray(val)
if( (!isA && !isObject(val)) || (Object.isFrozen(val)) ) {
return
}
if(val.__ob__) {
const depId = val.__ob__.dep.id
if(seen.has(depId)) {
return
}
seen.add(depId)
}
if(isA) {
i = val.length
while(i--) _traverse(val[i], seen)
} else {
keys =Object.keys(val)
i = keys.length
while(i--) _traverse(val[keys[i]], seen)
}
}
利用递归来判断子值的类型,数组则直接循环递归调用_traverse,而对象则利用key读取并递归子值,而val[keys[i]]会触发getter,所以要在target = undefined之前触发收集依赖的原因。
vm.$set
用法
vm.$set(target, key, value)
在object上设置一个属性,如果object是响应式的,Vue保证属性在创建后也是响应式的,并能够触发视图更新。此方法为了避开Vue不能侦测属性被添加的限制。
target 不能是Vue实例或Vue实例的根数据对象。
举个例子来看看
var vm = new Vue({
el:'#el',
template:'#demo-template',
data:{
obj
},
methods: {
action() {
this.obj.name = 'abc'
}
}
})
调用action方法时,会为obj新增一个name属性,但Vue不会得到任何通知,新增的属性也不是响应式的,Vue不知道这个obj新增了属性等同于不知道我们使用了array.length = 0来清空数组一样。而vm.$set就用来解决这类问题。
Array的处理
首先创建set方法,并规定接收与$set规定的参数一致的三个参数,并对数组进行处理。
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
}
}
上述代码中,如果target是数组并且key是有效的索引值,就先设置length属性,这样如果我们传的索引值大于length,就需要让target的lenth等于索引。接下来通过splice方法把val设置到target的指定位置,当我们使用splice方法时,数组拦截器会侦测到target变化,自动把新增的val转换成响应式的,最后返回val。
key已经存在target中
因为key已存在target中,所以它已经被侦测了变化,此时修改数据直接用key和val就好,修改的动作会被侦测到。
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
}
if(key in target && !(key in Object.prototype)) {
target[key] = val
return val
}
}
处理新增的属性
现在处理在target上新增的key:
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
}
if(key in target && !(key in Object.prototype)) {
target[key] = val
return 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] = val
return val
}
defineReactive(ob, value, key, val)
ob.dep.notify()
return val
}
上述代码,首先获取了target的__ob__属性,然后处理边界条件即“target不能是Vue实例或Vue实例的跟数据对象”和target不是响应式的情况。若果它身上没有__ob__,则不是响应式的,直接用key和val设置即可。如果前边所有条件都不满足那么说明这是新增的属性,使用defineReactive将新增的属性转成getter/setter即可。
vm.$delete
vm.$delete的作用是删除数据中的某个属性,因为Vue2采用Object.defineProperty实现监听,delete关键字删除无法侦测到。
用法
vm.$delete(target , key)
删除对象的属性,如果对象是响应式的,需要确保删除能更新视图。同样的,目标不能是Vue实例或Vue实例的跟数据对象。
实现原理
vm.$delete的实现原理和上述代码类似,删除属性后向依赖发消息。
export function del(target, key) {
if(Array.isArray(target) && isValidArrayIndex(key)) {
target.splice(key , l)
return
}
const ob = (target).__ob__
if(target._isVue || (ob && ob.vmCount)) {
process.env.NODE_ENV !== 'production' && warn(
'Avoid deleting properties on a Vue instance or its root $data' +
'- just set it to null.'
)
return
}
if(!hasOwn(target, key) ) {
return
}
delete target[key]
if(!ob) {
return
}
ob.dep.notify()
}