前言
在上篇文章
Vue的MVVM模式响应式原理之observe、Observer和defineReactive中提到在Observer类实例化的过程中,需要对传入的value做一个类型判断,区分是否为数组,然后调用不同的方法实现所有元素的[响应式]
一、为什么数组需要特殊处理
上文提到,Object.defineProperty()方法无法对数组长度变化做出响应。
let obj = {}
let value = [111,222,333]
Object.defineProperty(obj, 'array', {
enumerable: true,
configurable: true,
get() {
console.log('数组被访问')
return value
},
set(newVal) {
console.log('数组变化了')
value = newVal
}
})
obj.array //'数组被访问' **触发get**
obj.array[2] //'数组被访问' **触发get**
obj.array[2]= 88 // '数组被访问' **触发get** 可是我被修改了,为什么没有触发set ?
obj.array.push(55,66) //'数组被访问' **触发get** 可是我被修改了,为什么没有触发set ?
obj.array.length = 0 //'数组被访问' **触发get** 可是我被修改了,为什么没有触发set ?
(1)准确的说Object.defineProperty()可以对数组索引访问产生[响应式], 但是对通过索引修改值产生不完整的[响应性]。
(2)对长度的变化也无法产生完整的[响应式]。
- 重点看这三行。
- obj.array[2]= 88 // '数组被访问' 触发get 可是我被修改了,为什么没有触发set ?
- obj.array.push(55,66) //'数组被访问' 触发get 可是我被修改了,为什么没有触发set?
- obj.array.length = 0 //'数组被访问' 触发get 可是我被修改了,为什么没有触发set ?
引发的问题是,不知道用户添加或删除了什么,无法及时通知
notify(触发视图更新的入口方法后面讲)。
都可以触发get,所以肯定有办法,但是尤雨溪大人没有硬来很巧妙的就解决了,佩服并向您学习!
(3)需要对数组进行特殊的处理。
这里引出7个会影响数组自身索引值变化和长度变化的数组方法。
const arrayChanges = [
'push', // 在尾部插入 1 个或多个参数,并返回新 length
'pop', // 删除最后一个元素,并返回这个元素
'shift', // 删除第一个元素,并返回这个元素
'unshift', // 在头部插入 1 个或多个参数,并返回新 length
'splice', // 在任意的位置给数组添加或删除任意个元素。
'sort', // 接收一个函数
'reverse' // 反转数组
]
接下来思考一个问题
思考如何实现一个数组的push方法,并且打印’向他学习‘
看下面的这两段代码
const array = []
array.push(99) // [99]
如何即可以push又可以打印呢?
思路1
let list = []
pushPro = Array.prototype.push // 缓存原始方法
list.push = function (ele){
console.log('向他学习') // 某种方式增强
// 还可以一顿操作....
return pushPro.call(this,ele) // 还原原始功能
}
list.push(99) // '它很聪明'
console.log(list) // [99]
这个简单的例子说明,通过在数组身上定义一个同名方法,并且在同名方法中增强它,最终依然调用原始方法并return。有点像寄生式继承
这样就达到了拦截用户调用原始数组方法,从而用了增强过的方法。
思路2
因为我们知道在JS中读取属性会先从自身找,然后顺着__proto__一直找到头是undefined。
因此思路2就是修改数组的原型对象,替换成一个 7个方法被增强的原型对象。
list.__proto__ = 增强的原型对象
或者
Object.setPrototype(list,增强的原型对象)
其实两种思路都是都是从JS读取属性的特性上下手的
源码中两种方式都使用了,因为尤雨溪大人的用户受众很广要考虑兼容性问题。
二、只替换传入的数组的原型对象,而不是全局的。
在哪里得知传入的值value是数组,就在哪里下手。因此这个操作在Observer实例化中执行。
class Observer {
constructor(value) {
def(value, '__ob__', this, false)
if (Array.isArray(value)) {
Object.setPrototypeOf(value, arrayMethods) // 改写传入数组的原型
this.observeArray(value) // 遍历数组为每个数组调用observe
} else {
this.walk(value)
}
}
}
这样就把传入的数组的原型改写了,也就可以感知到数组长度的变化了。接下来就是看看这个被改写的原型arrayMethods具体是什么样实现可以感知数组长度等的变化。
三、数组变异方法的改写
(1)就像如何实现一个数组的push方法,并且打印’向他学习‘中一样。并不复杂。
const arrayPrototype = Array.prototype
const arrayMethods = Object.create(arrayPrototype)
const arrayChanges = [
'push', // 在尾部插入 1 个或多个参数,并返回新 length
'pop', // 删除最后一个元素,并返回这个元素
'shift', // 删除第一个元素,并返回这个元素
'unshift', // 在头部插入 1 个或多个参数,并返回新 length
'splice', // 在任意的位置给数组添加或删除任意个元素。
'sort', // 接收一个函数
'reverse' // 反转数组
]
- (1)首先用一个变量
arrayPrototype缓存原始原型对象,它携带着所有方式方法 - (2)以 arrayPrototype 为原型对象,创建一个新的对象
arrayMethods它就是拦截器 - (3)创建一个数组
arrayChanges并写入7个方法名字符串,用来对7个方法进行遍历 - (4)遍历这个数组会对
拦截器调用7次def函数。
arrayChanges.forEach(methodName => {
const original = arrayPrototype[methodName] // 缓存对应的方法(7个中的一个)
// 在arrayMethods上定义同名方法
def(arrayMethods, methodName, function () {
//⭐执行增强操作......
/**
* @original 最后依然用数组原始方法执行用户操作 ⭐还原了原始功能
* @this :调用方法的数组
* @arguments :开发者传入的参数
* @return :某些数组方法是具有返回值的
* */
return original.apply(this, arguments)
}, false)
})
这样拦截器身上那7个数组方法就被改写了,但是功能依旧还在,接下来我们来看看是如何实现[响应式]
这里的def函数是对Object.defineProperty()方法的简单封装,主要是用于定义访问器属性的。可以回到上篇文章中看下。def
(2)到这里我们不能忘记改写数组原型的目的
之前提到,由于Object.defineProperty()无法对数组长度变化做出完整的[响应性]。Vue不知道增加或者少了什么元素。
有了数组变异方法的改写,就可以知道用户添加和删除了什么。
- ⭐其中最重要的是能够感知长度变化了,因而可以在这里发布通知
notify了。 - 用户删除了什么不重要,只要感知到长度变化了就可以发布通知
notify。(update会重新读值,之后的章节讲) - ⭐其次是
添加了什么,因而可以为添加的元素也成为[响应式]的。
(3) 思考为什么是那7个方法?根据上面3点结论,可以得出什么?
- 答案1:只有那7个方法,会修改数组自身
索引值变化和长度变化。 - 答案2:只要最终关注会
增加数组长度的方法。pushunshiftsplice,也就是只在乎用户添加了什么,因为我们要为添加的元素也添加[响应式]特性。
(4)让我们着重看一下执行增强/变异操作的内容
arrayChanges.forEach(methodName => {
const original = arrayPrototype[methodName]
def(arrayMethods, methodName, function () {
⭐执行增强操作......
/**
* @methodName 我只在乎 push unshift splice 的增加操作
* @inserted 用户插入的值
*/
let inserted
switch(methodName){
case 'push':
case 'unshift':
inserted = [...arguments];
break
case 'splice':
inserted = [...arguments].slice(2)
}
⭐ observeArray(inserted) 让添加的元素成为`[响应式]`的
⭐ notify() // 通知更新,凡执行到这里数组内部顺序或者长度肯定发生变化了
return original.apply(this, arguments)
}, false)
})
源码就是这样呈现的。取得用户添加的元素,并让它[响应化]。但是这里有个地方由于我一直没有说,就是notify是谁的方法?,observeArray到底执行什么?
- 先看
observeArray,它是Observer类的方法。
在--上文--中我说这个方法会为数组的每个元素调用特殊的defineReactive方法,其实就是为每个元素调用observe这个核心入口,这样不管数组里是什么,都会被安排得明明白白。
Observer.prototype.observeArray = function (array) {
for (let i = 0, len = array.length; i < len; i++) {
observe(array[i])
}
}
再搬出这张图就明白了
- 再看
notify,它是Dep类的方法。但也是Observer实例的一个属性dep
由于本文还没有涉及到响应式原理-追踪变化的内容也就是Observer和 Watcher和Dep之间的关系。
就先明白Dep类的实例有notify方法会通知触发视图更新的回调。
它实例化的地方只有2处,Observer类和defineReactive函数调用中
class Observer {
constructor(value) {
def(value, '__ob__', this, false)
⭐this.dep = new Dep()
if () {
...
} else {
...
}
}
walk(value){ ... }
observeArray(value) { ... }
}
- (5)既然知道了Observer的实例中有dep属性,那么还记得每个被响应化后的数据都有个
标识__ob__吧,看上面代码 this.dep之前的操作! 因此完整的代码如下
const arrayPrototype = Array.prototype
const arrayMethods = Object.create(arrayPrototype)
[
'push', // 在尾部插入 1 个或多个参数,并返回新 length
'pop', // 删除最后一个元素,并返回这个元素
'shift', // 删除第一个元素,并返回这个元素
'unshift', // 在头部插入 1 个或多个参数,并返回新 length
'splice', // 在任意的位置给数组添加或删除任意个元素。
'sort', // 接收一个函数
'reverse' // 反转数组
].forEach(methodName => {
const original = arrayPrototype[methodName]
def(arrayMethods, methodName, function () {
执行增强操作......
⭐let ob = this.__ob__ // 取出当前数据的 __ob__属性
let inserted
switch(methodName){
case 'push':
case 'unshift':
inserted = [...arguments]; // 这两个方法只能增加
break
case 'splice':
inserted = [...arguments].slice(2) // 只有3个参数才说明要增加
}
⭐ if(inserted) ob.observeArray(inserted) ; //判空,JS真有趣,`空数组[]是true`呢😄
⭐ ob.dep.notify() // 通知更新,凡执行到这里数组内部顺序或者长度肯定发生变化了
return original.apply(this, arguments)
}, false)
})
export { arrayMethods } ⭐ 导出供数组享用
===================================================================================
class Observer {
constructor(value) {
def(value, '__ob__', this, false)
this.dep = new Dep()
if (Array.isArray(value)) {
Object.setPrototypeOf(value,⭐arrayMethods)
this.observeArray(value)
} else {
this.walk(value)
}
}
walk(value){ ... }
observeArray(value) { ... }
}
至此一张说明[数组的特殊处理之偷梁不换柱]的流程图
在Observer类实例化的过程中,判断value是否是数组,如果是数组就改写它的原型俗称偷梁不换柱。然后为这个数组的每个元素调用observe(),这个链式调用的循环又圆上了!
上篇文章是Vue的MVVM模式响应式原理之observe、Observer和defineReactive
下篇文章打算写Vue的MVVM模式响应式原理——如何追踪变化之Dep、Watcher、Observer。
一步步的揭盖Vue-MVVM模式的面纱!
笔者写作实践不多,如果可以的话,点个赞,或者点个倒赞或评论给我你的意见吧!
本文详细的源码和注释在我的GitHub仓库中。mvvm-webpack-demo