前端面试-手写深拷贝

776 阅读5分钟

前言

坚持更文第三天

拷贝?赋值

对于拷贝引用赋值大家得区分开,我们来看两个例子

const obj = {a: 1, b: {bb: 1}};
const obj2 = obj;
const obj3 = Object.assign({}, obj);

此时三个对象的值一致

经过以下操作:

obj2.a = 2;
obj3.a = 3;

此时我们再看obj对象的值

console.log(obj) // {a: 2, b: {bb: 1}}

可以发现,obj3修改了属性a,并没有引起obj对象的变化。

结论

引用赋值(上述obj2的赋值)是对源对象的直接引用,不管是访问还是修改,都是直接作用在源对象上

拷贝(obj3的创建)是创建了一个新的对象,所以对其进行修改、操作不会影响源对象

注意Object.assign的第一个参数,如果传递了obj,那也就变成了对obj对象的值的引用

拷贝真的不会影响源对象吗

我们再看看这个操作

obj3.b.bb = 2;

console.log(obj3); // {a: 3, b: {bb: 2}};
console.log(obj1); // {a: 2, b: {bb: 2}};

可以发现,在使用Object.assign拷贝一个对象后,第一层的基础数据类型实现了真正的拷贝,而对内部的对象(object)进行修改时,还是会影响源对象的值。这就是浅拷贝

注意我说的是修改内部的值,如果你进行了以下操作:

obj3.b = {what: 1};

console.log(obj); // {a: 2, b: {bb: 2}}

可以发现,直接对内部属性进行赋值,并不会影响源对象的值,这里稍微有点绕,望大家好好消化。实在不行,可以看看大佬的文章【JS 进阶】你真的掌握变量和类型了吗

思考题

let obj = {a: 1};
function set(obj) {
    obj.a = 2;
    obj = {a: 2, c: 3};
}
set(obj);
console.log(obj); // 输出什么

深拷贝

JSON.parse(JSON.stringify(obj))

这个方法对我们来说,其实大多数情况下并没有问题,可以实现对象的深拷贝,并且方便快捷。

但如果你的对象中包含正则表达式等数据,这个方法就会把这些数据吃了。

const obj = {a: {a: 1}, b: new RegExp("奇葩", "g")};
const obj2 = JSON.parse(JSON.stringify(ob));

console.log(obj2); // {a: {a: 1}, b: {}}

JSON.parse(JSON.stringify(obj))方法用于以下数据会出现异常(from MDN):

  • 转换值如果有 toJSON() 方法,该方法定义什么值将被序列化。
  • 非数组对象的属性不能保证以特定的顺序出现在序列化后的字符串中。
  • 布尔值、数字、字符串的包装对象在序列化过程中会自动转换成对应的原始值。
  • undefined、任意的函数以及 symbol 值,在序列化过程中会被忽略(出现在非数组对象的属性值中时)或者被转换成 null(出现在数组中时)。函数、undefined 被单独转换时,会返回 undefined,如JSON.stringify(function(){}) or JSON.stringify(undefined).
  • 对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误。
  • 所有以 symbol 为属性键的属性都会被完全忽略掉,即便 replacer 参数中强制指定包含了它们。
  • Date 日期调用了 toJSON() 将其转换为了 string 字符串(同Date.toISOString()),因此会被当做字符串处理。
  • NaN 和 Infinity 格式的数值及 null 都会被当做 null。
  • 其他类型的对象,包括 Map/Set/WeakMap/WeakSet,仅会序列化可枚举的属性。

其中循环引用可以举个例子

const obj = {};
const obj2 = {a: obj};
obj.a = obj2;

终极版本

咱么也不兜圈子了,总结一下“完美”的深拷贝方法应该具有的特质吧:

  • 原始类型,无需拷贝,直接返回
  • 如果是引用类型,创建一个新的对象,遍历需要克隆的对象,将需要克隆对象的属性执行深拷贝后(递归)依次添加到新对象上。
  • 循环引用 对于这个问题,我们可以创建一个存储结构(WeakMap),拷贝对象之前,先查看表中是否已经拷贝过,有则直接返回,没有的话,以该对象为key,拷贝后的对象为value进行存储
  • 数组和对象则遍历进行拷贝
  • 其他数据类型(null, function, Map, Set, RegExp...)
    • 可遍历(Map、Set)
    • 不可遍历

辅助函数(来自:大佬的仓库

const mapTag = '[object Map]';
const setTag = '[object Set]';
const arrayTag = '[object Array]';
const objectTag = '[object Object]';
const argsTag = '[object Arguments]';

const boolTag = '[object Boolean]';
const dateTag = '[object Date]';
const numberTag = '[object Number]';
const stringTag = '[object String]';
const symbolTag = '[object Symbol]';
const errorTag = '[object Error]';
const regexpTag = '[object RegExp]';
const funcTag = '[object Function]';

const deepTag = [mapTag, setTag, arrayTag, objectTag, argsTag];
function isObject(target) {
    const type = typeof target;
    return target !== null && (type === 'object' || type === 'function');
}

function getType(target) {
    return Object.prototype.toString.call(target);
}

function getInit(target) {
    // new Set,new Map,new Object,new Array
    const Ctor = target.constructor;
    return new Ctor();
}

function cloneSymbol(targe) {
    return Object(Symbol.prototype.valueOf.call(targe));
}

function cloneReg(targe) {
    const reFlags = /\w*$/;
    const result = new targe.constructor(targe.source, reFlags.exec(targe));
    result.lastIndex = targe.lastIndex;
    return result;
}

function cloneFunction(func) {
    const bodyReg = /(?<={)(.|\n)+(?=})/m;
    const paramReg = /(?<=\().+(?=\)\s+{)/;
    const funcString = func.toString();
    if (func.prototype) {
        const param = paramReg.exec(funcString);
        const body = bodyReg.exec(funcString);
        if (body) {
            if (param) {
                const paramArr = param[0].split(',');
                return new Function(...paramArr, body[0]);
            } else {
                return new Function(body[0]);
            }
        } else {
            return null;
        }
    } else {
        return eval(funcString);
    }
}

function cloneOtherType(targe, type) {
    const Ctor = targe.constructor;
    switch (type) {
        case boolTag:
        case numberTag:
        case stringTag:
        case errorTag:
        case dateTag:
            return new Ctor(targe);
        case regexpTag:
            return cloneReg(targe);
        case symbolTag:
            return cloneSymbol(targe);
        case funcTag:
            return cloneFunction(targe);
        default:
            return null;
    }
}

上述辅助工具代码了解就行,如果真的要搞,其中的函数拷贝值得一看

深拷贝主体:

推荐大家可以按照思路手写这段代码即可

function deepClone(obj, map = new WeakMap()) {
  // 基本类型
  if (!isObject(obj)) {
    return obj;
  }
  // 新建合适的初始化对象,便于后续拷贝赋值
  let target;
  const type = getType(obj);
  // 可遍历类型需要针对性地创建一个初始值
  if (deepTag.includes(type)) {
    target = getInit(obj, type);
  } else {
    return cloneOtherType(obj, type);
  }
  // 防止循环引用
  if (map.get(obj)) {
    return map.get(obj);
  }
  map.set(obj, target);
  // 克隆Map
  if (type === mapTag) {
    obj.forEach((value, key) => {
      target.set(key, deepClone(value, map));
    });
    return target;
  }
  // 克隆Set
  if (type === setTag) {
    obj.forEach(value => {
      target.add(deepClone(value, map));
    });
    return target;
  }
  // 克隆对象和数组
  Object.keys(obj).forEach(key => {
    target[key] = deepClone(obj[key], map);
  });
  return target;
}

WeakMap(MDN)

既然用到了,那我们就得直到WeakMap是干嘛用的,和普通Map有什么区别?

  • WeakMap的key只能是Object类型,不能是基础类型
  • WeakMap的key不能被遍历(弱引用)
  • new WeakMap(iterable),其中iterable必须是二维数组或者其他可以迭代的元素必须为键值对
    const w = new WeakMap( [ [{a: 1}, "value"] ] )
    

内存泄漏

普通的map对象在存值的时候,会维护两个数组,一个装key,一个装value。取值的时候会遍历keyList找到索引,然后通过索引在valueList中取值。

上述这个过程的时间复杂度都为O(n),而且最重要的是,JavaScript会一直保持每个keyvalue的引用,即使他们已经不可能被用到。这种行为被称为强引用

相比之下,WeakMap实现的是弱引用,比如说在我们的拷贝函数中所存在weakMap,当我们拷贝结束后,还需要它吗?

所以在特定的情况下,WeakMapMap更优,可以被垃圾回收机制处理,释放内存。

参考

如何写出一个让面试官惊艳的深拷贝函数