vue 数据变化侦测原理详解(Array篇)
上周写了数据劫持原理Object篇,还欠大家一个Array篇,趁今天周末,补给大家吧。(最近实在是有点忙,补的有点忙,见谅见谅)前言:上周我们说了vue数据劫持原理Object篇,我们先来回顾下。Object数据侦测分为3个部分,依赖者wathcer(和实际依赖者一对一关系),依赖的管理着Dep(也可以叫发布者),以及用于劫持数据的Observer。数据被劫持后,当获取数据时会触发get,而数据发
上周写了数据劫持原理Object篇,还欠大家一个Array篇,趁今天周末,补给大家吧。(最近实在是有点忙,补的有点晚,见谅见谅)
前言:上周我们说了vue数据劫持原理Object篇,我们先来回顾下。
- Object数据侦测分为3个部分,依赖者wathcer(和实际依赖者一对一关系),依赖的管理着Dep(也可以叫发布者),以及用于劫持数据的Observer。
- 数据被劫持后,当获取数据时会触发get,而数据发生变化时会触发set。
- 我们在get的时候收集依赖,即用dep去调用他的addSubs方法去添加watcher
- 在set的时候去通知wather,即用dep去调用他的notify方法,notify中去循环遍历dep的subs数组,然后取到每一项(即每一个wathcer),让每一个watcher去调用自己的update方法去告知数据更新
大致过程是这样。那么针对Array的数据监测过程,会有什么不同,我们下面来一起看一看
1、Array篇的总体思路分析
首先,我们需要了解一些Array和Object的数据的区别
1、相同点
收集依赖相同
假设有个数组是 data.list = [1,2,3]。有个属性值data.name=“zhangsan”。一个是数组,一个是对象属性值,他们在template模板中或者在vm.$watch()中的使用是不是一样的啊。
比如,v-text = data.name v-text = data.list[0] 。所以他一样的是一个使用的地方对应了一个watcher。然后我们只需要在收集的依赖的时候去用dep.addSubs(watcher)就行了。这点和object的属性监测是没有区别的
依赖通知相关方式相同
很显然,上面说了,依赖者依然是放在dep的subs数组中,那么通知依赖更新方式显然还是调用dep的notify方法去通知更新。dep.notify()
2、不同点
数组更新方式不同
Object,数据发生变化一般是直接通过obj.aaa = 123这种方式重新给他赋了一个新值。这样我们直接通过Object.defineProperty的set就能够知道数据的更新。
而数组的变化,是不可能通过arr.xx = yy这种形式的,那数组一般是怎么变化的啊。是不是一般调用一些数组方法,使数组发生改变啊。一般能改变数组的方法有:push、pop、shift、unshift、spice、sort、reverse。
通知更新的地点不同
上面我们说过了,对象的属性直接通过obj.aaa = 123的方式进行数据更新,那么必然是会触发set方法的,从而我们可以在set中去通知依赖更新
而数组是主要通过一些数组方法去进行数据更新,很显然是不会触发set方法的,所以数组是无法在set中是通知数据更新的。那数组究竟是怎么通知的。下面我们来进入正题
2、Array的拦截器
首先,要通知数据更新,我们得先能够知道数据发生变化了,而数据发生变化是调用了数组得某些方法。那么很显然,在那些被调用得方法内部,我们是可以知道数据发生了变化得。我们可以在那些方法内部去发送更新通知
但是,问题来了,那些方法都是Array得原型上得方法,我们怎么去在那些方法内部是发送通知呢,很显然是做不到得。
这个时候,vue是怎么做呢。
vue实际上是定义了一个拦截器,而这个拦截器就是个和Array的原型对象(Array.prototype)一模一样的对象。这个对象内存在着和Array原型对象中所有方法(所以肯定包括上面7个可以改变数组的方法)一模一样的方法。但是这个对象是我们自己定义的,我们是不是可以重写这些方法的逻辑啊。
拦截器原理分析:
- 当我们调用一个数组方法时,arr.push(123),实际上是调用了Array.prototype中的push方法。原因是因为arr存在__proto__属性,而arr.__proto__的值就是Array.prototype,所以arr可以使用Array.prototype中得方法。如果不懂得,请自己去看看js原型和原型链吧。
- 那如果我们可以让arr.__proto__得值指向一个其他得对象(假设这个对象是arrayMethodObj),那么,arr是不是就可以使用arrayMethodObj中得方法啊。那如果此时arrayMethodObj中刚好也存在一个push方法,那你想想,当arr.push(123)时,会调用哪个push啊。呵呵,看懂了得就应该知道,很显然是会调用arrayMethodObj中得push方法,原因就是,arr.__prroto__现在指向得是arrayMethodObj了,而不再是Array.prototype了。那这个时候,我们是不是就实现了一个很好得拦截啊。
- 那有得人可能会说了,那我们自己定义的push方法如何去实现push的功能呢。很简单,我们在自己定义的push方法中再return Array原型中的push方法的调用不就好了吗?
我们看两张图,第一张,添加拦截器之前的原型图
第二张,添加拦截器之后的原型图
应该很明了吧。我们将arr.__proto__修改成指向拦截器,那么我们调用数组方法时,比如push,就会调用拦截器中的push方法。在这个push方法内,我们就可以做自己想做的事,比如去发送更新通知。然后再这个push方法内,再返回Array的原型中push方法的调用,达到最终添加数据的功能。
好了。原理了解清楚了,我们再来看代码,就更容易看懂了
// 定义那些会改变数组的方法集合
const methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
// 复制一个Array.prototype对象
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto) // 这个是我们自己定义的原型对象
// 循环arr,重写arr中的相关方法
methods.forEach((method) => {
// 先缓存原始方法
const original = arrayProto[method]
// 在新的原型对象中重写自己的方法
Object.defineProperty(arrayMethods, method, {
value: function myMethod (...args) {
// 这里可以写自己逻辑,如果发送更新通知,我们先保留这一块,后面再完善
// 返回原始方法的调用
return original.apply(this, args)
},
enumerable: false,
writable: true,
configurable: true
})
})
// 将需要被劫持的数组的.__proto__属性指向我们自己定义的原型对象arrayMethods
export default class Observer {
// 参数就是需要被劫持(侦测)的数据。首次进来参数肯定是vue的data对象无疑
constructor (data) {
this.value = data
if (Array.isArray(this.value)) {
// 我们这次新增的代码,改变原型指向
this.value.__proto__ = arrayMethods
} else {
// 如果不是数组,必然就是对象
this.walk(this.value) // vue内部用来递归劫持数据的方法
}
}
walk (obj) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i], obj[keys[i]])
}
}
}
代码写完了,这样一来,上面的this.value当去调用某些方法去改变数组时,比如push(123),就会调用arrayMethods中的push方法,而这个push方法就是我们上面定义的方法
但是现在会有个问题,.__proto__并不是所有浏览器都支持的,如果不支持的情况下怎么办。很简单,如果不知道,我们就直接把相关方法写入到该数组中去。因为,数组调用某个方法时,是优先调用自己的方法,自己的方法找不到,才会去原型对象中找。
请看代码
// 判断是否支持.__proto__属性
const hasProto = '__proto__' in {}
// 获取原型对象下所有方法名的集合
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)
/**
* @description 定义数组自己的方法
* @Params obj: 需要劫持的数组对象,key:需要设置的属性名,如push。 val: obj.push的值,是一个function
* @Author chen
*/
function def (obj, key, val) {
Object.defineProperty(obj, key, {
value: val,
enumerable: false,
writable: true,
configurable: true
})
}
/**
* @description 浏览器支持__proto__属性时的执行函数
* @Params target: 需要劫持的数组, src: 复制的原型对象arrayMethods keys: arrayMethods中的方法名集合
* @Author chen
*/
function protoAugment (target, src, keys) {
target.__proto__ = src
}
/**
* @description 浏览器不支持__proto__属性时的执行函数
* @Params target: 需要劫持的数组, src: 复制的原型对象arrayMethods keys: arrayMethods中的方法名集合
* @Author chen
*/
function copyAugment (target, src, keys) {
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
def(target, key, src[key])
}
}
// 将需要被劫持的数组的.__proto__属性指向我们自己定义的原型对象arrayMethods
export default class Observer {
// 参数就是需要被劫持(侦测)的数据。首次进来参数肯定是vue的data对象无疑
constructor (data) {
this.value = data
if (Array.isArray(this.value)) {
// 更改的地方
const runFun = hasProto ? protoAugment : copyAugment
// 执行具体函数,目的反正都是重写数组方法
runFun(value, arrayMethods, arrayKeys)
} else {
// 如果不是数组,必然就是对象
this.walk(this.value) // vue内部用来递归劫持数据的方法
}
}
walk (obj) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i], obj[keys[i]])
}
}
}
好了,到这一步,我们的拦截器已经做好了。下面我们还有什么没做啊,是不是收集依赖和通知依赖更新这两点还没做啊
3、收集依赖和通知依赖更新
我们先来分析一下:
- 收集依赖,我们之前说了,和Object没啥区别,在get中通过dep.addSubs()去收集依赖。
- 但是通知依赖更新,和Object不同,通知更新是不是得到能立马知道数据更新了得地方去发送通知啊。Object是在set中立马知道数据更新了,那Array呢。经过上面我们写得拦截器,是不是可以知道,在拦截器中,我们自己定义得那些方法里面可以立马知道数据更新了啊。因为数组要改变时,必须得调那些方法。而一旦调用,我们是不是就知道数据更新了啊。
也就是说,dep去发送更新通知得在这个函数里面去发送,那么问题来了,我们这个函数是在要劫持得数组调用某个数组方法时才会执行得,那这里怎么拿得到dep实例呢。
我们之前说过,dep发布者和数据是一对一得关系,那么,我们每劫持一个数据得时候去创建一个dep实例肯定是没有错得。而每劫持一个数据,都会去调用new Observer()。那么我们很显然,是在Observer中去new Dep()。这样一来,这个dep实例肯定是可以在get中取到得(get中需要dep去收集依赖,所以get中必须能取到dep实例),但是,在上面那个函数中,dep我们就取不到了。那怎样才能把dep传过去呢。
思路:
- 上面图中那个函数是啥,是我们数组所需要调用得函数,那么在这个函数中,this是不是指向调用这个函数得数组啊。this ===> arr,也就是说,我们在这里可以拿到这个数组。那么我们是不是可以通过用这个数组和dep之间建立某种关系来解决dep传递得问题呢。
- 上面说过,dep在new Observe得时候去创建,那么我们是不是可以把dep挂载new 出来得observer实例上。此时,我们再把这个oberver实例挂载到需要劫持得下面。那是不是就在get和数组方法中都可以拿到啦。下面请看代码
// 将需要被劫持的数组的.__proto__属性指向我们自己定义的原型对象arrayMethods
export default class Observer {
// 参数就是需要被劫持(侦测)的数据。首次进来参数肯定是vue的data对象无疑
constructor (data) {
this.value = data
// 新增dep
this.dep = new Dep()
// 此函数得作用是在data(需要劫持得数组)下新增一个__ob__属性, 属性值是this(当前this就是new Observer出来得oberver实例)。同时在data下新增一个这样得属性,还可以作为当前数据是否已经是响应式数据得一个标识,如果是响应式数据,那么肯定就会有这个属性
def(data, '__ob__', this)
if (Array.isArray(this.value)) {
// 更改的地方
const runFun = hasProto ? protoAugment : copyAugment
// 执行具体函数,目的反正都是重写数组方法
runFun(value, arrayMethods, arrayKeys)
} else {
// 如果不是数组,必然就是对象
this.walk(this.value) // vue内部用来递归劫持数据的方法
}
}
walk (obj) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i], obj[keys[i]])
}
}
}
/**
* @description 用于返回当前observer实例,如果当前val存在__ob__属性,说明是响应式数 据,直接返回val的__ob__的值
* 否则说明是非响应式数据,也就是还没有被劫持过。那么直接返回new Observer()
* @Author chen
*/
function observe (val) {
let ob = ''
if (hasOwn(value, '__ob__') && value.__ob__ instanceof of Observer) {
ob = value.__ob__
} else {
ob = new Observer()
}
return ob
}
function defineReactive(data, key, val) {
// 新增判断当前属性值是否是对象,如果是对象,继续递归劫持
/*
** 这一段去掉,换成分别判断对象和数组
if (typeof val === 'object') {
new Obsever(val)
}*/
if (val.toString() === '[Object Array]') {
let childObserver = observe(val) // 获取observer实例
} else if (val.toString() === '[Object Object]') {
new Observer(val)
}
let dep = new Dep()
Object.defineProperty(data, key, function() {
enumerable: true, // 该属性是否可被枚举
configurable: true, // 该属性的配置后续是否可进行更改
get: function () { // 用于获取属性值
// 收集依赖
if (window.target) {
dep.addSub(window.target)
// 新增数组的依赖
if (childObserver) {
childObserver.dep.addSub(window.target)
}
}
return val
},
set: function (newVal) { // 设置属性值
if (newVal === val) return
// 用新值替换旧值
val = newVal
// 数据发生变化时,通知依赖者
dep.notify()
}
})
}
此时,需要被劫持的数组,observer实例, dep之间的关系就是
arr.ob = observer实例
observer.dep = dep 实例(new Dep())
此时,我们在get中可以通过observer拿到dep实例,然后添加依赖,如下
同时,我们在数据方法中,也可以通过value.ob.dep 去拿到dep实例,从而发送更新通知
如下:
// 循环arr,重写arr中的相关方法
methods.forEach((method) => {
// 先缓存原始方法
const original = arrayProto[method]
// 在新的原型对象中重写自己的方法
Object.defineProperty(arrayMethods, method, {
value: function myMethod (...args) {
// 这里可以发送更新通知
this.__ob__.dep.notify()
// 返回原始方法的调用结果
return original.apply(this, args)
},
enumerable: false,
writable: true,
configurable: true
})
})
此时,基本逻辑都已经实现了。我们先看一下完整代码,然后,再通过一个例子,来走一遍实现逻辑
// 判断是否支持.__proto__属性
const hasProto = '__proto__' in {}
// 获取原型对象下所有方法名的集合
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)
/**
* @description 定义数组自己的方法
* @Params obj: 需要劫持的数组对象,key:需要设置的属性名,如push。 val: obj.push的值,是一个function
* @Author chen
*/
function def (obj, key, val) {
Object.defineProperty(obj, key, {
value: val,
enumerable: false,
writable: true,
configurable: true
})
}
/**
* @description 浏览器支持__proto__属性时的执行函数
* @Params target: 需要劫持的数组, src: 复制的原型对象arrayMethods keys: arrayMethods中的方法名集合
* @Author chen
*/
function protoAugment (target, src, keys) {
target.__proto__ = src
}
/**
* @description 浏览器不支持__proto__属性时的执行函数
* @Params target: 需要劫持的数组, src: 复制的原型对象arrayMethods keys: arrayMethods中的方法名集合
* @Author chen
*/
function copyAugment (target, src, keys) {
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
def(target, key, src[key])
}
}
/**
* @description 用于返回当前observer实例,如果当前val存在__ob__属性,说明是响应式数 据,直接返回val的__ob__的值
* 否则说明是非响应式数据,也就是还没有被劫持过。那么直接返回new Observer()
* @Author chen
*/
function observe (val) {
let ob = ''
if (hasOwn(value, '__ob__') && value.__ob__ instanceof of Observer) {
ob = value.__ob__
} else {
ob = new Observer()
}
return ob
}
// 循环arr,重写arr中的相关方法
methods.forEach((method) => {
// 先缓存原始方法
const original = arrayProto[method]
// 在新的原型对象中重写自己的方法
Object.defineProperty(arrayMethods, method, {
value: function myMethod (...args) {
// 这里可以发送更新通知
this.__ob__.dep.notify()
// 返回原始方法的调用结果
return original.apply(this, args)
},
enumerable: false,
writable: true,
configurable: true
})
})
// 将需要被劫持的数组的.__proto__属性指向我们自己定义的原型对象arrayMethods
export default class Observer {
// 参数就是需要被劫持(侦测)的数据。首次进来参数肯定是vue的data对象无疑
constructor (data) {
this.value = data
// 新增dep
this.dep = new Dep()
// 此函数得作用是在data(需要劫持得数组)下新增一个__ob__属性, 属性值是this(当前this就是new Observer出来得oberver实例)。同时在data下新增一个这样得属性,还可以作为当前数据是否已经是响应式数据得一个标识,如果是响应式数据,那么肯定就会有这个属性
def(data, '__ob__', this)
if (Array.isArray(this.value)) {
// 更改的地方
const runFun = hasProto ? protoAugment : copyAugment
// 执行具体函数,目的反正都是重写数组方法
runFun(value, arrayMethods, arrayKeys)
} else {
// 如果不是数组,必然就是对象
this.walk(this.value) // vue内部用来递归劫持数据的方法
}
}
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 Obsever(val)
}*/
if (val.toString() === '[Object Array]') {
let childObserver = observe(val) // 获取observer实例
} else if (val.toString() === '[Object Object]') {
new Observer(val)
}
let dep = new Dep()
Object.defineProperty(data, key, function() {
enumerable: true, // 该属性是否可被枚举
configurable: true, // 该属性的配置后续是否可进行更改
get: function () { // 用于获取属性值
// 收集依赖
if (window.target) {
dep.addSub(window.target)
// 新增数组的依赖
if (childObserver) {
childObserver.dep.addSub(window.target)
}
}
return val
},
set: function (newVal) { // 设置属性值
if (newVal === val) return
// 用新值替换旧值
val = newVal
// 数据发生变化时,通知依赖者
dep.notify()
}
})
}
以上就是完整代码。至于Dep和Watch模块的代码,和Object篇中的是一样的,我就不贴出来了。
现在,我们来用一个例子,完整走一遍逻辑。
如vue data下,存在一个属性list,属性值是[1, 2, 3]
data: { list: [1, 2, 3]}
1、 new Observer(data),第一次进来,传递的是data。判断是对象,执行this.walk()
2、循环data中的各个属性,依次对每个属性执行defineReactive()
3、当属性名为list时,defineReactive内部执行,对list属性进行数据劫持,同时判断data.list的值是对象还是数组,如果是数组,执行observe()方法,去对这个属性的值(即数组)也进行劫持(即继续new Observer())
4、此时,Observer的构造器又被触发,只是此时传进来的参数不再是data对象,而是这个数组(list属性的值)
5、此时,在这个数组的obsever实例下新new了一个dep,并挂载observer实例下。
同时,在这个数组上新增一个__ob__属性,属性值就是这个observer实例
然后,判断值是数组,执行runFun。将数组的.__proto__指向复制出来的原型对象或者在这个数组下新增相关数组方法。
6、Observer构造器执行完后,跳出,回到原来的地方
7、此时,继续往下,给list属性添加get,和set。并在get中为这个数组新增依赖。
此时,劫持过程走完
8、当这个数组调用相关方法修改数组时,如,this.list.push(4)。那么会触发我们自己定义的push方法,发送更新通知。
整体工作流程就是这样。不知道大家看懂没有。如果没看懂,请再看一遍,哈哈
4、劫持所有子级
但是,此时,如果数组中某项也是数组或者数组中某一项是对象,那么我们是不是没有处理啊。此时,也很简单,直接上代码
// 侦测数组的每一项
function observeArray (items) {
for (let item of items) {
observe(item)
}
}
/**
* @description 用于返回当前observer实例,如果当前val存在__ob__属性,说明是响应式数据,直接返回val的__ob__的值
* 否则说明是非响应式数据,也就是还没有被劫持过。那么直接返回new Observer()
* @Author chen
*/
function observe (val) {
let ob = ''
if (hasOwn(value, '__ob__') && value.__ob__ instanceof of Observer) {
ob = value.__ob__
} else {
ob = new Observer()
}
return ob
}
// 将需要被劫持的数组的.__proto__属性指向我们自己定义的原型对象arrayMethods
export default class Observer {
// 参数就是需要被劫持(侦测)的数据。首次进来参数肯定是vue的data对象无疑
constructor (data) {
this.value = data
// 新增dep
this.dep = new Dep()
// 此函数得作用是在data(需要劫持得数组)下新增一个__ob__属性, 属性值是this(当前this就是new Observer出来得oberver实例)。同时在data下新增一个这样得属性,还可以作为当前数据是否已经是响应式数据得一个标识,如果是响应式数据,那么肯定就会有这个属性
def(data, '__ob__', this)
if (Array.isArray(this.value)) {
// 对每一项都执行new Observer()
this.observeArray(this.value)
// 更改的地方
const runFun = hasProto ? protoAugment : copyAugment
// 执行具体函数,目的反正都是重写数组方法
runFun(value, arrayMethods, arrayKeys)
} else {
// 如果不是数组,必然就是对象
this.walk(this.value) // vue内部用来递归劫持数据的方法
}
}
现在看似完美了,其实还有问题。你想一下,如果我们数组新增了一个元素,假如新增的是个简单类型,如数组第4项新增了数字4(arr.push(4))。我们能知道变化,并更新,然后我将第四项值更改为5 arr.splice(3, 1, 5)。我们也能发现变化。
但是如果我们第四项新增的是个对象呢。那我们是不是并没有对这个新增的对象去做数据劫持啊,那以后如果这个对象的属性值发生变化,我们是不是还是无法更新啊。所以我们还需要对数组新增的值进行数据劫持
5、使用Observer侦测新增元素
分析:
- 能新增元素的方法就3个,push,unshift, splice。我们是不是可以针对这3个方法,专门取到被新增的那个值啊
- 如果是push,unshift。那新增的值是不是就是这两个方法的参数啊
- 如果是splice。如 arr.splice(3, 0, 5)。意思是不是在第四项新增元素5啊。那我们是不是可以通过取args参数集合的第三个值来得到新增的这个元素啊。
好了。思路清楚了。我们继续来实现。请看代码
// 在新的原型对象中重写自己的方法
Object.defineProperty(arrayMethods, method, {
value: function myMethod (...args) {
let inserted // 用来存放新增的元素
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
default:
break
}
if (inserted) {
// this.__ob__获取到当前observer实例,然后用这个实例调用observerArray方法,对这个对象以及他的子级进行数据劫持
this.__ob__.observeArray(inserted)
}
// 这里可以发送更新通知
this.__ob__.dep.notify()
// 返回原始方法的调用结果
return original.apply(this, args)
},
enumerable: false,
writable: true,
configurable: true
})
})
到这一步,我们这个代码就算完成写完了。
6、总结
Array的数据拦截和Object的一样,也是存在缺陷的。从上面我们用的方法就能发现,Array是通过定义自己的数组方法去覆盖数组原型方法的方式去侦测数据的。那前提肯定就是数组调用这些方法去改变自己的时候,我们才能侦测到。
所以:
当数组不同这些方法,而是通过其他方法去更改数组时,我们依然无法得知。比如
arr[1] = 3 这样去修改,并没有调用数组方法,我们就无法得知数据发生了变化。
或者arr.length = 0去清空了数组,我们同样也是无法得知的。
所以这个问题,还是要到vue3中通过proxy去解决了。
好了,vue数据监测Array篇就说到这了。如果对你有帮助,欢迎关注一波。
更多推荐
所有评论(0)