常用工具函数:深拷贝・去重・扁平化手写实战|JS 进阶必会篇

253 阅读14分钟

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

1. 开篇:有库可用,为什么还要自己写?

lodashramda 等库已经提供这些工具函数,但在面试、基础补强、和「读懂库源码」的场景里,手写一遍很有价值:

  • 搞清概念:什么算「深拷贝」、什么算「去重」
  • 踩一遍坑:循环引用、NaNDateRegExpSymbol
  • 形成习惯:知道什么时候用浅拷贝、什么时候必须深拷贝

下面按「深拷贝 → 去重 → 扁平化」的顺序,每种都给出可直接用的实现和说明。

1.1、lodash、ramda 是什么

它们是 JavaScript 工具库,里面提前写好了大量常用的实用函数。

比如想做数组去重、对象复制、数据筛选,不用自己写代码,直接调用库里的函数就能完成,是前端开发中能节省时间的“现成工具包”。

1.2、NaN、Date、RegExp、Symbol 是什么

这四个都是 JavaScript 里的特殊数据相关内容,是手写代码时需要注意的特殊情况:

  1. NaN:特殊的数值,意思是“不是一个有效数字”,比如用数字除以非数字时,结果就是 NaN。

  2. Date:日期对象,专门用来表示时间,比如记录“2026年2月28日”这个时间点。

  3. RegExpRegExpRegular expression的缩写。正则表达式对象,用来制定字符串的匹配规则,比如验证手机号、邮箱格式是否正确。

    • Regular:/ˈreɡjələr/
    • Expression:/ɪkˈspreʃn/
  4. Symbol:ES6 新增的一种数据类型,特点是“独一无二”,常用来给对象设置不会重复的属性名。

2. 深拷贝

2.1 浅拷贝 vs 深拷贝,怎么选?

场景推荐方式原因
只改最外层、不改嵌套对象浅拷贝({...obj}Object.assign实现简单、性能好
需要改嵌套对象且不想影响原数据深拷贝避免引用共享
对象里有 DateRegExp、函数等深拷贝时需特殊处理否则会丢失类型或行为

一句话:只要会改到「嵌套对象/数组」,就考虑深拷贝。

2.2 常见坑

2.2.1 循环引用:obj.a = obj,递归会栈溢出

  • 人话翻译:你有一个对象 obj,然后你让它自己的属性 a 又指向了自己,就像一条蛇咬住了自己的尾巴。

  • 为什么是坑:当你写深拷贝函数时,代码会递归地去复制 obj,然后复制 obj.a,而 obj.a 又是 obj,于是就陷入了无限循环,最终导致程序崩溃(栈溢出)。

  • 怎么解决:在拷贝过程中记录已经拷贝过的对象,如果再次遇到,就直接返回记录的引用,而不是重新拷贝。

2.2.2 特殊类型:DateRegExpMapSetSymbol 不能只靠遍历属性复制

  • 人话翻译:这些类型的对象,就像是“精装礼盒”,里面的东西(数据)和普通的“纸盒”(普通对象)不一样。

  • 为什么是坑:如果你用复制普通对象的方法(比如遍历所有属性)去复制它们,只会得到一个空盒子,里面的核心数据丢了。

  • 怎么解决:遇到这些特殊类型,要“特殊对待”,比如复制 Date 时,要根据它的时间戳重新 new 一个新的 Date 对象。

2.2.3 Symbolkey:Object.keys 不会包含,需用 Reflect.ownKeysObject.getOwnPropertySymbols

  • 人话翻译Symbol 类型的属性名就像是对象的“隐藏抽屉”,用 Object.keys() 这种普通方法是找不到、打不开的。

  • 为什么是坑:如果你在深拷贝时只用 Object.keys(),那么对象里用 Symbol 作为键的那些“隐藏抽屉”里的东西,就会被漏掉,拷贝出来的新对象就不完整。

  • 怎么解决:需要用 Reflect.ownKeys() 或者 Object.getOwnPropertySymbols() 这种“特殊工具”,把这些隐藏的属性也找出来并进行拷贝。

有没有看到递归脑子突发发懵,在想:递归是啥?的同学:

  • 循环:告诉程序「重复做这件事 N 次,直到满足条件为止」(是「迭代」的思路,靠外部条件控制停止)。
  • 递归:让函数「自己调用自己」,每次调用都把问题拆小一点,直到碰到「最小的、能直接回答的问题」(是「拆解」的思路,靠自身的终止条件停止)。

2.3 避坑指南(含循环引用与特殊类型处理)

2.3.1 解决「循环引用」(蛇咬自己尾巴)


// 定义缓存容器(改为可选参数,避免全局污染),记录已拷贝过的对象
function deepCopy(obj, map = new Map()) {
  // 先判断:如果是基本类型(非对象/数组),直接返回(补充基础类型闭环)
  if (obj === null || typeof obj !== 'object') return obj;
  
  // 核心:如果是已经拷贝过的对象/数组,直接返回缓存的副本,避免无限循环
  if (map.has(obj)) return map.get(obj);
  
  // 补充:区分数组和普通对象
  let newObj = Array.isArray(obj) ? [] : {};
  
  // 把当前对象/数组记录到缓存里,防止循环引用
  map.set(obj, newObj);
  
  // 遍历对象/数组的属性/元素,逐个递归拷贝
  for (let key in obj) {
    // 补充:只拷贝自身属性(排除原型链属性)
    if (obj.hasOwnProperty(key)) {
      newObj[key] = deepCopy(obj[key], map);
    }
  }
  return newObj;
}

小白注释:map就像一个“备忘录”,记着已经拷贝过的对象,再次遇到就直接用,不会陷入无限循环

测试数据源 & 使用示例


// 构造带循环引用的测试对象(蛇咬自己尾巴)
const objWithLoop = {
  name: '循环引用测试',
  data: [1, 2, 3]
};
// 制造循环引用:objWithLoop的self属性指向自己
objWithLoop.self = objWithLoop;

// 测试拷贝(如果没有循环引用处理,这里会无限递归报错)
const copyObj = deepCopy(objWithLoop);

// 验证结果
console.log('原对象name:', objWithLoop.name); // 循环引用测试
console.log('拷贝对象name:', copyObj.name); // 循环引用测试
console.log('拷贝对象self是否指向自身:', copyObj.self === copyObj); // true(循环引用保留)
console.log('原对象和拷贝对象是否为同一引用:', objWithLoop === copyObj); // false(深拷贝成功)

2.3.2 解决「特殊类型」(Date、RegExp等精装礼盒)


function deepCopy(obj) {
  // 1. 补充:基础类型直接返回(闭环起点)
  if (obj === null || typeof obj !== 'object') {
    // 单独处理Symbol值
    if (typeof obj === 'symbol') return Symbol(obj.description);
    return obj;
  }
  
  // 2. 处理 Date 类型(重新创建新日期,保证类型正确)
  if (obj instanceof Date) {
    return new Date(obj);
  }
  
  // 3. 处理 RegExp 类型(保留原规则和修饰符)
  if (obj instanceof RegExp) {
    return new RegExp(obj.source, obj.flags);
  }
  
  // 4. 处理 Map 类型(逐个拷贝键值对,值递归深拷贝)
  if (obj instanceof Map) {
    const newMap = new Map();
    obj.forEach((value, key) => newMap.set(key, deepCopy(value)));
    return newMap;
  }
  
  // 5. 处理 Set 类型(逐个拷贝元素,元素递归深拷贝)
  if (obj instanceof Set) {
    const newSet = new Set();
    obj.forEach(value => newSet.add(deepCopy(value)));
    return newSet;
  }
  
  // 6. 补充:区分数组和普通对象,过滤原型链属性
  let newObj = Array.isArray(obj) ? [] : {};
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      newObj[key] = deepCopy(obj[key]);
    }
  }
  return newObj;
}

小白注释:遇到特殊类型,不按普通对象的方法拷贝,而是“重新创建”一个新的对应类型对象,保证数据不丢失

测试数据源 & 使用示例

// 澄清:RegExp对象默认自带source(正则表达式文本)和flags(修饰符)属性,无需手动定义
// 比如 /^[a-z0-9]+$/gi 中,source是 "^[a-z0-9]+$",flags是 "gi"
// 构造包含特殊类型的测试对象(精装礼盒合集)
const objWithSpecialType = {
  createTime: new Date('2026-03-01'), // Date类型
  regRule: /^[a-z0-9]+$/gi, // RegExp类型(默认有source和flags属性)
  userMap: new Map([ // Map类型
    ['张三', { age: 20 }],
    ['李四', { age: 22 }]
  ]),
  tagSet: new Set(['前端', 'JavaScript', '深拷贝']), // Set类型
  nestedArr: [1, { name: '嵌套对象' }, 3] // 嵌套数组+对象
};

// 可添加一行验证,查看RegExp的source和flags(可选,用于理解)
console.log('regRule的source:', objWithSpecialType.regRule.source); // 输出 ^[a-z0-9]+$
console.log('regRule的flags:', objWithSpecialType.regRule.flags); // 输出 gi

// 测试拷贝
const copyObj = deepCopy(objWithSpecialType);

// 验证结果
console.log('原对象Date:', objWithSpecialType.createTime); // 2026-03-01T00:00:00.000Z
console.log('拷贝对象Date:', copyObj.createTime); // 同上(Date类型正确拷贝)
console.log('Date是否为新对象:', objWithSpecialType.createTime === copyObj.createTime); // false(深拷贝)

console.log('原对象RegExp:', objWithSpecialType.regRule); // /^[a-z0-9]+$/gi
console.log('拷贝对象RegExp:', copyObj.regRule); // 同上(正则类型正确拷贝)
// 验证拷贝后的正则source和flags与原对象一致
console.log('拷贝后regRule的source:', copyObj.regRule.source); // 输出 ^[a-z0-9]+$
console.log('拷贝后regRule的flags:', copyObj.regRule.flags); // 输出 gi

console.log('原对象Map:', objWithSpecialType.userMap.get('张三')); // { age: 20 }
console.log('拷贝对象Map:', copyObj.userMap.get('张三')); // 同上(Map类型正确拷贝)
console.log('Map嵌套对象是否为新引用:', objWithSpecialType.userMap.get('张三') === copyObj.userMap.get('张三')); // false

2.3.3 解决「Symbol做key」(找到隐藏抽屉)


function deepCopy(obj) {
  // 1. 补充:基础类型直接返回(闭环)
  if (obj === null || typeof obj !== 'object') {
    if (typeof obj === 'symbol') return Symbol(obj.description);
    return obj;
  }
  
  // 2. 补充:特殊类型先处理(避免Symbol键判断覆盖)
  if (obj instanceof Date) return new Date(obj);
  if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags);
  if (obj instanceof Map) {
    const newMap = new Map();
    obj.forEach((v, k) => newMap.set(k, deepCopy(v)));
    return newMap;
  }
  if (obj instanceof Set) {
    const newSet = new Set();
    obj.forEach(v => newSet.add(deepCopy(v)));
    return newSet;
  }
  
  // 3. 核心:处理Symbol键 + 普通对象/数组
  let newObj = Array.isArray(obj) ? [] : {};
  
  // Reflect.ownKeys 能找到:普通字符串key + Symbol类型key(隐藏抽屉)
  const allKeys = Reflect.ownKeys(obj);
  
  // 遍历所有key,逐个递归拷贝(补充:过滤原型链属性)
  allKeys.forEach(key => {
    if (obj.hasOwnProperty(key)) {
      newObj[key] = deepCopy(obj[key]);
    }
  });
  
  return newObj;
}

小白注释:不用Object.keys()(找不到隐藏的Symbol key),改用Reflect.ownKeys(),能把所有属性都找出来,拷贝不遗漏

测试数据源 & 使用示例


// 构造带Symbol键的测试对象(隐藏抽屉)
const symbolKey = Symbol('userInfo'); // 定义Symbol类型的键
const symbolValue = Symbol('admin'); // 定义Symbol类型的值

const objWithSymbolKey = {
  name: 'Symbol键测试',
  role: symbolValue, // Symbol类型的值
  [symbolKey]: { // Symbol类型的键(隐藏抽屉)
    id: 1001,
    createTime: new Date('2026-03-01')
  }
};

// 测试拷贝
const copyObj = deepCopy(objWithSymbolKey);

// 验证结果
console.log('原对象普通key:', objWithSymbolKey.name); // Symbol键测试
console.log('拷贝对象普通key:', copyObj.name); // 同上

console.log('原对象Symbol值:', objWithSymbolKey.role); // Symbol(admin)
console.log('拷贝对象Symbol值:', copyObj.role); // 同上(Symbol值重新创建)
console.log('Symbol值是否为新引用:', objWithSymbolKey.role === copyObj.role); // false

console.log('原对象Symbol键对应值:', objWithSymbolKey[symbolKey]); // { id: 1001, createTime: 2026-03-01T00:00:00.000Z }
console.log('拷贝对象Symbol键对应值:', copyObj[symbolKey]); // 同上(Symbol键被正确拷贝)
console.log('Symbol键嵌套对象是否为新引用:', objWithSymbolKey[symbolKey] === copyObj[symbolKey]); // false

整合版(小白直接用)


// 整合3个坑的解决方案,完整闭环版,小白可直接复制使用
function deepCopy(obj, cache = new Map()) {
  // 1. 基础类型直接返回(包括Symbol值),闭环起点
  if (obj === null || typeof obj !== 'object') {
    return typeof obj === 'symbol' ? Symbol(obj.description) : obj;
  }
  
  // 2. 解决循环引用:缓存已拷贝的对象/数组
  if (cache.has(obj)) return cache.get(obj);
  
  // 3. 解决特殊类型:专属拷贝逻辑
  if (obj instanceof Date) return new Date(obj);
  if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags);
  
  if (obj instanceof Map) {
    const newMap = new Map();
    cache.set(obj, newMap); // Map也需缓存,避免循环引用
    obj.forEach((v, k) => newMap.set(deepCopy(k, cache), deepCopy(v, cache)));
    return newMap;
  }
  
  if (obj instanceof Set) {
    const newSet = new Set();
    cache.set(obj, newSet); // Set也需缓存,避免循环引用
    obj.forEach(v => newSet.add(deepCopy(v, cache)));
    return newSet;
  }
  
  // 4. 解决Symbol做key + 普通对象/数组拷贝
  let newObj = Array.isArray(obj) ? [] : {};
  cache.set(obj, newObj); // 先缓存,避免循环引用
  
  // 遍历所有键(普通key + Symbol key),过滤原型链属性
  Reflect.ownKeys(obj).forEach(key => {
    if (obj.hasOwnProperty(key)) {
      newObj[key] = deepCopy(obj[key], cache);
    }
  });
  
  return newObj;
}

小白提示:不用理解所有原理,复制上面的整合版函数,调用 deepCopy(你要拷贝的对象) 就能避开3个坑,直接用!

终极测试数据源 & 使用示例(覆盖所有场景)


// 构造包含所有坑的终极测试对象
const symbolKey = Symbol('userInfo');
const symbolValue = Symbol('admin');

// 终极测试数据源
const testObj = {
  // 基础类型
  name: '张三',
  age: 20,
  isStudent: false,
  // Symbol值
  role: symbolValue,
  // Symbol键
  [symbolKey]: {
    id: 1001,
    createTime: new Date('2026-03-01') // Date类型
  },
  // 正则类型
  reg: /^[a-z]+$/gi,
  // Map类型
  userMap: new Map([
    ['key1', 'value1'],
    ['key2', { nested: '嵌套值' }]
  ]),
  // Set类型
  tagSet: new Set(['前端', 'JavaScript', '深拷贝']),
  // 数组(包含嵌套对象)
  hobbies: ['篮球', { type: '游戏', name: '原神' }]
};
// 制造循环引用(蛇咬自己尾巴)
testObj.self = testObj;

// 执行深拷贝
const copyObj = deepCopy(testObj);

// 一键验证所有效果
console.log('===== 核心验证 =====');
console.log('1. 循环引用有效:', copyObj.self === copyObj); // true
console.log('2. 原对象和拷贝对象不是同一引用:', testObj === copyObj); // false
console.log('3. Symbol键被拷贝:', copyObj[symbolKey].id === 1001); // true
console.log('4. 特殊类型拷贝正确:', copyObj.reg.flags === 'gi'); // true
console.log('5. 嵌套对象是新引用:', testObj.hobbies[1] !== copyObj.hobbies[1]); // true

3. 去重

3.1 场景与选型

场景方法说明
基本类型数组(数字、字符串)Set写法简单、性能好
需要兼容 NaN自己写遍历逻辑NaN !== NaNSet 能去重 NaN,但逻辑要显式写清楚
对象数组、按某字段去重Mapfilter用唯一字段做 key

3.2 几种实现

1)简单数组去重(含 NaN)

// 方式一:Set(ES6 最常用)
function uniqueBySet(arr) {
  return [...new Set(arr)];
}

// 方式二:filter + indexOf(兼容性更好,但 NaN 会出问题)
function uniqueByFilter(arr) {
  return arr.filter((item, index) => arr.indexOf(item) === index);
}

// 方式三:兼容 NaN 的版本
function unique(arr) {
  const result = [];
  const seenNaN = false; // 用 flag 标记是否已经加入过 NaN
  for (const item of arr) {
    if (item !== item) { // NaN !== NaN
      if (!seenNaN) {
        result.push(item);
        seenNaN = true; // 这里需要闭包,下面用修正版
      }
    } else if (!result.includes(item)) {
      result.push(item);
    }
  }
  return result;
}

// 修正:用变量
function uniqueWithNaN(arr) {
  const result = [];
  let hasNaN = false;
  for (const item of arr) {
    if (Number.isNaN(item)) {
      if (!hasNaN) {
        result.push(NaN);
        hasNaN = true;
      }
    } else if (!result.includes(item)) {
      result.push(item);
    }
  }
  return result;
}

注意:Set 本身对 NaN 是去重的(ES2015 规范),所以 [...new Set([1, NaN, 2, NaN])] 结果正确。需要兼容 NaN 的,多是旧环境或面试题场景。

2)对象数组按某字段去重

function uniqueByKey(arr, key) {
  const seen = new Map();
  return arr.filter(item => {
    const k = item[key];
    if (seen.has(k)) return false;
    seen.set(k, true);
    return true;
  });
}

// 使用
const users = [
  { id: 1, name: '张三' },
  { id: 2, name: '李四' },
  { id: 1, name: '张三2' }
];
console.log(uniqueByKey(users, 'id'));
// [{ id: 1, name: '张三' }, { id: 2, name: '李四' }]

4. 扁平化

4.1 场景

  • [1, [2, [3, 4]]] 变成 [1, 2, 3, 4]
  • 有时候需要「只扁平一层」或「扁平到指定层数」

4.2 实现

1)递归全扁平

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

console.log(flatten([1, [2, [3, 4], 5]])); // [1, 2, 3, 4, 5]

2)指定深度扁平(如 Array.prototype.flat)

function flattenDepth(arr, depth = 1) {
  if (depth <= 0) return arr;

  const result = [];
  for (const item of arr) {
    if (Array.isArray(item) && depth > 0) {
      result.push(...flattenDepth(item, depth - 1));
    } else {
      result.push(item);
    }
  }
  return result;
}

console.log(flattenDepth([1, [2, [3, 4]]], 1)); // [1, 2, [3, 4]]
console.log(flattenDepth([1, [2, [3, 4]]], 2)); // [1, 2, 3, 4]

3)用 reduce 递归写法(另一种常见写法)

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

5. 小结:日常怎么选

函数生产环境面试 / 巩固基础
深拷贝优先用 structuredClone(支持循环引用)或 lodash cloneDeep自己实现,要处理循环引用和特殊类型
去重基本类型用 [...new Set(arr)],对象用 Map 按 key 去重要能解释 NaNindexOf 等细节
扁平化用原生 arr.flat(Infinity)手写递归或 reduce 版本

自己写一遍的价值在于:搞清楚边界情况、循环引用、特殊类型,以后选库或读源码时心里有数。

🔍 本系列专栏导航

一、《闭包实战:从原理到防抖・节流・缓存|JS 进阶必会篇》

二、《异步基础:Promise & async/await 实战|JS 进阶必会篇》

三、《常用工具函数:深拷贝・去重・扁平化手写实战|JS 进阶必会篇》

四、《事件循环与宏微任务:log 顺序实战解析|JS 进阶必会篇》

五、《设计模式实战:单例・发布订阅・策略 JS 轻量用法|JS 进阶必会篇》

六、《浏览器存储实战:localStorage/sessionStorage/cookie 用法详解|JS 进阶必会篇》

👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~