通过手撸深入学习 Array.prototype.slice()

1,632 阅读1分钟

大家好,我是 AK

前言

slice 是 JS 中经常被用到的一个 API, 之前也没有细致的研究过它是怎么实现的,这里还是想拿来再深入学习一下,加深印象。

学习的过程主要分成以下几步:

  1. 根据 MDN 的文档描述/自己的使用的经验,一步一步手撸一个版本
  2. 参考 MDN 跑遍所有 case
  3. 参考 ECMA 规范实现一遍
  4. 对比差异,思考总结

基础功能

先实现一个基础功能的版本

// + 基础功能
Array.prototype.mySlice = function(start, end) {
  const array = this;
  const newArray = [];
  for(let i=start; i<end; i++) {
      newArray.push(array[i]);
  }
  return newArray;
}

function testCase() {
   var fruits = ['Banana', 'Orange', 'Lemon', 'Apple', 'Mango'];
   var citrus = fruits.mySlice(1, 3);
   console.log('mySlice citrus', citrus);
   citrus = fruits.slice(1, 3);
   console.log('slice citrus', citrus);
}

testCase();

OUTPUT

// OUTPUT
"mySlice citrus" // [object Array] (2)
["Orange","Lemon"]

"slice citrus" // [object Array] (2)
["Orange","Lemon"]

基础功能实现比较简单,这里就不加解释了

异常值处理

上述基础功能并没有对异常值进行处理,这里参考 MDN 的描述和自己的一些理解加上以下几条

  • 如果 end 被省略,则 slice 会一直提取到原数组末尾。
  • 如果 end 大于数组的长度,slice 也会一直提取到原数组末尾。
  • 支持 String 和 Boolean 转成 Number
// 基础功能
// + 如果 end 被省略,则 slice 会一直提取到原数组末尾。
// + 如果 end 大于数组的长度,slice 也会一直提取到原数组末尾。
// + 支持 String 和 Boolean 转成 Number
Array.prototype.mySlice = function(start, end) {
  const array = this;
  const arrayLength = array.length;
  const newArray = [];
  start = Number(start), end = Number(end);
  
  if(!end || end > arrayLength) {
    end = arrayLength;
  }
  
  for(let i=start; i<end; i++) {
      newArray.push(array[i]);
  }
  return newArray;
}

function testCase() {
   var fruits = ['Banana', 'Orange', 'Lemon', 'Apple', 'Mango'];
   var citrusEndBig = fruits.mySlice(1, 9);
   console.log('mySlice citrusEndBig', citrusEndBig);
   var citrusEndNull = fruits.mySlice(1);
   console.log('mySlice citrusEndNull', citrusEndNull);
   var citrusString = fruits.mySlice('1', '9');
   console.log('mySlice citrusString', citrusString);
   var citrusBoolean = fruits.mySlice(false, true);
   console.log('mySlice citrusBoolean', citrusBoolean);
   var citrusObject = fruits.mySlice({"a":"b"}, {});
   console.log('mySlice citrusObject', citrusObject);  
  
   citrusEndBig = fruits.slice(1, 9);
   console.log('slice citrusEndBig', citrusEndBig);
   citrusEndNull = fruits.slice(1);
   console.log('slice citrusEndNull', citrusEndNull);
   citrusString = fruits.slice('1', '9');
   console.log('mySlice citrusString', citrusString);
   citrusBoolean = fruits.slice(false, true);
   console.log('mySlice citrusBoolean', citrusBoolean);  
   var citrusObject = fruits.slice({"a":"b"}, {});
   console.log('mySlice citrusObject', citrusObject);    
  
}

testCase();

OUTPUT

"mySlice citrusEndBig" // [object Array] (4)
["Orange","Lemon","Apple","Mango"]
"mySlice citrusEndNull" // [object Array] (4)
["Orange","Lemon","Apple","Mango"]
"mySlice citrusString" // [object Array] (4)
["Orange","Lemon","Apple","Mango"]
"mySlice citrusBoolean" // [object Array] (1)
["Banana"]
"mySlice citrusObject" // [object Array] (0)
[]
"slice citrusEndBig" // [object Array] (4)
["Orange","Lemon","Apple","Mango"]
"slice citrusEndNull" // [object Array] (4)
["Orange","Lemon","Apple","Mango"]
"mySlice citrusString" // [object Array] (4)
["Orange","Lemon","Apple","Mango"]
"mySlice citrusBoolean" // [object Array] (1)
["Banana"]
"mySlice citrusObject" // [object Array] (0)
[]

这里直接用了 Number 转,由于其他类型转成 Number 基本都会是 NaN,在执行判断逻辑的时候恒为 false,所以即使传入也不会报错

支持负数参数

我们都知道,slice 是支持负数参数,这里我们再看一下 MDN 的描述,思考一下怎么实现比较好

  • 如果 begin 为负数,则表示从原数组中的倒数第几个元素开始提取,slice(-2) 表示提取原数组中的倒数第二个元素到最后一个元素(包含最后一个元素)。
  • 如果 end 为负数, 则它表示在原数组中的倒数第几个元素结束抽取。 slice(-2,-1) 表示抽取了原数组中的倒数第二个元素到最后一个元素(不包含最后一个元素,也就是只有倒数第二个元素)。 和平时使用的时候差不多,本质上就是从数组尾部开始找索引位置,那就引入数组 length 计算吧
// 基础功能
// 如果 end 被省略,则 slice 会一直提取到原数组末尾。
// 如果 end 大于数组的长度,slice 也会一直提取到原数组末尾。
// 支持 String 和 Boolean 转成 Number
// + 如果 begin 为负数,则表示从原数组中的倒数第几个元素开始提取,slice(-2) 表示提取原数组中的倒数第二个元素到最后一个元素(包含最后一个元素)。
// + 如果 end 为负数, 则它表示在原数组中的倒数第几个元素结束抽取。 slice(-2,-1) 表示抽取了原数组中的倒数第二个元素到最后一个元素(不包含最后一个元素,也就是只有倒数第二个元素)。
Array.prototype.mySlice = function(start, end) {
  const array = this;
  const arrayLength = array.length;
  const newArray = [];
  start = Number(start), end = Number(end);
  start = start < 0 ? arrayLength + start : start;
  end = !end || end > arrayLength ? arrayLength : end
  end = end < 0 ?  arrayLength + end : end;
  
  for(let i=start; i<end; i++) {
      newArray.push(array[i]);
  }
  return newArray;
}

function testCase() {
   var fruits = ['Banana', 'Orange', 'Lemon', 'Apple', 'Mango'];
   var citrus = fruits.mySlice(-3, -1);
   console.log('mySlice citrus', citrus);
   var citrus2 = fruits.mySlice(0, -1);
   console.log('mySlice citrus2', citrus2);  
  
   citrus = fruits.slice(-3, -1);
   console.log('slice citrus', citrus);
   citrus2 = fruits.slice(0, -1);
   console.log('slice citrus2', citrus2);  
}

testCase();

OUTPUT

"mySlice citrus" // [object Array] (2)
["Lemon","Apple"]
"mySlice citrus2" // [object Array] (4)
["Banana","Orange","Lemon","Apple"]
"slice citrus" // [object Array] (2)
["Lemon","Apple"]
"slice citrus2" // [object Array] (4)
["Banana","Orange","Lemon","Apple"]

核心就是在 arrayLength + start ,比较简单

元素对象是否同步更新

至此 slice 的核心基本功能差不多了,由于我的数组元素赋值是引用赋值,理论上是支持对象更新的,所以这里就直接测试一下 MDN 上的 case,我们先来看一下 MDN 的描述

  • 如果该元素是个对象引用 (不是实际的对象),slice 会拷贝这个对象引用到新的数组里。两个对象引用都引用了同一个对象。如果被引用的对象发生改变,则新的和原来的数组中的这个元素也会发生改变。
function testCase() {
  // 使用 slice 方法从 myCar 中创建一个 newCar。
  var myHonda = { color: 'red', wheels: 4, engine: { cylinders: 4, size: 2.2 } };
  var myCar = [myHonda, 2, "cherry condition", "purchased 1997"];
  var newCar = myCar.slice(0, 2);

  // 输出 myCar、newCar 以及各自的 myHonda 对象引用的 color 属性。
  console.log(' myCar = ' + JSON.stringify(myCar));
  console.log('newCar = ' + JSON.stringify(newCar));
  console.log(' myCar[0].color = ' + JSON.stringify(myCar[0].color));
  console.log('newCar[0].color = ' + JSON.stringify(newCar[0].color));

  // 改变 myHonda 对象的 color 属性.
  myHonda.color = 'purple';
  console.log('The new color of my Honda is ' + myHonda.color);

  //输出 myCar、newCar 中各自的 myHonda 对象引用的 color 属性。
  console.log(' myCar[0].color = ' + myCar[0].color);
  console.log('newCar[0].color = ' + newCar[0].color);
}

testCase();

OUTPUT

" myCar = [{'color':'red','wheels':4,'engine':{'cylinders':4,'size':2.2}},2,'cherry condition','purchased 1997']"
"newCar = [{'color':'red','wheels':4,'engine':{'cylinders':4,'size':2.2}},2]"
" myCar[0].color = 'red'"
"newCar[0].color = 'red'"
"The new color of my Honda is purple"
" myCar[0].color = purple"
"newCar[0].color = purple"

MDN 上的结果是一致的,符合预期

参考 ECMA 规范实现

我们先来看一下 ECMA 的伪代码

image.png

粗略看了一下大部分和自己实现的差不多,一步一步来实现一下看看

Array.prototype.mySlice = function(start, end) {
  // 1. Let A be a new array created as if by the expression new Array().
  const newArray = []; 
  // 2. Call the [[Get]] method of this object with argument "length". 
  // 3. Call ToUint32(Result(2))
  const arrayLength = Number(this.length); 
  // 4. Call ToInteger(start).
  start = Number(start); 
  // 5. If Result(4) is negative, use max((Result(3)+Result(4)),0); else use min(Result(4),Result(3)). 
  // 6. Let k be Result(5).
  let kStart = start < 0 ? Math.max(arrayLength + start, 0) : Math.min(start, arrayLength); 
  // 7. If end is undefined, use Result(3); else use ToInteger(end).
  end = !end ? arrayLength : Number(end); 
  // 8. If Result(7) is negative, use max((Result(3)+Result(7)),0); else use min(Result(7),Result(3)).
  end = end < 0 ? Math.max(arrayLength + end, 0) : Math.min(end, arrayLength); 
  // 9. Let n be 0.
  let n = 0; 
  
  while(true) {
    // 10. If k is greater than or equal to Result(8), go to step 19.
    if( kStart >= end) {
      // 19. Call the [[Put]] method of A with arguments "length" and n.
      // 20. Return A.
      return newArray;
    }
    // 11. Call ToString(k).
    kStart = kStart.toString();
    // 12. If this object has a property named by Result(11), go to step 13; but if this object has no property named by Result(11), then go to step 16.
    if(this.hasOwnProperty(kStart)) {
      // 13. Call ToString(n).
      n = n.toString();
      // 14. Call the [[Get]] method of this object with argument Result(11).
      // 15. Call the [[Put]] method of A with arguments Result(13) and Result(14).
      this[n] = this[kStart];
    } 
    // 16. Increase k by 1.
    // 17. Increase n by 1.
    kStart++;
    n++;
  }
 
}

当把伪代码翻译完后,我发现主要有两个不太一样的地方

  1. 规范里面使用了 max 和 min 来处理,思考了一下这样的好处是当传入的索引位置 index 超出总长度时,自动设置为最大值,负数的时候为 0 ,正数的时候就是 length,这个是之前我的代码里面也没有考虑到的,并且这样的代码也很简洁,避免写很多 if else
  let kStart = start < 0 ? Math.max(arrayLength + start, 0) : Math.min(start, arrayLength);
  1. 把 start 转成了 string 去匹配,这里一开始没太看懂,后面思考了一下主要是为了兼容输入值为 array-like ,因为 array-like 的 key 可能是一个 string

最后

通过手撸了一遍代码,可以对 slice 有了更多更全面的理解,本例中主要可以学到

  1. 规范的伪代码还是有很多思考很全面的地方,并且实现得比较简洁,在日常开发中可以借鉴一些思路
  2. slice 的包容性很强,不论是数据源,还是参数,都支持多种类型,和 js 本身一样是一把双刃剑,考虑代码可维护性在开发过程中还是让变量可控一些比较好

欢迎各种拍砖,讨论