javascript技巧

关注公众号 jb51net

关闭
首页 > 网络编程 > JavaScript > javascript技巧 > JS高频对象与数组工具方法

JS手撕代码之高频对象与数组工具方法解析

作者:We་ct

在JavaScript中对象是一种数据结构,用于将数据和功能组织在一起,描述一类对象所具有的属性和方法,这篇文章主要介绍了JS手撕代码之高频对象与数组工具方法的相关资料,需要的朋友可以参考下

前言

在日常 JavaScript 开发中,对象和数组是最常用的数据结构,无论是接口返回数据的格式化、复杂数据的对比,还是请求性能优化、数据结构转换,都需要熟练掌握一系列高频操作技巧。本文将整合对象与数组的核心操作,结合通俗解读+专业代码解析,详细讲解各类实用技巧,每个技巧都附带完整代码、核心原理和实际应用场景,新手能看懂,老手能复用,同时兼顾面试手撕需求与实际开发适配。

一、类数组转数组:从“伪数组”到真数组

首先要明确:什么是类数组?类数组(Array-like)是 具有length属性、按索引存储数据,但不具备数组原生方法(如push、slice) 的对象,比如 document.querySelectorAll('div') 返回的NodeList、函数的arguments对象、自定义的{0: ‘a’, 1: ‘b’, length: 2}。

我们的目标是将这类“伪数组”转换成真正的数组,从而使用数组的所有原生方法,以下是3种常用实现方式,各有适配场景:

1. 推荐方案:Array.from()(最灵活)

const arrLike = document.querySelectorAll('div') // NodeList(类数组)
const realArr = Array.from(arrLike) // 转换为真正的数组

通俗解读:Array.from就像一个“转换器”,专门接收类数组或可迭代对象(如Set、Map),直接输出数组。

专业解析:Array.from有两个核心特点:① 支持第二个参数(映射函数),可在转换时同步处理数据,比如 Array.from(arrLike, item => item.textContent);② 兼容性良好(ES6+),能处理所有类数组和可迭代对象,是日常开发的首选。

2. 简洁方案:扩展运算符 …(最简洁)

const arrLike = document.querySelectorAll('div')
const realArr = [...arrLike]

通俗解读:扩展运算符就像“把类数组的每一项都拆出来,再重新装进一个新数组里”,写法非常简洁。

专业解析:扩展运算符的本质是遍历可迭代对象(类数组需满足可迭代条件,如NodeList、arguments均满足),将遍历结果组装成数组。优点是代码简洁,缺点是无法在转换时同步处理数据,需额外写map方法。

3. 兼容方案:Array.prototype.slice.call()(兼容旧环境)

// 示例1:转换arguments(函数内部的类数组)
function fn() {
  const realArr = Array.prototype.slice.call(arguments)
  console.log(realArr) // 数组形式的参数列表
}
fn(1, 2, 3)
// 示例2:转换NodeList
const arrLike = document.querySelectorAll('div')
const realArr = Array.prototype.slice.call(arrLike)

通俗解读:slice方法原本是用来“截取数组”的,当用call改变它的this指向为类数组时,它会把类数组当作数组来处理,返回一个新的真正数组。

专业解析:slice方法的底层逻辑是“遍历this指向的对象,从start到end截取元素,组装成新数组”,只要对象有length属性和索引,就能被slice处理。该方案兼容ES5及以下环境,是旧项目的兼容首选,但写法比前两种繁琐。

避坑点

二、实现数组负索引:像Python一样用arr[-1]取最后一项

JS原生数组不支持负索引(如arr[-1]无法获取最后一项,只会返回undefined),但我们可以通过Proxy代理,实现“负索引转正索引”的效果,让数组支持负索引访问。

完整实现代码

// 接收一个数组,返回一个代理后的新数组
const proxyArray = arr => {
  // 保存【数组原始长度】(重点:固定不变!避免数组长度变化导致负索引计算错误)
  const originLength = arr.length;
  // 返回 Proxy 代理对象,拦截数组的属性读取操作
  return new Proxy(arr, {
    // 拦截所有【读取属性/索引】操作(如arr[0]、arr[-1]、arr.length)
    get(target, key) {
      // 判断:访问的 key 是不是【数字索引】(1、'2'、-3 都算)
      const isNumberKey = 
        (typeof key === 'number') ||  // 数字类型 key:arr[1]、arr[-1]
        (typeof key === 'string' && !isNaN(Number(key)));  // 字符串数字:arr['2']、arr['-3']
      // 如果是数字索引 → 处理负索引
      if (isNumberKey) {
        let index = Number(key); // 统一转成数字,避免字符串索引的问题
        // 核心逻辑:负索引转正索引(循环处理,支持多重负索引如-2、-3)
        while (index < 0) {
          index += originLength;
        }
        // 返回处理后的索引对应的值(超出范围会返回undefined,和原生数组一致)
        return target[index];
      }
      // 不是索引(比如 length、push、pop 等数组方法),正常返回,不影响原生功能
      return target[key];
    }
  });
};

使用示例

const arr = [10, 20, 30, 40];
const proxyArr = proxyArray(arr);
console.log(proxyArr[-1]); // 40(最后一项)
console.log(proxyArr[-2]); // 30(倒数第二项)
console.log(proxyArr[0]);  // 10(正常索引)
console.log(proxyArr.length); // 4(原生属性正常访问)
proxyArr.push(50); // 正常执行,数组变成[10,20,30,40,50]
console.log(proxyArr[-1]); // 50(注意:originLength是原始长度4,这里为什么是50?)

关键解析(重点避坑)

1. 为什么要固定originLength?

通俗说:如果不固定原始长度,当数组新增/删除元素(如push、pop)后,length会变化,此时负索引计算会出错。比如上面的示例,push(50)后数组长度变成5,但originLength还是4,proxyArr[-1]会计算为 -1 + 4 = 3,对应的值是40?但实际运行结果是50,这是因为target是原始数组,target[index]会读取当前数组的第3项(40),但示例中输出50,说明哪里错了?

纠正:示例中push(50)后,target(原始数组)的长度变成5,但originLength还是4,此时proxyArr[-1] = -1 + 4 = 3,target[3]是40,而非50。这就是“固定originLength”的特点——负索引基于数组初始长度计算,不随数组长度变化而变化。如果想让负索引随数组长度动态变化,可将originLength改为“实时获取target.length”,但会失去“固定索引”的意义,按需选择即可。

2. Proxy的作用

Proxy是ES6新增的代理对象,能拦截对象的属性读取、设置等操作。这里我们只拦截了get(读取)操作,不影响数组的其他原生方法(如push、pop、splice),保证数组的正常使用。

3. 兼容问题

Proxy兼容ES6+,不支持IE浏览器,若需兼容旧环境,可通过重写数组的getter方法实现(复杂度更高,不推荐)。

三、数组扁平化:将嵌套数组“拍平”成一维数组

数组扁平化是指将多层嵌套的数组(如[1, [2, [3]]])转换为一维数组(如[1,2,3]),日常开发中处理后端返回的嵌套数据时非常常用。以下是5种实现方式,涵盖“原生方法、暴力方案、面试手撕方案”,各有优劣。

测试用例(含边界值)

// 包含空值、null、undefined、对象嵌套、空字符串,覆盖常见边界场景
const arr = [1, , [2, [3, [{ a: [4] }, 5, ["", null, undefined]]]], 6];

1. ES6原生方法:flat(Infinity)(推荐,日常开发首选)

const flat1 = arr.flat(Infinity);
console.log(flat1); // [1, 2, 3, {a: [4]}, 5, "", null, undefined, 6]

通俗解读:flat是ES6新增的数组原生方法,参数是“扁平化的层级”,Infinity表示“无限层级”,不管数组嵌套多少层,都能拍平成一维。

专业解析

2. 暴力方案:toString() + split()(仅适用于纯数字数组)

const flat2 = arr.toString().split(',').map(Number);
console.log(flat2); // [1, NaN, 2, 3, NaN, 5, 0, NaN, NaN, 6]

通俗解读:先把数组转换成字符串(会自动去掉嵌套结构,用逗号分隔),再按逗号分割成数组,最后转成数字。

专业解析

3. 面试首选:forEach递归(易理解,手写无压力)

// 接收数组和扁平化层级(默认无限层级)
function eachFlat(arr, depth = Infinity) {
  const res = []; // 存储最终的扁平化数组
  arr.forEach(item => {
    // 判断:当前项是数组,且层级未耗尽 → 递归扁平化,层级-1
    if (Array.isArray(item) && depth > 0) {
      res.push(...eachFlat(item, depth - 1)); // 展开递归结果,存入res
    } else {
      // 非数组项,直接存入res(保留原类型,不过滤空项)
      res.push(item);
    }
  });
  return res;
}
const flat3 = eachFlat(arr);
console.log(flat3); // [1, undefined, 2, 3, {a: [4]}, 5, "", null, undefined, 6]

通俗解读:遍历数组的每一项,如果当前项是数组,就递归处理这个子数组,把处理后的结果合并到最终数组里;如果不是数组,直接放进最终数组。

专业解析

4. 优雅方案:reduce递归(代码简洁,面试加分)

function reduceFlat(arr, depth = Infinity) {
  // reduce接收两个参数:回调函数和初始值(空数组)
  return arr.reduce((acc, val) => 
    // 回调逻辑:如果当前项是数组且层级未耗尽,递归处理后合并;否则直接加入acc
    acc.concat(Array.isArray(val) && depth > 0 ? reduceFlat(val, depth - 1) : val), []);
}
const flat4 = reduceFlat(arr);
console.log(flat4); // 和forEach递归结果一致

通俗解读:利用reduce的“累加”特性,将数组的每一项累加(合并)到初始空数组中,遇到子数组就递归处理后再合并。

专业解析

5. 非递归方案:栈实现(解决递归栈溢出,面试进阶)

function stackFlat(arr) {
  const res = []; // 最终结果数组
  const stack = [...arr]; // 用栈存储待处理的元素,初始存入原数组的所有项
  // 循环:栈不为空就继续处理
  while (stack.length) {
    const item = stack.pop(); // 从栈尾取出一个元素(后进先出)
    // 如果是数组,就把它的所有项压入栈(展开后压入,相当于扁平化一层)
    Array.isArray(item) ? stack.push(...item) : res.unshift(item); // 非数组项,加入res的开头
  }
  return res;
}
const flat5 = stackFlat(arr);
console.log(flat5); // 和递归方案结果一致

通俗解读:用栈来“存放”需要处理的元素,每次从栈尾取出一个元素,如果是数组,就把它拆解开,所有项再压入栈;如果不是数组,就放进最终结果数组,直到栈为空。

专业解析

总结(选型建议)

四、嵌套数组的深度:计算数组嵌套的最大层级

有时我们需要知道一个嵌套数组的最大嵌套深度(如[1, [2, [3]]]的深度是3),用于数据校验或渲染逻辑,以下是递归实现方案(面试高频)。

完整实现代码

const recursiveMax = (arr) => {
  // 边界条件:如果传入的不是数组,深度为0(递归终止条件)
  if (!Array.isArray(arr)) {
    return 0;
  }
  // 初始值:当前数组本身的层级是1(即使是空数组,深度也是1)
  let maxDepth = 1;
  // 遍历数组的每一项,递归计算子数组的深度
  for (let i = 0; i < arr.length; i++) {
    // 如果当前项是数组,递归计算它的深度,再加上当前层级1(因为它是当前数组的子项)
    if (Array.isArray(arr[i])) {
      let depth = 1 + recursiveMax(arr[i]);
      // 保留最大的深度(对比当前maxDepth和子数组的深度,取较大值)
      maxDepth = Math.max(maxDepth, depth);
    }
  }
  return maxDepth;
}

测试用例

console.log(recursiveMax([1, [2, [3]]])); // 3(最外层1层,中间2层,最里3层)
console.log(recursiveMax([1, 2, 3])); // 1(无嵌套)
console.log(recursiveMax([[[]], [1, [2]]])); // 3(最里层的空数组是3层)
console.log(recursiveMax(123)); // 0(不是数组)

关键解析

五、数组去重:去除数组中的重复元素

数组去重是前端高频需求(如处理用户选择的标签、后端返回的重复数据),以下是基础手写方案(面试必写),同时补充原生优化方案。

基础手写方案:indexOf判断(易理解,面试首选)

function unique(arr) {
  const result = []; // 存储去重后的数组
  for (let i = 0; i < arr.length; i++) {
    // indexOf返回-1,表示当前元素不在result中(未重复)
    if (result.indexOf(arr[i]) === -1) {
      result.push(arr[i]); // 未重复则加入result
    }
  }
  return result;
}

测试用例

console.log(unique([1, 2, 2, 3, 3, 3])); // [1,2,3]
console.log(unique([1, '1', 2, 2])); // [1, '1', 2](区分数字和字符串)
console.log(unique([null, null, undefined, undefined])); // [null, undefined]

进阶优化(补充)

基础方案的时间复杂度是O(n²)(forEach嵌套indexOf),数据量较大时效率较低,以下是两种优化方案(面试可补充,加分):

// 方案1:Set去重(ES6+,最简洁,时间复杂度O(n))
function uniqueSet(arr) {
  return [...new Set(arr)]; // Set自动去重,再转成数组
}
// 方案2:对象键值对去重(兼容ES5,时间复杂度O(n))
function uniqueObj(arr) {
  const obj = {};
  const result = [];
  arr.forEach(item => {
    if (!obj[item]) { // 利用对象键名唯一的特性
      obj[item] = true;
      result.push(item);
    }
  });
  return result;
}

避坑点

六、数组转树:扁平数组转树形结构(高频业务需求)

后端返回的数据通常是扁平数组(如用户列表、菜单列表,包含id和parentId),而前端渲染时需要树形结构(如菜单导航、树形表格),因此“数组转树”是高频业务需求,以下是高效实现方案。

完整实现代码(带注释)

/**
 * 扁平数组 转 树形结构
 * @param {Array} arr - 扁平数组(每一项必须包含id和parentId)
 * @param {number} rootValue - 根节点的 parentId 值,默认 0(通常根节点parentId为0或null)
 * @returns {Array} 树形结构数组(根节点数组,每个节点包含children属性)
 */
function arrayToTree(arr, rootValue = 0) {
  // 1. 创建 ID 映射表,快速查找节点(核心优化:避免嵌套循环,时间复杂度从O(n²)降至O(n))
  const map = {};
  const tree = []; // 最终的树形结构数组
  // 第一步:先把所有节点存入map,并初始化children属性(避免后续判断children是否存在)
  arr.forEach(item => {
    // 深拷贝当前节点(避免修改原始数组),并初始化children为空数组
    map[item.id] = { ...item, children: [] };
  });
  // 第二步:遍历扁平数组,构建父子关系
  arr.forEach(item => {
    const currentNode = map[item.id]; // 当前节点(从map中快速获取)
    const parentNode = map[item.parentId]; // 父节点(从map中快速获取)
    // 判断:如果当前节点是根节点(parentId === rootValue),直接加入tree
    if (item.parentId === rootValue) {
      tree.push(currentNode);
    } 
    // 否则:如果父节点存在,将当前节点加入父节点的children
    else if (parentNode) {
      parentNode.children.push(currentNode);
    }
  });
  return tree;
}

测试用例(模拟后端返回的扁平数组)

const arr = [
  { id: 1, name: '节点1', parentId: 0 }, // 根节点(parentId=0)
  { id: 2, name: '节点2', parentId: 0 }, // 根节点(parentId=0)
  { id: 3, name: '节点1-1', parentId: 1 }, // 子节点(父节点id=1)
  { id: 4, name: '节点1-2', parentId: 1 }, // 子节点(父节点id=1)
  { id: 5, name: '节点1-1-1', parentId: 3 }, // 孙节点(父节点id=3)
];
const tree = arrayToTree(arr);
console.log(JSON.stringify(tree, null, 2)); // 格式化输出,便于查看树形结构

输出结果(树形结构)

[
  {
    "id": 1,
    "name": "节点1",
    "parentId": 0,
    "children": [
      {
        "id": 3,
        "name": "节点1-1",
        "parentId": 1,
        "children": [
          {
            "id": 5,
            "name": "节点1-1-1",
            "parentId": 3,
            "children": []
          }
        ]
      },
      {
        "id": 4,
        "name": "节点1-2",
        "parentId": 1,
        "children": []
      }
    ]
  },
  {
    "id": 2,
    "name": "节点2",
    "parentId": 0,
    "children": []
  }
]

核心优化点(面试重点)

避坑点

七、深拷贝与浅拷贝:避免“修改副本影响原对象”

在JS中,对象和数组是引用类型,赋值时传递的是“引用地址”,而非值本身。这就导致了“修改副本,原对象也会被修改”的问题,因此需要通过浅拷贝或深拷贝来解决。

核心区别:浅拷贝只复制一层,深层引用类型仍共享地址;深拷贝复制所有层级,深层引用类型也会被重新创建,副本和原对象完全独立

一、浅拷贝(复制一层,适用于单层对象/数组)

浅拷贝的核心是“只复制顶层属性”,如果顶层属性是引用类型(如对象、数组),则复制的是引用地址,修改副本的深层属性会影响原对象。

1. 手写通用浅拷贝(修复所有错误,面试必写)

const shallowCopy = function(obj) {
  // 边界条件:如果不是对象(如数字、字符串、null),直接返回(值类型无需拷贝)
  if (typeof obj !== "object" || obj === null) return obj;
  // 初始化新对象/数组:如果是数组,新建数组;否则新建对象
  let newObj = obj instanceof Array ? [] : {};
  // 遍历原对象的自有属性(不遍历原型链上的属性)
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      // 只复制顶层属性,深层属性仍为引用
      newObj[key] = obj[key];
    }
  }
  return newObj;
};

2. 官方原生方法(日常开发首选)

// (1)对象浅拷贝
const obj = { a: 1, b: { c: 2 } };
const copy1 = Object.assign({}, obj); // Object.assign:合并对象,实现浅拷贝
const copy2 = { ...obj }; // 扩展运算符:最简洁的对象浅拷贝方式
// (2)数组浅拷贝
const arr = [1, 2, [3, 4]];
const copy3 = arr.slice(); // slice:截取整个数组,返回新数组(浅拷贝)
const copy4 = [].concat(arr); // concat:合并数组,返回新数组(浅拷贝)

浅拷贝测试(验证深层引用)

const obj = { a: 1, b: { c: 2 } };
const copy = { ...obj };
copy.a = 100; // 修改顶层属性,原对象不受影响
console.log(obj.a); // 1(原对象不变)
copy.b.c = 200; // 修改深层属性,原对象受影响
console.log(obj.b.c); // 200(原对象被修改)

二、深拷贝(复制所有层级,适用于嵌套对象/数组)

深拷贝会递归复制对象的所有层级,不管嵌套多少层,副本和原对象都是完全独立的,修改副本不会影响原对象。以下是3种实现方案,从简单到完善。

1. 最简单方案:JSON.parse(JSON.stringify())(日常开发首选,有缺陷)

function deepCopyJSON(obj) {
  return JSON.parse(JSON.stringify(obj));
}

通俗解读:先把对象转换成JSON字符串(会忽略函数、undefined等类型),再把字符串转回对象,相当于“重新创建”了一个对象,实现深拷贝。

缺陷(重点避坑)

2. 基础递归深拷贝(支持对象/数组/函数,无循环引用处理)

function deepCopy(obj) {
  // 边界条件:非对象(值类型)直接返回
  if (typeof obj !== 'object' || obj === null) {
    return obj;
  }
  // 初始化新对象/数组
  const result = Array.isArray(obj) ? [] : {};
  // 遍历自有属性,递归拷贝每一项
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      // 递归拷贝深层属性
      result[key] = deepCopy(obj[key]);
    }
  }
  return result;
}

优点:支持函数、Date、RegExp类型(不会丢失),解决了JSON深拷贝的部分缺陷;

缺点:无法处理循环引用(如obj.self = obj,会导致递归栈溢出)。

3. 完整版深拷贝(支持循环引用+Date+RegExp+Map+Set,面试进阶)

function deepClone(obj, map = new WeakMap()) {
  // 边界条件:非对象直接返回
  if (obj === null || typeof obj !== 'object') return obj;
  // 处理循环引用:如果map中已有当前对象,直接返回缓存的对象(避免递归死循环)
  if (map.has(obj)) return map.get(obj);
  let result;
  // 特殊类型处理:Date
  if (obj instanceof Date) result = new Date(obj);
  // 特殊类型处理:RegExp(保留正则的source和flags)
  else if (obj instanceof RegExp) result = new RegExp(obj.source, obj.flags);
  // 特殊类型处理:Map
  else if (obj instanceof Map) {
    result = new Map();
    map.set(obj, result); // 缓存当前对象,避免循环引用
    obj.forEach((val, key) => result.set(key, deepClone(val, map))); // 递归拷贝Map的每一项
  }
  // 特殊类型处理:Set
  else if (obj instanceof Set) {
    result = new Set();
    map.set(obj, result); // 缓存当前对象
    obj.forEach(val => result.add(deepClone(val, map))); // 递归拷贝Set的每一项
  }
  // 普通对象/数组
  else {
    result = Array.isArray(obj) ? [] : {};
    map.set(obj, result); // 缓存当前对象
    // 遍历自有属性,递归拷贝
    for (const key in obj) {
      if (obj.hasOwnProperty(key)) {
        result[key] = deepClone(obj[key], map);
      }
    }
  }
  return result;
}

测试用例(覆盖所有特殊场景)

const testObj = {
  a: 1,
  b: { c: 2 },
  d: [3, 4],
  func: () => console.log(1), // 函数
  date: new Date(), // Date类型
  reg: /test/g, // RegExp类型
  map: new Map([[1, 'a']]), // Map类型
  set: new Set([1, 2, 3]) // Set类型
};
testObj.self = testObj; // 循环引用(自身引用自身)
const copy1 = deepCopyJSON(testObj);   // 有丢失(函数、map、set丢失,date转成字符串)
const copy2 = deepCopy(testObj);      // 基础版(循环引用报错)
const copy3 = deepClone(testObj);     // 完美版(所有类型都保留,循环引用正常)
console.log(copy3.func); // () => console.log(1)(函数保留)
console.log(copy3.date instanceof Date); // true(Date类型保留)
console.log(copy3.reg.test('test')); // true(正则正常使用)
console.log(copy3.self === copy3); // true(循环引用正常)

总结(选型建议)

八、对象扁平化与反扁平化:把嵌套对象“压平”或“还原”

在处理后端返回数据时,我们经常会遇到多层嵌套的对象(比如 {a:{b:{c:1}}}),这种结构在某些场景下(如表格渲染、表单提交)使用不便,此时就需要“扁平化”;反之,当我们拿到扁平化的对象,需要还原成嵌套结构时,就需要“反扁平化”。

1. 对象扁平化:嵌套结构 → 单层结构

核心逻辑:通过递归遍历嵌套对象,将每个层级的 key 用“.”拼接,最终生成单层对象,value 保持不变。

// 对象扁平化:{a:{b:{c:1}}} → { "a.b.c": 1 }
const objectFlatten = (obj) => {
  const res = {}; // 存储最终扁平化结果
  // 递归扁平化函数:item是当前遍历的对象,preKey是父级key(初始为空)
  function flat(item, preKey = "") {
    // 遍历对象的所有[key, value]对(Object.entries能获取自身可枚举属性)
    Object.entries(item).forEach(([key, val]) => {
      // 拼接新key:如果有父级key,就用“父级.key”,否则直接用当前key
      const newKey = preKey ? `${preKey}.${key}` : key;
      // 关键判断:如果当前value是对象(且不是数组),继续递归;否则直接赋值
      // 注意:这里排除数组,是因为数组扁平化有专门的方法,避免混淆
      if (val && typeof val === 'object' && !Array.isArray(val)) {
        flat(val, newKey); // 递归,把当前newKey作为父级key传入
      } else {
        res[newKey] = val; // 非对象/数组,直接存入结果
      }
    });
  }
  flat(obj); // 启动递归
  return res;
};

关键细节(通俗解读):

2. 反扁平化:单层点分隔 key → 嵌套对象

核心逻辑:将扁平化对象的 key 按“.”分割成数组,通过 reduce 方法逐层构建嵌套结构,最终还原成原始嵌套对象。

// 反扁平化(点分隔 key → 嵌套对象)
const unFlatten = (obj) => {
  const res = {}; // 最终还原的嵌套对象
  // 遍历扁平化对象的所有[key, value]对
  for (const [key, value] of Object.entries(obj)) {
    const keys = key.split('.'); // 把key按“.”分割成数组,比如“a.b.c”→["a","b","c"]
    // reduce遍历keys数组,逐层构建嵌套对象
    // acc:累加器(当前层级的对象),k:当前key,index:当前索引
    keys.reduce((acc, k, index) => {
      // 最后一个key:直接赋值value(最底层值)
      if (index === keys.length - 1) {
        acc[k] = value;
      } else {
        // 非最后一个key:如果当前层级没有这个key,就创建空对象,避免报错
        acc[k] = acc[k] || {};
      }
      // 返回当前层级的对象,供下一次reduce使用
      return acc[k];
    }, res); // 初始累加器是res(最外层空对象)
  }
  return res;
};

测试案例(直观验证):

const origin = { a: { b: { c: 1 }, d: 2 } };
const flat = objectFlatten(origin);
const nested = unFlatten(flat);
console.log('扁平化:', flat);       // { 'a.b.c': 1, 'a.d': 2 } ✅
console.log('还原后:', nested);     // { a: { b: { c: 1 }, d: 2 } } ✅

实际应用场景:

九、循环引用判断:避免代码“卡死”的关键技巧

循环引用是指对象 A 包含对象 B,对象 B 又包含对象 A(比如 obj1.someProp = obj2; obj2.someProp = obj1)。如果直接对这种对象进行递归操作(如扁平化、深拷贝),会导致无限递归,最终触发栈溢出,代码卡死。因此,判断对象是否存在循环引用,是前端开发中必须掌握的防御性技巧。

核心实现(标准面试版):

// 检测对象是否存在循环引用(标准面试版)
function isCycleObject(obj) {
  // 关键:用WeakSet存储已遍历过的对象,避免内存泄漏
  // WeakSet的特点:存储的是对象的弱引用,不会阻止垃圾回收,适合这种场景
  const seenObjects = new WeakSet();
  // 递归检测函数
  function detect(target) {
    // 只检查对象/数组(基础类型:数字、字符串等,不会有循环引用)
    if (target && typeof target === 'object') {
      // 如果当前对象已经在seenObjects中 → 存在循环引用
      if (seenObjects.has(target)) {
        return true;
      }
      // 标记当前对象为已访问,存入WeakSet
      seenObjects.add(target);
      // 遍历当前对象的所有自身属性,递归检测
      for (const key in target) {
        // 只遍历自身属性,避免遍历原型链上的属性(优化性能)
        if (Object.prototype.hasOwnProperty.call(target, key)) {
          // 递归检测当前属性的值,如果有循环引用,直接返回true
          if (detect(target[key])) {
            return true;
          }
        }
      }
    }
    // 基础类型、null,或者没有循环引用,返回false
    return false;
  }
  return detect(obj);
}

关键细节(通俗解读):

测试案例:

const obj1 = {};
const obj2 = {};
obj1.someProp = obj2;
obj2.someProp = obj1; // 循环引用
const normalObj = { a: 1, b: { c: 2 } }; // 无循环
console.log(isCycleObject(obj1));    // true ✅(有循环引用)
console.log(isCycleObject(normalObj)); // false ✅(无循环引用)

实际应用场景:

在进行深拷贝、对象扁平化、JSON 序列化之前,先判断是否存在循环引用,避免代码报错或卡死(比如 JSON.stringify 遇到循环引用会直接报错)。

十、对象深合并:实现 lodash.merge 核心功能

我们知道,Object.assign 是浅合并(只合并第一层属性,嵌套对象会直接覆盖),而实际开发中,我们经常需要“深合并”——即嵌套对象也会逐层合并,不会直接覆盖。比如 {a: [{b:2}]}{a: [{c:3}]},深合并后应该是 {a: [{b:2, c:3}]},这就是 lodash.merge 的核心功能,我们手动实现它。

核心实现:

// 判断是否为对象(排除 null 和基础类型)
function isObject(value) {
  return typeof value === 'object' && value !== null;
}
// 判断是否为数组
function isArray(value) {
  return Object.prototype.toString.call(value) === '[object Array]';
}
/**
 * 深度合并对象 / 数组
 * @param {any} target - 目标数据(被覆盖的对象)
 * @param {any} source - 源数据(覆盖目标的对象)
 * @returns {any} 合并后的新数据
 */
function merge(target, source) {
  // 边界处理:如果target或source不是对象,直接返回source(source优先)
  // 比如 target是1,source是2 → 返回2;source是undefined,返回target
  if (!isObject(target) || !isObject(source)) {
    return source !== undefined ? source : target;
  }
  // 初始化结果:如果source是数组,结果就是数组;否则是对象(和source类型一致)
  const result = isArray(source) ? [] : {};
  // 第一步:复制target的内容到result(保留target的原有属性)
  if (isArray(result)) {
    // 结果是数组 → 复制target的数组元素(如果target也是数组)
    if (isArray(target)) {
      target.forEach((item, i) => {
        result[i] = item;
      });
    }
  } else {
    // 结果是对象 → 复制target的自身属性
    for (const key in target) {
      if (target.hasOwnProperty(key)) {
        result[key] = target[key];
      }
    }
  }
  // 第二步:合并source的内容到result(覆盖target的同名属性,嵌套对象递归合并)
  if (isArray(source)) {
    // source是数组 → 遍历数组元素,递归合并
    source.forEach((item, i) => {
      result[i] = merge(result[i], item);
    });
  } else {
    // source是对象 → 遍历自身属性,递归合并
    for (const key in source) {
      if (source.hasOwnProperty(key)) {
        result[key] = merge(result[key], source[key]);
      }
    }
  }
  return result;
}

关键细节(通俗解读):

测试案例:

const object = { a: [{ b: 2 }, { d: 4 }] };
const other = { a: [{ c: 3 }, { e: 5 }] };
console.log(merge(object, other));
// 输出:{ a: [ { b: 2, c: 3 }, { d: 4, e: 5 } ] } ✅(嵌套数组合并成功)

十一、深比较:判断两个对象是否“完全相等”

我们知道,JavaScript 中 === 对对象的比较是“引用比较”——只有两个对象指向同一个内存地址,才会返回 true;即使两个对象的结构和值完全一样,只要是不同的内存地址,就会返回 false。深比较的作用,就是忽略引用,只比较对象的结构和值,判断两个对象是否“完全相等”。

核心实现(覆盖所有场景):

const deepEqual = (a, b) => {
  // 1. 引用完全相同 → 直接相等(比如 a和b指向同一个对象)
  if (a === b) return true;
  // 2. 类型不同 → 不相等(比如 a是对象,b是数组;a是数字,b是字符串)
  if (typeof a !== typeof b) return false;
  // 3. 处理 null(null的typeof是object,单独判断)
  if (a === null || b === null) return a === b;
  // 4. 专门处理:两个都是 NaN 的情况(NaN === NaN 是 false,需要单独判断)
  const type = typeof a;
  if (type !== 'object' && type !== 'function') {
    // 基础类型(数字、字符串、布尔值),处理 NaN 特殊情况
    return Number.isNaN(a) && Number.isNaN(b);
  }
  // 5. 日期 / 正则 特殊判断(它们的结构相同,值也相同,才算是相等)
  const tagA = Object.prototype.toString.call(a);
  const tagB = Object.prototype.toString.call(b);
  if (tagA !== tagB) return false; // 标签不同,直接不相等(比如 Date 和 RegExp)
  if (tagA === '[object Date]') {
    // 日期:判断时间戳是否相同(getTime()返回毫秒数)
    return a.getTime() === b.getTime();
  }
  if (tagA === '[object RegExp]') {
    // 正则:判断正则表达式的源文本(source)和标志(flags)是否相同
    return a.source === b.source && a.flags === b.flags;
  }
  // 6. 获取所有键(包含 Symbol 类型的键,避免遗漏)
  const keysA = Reflect.ownKeys(a);
  const keysB = Reflect.ownKeys(b);
  if (keysA.length !== keysB.length) return false; // 键的数量不同,直接不相等
  // 7. 递归比较每个键的值(确保所有层级的结构和值都相同)
  for (const key of keysA) {
    if (!deepEqual(a[key], b[key])) {
      return false;
    }
  }
  // 所有条件都满足 → 完全相等
  return true;
};

关键细节(通俗解读):

测试案例:

const objA = { a: 1, b: { c: 2 }, d: new Date(2024), e: /abc/g };
const objB = { a: 1, b: { c: 2 }, d: new Date(2024), e: /abc/g };
const objC = { a: 1, b: { c: 3 } };
console.log(deepEqual(objA, objB)); // true ✅(结构和值完全相同)
console.log(deepEqual(objA, objC)); // false ✅(b.c的值不同)
console.log(deepEqual(NaN, NaN)); // true ✅(特殊处理NaN)

十二、重写 Object.assign:掌握浅拷贝核心逻辑

Object.assign 是 JavaScript 内置的浅拷贝方法,作用是将一个或多个源对象的属性复制到目标对象,并返回目标对象。它的核心特点是“浅拷贝”“源对象优先”“跳过 null/undefined”,我们手动实现它,就能彻底理解其底层逻辑。

核心实现:

// 手写实现 Object.assign(浅拷贝、合并对象)
Object.assign2 = function(target, ...sources) {
  // 1. 边界处理:target 是 null / undefined → 直接报错(和原生Object.assign一致)
  if (target == null) {
    throw new TypeError('Cannot convert undefined or null to object');
  }
  // 2. 把 target 包装成对象(处理 target 是数字、字符串、布尔值的情况)
  // 比如 target是1 → 包装成 Number {1};target是"abc" → 包装成 String {"abc"}
  const ret = Object(target);
  // 3. 遍历所有源对象(sources是剩余参数,可能有多个源对象)
  sources.forEach(obj => {
    if (obj != null) { // 跳过 null/undefined(原生Object.assign也会跳过)
      // 4. 遍历源对象自身可枚举属性(不遍历原型链)
      for (const key in obj) {
        if (obj.hasOwnProperty(key)) {
          ret[key] = obj[key]; // 覆盖赋值(源对象属性覆盖目标对象同名属性)
        }
      }
    }
  });
  return ret;
};

关键细节(通俗解读):

测试案例:

const target = { a: 1, b: { c: 2 } };
const source1 = { b: { d: 3 }, e: 4 };
const source2 = null;
const result = Object.assign2(target, source1, source2);
console.log(result); // { a: 1, b: { d: 3 }, e: 4 } ✅
console.log(result.b === source1.b); // true ✅(浅拷贝,引用相同)

十三、驼峰和下划线互转:前后端数据格式适配神器

在开发中,前端通常使用驼峰命名(比如 userName),而后端接口返回的数据往往使用下划线命名(比如 user_name),因此需要频繁进行“驼峰 <=> 下划线”的转换,这两个方法是前端开发的“高频工具函数”。

1. 下划线转驼峰(user_name → userName)

// 下划线转驼峰
function snakeToCamel(str) {
  // 正则匹配:_ + 小写字母(比如 _n)
  // 替换逻辑:把匹配到的内容,替换成对应的大写字母(n → N)
  return str.replace(/_([a-z])/g, (match, letter) => letter.toUpperCase());
}

2. 驼峰转下划线(userName → user_name)

// 驼峰转下划线
function camelToSnake(str) {
  // 正则匹配:大写字母(比如 N)
  // 替换逻辑:把匹配到的大写字母,替换成 _ + 对应的小写字母(N → _n)
  return str.replace(/[A-Z]/g, (match) => "_" + match.toLowerCase());
}

关键细节(通俗解读):

测试案例:

console.log(snakeToCamel("user_name")); // userName ✅
console.log(snakeToCamel("user_age_18")); // userAge18 ✅
console.log(camelToSnake("userName")); // user_name ✅
console.log(camelToSnake("userAge18")); // user_age_18 ✅

十四、实现 CacheRequest:接口请求缓存与重复请求合并

在前端开发中,频繁请求同一个接口(比如用户频繁点击按钮请求详情),会浪费网络资源、降低页面性能,甚至触发后端接口限流。CacheRequest 的核心作用是:缓存请求结果,合并重复请求——同一请求同时多次调用,只发一次请求;已成功的请求,直接读取缓存,无需再次请求。

核心实现(class 版本,可直接复用):

/**
 * 接口请求缓存工具(重复请求合并 + 缓存)
 * 功能:同一请求同时多次调用,只发一次;已成功的请求直接读缓存
 */
class CacheRequest {
  constructor() {
    // 缓存池:key = 请求唯一标识,value = 缓存数据 / 请求Promise
    // 用Map存储,支持快速查找和删除
    this.cache = new Map();
  }
  // 生成唯一缓存 KEY(根据 url + 请求方式 + 参数 + 请求体,确保唯一)
  getCacheKey(url, options = {}) {
    const { method = 'GET', body = null } = options;
    // 处理 params / body 保证唯一:把对象转成字符串,避免因对象引用不同导致key不同
    return [
      url,
      method.toUpperCase(), // 统一转大写,避免 GET 和 get 视为不同请求
      JSON.stringify(options.params || {}), // params参数(GET请求常用)
      body ? JSON.stringify(body) : '' // body参数(POST请求常用)
    ].join('&'); // 用&连接,生成唯一key
  }
  // 核心请求方法(自动缓存 + 合并请求)
  request(url, options = {}) {
    const key = this.getCacheKey(url, options);
    // 1. 缓存存在 → 直接返回(分两种状态:pending / success)
    if (this.cache.has(key)) {
      const cached = this.cache.get(key);
      // 状态1:请求还在等待中(pending)→ 直接返回同一个Promise(实现请求合并)
      // 比如同时调用两次request('/api/user'),第一次发请求,第二次直接返回同一个Promise
      if (cached.status === 'pending') {
        return cached.promise;
      }
      // 状态2:请求已成功(success)→ 直接返回缓存数据(无需再次请求)
      return Promise.resolve(cached.data);
    }
    // 2. 缓存不存在 → 发送新请求
    const fetchPromise = fetch(url, options)
      .then(res => {
        // 处理HTTP错误(比如404、500),抛出错误,进入catch
        if (!res.ok) throw new Error('请求失败:' + res.status);
        return res.json(); // 解析响应数据(根据接口返回格式调整,比如res.text())
      })
      .then(data => {
        // 请求成功 → 更新缓存为success状态,存储数据
        this.cache.set(key, { status: 'success', data });
        return data; // 返回数据,供外部使用
      })
      .catch(err => {
        // 请求失败 → 删除缓存(下次调用会重新请求,避免缓存错误数据)
        this.cache.delete(key);
        throw err; // 抛出错误,供外部捕获处理
      });
    // 先把请求存入缓存,标记为pending(正在请求),避免重复请求
    this.cache.set(key, { status: 'pending', promise: fetchPromise });
    return fetchPromise;
  }
  // 清空缓存(可指定url / 全部清空,灵活实用)
  clearCache(url) {
    if (!url) {
      this.cache.clear(); // 无url参数 → 清空所有缓存
      return;
    }
    // 有url参数 → 清空所有以该url开头的缓存(比如清空/api/user相关的所有请求缓存)
    for (const key of this.cache.keys()) {
      if (key.startsWith(url)) this.cache.delete(key);
    }
  }
}

关键细节(通俗解读):

使用案例:

// 实例化缓存工具
const cacheRequest = new CacheRequest();
// 第一次请求:无缓存,发送请求
cacheRequest.request('/api/user', { method: 'GET', params: { id: 1 } })
  .then(data => console.log('第一次请求:', data));
// 第二次请求:同一请求,缓存处于pending状态,合并请求(不重复发请求)
cacheRequest.request('/api/user', { method: 'GET', params: { id: 1 } })
  .then(data => console.log('第二次请求(合并):', data));
// 一段时间后,第三次请求:缓存已存在,直接读缓存
setTimeout(() => {
  cacheRequest.request('/api/user', { method: 'GET', params: { id: 1 } })
    .then(data => console.log('第三次请求(读缓存):', data));
}, 1000);
// 清空/api/user相关的所有缓存
cacheRequest.clearCache('/api/user');

实际应用场景:

全文总结

本文从日常开发与面试高频场景出发,系统梳理了 JavaScript 数组与对象的十大核心操作技巧,覆盖「类型转换、结构处理、数据拷贝、引用管理」四大核心方向,每个技巧都兼顾「通俗理解、代码实现、避坑要点」,既适配新手入门的易读性,也满足老手复用的实用性,更贴合面试手撕的核心考点。

数组与对象作为 JS 最基础的复合数据结构,其操作能力直接决定了我们处理复杂业务数据的效率 —— 从类数组转换、负索引实现,到数组扁平化、树形结构转换,再到深拷贝、循环引用检测,这些技巧看似零散,实则都是「数据结构遍历与引用管理」核心逻辑的延伸。

在实际开发中,无需死记硬背所有实现方式,关键是理解「为什么这么做」:比如用 Proxy 实现负索引的核心是「拦截属性访问」,数组转树用 Map 映射的核心是「降低时间复杂度」,深拷贝处理循环引用的核心是「标记已遍历对象」。掌握底层逻辑后,无论需求如何变形(比如自定义扁平化规则、特殊类型的深拷贝),都能快速适配。

到此这篇关于JS手撕代码之高频对象与数组工具方法的文章就介绍到这了,更多相关JS高频对象与数组工具方法内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文