前言
最近在看 Vue3 源码,发现在响应式对象调用数组方法时,Vue 做了特殊的处理,回想起之前在原生 Proxy 调用push 方法时输出的异常——读写操作各拦截了两次
然后又调用了一些其他方法,尝试根据代理输出的结果,理解并实现了数组方法的底层逻辑
代理数组
就是用 Proxy 代理数组,监听其读写操作,不懂 Proxy 上 MDN 学习
监听数组的代码如下
const arr = [1, 2, 3, 4]
const p = new Proxy(arr, {
get(target, key) {
console.log(`get ${key}`)
return target[key]
},
set(target, key, value) {
console.log(`set ${String(key)} ${value}`)
target[key] = value
return true
},
})
数组的方法
接下来我们在代理身上调用数组方法,探究并实现其底层原理
push
先从最常见的 push 方法开始
console.log('res:', p.push(5))
// 输出如下
// get push
// get length
// set 4 5
// set length 5
// res: 5
先获取数组的 push 方法调用,然后读取了 length 属性,之后赋值,设置 length,最后返回新的数组长度
我们知道调用push方法时也可以一次传入多个参数,结果如下,多了一次赋值
console.log('res:', p.push(5, 6))
// 输出如下
// get push
// get length
// set 4 5
// set 5 6
// set length 6
// res: 6
push 的作用不难理解,实现起来也很简单
function push(arr, ...args) {
const arrLength = arr.length // 读取数组长度
const argLength = args.length // 记录参数长度
for (let i = 0; i < argLength; i++) {
arr[arrLength + i] = args[i] // 遍历赋值
}
arr.length = arrLength + argLength // 设置新长度
return arrLength + argLength // 返回新长度
}
因为我们是定义函数实现的,相比之前的输出少了数组方法的读取 'get push'
,之后的代码实现中不再赘述
pop
聊完 push 紧跟的肯定就是 pop
console.log('res:', p.pop())
// 输出如下
// get pop
// get length
// get 3
// set length 3
// res: 4
pop 的流程也很简单,读 length,根据长度读末尾元素,然后通过设置长度实现元素的删除,最后返回删除的末尾元素
上代码实现
function pop(arr) {
const arrLength = arr.length
let res // 定义结果
if (arrLength > 0) {
res = arr[arrLength - 1] // 读末尾元素
}
arr.length = Math.max(0, arrLength - 1) // 删除元素 长度最小为0
return res
}
需要注意的是,必须要判断长度大于 0 才可以为结果赋值,因为有时数组会存在 '-1'
这个属性
shift
然后是头部删除 shift,这个方法会导致所有元素前移
console.log('res:', p.shift())
// 输出如下
// get shift
// get length
// get 0
// get 1
// set 0 2
// get 2
// set 1 3
// get 3
// set 2 4
// set length 3
// res: 1
实现也很简单,从前往后依次赋值,然后设置长度删除元素
function shift(arr) {
const arrLength = arr.length
let res
if (arrLength > 0) {
res = arr[0]
for (let i = 0; i < arrLength - 1; i++) {
arr[i] = arr[i + 1] // 从前往后依次赋值
}
}
arr.length = Math.max(0, arrLength - 1)
return res
}
因为原生数组每次 shift 会将都会将所有元素赋值一遍,直接当作队列使用性能并不好,在这里安利一篇[实现 JS
队列的文章](https://segmentfault.com/a/11...)
unshift
头部删除之后是头部插入,这个方法会导致所有元素后移
console.log('res:', p.unshift(-1, 0))
// 输出如下
// get unshift
// get length
// get 3
// set 5 4
// get 2
// set 4 3
// get 1
// set 3 2
// get 0
// set 2 1
// set 0 -1
// set 1 0
// set length 6
// res: 6
从输出的结果可以看出,所有元素后移,从后往前依次赋值,然后再设置新元素
function unshift(arr, ...args) {
const arrLength = arr.length
const argLength = args.length
for (let i = arrLength + argLength - 1; i >= argLength; i--) {
arr[i] = args[i - argLength] // 从后往前依次赋值
}
for (let i = 0; i < argLength; i++) {
arr[i] = args[i] // 从前往后设置新元素
}
arr.length = arrLength + argLength
return arrLength + argLength
}
splice
splice 是最复杂的一个方法了,它分很多种情况,让我们一点点实现
处理参数
splice 方法的参数分 3 部分,起始位置,删除元素数目,添加的元素
array.splice(start[, deleteCount[, item1[, item2[, ...]]]] )
搭个简单框架
function splice(arr, start, deleteCount, ...args) {}
先处理起始位置,他可能为负数,表示从数组末位开始的第几位
if (start < 0) {
start = arrLength + start
}
并且还要限制起始位置在数组范围内
if (start < 0) {
start = Math.max(0, arrLength + start)
} else {
start = Math.min(start, arrLength)
}
然后是删除元素数目,抛去起始位置之前的元素后,不能比剩余元素还多
而且实际删除元素的数目,也就是函数返回数组的长度
const resLength = Math.min(deleteCount, arrLength - start)
删除数目与新值数目相等
处理完参数,咱先分析最简单的,删除数目与新值数目相等的情况
看看代理输出的结果
console.log('res:', p.splice(1, 2, ...[5, 6]))
// get splice
// get length
// get constructor
// get 1
// get 2
// set 1 5
// set 2 6
// set length 4
// res: [ 2, 3 ]
console.log('arr:', p)
// arr: [ 1, 5, 6, 4 ]
发现读取了一个特殊的属性,构造器 constructor
考虑到 splice 返回的也是一个数组,莫非是调用构造器创建的?
定义一个新类测试一下,发现 splice 返回的类型与调用函数对象的类型相同
class MyArray extends Array {}
const myArr = new MyArray()
const res = myArr.splice()
console.log(res instanceof MyArray) // true
所以在我们的代码中,也调用一下构造器来创建结果
const res = arr.constructor(resLength) // 也可以不初始化数组长度
创建数组之后,读取要删除的元素,赋值给结果数组
for (let i = 0; i < resLength; i++) {
res[i] = arr[start + i]
}
然后用新增元素,覆盖原来的数据
for (let i = 0; i < argLength; i++) {
arr[start + i] = args[i]
}
设置一下长度,返回结果
arr.length = arrLength - resLength + argLength
return res
新增元素比删除元素多
接下来分析新增元素比删除元素多的情况
console.log('res:', p.splice(1, 1, ...[5, 6]))
// get splice
// get length
// get constructor
// get 1 构建要返回的数组
// get 3
// set 4 4
// get 2
// set 3 3 剩余元素后移
// set 1 5
// set 2 6 设置新增的元素
// set length 5
// res: [ 2 ]
console.log('arr:', p)
// arr: [ 1, 5, 6, 3, 4 ]
还是先构建要返回的数组,然后将删除元素之后的元素后移,后移位数为新增数目与删除数目之差:argLength - resLength
需要注意的是,要从后向前处理,所以循环是从数组末尾到起始位置加删除数目
for (let i = arrLength - 1; i >= start + resLength; i--) {
arr[i + argLength - resLength] = arr[i]
}
然后赋值新增元素……
for (let i = 0; i < argLength; i++) {
arr[start + i] = args[i]
}
新增元素比删除元素少
再接着分析新增元素比删除元素少的情况
console.log('res:', p.splice(0, 2, ...[5]))
// get splice
// get length
// get constructor
// get 0
// get 1 构建要返回的数组
// get 2
// set 1 3
// get 3
// set 2 4 剩余元素前移
// set 0 5 设置新增的元素
// set length 3
// res: [ 1, 2 ]
console.log('arr:', p)
// arr: [ 5, 3, 4 ]
这次是要将删除元素之后的元素前移,前移位数也还是新增数目与删除数目之差(负数):argLength - resLength
需要注意的是,这次是从前向后处理,所以循环是从起始位置加删除数目到数组末尾
for (let i = start + resLength; i < arrLength; i++) {
arr[i + argLength - resLength] = arr[i]
}
然后也是赋值新增元素……
for (let i = 0; i < argLength; i++) {
arr[start + i] = args[i]
}
完整代码
至此三种情况分析完毕,我们发现,任一情况都会赋值新元素,区别是在新增元素与删除元素数目不同时,要先处理原数组调整空位,所以实现代码如下
function splice(arr, start, deleteCount, ...args) {
const arrLength = arr.length
const argLength = args.length
// 处理起始索引
if (start < 0) {
start = Math.max(0, arrLength + start)
} else {
start = Math.min(start, arrLength)
}
// 返回数组长度
const resLength = Math.min(deleteCount, arrLength - start)
// 调用构造函数,生成数组或继承数组的类实例
const res = arr.constructor(resLength)
// 先处理好要作为函数结果返回的数组
for (let i = 0; i < resLength; i++) {
res[i] = arr[start + i]
}
// 如果新增元素与删除元素数目不同,要处理原数组,调整空位
if (argLength > resLength) {
// 新增元素比删除元素多 原始元素要后移 从后向前处理
for (let i = arrLength - 1; i >= start + resLength; i--) {
arr[i + argLength - resLength] = arr[i]
}
} else if (argLength < resLength) {
// 新增元素比删除元素少,后面的元素前移 从前向后处理
for (let i = start + resLength; i < arrLength; i++) {
arr[i + argLength - resLength] = arr[i]
}
}
// 将新增的数据填入空位
for (let i = 0; i < argLength; i++) {
arr[start + i] = args[i]
}
// 设置长度,返回删除元素的数组
arr.length = arrLength - resLength + argLength
return res
}
其他
数组的方法还有很多,就不一一实现了,感兴趣的可以根据代理的输出自行尝试
- indexOf、includes、forEach、join、every、reduce 等方法只涉及读取
- map、slice、fliter 还调用了构造器
- reverse 是取值与赋值交替进行、sort 是读取所有值排序后一次性赋值
总结
ES6 推出的 Proxy 让我们有了理解原生函数的另一种方式,而不用去看编译器的 C++ 源码
本文我们借助 Proxy 拦截数组的读写,仿照代理的输出理解并实现了修改数组的 5 个方法,其中 splice 较为复杂,需要分情况讨论。
还要说一点,本文只考虑了正常的情况。一些违规操作:比如参数类型错误、数组长度达到最大值(2^32-1)或是在密封/冻结数组上调用。由于在我们实际使用不会遇到,也就没有去探究与实现。
如果觉得文章的内容有所帮助,希望能点赞关注,鼓励一下作者。