手写 Vue2 源码之实现数组的劫持
前言
Vue
源码一直是前端面试必问的题目,熟悉源码能让我们在面试过程中脱颖而出,熟悉源码也能让我们在开发过程中知道整个框架是如何运转的,遇到了问题,我们能第一时间知道是哪里出了问题。
上一篇文章,实现了 new Vue()
- 手写 Vue2 源码之实现 new Vue,知道了 new Vue()
进行了哪些操作。今天我们继续手写源码: Vue2
中如何实现对数组的劫持。
Object defineProperty 缺陷
虽然 Vue2
的响应式原理核心是使用 Object defineProperty
实现,但是 Object defineProperty
并不是万能的,它存在以下几个问题:
- 无法监听动态属性,即熟悉的新增和删除。
- 无法监听数组长度的变化。
- 无法监听数组索引值的修改。
第一个缺陷, Vue2
是通过 $set
和 $delete
这两个方法实现了,后面我们也会手写他们的源码。第二个缺陷是 api
本身没办法监听数组长度的变化,最后一个缺陷不是 api
的原因,而是尤大认为性能消耗与带来的用户体验不成正比。对数组进行响应式检测会带来很大的性能消耗,因为数组项可能会大,比如10000条、100000条。
所以为了更友好的操作数组并触发响应式检测,Vue2
重写了对数组引起副作用(改变原数组)
的7个方法。
重写数组方法
迭代数组
由于考虑到性能问题,我们在 Observe
构造器里就不能直接调用 walking
方法,需要判断是否为数组,然后对数组类型的数据单独进行处理:
src/observe/index.js:
class Observe {
constructor(data) {
if (Array.isArray(data)) {
this.observeArray(data)
} else {
this.walking(data)
}
}
// 迭代对象
walking(data) {
Object.keys(data).forEach((key) => defineReactive(data, key, data[key])) // 遍历 data 对象属性,依次执行defineReactive方法进行数据劫持
}
// 迭代数组
observeArray(data) {
data.forEach((item) => observe(item))
}
}
由于数组里面的子元素仍然可能是对象类型,所以我们在迭代数组方法 observeArray
中将子元素每一项重新调用 observe
,依次往下劫持属性。
到现在为止,数组的元素新增、删除还是没法将听到的:
下面我们开始重写数组方法。
变异数组
Vue2
规定了7个触发响应式的方法:
- push()
- pop()
- shift()
- unshift()
- splice()
- sort()
- reverse()
这7个方法和数组原型上的方法同名,那为什么这7个方法可以触发响应式,进而更新试图呢?
那是因为他们是变异数组的方法。
什么是变异数组
变异数组
就是在保证数组原有方法不变的情况下,对数组现有方法进行功能扩展
。这里我们对数组方法功能扩展就是给这些方法添加响应式。
例如:有个打印函数,他会打印出'这是原始打印',现有我们有个需求,函数打印之前我们需要打印'这是新增的打印',这个我们应该去怎么实现呢?
我们可以分为以下几步进行操作:
- 定义原函数。
- 将函数赋值给变量。
- 重新定义原函数,并在原函数里面打印'这是新增的打印',然后在调用原函数。
我们结合代码,实现一下:
function print() {// 原始打印函数
console.log('这是原始打印');
}
let newPrint = print // 将原始函数存储在变量newPrint里
print = function () { // 重新定义原函数,添加打印内容,然后调用原函数
console.log('这是新增的打印')
newPrint()
}
print()
这样,我们实现了在保证 print 函数功能不变的前提下,给 print 函数添加了新的功能即功能扩展。
在 Vue2中,也是通过这种思路对数组的7个方法进行功能扩展。
重写数组方法
我们在 src/observe
文件夹下新建 array.js
存放我们实现变异数组的所有逻辑。
我们需要在数组原型上修改7个方法,但是直接修改会干掉原有的方法,我们需要拷贝一份数组原型出来,在复制出来的原型上面做功能扩展,即我们前面说的思路第二点'将函数赋值给变量'。
src/observe/array.js:
// 数组原型,拷贝到arrayProto
const arrayProto = Object.prototype
// 继承原有数组的方法 newArrayProto.__proto__ === arrayProto
export const newArrayProto = Object.create(arrayProto)
然后我们将7个变异数组方法放在数组 methodsToPatch
里:
// 存放7个会改变原数组变异数组方法
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
我们需要在原型 newArrayProto
上面新增7个变异方法,当我们在 newArrayProto
上访问这些方法的时候,我们其实是调用了数组的同名方法。
src/observe/array.js:
// 遍历7个变异数组方法,添加到拷贝出来的原型newArrayProto上
methodsToPatch.forEach((method) => {
newArrayProto[method] = function (...args) {
// 访问newArrayProto上的方法实则是访问的数组原型方法
let result = arrayProto[method].apply(this, args)
console.log('调用方法:' + method)
return result
}
})
定义好变异数组后,我们需要让 data
的隐士原型指向重写的数组原型对象 newArrayProto
上。
src/observe/index.js:
constructor(data) {
if (Array.isArray(data)) {
// data 隐士原型指向重写数组原型对象
data.__proto__ = newArrayProto
this.observeArray(data)
} else {
this.walking(data)
}
}
我们可以在 index.html
里测试下:
let vm = new Vue({
data() {
return {
name: 'ts',
age: 18,
sports: ['basketball', 'football']
}
}
})
vm.sports.push('tennis')
vm.sports.shift()
通过上面的代码,我们可以知道,当我们调用 newArrayProto
对象上 7个变异方法的时候,会做两个事情:
- 调用扩展功能,比如这里的打印'console.log('调用方法:' + method)'。
- 调用数组原生方法。
至此,我们重写了数组原型,在新的数组原型上面添加了数组的变异方法,扩展了功能,对于通过方法对数组进行新增和删除元素我们还需要对这部分进行数据劫持。
对数组新增数据再次劫持
变异方法中,push
、unshift
和 splice
会新增数组元素,我们需要对新增的内容进行响应式处理。
src/observe/array.js:
// 遍历7个变异数组方法,添加到拷贝出来的原型newArrayProto上
methodsToPatch.forEach((method) => {
newArrayProto[method] = function (...args) {
// 访问newArrayProto上的方法实则是访问的数组原型方法
let result = arrayProto[method].apply(this, args)
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
console.log(inserted, 'inserted')
return result
}
})
我们将新增的内容存储在变量 inserted
中,很明显,inserted
可以看出是一个数组:
这样,我们只需要对 inserted
进行观测,就能实现对新增数组元素的劫持了。
我们在 src/observe/index.js
文件里面已经在观察者 Observe
里面定义了 observeArray
方法对数组进行观测,那我们这里只需要调用 Observe
里面的 observeArray
就解决了。那么问题来了,我在 src/observe/array.js
文件里面如何调用 src/observe/index.js
里 Observe
类里的 observeArray
方法呢?
Vue2
源码里用了一个很巧妙的方法解决这个问题:
在 src/observe/array.js
里面,我们调用变异数组方法的是 arr
,和 src/observe/index.js
里面 Observe
里面的 data
是相同的,我们可以通过 data
拿到 this
即 Observe
的实例,将 this
添加到 data
属性上,这样我们在 arr
上面也能拿到 this
了。
src/observe/index.js:
// 将this绑定到data属性__ob__上,方便对新增数组元素进行观测
data.__ob__ = this
src/observe/array.js:
// 响应式处理,获取Observe实例
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
// 对新增的进行观测
if (inserted) ob.observeArray(inserted)
添加好了,我们运行发现 "超出最大调用堆栈大小":
出现这种错误最常见的原因是:在代码中的某个地方,您正在调用一个函数,该函数又调用另一个函数,依此类推,直到达到调用堆栈限制。这几乎总是因为具有未满足的基本情况的递归函数,即死递归。
出现死递归的原因是因为我们对对象添加 __ob__
属性,然后对对象属性进行遍历,__ob__
属性上又有 this
,this
上面又有 walking
、observeArray
这些方法,然后又要执行这些方法,继续遍历属性,形成死递归
。
解决这个问题的方法就是将 __ob__
属性变为不可枚举
的:
Object.defineProperty(data, '__ob__', {
value: this,
enumerable: false
})
这样,完整的数组劫持代码就写完了,最后贴一下 src/observe/array.js
和 src/observe/index.js
代码:
src/observe/array.js:
// 数组原型,拷贝到arrayProto
const arrayProto = Array.prototype
// 继承原有数组的方法 newArrayProto.__proto__ === arrayProto
export let newArrayProto = Object.create(arrayProto)
// 存放7个会改变原数组变异数组方法
const methodsToPatch = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
// 遍历7个变异数组方法,添加到拷贝出来的原型newArrayProto上
methodsToPatch.forEach((method) => {
newArrayProto[method] = function (...args) {
// 访问newArrayProto上的方法实则是访问的数组原型方法
let result = arrayProto[method].apply(this, args)
// 响应式处理,获取Observe实例
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
// 对新增的进行观测
if (inserted) ob.observeArray(inserted)
console.log(inserted, 'inserted')
return result
}
})
src/observe/index.js:
import { newArrayProto } from './array'
export function observe(data) {
if (typeof data !== 'object' && data !== 'null') return // data不是对象就不用劫持
// data.__ob__ instanceof Observe 为true表示data.__ob__为Observe实例,即已经被观测过了,不需要再观测了
if (data.__ob__ instanceof Observe) {
return data.__ob__
}
return new Observe(data)
}
// 观察者
class Observe {
constructor(data) {
// 将this绑定到data属性__ob__上,方便对新增数组元素进行观测,同时给数据加了标识,表示数据已经被观测过了
Object.defineProperty(data, '__ob__', {
value: this,
enumerable: false
})
if (Array.isArray(data)) {
// data 隐士原型指向重写数组原型对象
data.__proto__ = newArrayProto
this.observeArray(data)
} else {
this.walking(data)
}
}
// 迭代对象
walking(data) {
Object.keys(data).forEach((key) => defineReactive(data, key, data[key])) // 遍历 data 对象属性,依次执行defineReactive方法进行数据劫持
}
// 迭代数组
observeArray(data) {
data.forEach((item) => observe(item))
}
}
/**
* defineReactive 通过Object.defineProperty api 对数据进行数据劫持
* @param target 目标数据对象
* @param key 属性
* @param value 值
*/
export function defineReactive(target, key, value) {
observe(value) // 深层次对象递归
Object.defineProperty(target, key, {
get() {
console.log('访问属性' + key)
// 访问属性时候执行
return value
},
set(newValue) {
console.log('修改属性' + key)
// 修改属性值时候执行
if (value === newValue) return // 新值和旧值相等就不用赋值
observe(newValue) // 新值仍然可能是引用类型,需要继续观测
value = newValue // 新值赋值给属性
}
})
}
总结
数组劫持核心就是重写数组的方法
然后重新观测数组
里的元素。
转载自:https://juejin.cn/post/7165750483783516168