3

前言

最近在看 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)或是在密封/冻结数组上调用。由于在我们实际使用不会遇到,也就没有去探究与实现。

如果觉得文章的内容有所帮助,希望能点赞关注,鼓励一下作者。


清隆
29 声望2 粉丝

学完某项技能一定要写写文章,用的时候都是照搬代码,写出来才能深入理解!