手把手写几种常用工具函数:深拷贝、去重、扁平化

0 阅读5分钟

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2. 深拷贝

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

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

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

2.2 常见坑

  1. 循环引用obj.a = obj,递归会栈溢出
  2. 特殊类型DateRegExpMapSetSymbol 不能只靠遍历属性复制
  3. Symbol 做 keyObject.keys 不会包含,需用 Reflect.ownKeysObject.getOwnPropertySymbols

2.3 实现示例(含循环引用与特殊类型处理)

function deepClone(obj, cache = new WeakMap()) {
  // 1. 基本类型、null、函数 直接返回
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }

  // 2. 循环引用:用 WeakMap 缓存已拷贝对象
  if (cache.has(obj)) {
    return cache.get(obj);
  }

  // 3. 特殊对象类型
  if (obj instanceof Date) return new Date(obj.getTime());
  if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags);
  if (obj instanceof Map) {
    const mapCopy = new Map();
    cache.set(obj, mapCopy);
    obj.forEach((v, k) => mapCopy.set(deepClone(k, cache), deepClone(v, cache)));
    return mapCopy;
  }
  if (obj instanceof Set) {
    const setCopy = new Set();
    cache.set(obj, setCopy);
    obj.forEach(v => setCopy.add(deepClone(v, cache)));
    return setCopy;
  }

  // 4. 普通对象 / 数组
  const clone = Array.isArray(obj) ? [] : {};
  cache.set(obj, clone);

  // 包含 Symbol 作为 key
  const keys = [...Object.keys(obj), ...Object.getOwnPropertySymbols(obj)];
  keys.forEach(key => {
    clone[key] = deepClone(obj[key], cache);
  });

  return clone;
}

// 使用示例
const original = { a: 1, b: { c: 2 }, d: [3, 4] };
original.self = original; // 循环引用
const cloned = deepClone(original);
cloned.b.c = 999;
console.log(original.b.c); // 2,原对象未被修改

要点:WeakMap 解决循环引用,Date/RegExp/Map/Set 单独分支,Object.getOwnPropertySymbols 保证 Symbol key 不丢失。

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 版本

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


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

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

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

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

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