手写JavaScript数组核心方法

11 阅读6分钟

前言:为什么要手写数组方法?

在日常开发中,我们会这样使用数组方法:

const numbers = [1, 2, 3, 4, 5];

// map:转换数组
const doubled = numbers.map(n => n * 2);
console.log(doubled); // [2, 4, 6, 8, 10]

// filter:过滤数组
const evenNumbers = numbers.filter(n => n % 2 === 0);
console.log(evenNumbers); // [2, 4]

// reduce:累积计算
const sum = numbers.reduce((acc, cur) => acc + cur, 0);
console.log(sum); // 15

这些代码看起来很简单,但你知道它们内部是如何工作的吗?如果让我们自己实现这些方法,又该怎么做呢?理解数组方法的内部实现不仅能帮助我们在面试中脱颖而出,更能让我们在实际开发中做出更明智的选择。

实现 forEach 方法

原生 forEach 的基本使用

const arr = [1, 2, 3];
arr.forEach((item, index, array) => {
    console.log(item, index, array);
});

手写实现

Array.prototype.myForEach = function(callback, thisArg) {
  // 1. 检查this是否合法
  if (this == null) {
      throw new TypeError('this为空,无法调用forEach!');
  }
  // 2. 检查callback是否是函数
  if (typeof callback !== 'function') {
      throw new TypeError(callback + ' 不是函数,无法调用forEach!');
  }
  // 3. 将this转换为对象(处理基本类型)
  const thisObj = Object(this);
  // 4. 获取数组长度(使用无符号右移,确保是正整数,防止长度为负数或非整数)
  const len = thisObj.length >>> 0;
  // 5. 使用 for 循环遍历数组
  for (let k = 0; k < len; k++) {
    callback.call(thisArg, thisObj[k], k, thisObj);
  }
  // 6. 返回undefined(forEach没有返回值)
  return undefined;
};

实现 map 方法

原生的 map 的基本使用

const arr = [1, 2, 3];
const mapped = arr.map(x => x * 2);
console.log(mapped); // [2, 4, 6]

手写实现

Array.prototype.myMap = function (callback, thisArg) {
  // 1. 检查this是否合法
  if (this == null) {
    throw new TypeError('this为空,无法调用forEach!');
  }
  // 2. 检查callback是否是函数
  if (typeof callback !== 'function') {
    throw new TypeError(callback + ' 不是函数,无法调用forEach!');
  }
  // 3. 将this转换为对象(处理基本类型)
  const thisObj = Object(this);
  // 4. 获取数组长度(使用无符号右移,确保是正整数,防止长度为负数或非整数)
  const len = thisObj.length >>> 0;
  
  // 5. 创建结果数组(指定长度提高性能)
  const result = new Array(len);
  // 6. 遍历处理
  for (let k = 0; k < len; k++) {
    // 调用回调函数,获取转换后的值
    const mappedValue = callback.call(thisArg, thisObj[k], k, thisObj);
    result[k] = mappedValue;
  }
  // 7. 返回新数组
  return result;
};

实现 filter 方法

原生的 filter 的基本使用

const arr = [1, 2, 3];
const evens = arr.filter(n => n % 2 === 0);
console.log(evens); // [2]

手写实现

Array.prototype.myFilter = function (callback, thisArg) {
  // 1. 检查this是否合法
  if (this == null) {
    throw new TypeError('this为空,无法调用forEach!');
  }
  // 2. 检查callback是否是函数
  if (typeof callback !== 'function') {
    throw new TypeError(callback + ' 不是函数,无法调用forEach!');
  }
  // 3. 将this转换为对象(处理基本类型)
  const thisObj = Object(this);
  // 4. 获取数组长度(使用无符号右移,确保是正整数,防止长度为负数或非整数)
  const len = thisObj.length >>> 0;

  // 5. 结果数组(初始为空)
  const result = [];
  // 6. 遍历数组
  for (let k = 0; k < len; k++) {
    // 调用回调函数判断是否保留
    if (callback.call(thisArg, thisObj[k], k, thisObj)) {
      result.push(thisObj[k]);
    }
  }
  // 7. 返回新数组
  return result;
};

实现 reduce 方法

原生的 reduce 的基本使用

const arr = [1, 2, 3];
const sum = arr.reduce((acc, cur) => acc + cur, 0);
console.log(sum); // 6

手写实现

Array.prototype.myReduce = function (callback, initialValue) {
  // 1. 检查this是否合法
  if (this == null) {
    throw new TypeError('this为空,无法调用forEach!');
  }
  // 2. 检查callback是否是函数
  if (typeof callback !== 'function') {
    throw new TypeError(callback + ' 不是函数,无法调用forEach!');
  }
  // 3. 将this转换为对象(处理基本类型)
  const thisObj = Object(this);
  // 4. 获取数组长度(使用无符号右移,确保是正整数,防止长度为负数或非整数)
  const len = thisObj.length >>> 0;

  // 5. 处理空数组且没有初始值的情况
  if (len === 0 && initialValue === undefined) {
    throw new TypeError('数组为空且没有初始值,无法调用reduce!');
  }

  let accumulator;
  let startIndex;

  // 6. 确定初始值和起始索引
  if (initialValue !== undefined) {
    accumulator = initialValue;
    startIndex = 0;
  } else {
    // 使用第一个元素作为初始值
    accumulator = arr[0];
    startIndex = 1;
  }

  // 7. 遍历数组
  for (let i = startIndex; i < len; i++) {
    accumulator = callback(accumulator, thisObj[i], i, thisObj);
  }
  // 8. 返回累加结果
  return accumulator;
}

面试题:数组去重的方法

方法1:Set(ES6,最简单)

function uniqueBySet(arr) {
    // 一行代码解决
    return [...new Set(arr)];
}
  • 时间复杂度: O(n)
  • 空间复杂度: O(n)
  • 最简单,代码最少,保持插入顺序,支持任何类型的数组

方法2:filter + indexOf

function uniqueByFilterIndexOf(arr) {
    return arr.filter((item, index) => {
        // indexOf返回第一个匹配项的索引
        // 如果当前索引不是第一个匹配项,说明是重复的
        return arr.indexOf(item) === index;
    });
}
  • 时间复杂度: O(n²) - 每次indexOf都需要遍历
  • 空间复杂度: O(n)
  • 兼容性好,保持顺序,但性能差

方法3:reduce + includes

function uniqueByReduceIncludes(arr) {
    return arr.reduce((acc, current) => {
        if (!acc.includes(current)) {
            acc.push(current);
        }
        return acc;
    }, []);
}
  • 时间复杂度: O(n²) - includes内部也是遍历
  • 空间复杂度: O(n)
  • 函数式编程风格,保持顺序

方法4:排序后相邻比较

function uniqueBySort(arr) {
    // 先复制数组,避免修改原数组
    const sortedArr = [...arr].sort();
    const result = [];
    
    for (let i = 0; i < sortedArr.length; i++) {
        // 第一个元素或与上一个元素不同
        if (i === 0 || sortedArr[i] !== sortedArr[i - 1]) {
            result.push(sortedArr[i]);
        }
    }
    return result;
}
  • 时间复杂度: O(n log n) - 排序的复杂度
  • 空间复杂度: O(n)
  • 会改变顺序,适合不在乎顺序的场景

方法5:Map实现(保持顺序)

function uniqueByMap(arr) {
    const map = new Map();
    const result = [];
    
    for (const item of arr) {
        if (!map.has(item)) {
            map.set(item, true);
            result.push(item);
        }
    }
    return result;
}
  • 时间复杂度: O(n)
  • 空间复杂度: O(n)
  • 保持顺序,支持任意类型,性能优秀

方法6:双重循环+splice(性能最差)

function uniqueByDoubleLoop(arr) {
    // 复制数组,避免修改原数组
    const result = [...arr];
    
    for (let i = 0; i < result.length; i++) {
        for (let j = i + 1; j < result.length; j++) {
            if (result[i] === result[j]) {
                result.splice(j, 1); // 删除重复元素
                j--; // 调整索引
            }
        }
    }
    return result;
}
  • 时间复杂度: O(n²) - 最坏情况
  • 空间复杂度: O(1) - 原地修改
  • 原地修改,不需要额外空间,但性能差,会修改原数组

面试题:数组扁平化的方法

方法1:flat实现

const arr = [1, [2, [3, [4, 5]]], 6];
// 无限深度扁平化
console.log(arr.flat(Infinity));  // [1, 2, 3, 4, 5]

方法2:递归实现

function flatten(arr) {
  let result = [];
  for (let item of arr) {
    if (Array.isArray(item)) {
      result = result.concat(flatten(item));
    } else {
      result.push(item);
    }
  }
  return result;
}

方法3:reduce 实现

function flatten(arr) {
  return arr.reduce((acc, cur) => {
    return acc.concat(Array.isArray(cur) ? flatten(cur) : cur);
  }, []);
}

方法4:迭代实现

function flatten(arr) {
  const stack = [...arr];
  const result = [];
  
  while (stack.length) {
    const next = stack.pop();
    if (Array.isArray(next)) {
      stack.push(...next);
    } else {
      result.push(next);
    }
  }
  return result.reverse();
}

方法5:扩展运算符实现

function flatten(arr) {
  while (arr.some(item => Array.isArray(item))) {
    arr = [].concat(...arr);
  }
  return arr;
}

方法6:正则表达式替换

function flatten(arr) {
  const str = JSON.stringify(arr);
  // 移除所有方括号(除了最外层的)
  const flatStr = str.replace(/(\[|\])/g, '');
  return JSON.parse(`[${flatStr}]`);
}

扩展题:将字符串数组转成对象数组

假设有这样一个字符串数组:['a.b.c.d:1', 'a.b.e:2', 'x.m.n:3', 'x.y:4'],请你将它转出对象数组:

[
  {
    "a": {
      "b": {
        "c": {
          "d": "1"
        },
        "e": "2"
      }
    }
  },
  {
    "x": {
      "m": {
        "n": "3"
      },
      "y": "4"
    }
  }
]

解题思路

  1. 遍历数组
  2. 将数组中的元素先按冒号 : 进行分割,左边是键,右边是值
  3. 对键进行遍历,构建嵌套数组

代码实现

Array.prototype.toNestedObjects = function () {
    const map = new Map();
    // 数组遍历
    this.forEach(item => {
        // 分割路径和值
        const [path, value] = item.split(':');
        const keys = path.split('.');
        // 用第一个键作为根键
        const rootKey = keys[0];
        if (!map.has(rootKey)) {
            map.set(rootKey, {});
        }
        // 获取根键对应的对象
        let obj = map.get(rootKey);
        // 构建嵌套对象
        for (let i = 1; i < keys.length - 1; i++) {
            obj[keys[i]] = obj[keys[i]] || {};
            obj = obj[keys[i]];
        }
        // 设置叶子节点的值
        obj[keys[keys.length - 1]] = value;
    });

    return Array.from(map, ([key, value]) => ({ [key]: value }));
};

结语

通过手写实现这些数组方法,不仅能让我们掌握它们的原理,更能根据实际需求选择或创造最适合的解决方案。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!