胖蔡叨叨叨
你听我说

Vue2的变化侦测机制

Vue最引人所乐道的就是其数据双向绑定式模式,以数据驱动视图。通过数据的改变实现视图的实时更新的这一过程其实就是对数据的一个状态的追踪,那么Vue2是通过何种方式实现界面的实时动态变化的呢?本篇文章主要介绍Vue2的这种变化侦测机制的实现原理。

Object的变化侦测

由于JSObjectArray提供的方法机制不同,所以Vue针对ObjectArray,采用了两套不同的变化侦

测机制。本章,我们先来详细介绍一下Object的变化侦测。

Object变化如何侦测

Vue变化侦测机制的关键点在于观测数据变化,那么我们如何观测Object的变化呢?

Vue为我们定义了Observer类。通过Observer类,可以将一个正常的数据转换成可观测的数据。

例如:

let apple = new Observer({
'weight':'1斤',
'price':10
})

这样,apple的两个属性都变得可观测了。

那么, observer 类究竟是怎样的一个存在呢?我们来看看源码:

/**
* Observer类会通过递归的方式把一个对象的所有属性包括子属性都转化成可观测对象
*/
export class Observer {
constructor (value) {
this.value = value
// 给value新增一个__ob__属性,值为该value的Observer实例
// 相当于为value打上标记,表示它已经被转化成响应式了,避免重复操作
def(value,'__ob__',this)
if (Array.isArray(value)) {
// 当value为数组时的逻辑
// ...
} else {
this.walk(value)
}
}
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
}

看到这里,大家可能还是不知道Observer类究竟是如何监测数据变化,实现数据可观测呢?

我们再把上述代码中的defineReactive方法代码放出来,大家就明白了。

/**
* 使一个对象转化成可观测对象
* @param { Object } obj 对象
* @param { String } key 对象的key
* @param { Any } val 对象的某个key的值
*/
function defineReactive (obj,key,val) {
// 如果只传了obj和key,那么val = obj[key]
if (arguments.length === 2) {
val = obj[key]
}
if(typeof val === 'object'){
new Observer(val)
}
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get(){
console.log(`${key}属性被读取了`);
return val;
},
set(newVal){
if(val === newVal){
return
}
console.log(`${key}属性被修改了`);
val = newVal;
}
})
}

defineReactive方法用到了JS一个非常重要的原生方法Object.defineProperty。通过

Object.defineProperty() 方法,可以给数据对象定义一个属性(key),并把这个属性的读和写分别

使用 get() set() 进行拦截,每当该属性进行读或写操作的时候就会触发 get() set() ,从而使得

数据变化可以观测。

谁使用了数据成为依赖

现在,我们知道了数据什么时候发生变化,该去通知视图去更新了。但是,视图那么大,我们到底该通

知谁去更新?总不能一个数据变化了,把整个视图全部更新一遍吧。

理想的情况是:视图里谁用到了这个数据、谁依赖了这个数据,就更新谁!

Vue当然是这么做的。

那么,Vue究竟怎么用代码的形式,来描述这个“谁”呢?请看以下代码:

/**
* @param { vm } vue对象实例
* @param { expOrFn } 对象的属性
* @param { cb } 真正包含数据更新逻辑的函数
*/
export default class Watcher {
constructor (vm,expOrFn,cb) {
this.vm = vm;
this.cb = cb;
this.getter = parsePath(expOrFn) //parsePath方法把一个形如'data.a.b.c'的字符串路
径所表示的值,从真实的data对象中取出来
//初始化的时候触发数据属性的get方法
this.value = this.get()
}
// 触发数据属性的get方法,访问数据属性即可实现
get () {
// 把Watcher实例保存到Dep类的target属性上
Dep.target = this;
const vm = this.vm
let value = this.getter.call(vm, vm)
Dep.target = null;
return value
}
// 当update被触发时,此时获取到的数据属性值时已经被修改后的新值
update () {
const oldValue = this.value
this.value = this.get()
// 触发传递给Watcher的更新数据的函数
this.cb.call(this.vm, this.value, oldValue)
}
}

从上述代码可以知道,在创建 Watcher 实例的过程中会自动的把自己添加到这个数据对应的依赖管理器

中,以后这个 Watcher 实例就代表这个依赖,当数据变化时,我们就通知 Watcher 实例,由 Watcher

实例再去通知真正的依赖。

依赖放在哪里集中管理

一个数据可能被多处使用,产生众多依赖,这些依赖如何管理?

简单的做法是,我们给每个数据都建一个依赖数组,谁依赖了这个数据,我们就把谁放入这个依赖数组

中,那么当这个数据发生变化的时候,我们就去它对应的依赖数组中,把每个依赖都通知一遍。

更好的做法是,我们为每一个数据都建立一个依赖管理器,把这个数据所有的依赖都管理起来。所以,

vue创建了Dep类。

/**
* A dep is an observable that can have multiple
* directives subscribing to it.
*/
export default class Dep {
static target: ?Watcher;
id: number;
subs: Array<Watcher>;
constructor () {
this.subs = []
}
addSub (sub: Watcher) {
this.subs.push(sub)
}
// 删除一个依赖
removeSub (sub: Watcher) {
remove(this.subs, sub)
}
/*依赖收集,当存在Dep.target的时候添加依赖*/
depend () {
if (Dep.target) {
Dep.target.addDep(this) // *Watcher里的addDep方法,添加一个依赖关系到Deps集合中
*/
}
}
/*通知所有依赖更新*/
notify () {
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update() //Watcher里面的调度者接口,当依赖发生改变的时候进行回调
}
}
}
/*依赖收集完需要将Dep.target设为null,防止后面重复添加依赖。*/
Dep.target = null
const targetStack = []
/*将watcher实例设置给Dep.target,用以依赖收集。同时将该实例存入target栈中*/
export function pushTarget (target: ?Watcher) {
targetStack.push(target)
Dep.target = target
}
/*将观察者实例从target栈中取出并设置给Dep.target*/
export function popTarget () {
targetStack.pop()
Dep.target = targetStack[targetStack.length - 1]
}

Object依赖何时收集何时更新

数据变化知道了,依赖是谁也知道了,依赖放哪也明确了,那么何时才能收集依赖,又是何时才能去通

知依赖进行更新:

function defineReactive (obj,key,val) {
if (arguments.length === 2) {
val = obj[key]
}
if(typeof val === 'object'){
new Observer(val)
}
const dep = new Dep() //实例化一个依赖管理器,生成一个依赖管理数组dep
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get(){
dep.depend() // 在getter中收集依赖
return val;
},
set(newVal){
if(val === newVal){
return
}
val = newVal;
dep.notify() // 在setter中通知依赖更新
}
})
}

可观测的数据被获取时会触发 getter 属性,所以我们就可以在 getter 中收集这个依赖。同样,当这个

数据变化时会触发 setter 属性,那么我们就可以在 setter 中通知依赖更新。

关于Object侦测的问题

前面介绍了Object类型数据的变化侦测原理,了解了数据的变化是通过getter/setter来追踪的。

但是,也正是由于这种追踪方式,有些语法中即便是数据发生了变化,Vue也追踪不到。例如:

data() {
return {
obj: {}
}
}
methods: {
addKey() { //在obj上面新增name属性
this.obj.name = 'apple'
}
deleteKey() {//在obj上面删除name属性
delete this.obj.name
}
}

在上面代码中,无论是为obj新增name属性,还是删除name属性,Vue都无法侦测到。

Vue是通过Object.defineProperty来将对象的key转换成getter/setter的形式来追踪变化,但

getter/setter只能追踪一个数据是否被修改,无法追踪新增属性和删除属性,所以才会导致上述问题。

为了解决这一问题,Vue增加了两个全局API:Vue.set和Vue.delete。关于这两个API的实现原理,我们

后续再介绍。

总结

Object通过Observer类将其所有数据包括子数据都转换成getter/setter的形式来追踪变化。

我们在getter中收集依赖,在setter中通知依赖。

收集依赖需要为依赖找一个存储依赖的地方,为此我们创建了Dep,它用来收集依赖、删除依赖和向依

赖发送消息等。

所谓的依赖,其实就是Watcher。只有Watcher触发的getter来会收集依赖,哪个Watcher触发了

getter,就把哪个Watcher收集到Dep中,当数据发生变化时,会循环依赖列表,把所有的watcher都通

知一遍。

数据与Observer、Dep、watcher之间的运转流程如下:

  • 数据 通过 Observer 转换成可侦测的对象。
  • 当外界通过 Watcher 读取数据时,会将 Watcher 添加到 Dep 中
  • 当数据发生变化时,则会向 Dep 中的依赖即 Watcher 发送通知。
  • Watcher 接收到通知后,会向外界发送通知。外界接收到通知后进行相应的更新。

Array的变化侦测

我们知道,Object的变化是靠setter来追踪的,只要一个数据发生变化,就一定会触发setter。那Array

是不是这样呢?

我们先来看下面这个例子:

this.list.push(1)

该例子中,我们使用了push方法向list中新增了数字1,改变了list数组,但并没有触发setter。

也就是说,我们可以通过Array原型上的方法来改变数组的内容,而无需触发setter,所以Object那种通

过getter/setter来实现侦测的方式用在Array身上就行不通了。

为此,Vue中,专门创建了Array变化侦测机制。

虽然Object和Array的变化侦测机制不同,但是在讲述Object变化侦测机制中提到的Observer、Dep、

watcher三个类及其概念,同样适用于Array。

下面我们正式开始Array的变化侦测的介绍。

Array变化如何侦测

前面的例子使用push来改变数组的内容,那么我们只要能在用户使用push操作数组的时候得到通知,

那么就能追踪数据的变化了。来看看下面这段代码:

let arr = [1,2,3]
arr.push(4)
Array.prototype.newPush = function(val){
console.log('arr被修改了')
this.push(val)
}
arr.newPush(4)

我们针对数组的原生push方法定义了一个新的newPush方法,这个newPush方法内部调用了原生push

方法,这样既能保证新的newPush方法跟原生push方法具有相同的功能,而且我们还能得知数组变化。

创建拦截器

基于上述思想,Vue中创建了一个数组方法拦截器,重写了操作数组的方法,并把它拦截在数组实例与

Array.prototype之间。当数组实例使用操作数组方法时,其实使用的是拦截器中重写的方法,而不再使

用Array.prototype上的原生方法。

具体实现代码如下:

const arrayProto = Array.prototype
// 创建一个对象作为拦截器
export const arrayMethods = Object.create(arrayProto)
// 改变数组自身内容的7个方法
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
methodsToPatch.forEach(function (method) {
const original = arrayProto[method] // 缓存原生方法
Object.defineProperty(arrayMethods, method, {
enumerable: false,
configurable: true,
writable: true,
value:function mutator(...args){
const result = original.apply(this, args)
return result
}
})
})

在上面的代码中,我们创建了变量arrayMethods,它继承自Array.prototype,具备其所有功能。

接下来,在arrayMethods上使用Object.defineProperty方法,将那些可以 改变数组自身内容的方法

(push,pop,shift,unshift,splice, sort, reverse)进行封装。

所以,当使用push方法的时候,其实调用的是arrayMethods.push,执行的是mutator函数,而

mutator是可控的,我们可以在里面做一些其他的事情,诸如发送变化通知等。

挂载拦截器

有了拦截器之后,想要它生效,就需要使用它去覆盖Array.prototype。但是我们不能直接覆盖,因为这

样会污染全局的Array,所以我们可以这样操作:

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 {
this.walk(value)
}
}
}
function protoAugment (target, src: Object, keys: any) {
target.__proto__ = src
}
function copyAugment (target: Object, src: Object, keys: Array<string>) {
for (let i = 0, l = keys.length; i < l; i++) {
const key = keys[i]
def(target, key, src[key])
}
}

上面代码中首先判断了浏览器是否支持 __proto__ ,如果支持,则调用protoAugment函数覆盖value

原型功能;如果不支持,则调用copyAugment函数把拦截器中重写的7个方法循环加入到value上。这

样,拦截器就可以生效了。

拦截器生效以后,当数组数据再发生变化时,我们就可以在拦截器中通知变化了。

Array依赖在哪收集

我们创建了拦截器,让我们具备了当数组内容发生变化时得到通知的能力。但是变化了通知谁呢?当然

是用到了Array型数据的那些依赖。那么这些依赖,我们该如何收集呢?

在这之前,我们先简单回顾一下Object的依赖的在哪收集的。

Object的依赖是在Object.defineProperty中的getter里收集的,每个key都会有一个对应的Dep列表来存

储依赖。

那么,数组在哪里收集依赖呢?其实,数组也是在getter中收集依赖的。为什么这么说呢?我们来看看

下面这个例子。

data(){
return {
list:[1,2,3]
}
}

想想看,list这个数据始终都存在于一个object数据对象中,而且我们也说了,谁用到了数据谁就是依

赖,那么要用到list这个数据,是不是得先从object数据对象中获取一下list数据,而从object数据对象中

获取list数据自然就会触发list的getter,所以我们就可以在getter中收集依赖。

总结一句话就是:Array型数据还是在getter中收集依赖。

Array依赖列表放在哪

知道在哪收集依赖了,那么将收集来的的依赖列表放在哪呢?Vue中,把Array的依赖列表放在Observer

中。

export class Observer {
constructor (value) {
this.value = value
this.dep = new Dep() // 实例化一个依赖管理器,用来收集数组依赖
if (Array.isArray(value)) {
const augment = hasProto
? protoAugment
: copyAugment
augment(value, arrayMethods, arrayKeys)
} else {
this.walk(value)
}
}
}

从上面的介绍中,我们知道,Array在getter中收集依赖,在拦截器中通知依赖更新。所以这个依赖保存

的位置就很关键,它必须在getter和拦截器中都可以访问到,而Observer实例正好在getter中、拦截器

中都能访问到。

Array依赖如何收集

把Dep实例保存在Observer的属性上之后,我们可以在getter中像下面这样访问并收集依赖。

function defineReactive (obj,key,val) {
let childOb = observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get(){
//收集依赖
if (childOb) {
childOb.dep.depend()
}
return val;
},
set(newVal){
}
})
}
/**
* 尝试为value创建一个0bserver实例,如果创建成功,直接返回新创建的Observer实例。
* 如果 Value 已经存在一个Observer实例,则直接返回它
*/
export function observe (value, asRootData){
if (!isObject(value) || value instanceof VNode) {
return
}
let ob
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__
} else {
ob = new Observer(value)
}
return ob
}

在上面的代码中,我们通过 observe函数,创建一个Observer实例,如果value已经是响应式数据,则

不需要再次创建Observer实例,直接返回已经创建的Observer实例即可,避免了重复侦测value变化的

问题。

Observer实例childOb创建后,我们就可以访问其dep属性 ,调用该属性上的depend()方法,收集依赖

了。

如何通知Array依赖更新

到现在为止,依赖已经收集好了,并且也已经存放好了,那么我们该如何通知依赖呢?

因为我们是在拦截器中获知数组变化的,所以我们应该在拦截器里通知依赖,而要想通知依赖,首先要

能访问到依赖。

export class Observer实例。 {
constructor (value) {
this.value = value
this.dep = new Dep()
def(value,'__ob__',this) //通过def工具函数,在value中新增一个__ob__属性,这个属性的
值就是当前的Observer实例。
if (Array.isArray(value)) {
const augment = hasProto
? protoAugment
: copyAugment
augment(value, arrayMethods, arrayKeys)
} else {
this.walk(value)
}
}
}

上述代码中,我们通过def工具函数,在value中新增一个 __ob__ 属性,这个属性的值就是当前的

Observer实例。然后我们就可以在拦截器中,通过 __ob__ 属性拿到Observer实例,然后就可以拿到

__ob__ 的dep了,从而调用dep身上的notify()方法通知依赖更新。具体代码如下:

methodsToPatch.forEach(function (method) {
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) {
const result = original.apply(this, args)
const ob = this.__ob__ //拿到Observer实例
ob.dep.notify()//拿到Observer实例的dep属性,调用notify()方法通知依赖更新
return result
})
})

Array的深度监测

我们上面说的侦测数组的变化,指的是数组自身的变化,比如是否新增一个元素,是否删除一个元素

等。

实际上,数组的子数据的变化也要侦测。比如数组中Object身上某个属性的值发生了变化也要发送通

知。再比如,使用了push往数组中新增了元素,这个新增元素的变化也要侦测。

1、侦测数组中元素的变化

如何侦测数组中子数据的变化,我们来看看下面这段代码:

export class Observer {
value: any;
dep: Dep;
constructor (value: any) {
this.value = value
this.dep = new Dep()
def(value, '__ob__', this)
if (Array.isArray(value)) {
const augment = hasProto
? protoAugment
: copyAugment
augment(value, arrayMethods, arrayKeys)
this.observeArray(value) // 将数组中的所有元素都转化为可被侦测的响应式
} else {
this.walk(value)
}
}
// 侦测数组中的每一项
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
}

在上面代码中,我们调用observeArray方法,循环Array中的每一项,执行observe函数,将数组中的每

个元素都执行一遍new Observer,即可将这个数组的所有子数据转换成响应式的。

2、侦测新增元素的变化

对于数组中已有的元素我们已经可以将其全部转化成可侦测的响应式数据了,但是如果向数组里新增一

个元素的话,我们也需要将新增的这个元素转化成可侦测的响应式数据。

如若达到此目的,我们需要拿到新增的这个元素,然后调用observeArray函数将其转化即可。

我们知道,可以向数组内新增元素的方法有3个,分别是:push、unshift、splice。我们只需对这3中方

法分别处理,拿到新增的元素,再将其转化即可。

methodsToPatch.forEach(function (method) {
// cache original 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 // 如果是push或unshift方法,那么传入参数就是新增的元素
break
case 'splice':
inserted = args.slice(2) // 如果是splice方法,那么传入参数列表中下标为2的就是新
增的元素
break
}
if (inserted) ob.observeArray(inserted) // 调用observeArray函数将新增的元素转化成
响应式
// notify change
ob.dep.notify()
return result
})
})

在上述代码中,我们从 this.__ob__ 上拿到Observer实例后,如果有新增元素,则使用

ob.observeArray来侦测这些新元素的变化。

关于Array侦测的问题

从前面的介绍,我们知道,Vue2中对Array的变化侦测是通过拦截原型中操作数组的方法的方式实现

的,但是,其实我们是可以不通过使用数组原型方法来改变数组的。例如:

this.list[0] = 2 // 改变数组的第一个值
this.list.length = 0 // 清空数组

如果使用上述方式改变数组,Vue是侦测不到的。

为了解决这一问题,与Object一样,同样需用到了Vue.set和Vue.delete这两个API。

总结

Array追踪变化的方式和Object不一样。因为它是通过方法来改变内容的,所以我们通过创建拦截器去覆

盖数组原型的方式来追踪变化。

Array收集依赖的方式和Object一样,都是在getter中收集。但是由于使用依赖的位置不同,数组要在拦

截器中向依赖发送消息,所以依赖不能像Object那样保存在defineReactive中,而是把依赖保存在了

Observer实例上。

除了侦测数组自身的变化外,数组中元素发生的变化也要侦测。

除了侦测已有数据外,当使用push等方法向数组中新增数据时,新增的数据也要进行变化侦测。

赞(0) 打赏
转载请附上原文出处链接:胖蔡叨叨叨 » Vue2的变化侦测机制
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏