从零手写一个深拷贝(进阶篇)

lxf2023-02-17 01:52:20

壹 ❀ 引

在深拷贝和浅拷贝的区别,实现一个简单的深拷贝(基础篇)一文中,我们阐述了深浅拷贝的概念与区别,普及了部分具有迷惑性的浅拷贝api。当然,我们也实现了乞丐版的深拷贝方法,能解决部分拷贝场景,虽然它仍有很多缺陷。那么这一篇文章我们将从零手写一个强大的深拷贝方法,在方法逐渐升级的过程中,我们也能亲身感受深拷贝中需要考虑的边界问题,那么本文开始。

贰 ❀ 从零手写深拷贝

贰 ❀ 壹 从基础对象复制开始

在上文中,我们已经得知深拷贝主要还是考虑引用数据类型,假定现在有一个deepClone方法,假设我们传递的拷贝参数是基础类型,我们完全可以原封不动返回即可。而引用数据类型类型繁多,可以猜到这个方法要做的事应该集中在类型判断,以及针对不同对象类型如何去复制。

先不说深拷贝,我们由浅至深,如何复制一个{}呢?你可能毫不犹豫能写出如下代码

const deepClone = (obj) => {
  // 创建一个新对象
  const obj_ = {};
  for (let i in obj) {
    // 按key依次拷贝
    obj_[i] = obj[i];
  };
  return obj_;
};

我们可以使用for...in遍历对象的所有key,从而达到复制所有属性的目的。但这个实现只能满足复制value是基本数据类型的例子

const obj = {
  name: '听风',
  age: 29
}

一旦属性的value有引用数据类型,上述方法就只能达到浅拷贝的作用了,比如:

const obj = {
  name: '听风',
  age: 29,
  other: {
    gender: 'male'
  }
};
const o = deepClone(obj);
// 修改原对象引用数据类型的值
obj.other.gender = null;
// 可以看到简单的复制没办法解决深拷贝的问题
console.log(obj, o)

从零手写一个深拷贝(进阶篇)

贰 ❀ 贰 增加对象判断与递归深度复制

前文我们已经说过了,假设拷贝传递的是基本类型的值,我们只需原封不动返回即可;其次,考虑到对象的某个值可能还是对象,比如上面的other,我们继续遍历{gender: 'male'}复制,这样依次把gender属性拿过来,就不可能有引用的问题了,因此这里我们将方法升级:

const deepClone = (obj) => {
  // 是对象吗?是就新建对象开始复制
  if (typeof obj === 'object') {
    const obj_ = {};
    for (let i in obj) {
      // 不管是不是对象,直接递归,外面的typeof会帮我们做判断是否要继续遍历
      obj_[i] = deepClone(obj[i]);
    };
    return obj_;
    // 不是对象?直接返回
  } else {
    return obj;
  };
};

有同学第一眼看上去可能就比较奇怪,为什么obj_[i] = deepClone(obj[i]);这一句不继续加一个是否是对象的判断?不是对象没必要递归啊。其实递归的诀窍就是,想好当前要做什么以及什么时候跳出递归,之后递归会重复帮你做好你预期的操作。

比如当遇到name: '听风'这个属性后,再次调用deepClone后因为typeof的判断不是对象,会直接原封不动返回,所以并不会有什么大的性能浪费。

贰 ❀ 叁 兼容数组类型

除了{}类型,数组也是我们打交道非常多的引用类型,很明显上述代码并不满足数组的拷贝,我们需要对obj的类型进一步细化,如下:

const deepClone = (obj) => {
  // 是对象吗?是就新建对象开始复制
  if (typeof obj === 'object') {
    // 是对象,我们进一步确定是数组还是{}
    const obj_ = Array.isArray(obj) ? [] : {};
    for (let i in obj) {
      // 不管是不是对象,直接递归,外面的typeof会帮我们做判断是否要继续遍历
      obj_[i] = deepClone(obj[i]);
    };
    return obj_;
    // 不是对象?直接返回
  } else {
    return obj;
  };
};

现在我们再来测试对象值是对象以及数组的情况,可以看到此时已经满足了预期。

const obj = {
  name: '听风',
  age: 29,
  other: {
    gender: 'male',
    arr: [1, 2, 3]
  }
};
const o = deepClone(obj);
obj.other.gender = null;
obj.other.arr[0] = 1;
console.log(obj, o)

从零手写一个深拷贝(进阶篇)

贰 ❀ 肆 解决typeof类型判断误差

上述的实现我们都依赖了typeof来判断参数是不是一个对象,如果不是对象哪来的for...in呢?但typeof一直有一个javascript遗留bug,那就是typeof null的类型是object,所以如果参数传递null,我们的深拷贝方法会返回一个{}而不是null,这里得优化下。

这里我查阅了typeof MDN,具体类型如下:

类型结果
Undefined"undefined"
Null"Object"
Boolean"boolean"
Number"Number"
String"String"
Symbol"Symbol"
Bigint"bigint"
Function"Function"
其它任何对象"object"

我们总结下,只要参数不是null,且类型结果不是objectfunction,那说明这个参数一定不是对象类型,我们来定义一个更精准的对象类型判断方法isObject,同时优化之前的代码:

const isObject = (obj) => {
  const type = typeof obj;
  return obj !== null && (type === 'object' || type === 'function');
};
​
const deepClone = (obj) => {
  // 如果不是对象直接返回
  if (!isObject(obj)) {
    return obj;
  };
  // 是对象,我们进一步确定是数组还是{}
  const obj_ = Array.isArray(obj) ? [] : {};
  for (let i in obj) {
    // 不管是不是对象,直接递归,外面的typeof会帮我们做判断是否要继续遍历
    obj_[i] = deepClone(obj[i]);
  };
  return obj_;
};

贰 ❀ 伍 解决对象循环引用

虽然不常见,但对象其实可以将自己设置成自己的属性,比如:

const obj = {
  name: '听风',
  age: 29,
  other: {
    gender: 'male',
    arr: [1, 2, 3]
  }
};
obj.obj = obj;

我们现在来使用自己定义的深拷贝方法拷贝上述对象,你会发现直接爆栈:

从零手写一个深拷贝(进阶篇)

为什么?我们来打印一下obj的结构就清楚了:

从零手写一个深拷贝(进阶篇)

key遇到obj时,因为是对象类型,继续递归,结果发现这个key可以无限递归下去,直接爆栈。

那怎么解决呢?我们可以想想被拷贝的原对象是怎么诞生的,将对象的key指向自己即可。也就是在拷贝时,我们只要保证执行一次obj['obj'] = obj即可,只要让自己指向自己,这个循环引用自然就会诞生,并不需要我们无限递归来模拟这个循环引用。

怎么跳出这个递归呢?设想下,obj在第一次传入后,开始第一次递归,然后把自己又作为参数传了下去,后续做的事情完全是相同的,那我们是不是可以记录我们要拷贝的obj以及它拷贝的后的结果,当下次遇到相同的obj跳出递归,直接返回之前的结果就好了。

考虑到我们要记录的参数可能是对象类型,使用普通的对象肯定不行,而es6新增的Map数据类型key就可以使用对象:

const deepClone = (obj, map = new Map()) => {
  // 如果不是对象直接返回
  if (!isObject(obj)) {
    return obj;
  };
  // 之前有拷贝过吗?
  if (map.has(obj)) {
    return map.get(obj);
  };
  // 是对象,我们进一步确定是数组还是{}
  const obj_ = Array.isArray(obj) ? [] : {};
  // 存储当前拷贝的对象,以及我们要返回的对象
  map.set(obj, obj_);
  for (let i in obj) {
    // 不管是不是对象,直接递归,外面的typeof会帮我们做判断是否要继续遍历
    obj_[i] = deepClone(obj[i], map);
  };
  return obj_;
};

此时再拷贝上述的循环引用的对象,你会发现爆栈的问题已经得到解决,我们成功拷贝了一个循环引用的问题。

贰 ❀ 陆 兼容其它可遍历引用数据类型

虽然上文我们做了isObject的判断,但事实上我们也只做了{}[]两种数据的拷贝,像正则,日期以及new Number(1)这种对象其实都未兼顾。

说直白点就是,isObject只是告诉了我们这个参数是不是对象,是对象后我们得进一步细化,看看它到底是什么类型,毕竟有些对象根本不可遍历,那我们现在的代码就无法拷贝这类对象。

我们可以通过如下代码来精准获取当前参数的对象类型:

const getObjectType = (obj) => {
  return Object.prototype.toString.call(obj);
}

举个例子,比如当传入一个函数或者一个正则就能精准得到它的对象类型:

Object.prototype.toString.call(function (){});// '[object Function]'
Object.prototype.toString.call(/1/);// '[object RegExp]'

我们可以列举常见的部分对象类型,将其定义成常量便于后续使用:

// 可遍历类型
const arrType = '[object Array]';
const objType = '[object Object]';
const mapType = '[object Map]';
const setType = '[object Set]';
const argType = '[object Arguments]';
​
// 不可遍历
const boolType = '[object Boolean]';
const numType = '[object Number]';
const strType = '[object String]';
const dateType = '[object Date]';
const errType = '[object Error]';
const regexpType = '[object Regexp]';
const symbolType = '[object Symbol]';
const funType = '[object Function]';
​
// 将可遍历类型做个集合
const traverseTypes = [arrType, objType, mapType, setType, argType];

其实初略做个分类,大家虽然都是对象,但并不是所有对象都可以遍历,比如日期,数字对象,这种我们都不太好直接遍历。

而数组,普通对象{}arguments以及新增的Map Set虽然都可以遍历,但像Map添加属性通过add方法,并不是传统的key-value赋值形式,所以并不能通过for...in一招通吃。

有同学这里可能就已经有疑问了,不是数字,字符串直接返回吗?怎么对象还考虑这些呢?这是因为我们创建数字习惯使用对象字面量的创建方式,比如:

const s = '听风';

但我们依然可以通过构造器String来创建一个字符串对象:

const s = new String('听风');

从零手写一个深拷贝(进阶篇)

OK,让我们回到上文已实现的深拷贝,目前我们根据isArray来判断是否是一个数组,从而初始化obj_是一个[]或者{},很显然这种做法没办法满足需求,当时一个对象时,我们希望直接创建一个同类型的空对象,然后再往这个空对象上复制属性。

怎么做呢?其实这里我们可以借用传递参数的constructor属性,访问到该参数的原始构造函数,举个例子:

const num = 1;
const arr = [];
const bool = true;
​
num.__proto__.constructor === Number;// true
arr.__proto__.constructor === Array;// true
bool.__proto__.constructor === Boolean;// true

因此只要当前可以认定这是一个值得深拷贝的对象,我们直接通过.constructor访问到构造器,然后执行new操作即可。

若对于这一步有疑惑,说明你对于javascript中的原型掌握不是很扎实,这里可以阅读博主JS 疫情宅在家,学习不能停,七千字长文助你彻底弄懂原型与原型链一文,这里就不多赘述了。

让我们改写深拷贝方法,让它能根据任意对象类型创建对应空对象:

const deepClone = (obj, map = new Map()) => {
  // 如果不是对象直接返回
  if (!isObject(obj)) {
    return obj;
  };
​
  // 获取当前参数的对象类型
  const objType = getObjectType(obj);
  // 根据constructor找到原始构造器,创建初始化对象
  let obj_ = new obj.constructor();
​
  // 解决循环引用问题
  if (map.has(obj)) {
    return map.get(obj);
  };
  // 存储当前拷贝的对象,以及我们要返回的对象
  map.set(obj, obj_);
​
  // 拷贝Set
  if (objType === setType) {
    obj.forEach((val, key) => {
      obj_.add(deepClone(val, map));
    });
    return obj_;
  };
​
  // 拷贝Map
  if (objType === mapType) {
    obj.forEach((val, key) => {
      obj_.set(key, deepClone(val, map));
    });
    return obj_;
  };
​
  // 如果是数组或者{}
  for (let i in obj) {
    // 不管是不是对象,直接递归,外面的typeof会帮我们做判断是否要继续遍历
    obj_[i] = deepClone(obj[i], map);
  };
  return obj_;
};
​
const obj = {
  name: '听风',
  arr: [1, 2, 3],
  set: new Set([1, 2, 3]),
  map: new Map([
    ['age', 29]
  ])
};
​
const o = deepClone(obj);
obj.name = '1';
obj.set.add(4);
obj.map.set('sex', 'male');
console.log(obj, o);

从零手写一个深拷贝(进阶篇)

上述代码我们兼容了五种可遍历的对象类型,Set Map需要特有的复制的方式,除此之外的对象,数组以及arguments均可通过for...in复制,运行了例子发现非常顺利。

题外话,我在查阅深拷贝资料时,发现有不少评论说使用Object.create(obj.constructor.prototype)来取代new obj.constructor()的做法,因为前者是直接使用你要复制对象的原型来创建空对象,这要比后者再次调用构造器性能要好,这个说法是有问题的,我们来看个例子:

const arr1 = Object.create([].constructor.prototype);
const arr2 = new [].constructor();
​
arr1[0] = 1;;
arr2[0] = 1;
console.log(arr1.length, arr2.length); // 0 1

从零手写一个深拷贝(进阶篇)

可以看到,通过以数组原型创建的空数组,它自身居然没有带length属性,假设我们以此拷贝出了一个数组,你会发现虽然它有元素,但因为缺少自己的length从而无法成功遍历。

贰 ❀ 柒 兼容不可遍历类型

OK,让我们继续分析剩余不可遍历或者说不便于遍历的对象类型,像布尔值,数字,字符串以及日期这类对象,我们要拷贝比较简单,我们可以通过valueOf访问到对象的原始值,举个例子:

const num = new Number(1);
console.log(num.vauleOf());// 1

但需要注意的是Symbol这个类型它不能使用new调用,且Symbole本身就是基础数据类型,一般情况下我们让它跟普通的数字一样,传入原封不动返回,但我们需要额外考虑包装对象形式的Symbol,比如:

const s = Object(Symbol(1));
Object.prototype.toString.call(s);// '[object Symbol]'

这种形式的对象,我们可以也利用Object(obj.valueOf())进行返回即可。

现在,我们将这些不方便遍历的类型单独做个抽离,能遍历的对象还是用使用上述实现,具体实现如下:

// 拷贝不便于遍历的对象类型
const cloneOtherType = (obj, type) => {
  switch (type) {
    case boolType:
    case numType:
    case strType:
    case dateType:
      return new obj.constructor(obj.valueOf());
    case symbolType:
      return Object(obj.valueOf());
    case regexpType:
      // 待实现
    case funType:
      // 待实现
  }
};
​
const deepClone = (obj, map = new Map()) => {
  // 如果不是对象直接返回
  if (!isObject(obj)) {
    return obj;
  };
​
  // 获取当前参数的对象类型
  const objType = getObjectType(obj);
​
  // 根据constructor找到原始构造器,创建初始化对象
  let obj_;
  if (traverseTypes.includes(objType)) {
    // 如果是可遍历类型,直接创建空对象
    obj_ = new obj.constructor();
  } else {
    // 若不是,则走额外的处理
    return cloneOtherType(obj, objType);
  }
  // 相同代码省略.....
};

现在我们只差正则和函数的实现了,先来说说正则。

贰 ❀ 捌 实现正则深拷贝

声明一个正则一般有两种写法,常见的正则字面量直接创建,或者使用new结合正则构造器来创建,如下:

cosnt reg1 = /123/g;
const reg2 = new Regexp(/123/,'g');

当我们接受一个正则时,肯定也是希望通过new然后传入参数来得到一个新的正则对象,这里我们就得提取两部分,一部分是正则的匹配文本(123),一部分是正则的修饰符(g)。至于前者,我们能通过source属性访问,后者则可以通过flags访问,举个例子:

const reg = /1/ig;
const {source, flags} = reg;
console.log(source, flags);// 1  gi

所以我们很容易写出如下的代码:

return new Regexp(obj.source, obj.flags);

但需要注意的是,正则表达式有个lastIndex属性,用于指定下次匹配从什么时候开始,举个例子:

const reg = /echo/g;
const str = 'echo echo echo';
let res;
// exec匹配到会返回一个数组,匹配不到返回null
while (res = reg.exec(str) !== null) {
  console.log(reg.lastIndex); // 4 9 14
}

第一匹配到echo后,下一次匹配的位置很明显是第一个空格处,所以索引肯定是4,第二次匹配成功后lastIndex就变成9了,以此类推。而lastIndex这个东西是可以手动设置的,我们改改上面的例子:

const reg = /echo/g;
const str = 'echo echo echo';
reg.lastIndex = 9;
let res;
// exec匹配到会返回一个数组,匹配不到返回null
while (res = reg.exec(str) !== null) {
  console.log(reg.lastIndex); // 14
}

所以你看看,单纯拿sourceflags还不够,我们还得把传递的正则的lastIndex也抄过来,不然匹配的行为可能跟原正则不一致,因此完整的正则拷贝应该是:

// 克隆正则
const cloneRegexp = (obj) => {
  const {
    resource,
    flags,
    lastIndex
  } = obj;
  const obj_ = new Regexp(resource, flags);
  obj_.lastIndex = lastIndex;
  return obj_;
}

贰 ❀ 玖 实现函数克隆

实话实说,一般针对函数的拷贝,我们都是原封不动的返回,即便博主工作了5年,说实话也没遇到要拷贝函数的场景。这里我也查阅了网上一些拷贝函数的思路,简单说说。

第一种思路与正则一样,把函数转为字符串,然后正则匹配函数的参数,函数体,最后通过new Function()的形式创建一个新函数,但考虑到函数柯里化,闭包,等等复杂的场景,以及函数还存在自调用函数,箭头函数,匿名函数等复杂因素,目前我能找到的此类实现其实都有问题,所以这里我就不做代码补充了。

第二种,借用eval,看个例子:

const foo = x => console.log(x + 1);
const fn = eval(foo.toString())
fn(1);// 2

通过toString将函数转为字符串后,借用eval执行再次得到函数,看似可以,但只要函数是函数声明,这种做法就完全行不通了:

function foo(x) {
  console.log(x + 1);
};
console.log(foo.toString())
const fn = eval(foo.toString());
fn(1);// 报错,fn是undefined

第三种,借用bind返回一个boundFn,也算是投机取巧的一种做法,并不符合我们心中的函数深拷贝,所以综合来说,不如不拷贝,毕竟本身就没这个需求在,如果面试真的问到,可以阐述以上三种做法,其中复杂性我想面试官自己也能体会。

叁 ❀ 总

那么总结上述所有改写,我们直接贴上完整版代码:

// 可遍历类型
const arrType = '[object Array]';
const objType = '[object Object]';
const mapType = '[object Map]';
const setType = '[object Set]';
const argType = '[object Arguments]';
​
// 不可遍历
const boolType = '[object Boolean]';
const numType = '[object Number]';
const strType = '[object String]';
const dateType = '[object Date]';
const errType = '[object Error]';
const regexpType = '[object Regexp]';
const symbolType = '[object Symbol]';
const funType = '[object Function]';
​
// 将可遍历类型做个集合
const traverseTypes = [arrType, objType, mapType, setType, argType];
​
​
const isObject = (obj) => {
  const type = typeof obj;
  return obj !== null && (type === 'object' || type === 'function');
};
​
const getObjectType = (obj) => {
  return Object.prototype.toString.call(obj);
};
​
// 克隆正则
const cloneRegexp = (obj) => {
  const {
    resource,
    flags,
    lastIndex
  } = obj;
  const obj_ = new Regexp(resource, flags);
  obj_.lastIndex = lastIndex;
  return obj_;
}
​
// 拷贝不便于遍历的对象类型
const cloneOtherType = (obj, type) => {
  switch (type) {
    case boolType:
    case numType:
    case strType:
    case dateType:
      return new obj.constructor(obj.valueOf());
    case symbolType:
      return Object(obj.valueOf());
    case regexpType:
      return cloneRegexp(obj);
    case funType:
      return obj;
  }
}
​
const deepClone = (obj, map = new Map()) => {
  // 如果不是对象直接返回
  if (!isObject(obj)) {
    return obj;
  };
​
  // 获取当前参数的对象类型
  const objType = getObjectType(obj);
​
  // 根据constructor找到原始构造器,创建初始化对象
  let obj_;
  if (traverseTypes.includes(objType)) {
    // 如果是可遍历类型,直接创建空对象
    obj_ = new obj.constructor();
  } else {
    // 若不是,则走额外的处理
    return cloneOtherType(obj, objType);
  }
​
  // 解决循环引用问题
  if (map.has(obj)) {
    return map.get(obj);
  };
  // 存储当前拷贝的对象,以及我们要返回的对象
  map.set(obj, obj_);
​
  // 拷贝Set
  if (objType === setType) {
    obj.forEach((val, key) => {
      obj_.add(deepClone(val, map));
    });
    return obj_;
  };
​
  // 拷贝Map
  if (objType === mapType) {
    obj.forEach((val, key) => {
      obj_.set(key, deepClone(val, map));
    });
    return obj_;
  };
​
  // 如果是数组或者{}
  for (let i in obj) {
    // 不管是不是对象,直接递归,外面的typeof会帮我们做判断是否要继续遍历
    obj_[i] = deepClone(obj[i], map);
  };
  return obj_;
};

如果你是跟着本文思路一步步走下来,上述代码理论上来说不存在难以理解的点,简单测试下例子,也暂未发现有什么问题,不过再怎么说,上述实现也不能用于生产环境,毕竟当下就有更专业的三方库来帮我解决深拷贝的问题。

你也许会想,面试真的会让你手写一个深拷贝吗?我没遇到过手写,但确实遇到过讲解实现思路以及有哪些边界情况需要考虑的问题,若真手写,能将上述思路实现到七七八八,就已经非常优秀了,看似在实现深拷贝,其实这段实现中,我想大家也发现考核了javascript中很多基础知识,大的有原型链与继承,es6Map Set,小的知识点甚至考虑到了正则的lastIndex;所以站在我的角度,这篇文章也能作为js综合复习的一个入口。

写到最后,若文章存在错误以及有所疑问,也欢迎留言讨论,那么到这里本文结束。