JavaScript中的浅拷贝与深拷贝

4,746

JS中有两种数据类型,值类型和引用类型,当我们需要把一个变量赋给另一个变量时,对于值类型很简单:

let a = 1;
let b = a;
b = 10;
console.log(a, b); // 1, 10

但是如果a是一个对象,这就有问题了

let a = {value: 1};
let b = a;
b.value = 10;
console.log(a.value, b.value); // 10, 10

我们发现改变b.value的时候,a.value的值也跟着变了,这是因为JS里面的对象是引用类型,我们在把变量a赋值给变量b的时候,赋值过去的其实是a的引用地址,b有了相同的引用地址,那a跟b指向的都是同一块内存空间,操作b的属性,其实就是操作了这块内存,因为a也指向这块内存,所以a的属性也变了。这其实就是一个浅拷贝。

浅拷贝

上面这样我们直接将一个引用变量赋值给另一个变量是一种浅拷贝,浅拷贝其实还有其他形式。这次我们需要拷贝的目标是

let target = {
    name: 'John',
  age: 20,
  friend: {
    name: 'Michel',
    age: 30
  }
}

我们可以直接遍历target对象,将它赋给一个新对象就行。

const shallowCopy = (obj) => {
  // 判断参数是数组还是对象
  const result = Array.isArray(obj) ? [] : {};
  for(let key in obj) {
    // 使用hasOwnProperty来判断是否是自身属性
    // 只拷贝自身属性,不拷贝原型链上的属性,即继承属性
    if(obj.hasOwnProperty(key)){
      result[key] = obj[key];
    }
  }

  return result;
}

然后我们来用一下这个方法:

let newObj = shallowCopy(target);
newObj.age = 50;
console.log(target.age, newObj.age); //20, 50

我们可以看到当我们改变newObj的属性时,原对象的属性并没有受影响,但是如果我们改变newObj.friend呢?

newObj.friend.age = 50;
console.log(target.friend.age, newObj.friend.age); //50, 50

我们发现当我们改变newObj.friend的属性的时候,原对象的newObj.friend的属性也改变了,这是因为target.friend本身也是一个对象,我们拷贝的时候只拷贝了他的引用地址,所以我们通过newObj操作他的时候也改变了原来的target

从上面可以看出我们的shallowCopy方法只拷贝了对象的一层,这也是一种浅拷贝。其实还有一些原生方法也是只拷贝一层的,比如Object.assign...扩展运算符

let newObj = Object.assign({}, target); // 这是一层的浅拷贝
let newObj = {...target};  // 这也是一层的浅拷贝

那深拷贝应该怎么实现呢?

深拷贝

JSON

最简单的实现方法就是用JSON.stringify先将对象转换为字符串,然后再用JSON.parse重新解析为JSON,这样新生成的对象与原对象就完全没有关系了,还是以前面的target为例:

let newObj = JSON.parse(JSON.stringify(target));

newObj.friend.age = 50;
console.log(target.friend.age, newObj.friend.age); //30, 50

但是我们换一个target再来试试:

let target2 = {
  name: 'John',
  age: 20,
  drive: () => {},
  girlFriend: undefined
}

let newObj = JSON.parse(JSON.stringify(target2));
console.log(newObj);

结果如下图,我们发现drivegirlFriend两个属性都丢了,这是因为JSON.stringify不能将方法和undefined属性转化为字符串,在转换为字符串过程中就丢了,再解析回来自然也没有了

image-20200115153516776

递归遍历

要解决上面的问题,我们还要自己动手,我们改造下上面的shallowCopy方法,让他能够递归复制。

const deepCopy = (obj) => {
  const result = Array.isArray(obj) ? [] : {};
  for(let key in obj) {
    if(obj.hasOwnProperty(key)){
      // 如果属性也是对象,递归调用自身
      if(obj[key] && typeof obj[key] === 'object'){
        result[key] = deepCopy(obj[key])
      } else {
        result[key] = obj[key];
      }
    }
  }

  return result;
}

来看下结果:

let newObj = deepCopy(target2);
console.log(newObj);

image-20200115154213386

这下我们的drive方法和girlFriend属性都复制过来了。

拷贝Symbol

那如果换一个带有Symbol属性的对象呢?

let target3 = {
  [Symbol('name')]: 'John',
  age: 20,
  drive: () => {},
  girlFriend: undefined
}

我们来看看结果:

let newObj = deepCopy(target3);
console.log(newObj);

image-20200115155047797

我们发现Symbol属性丢了,那怎么办呢?这个原因是for...in...循环拿不到Symbol属性,如果要拿Symbol属性,我们可以用Object.getOwnPropertySymbolsReflect.ownKeysObject.getOwnPropertySymbols会返回对象的Symbol属性列表:

image-20200115160547564

Reflect.ownKeys会返回对象的所有自有属性,包括Symbol属性和不可枚举属性,但是不包括继承属性。所以我们的deepCopy方法改为:

const deepCopy = (obj) => {
  const result = Array.isArray(obj) ? [] : {};
  // 用 Reflect.ownKeys可以获取Symbol属性,用for...of来循环数组
  for(let key of Reflect.ownKeys(obj)) {
    if(obj.hasOwnProperty(key)){
      if(obj[key] && typeof obj[key] === 'object'){
        result[key] = deepCopy(obj[key])
      } else {
        result[key] = obj[key];
      }
    }
  }

  return result;
}

再来看看结果:

let newObj = deepCopy(target3);
console.log(newObj);

image-20200115161745677

解决循环引用

我们来考虑一个新的目标对象

let target4 = {
  [Symbol('name')]: 'John',
  age: 20,
  drive: () => {},
  girlFriend: undefined
}

target4.target = target4;

这个对象的target属性又引用了自身,所以有了循环引用,用我们之前的深拷贝方法直接会报错

image-20200115162212993

要解决这个问题,我们需要每次都将引用类型的键和值都记录下来,由于Object的键不能是对象,所以我们不能用Object记录,这里采用了WeakMap来记录:

const deepCopy2 = (originObj) => {
  // 全局只能有一个记录的map,所以里面又嵌了一个方法
  const map = new WeakMap();
  function dp(obj){
    const result = Array.isArray(obj) ? [] : {};

    const existObj = map.get(obj);
    // 检查map中是不是已经有这个对象了,有了就直接返回,不再递归
    if(existObj){
      return existObj;
    }

    // 没有就记录下来
    map.set(obj, result);

    for(let key of Reflect.ownKeys(obj)) {
      if(obj.hasOwnProperty(key)){
        if(obj[key] && typeof obj[key] === 'object'){
          result[key] = dp(obj[key])
        } else {
          result[key] = obj[key];
        }
      }
    }

    return result;
  }

  return dp(originObj);
}

WeakMap的兼容性不是很好,如果是老浏览器不支持WeakMap,我们可以用两个数组来模拟,一个数组存键,一个数组存值,每次都只在两个数组末尾新增值,这样键和值在数组中的索引就是一样的,我们可以通过这个索引来进行键和值的匹配。

浅拷贝的应用:mixin--混合模式

直接看代码

const mixin = {
  // 注意:这里的say和run不能写成箭头函数,因为箭头函数拿不到正确的this
  say() {
    console.log(`${this.name}在说话`)
  },
  run() {
    console.log(`${this.name}在跑步`)
  }
}

class Student{
  constructor(name){
    this.name = name
  }
}

Object.assign(Student.prototype, mixin);

const student1 = new Student('Jhon');
student1.say();

上面的代码我们没有用继承,而是用了拷贝的方式,让Student类具有了mixin的方法,我们直接将mixin里面的方法复制到了Student的原型链上。这种模式在很多地方都有应用,比如Vue:

image-20200115175945936

深拷贝应用:pick函数

在underscore里面有一个pick函数,可以实现如下效果:

image-20200115180254369

上述代码的输出是一个只包含age属性的新对象{age: 30},下面让我们自己来实现一个pick函数,实现在原理很简单,把我们之前深拷贝的方法改一下就行,让他只拷贝我们需要的属性:

const pick = (originObj, property) => {
  const map = new WeakMap();
  function dp(obj){
    const result = Array.isArray(obj) ? [] : {};

    const existObj = map.get(obj);

    if(existObj){
      return existObj;
    }

    map.set(obj, result);

    for(let key of Reflect.ownKeys(obj)) {
      // 只需要加一个检测,看看key是不是我们需要的属性就行
      if(obj.hasOwnProperty(key) && key === property){
        if(obj[key] && typeof obj[key] === 'object'){
          result[key] = dp(obj[key])
        } else {
          result[key] = obj[key];
        }
      }
    }

    return result;
  }

  return dp(originObj);
}

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章:juejin.im/post/5e3ffc…

“前端进阶知识”系列文章源码GitHub地址: github.com/dennis-jian…