likes
comments
collection
share

【JS手写系列】手写实现深拷贝、浅拷贝

作者站长头像
站长
· 阅读数 41

靡不有初,鲜克有终

不积跬步无以至千里

0、前言

  • JavaScript的数据类型分为基本数据类型引用数据类型
  • 如果对于基本数据类型的拷贝,并没有深浅拷贝的区别
  • 深浅拷贝都是对于引用数据类型而言的

本文会涉及到一些 数据类型 && 类型检测方法 这两个方面的知识,不太熟悉的朋友可以看一下我的这篇文章:

JS中8种数据类型、4种类型检测方法总结

1、浅拷贝

1.1、原理

所谓浅拷贝,就是只复制最外一层,里面的都还是相同引用

  • 如果拷贝的是基本数据类型,拷贝的就是基本数据类型的值
  • 如果拷贝的是引用数据类型,拷贝的仅仅只是内存地址(引用)

看个🌰:

let a = { name: 'test', age: 18, arr: [1, 2, 3] }
let b = a
​
b.age = 20
b.arr[0] = 666console.log(a); // { name: 'test', age: 20, arr: [666, 2, 3] }
console.log(b); // { name: 'test', age: 20, arr: [666, 2, 3] }

1.2、实现方式

a、Object.assign

  • MDN官方参考:

    • Object.assign,将所有可枚举Object.propertyIsEnumerable() 返回 true)的自有属性从一个或多个源对象复制到目标对象,返回修改后的对象。
  • object.assignES6Object 的一个方法,该方法可以用于JS对象的合并。我们可以使用它来实现浅拷贝。

  • 该方法的参数target 指的是目标对象,sources指的是源对象。使用形式如下:

Object.assign(target, ...sources)

看个🌰:

let target = {a: 1};
let object2 = {b: {d : 2}};
let object3 = {c: 3};
let finalObj = Object.assign(target, object2, object3);  
​
// 返回的是已经被新增的target本身
console.log(target);  // {a: 1, b: {d : 2}, c: 3}
console.log(finalObj);  // {a: 1, b: {d : 2}, c: 3}
​
object2.b.d = 666;
console.log(target); // {a: 1, b: {d: 666}, c: 3} target中的d值已经发生改变
​
object3.c = 888;
console.log(object3); // {c: 888}  实际上object3已经发生改变
console.log(target); // {a: 1, b: {d: 666}, c: 3}  但target中的c值未发生改变。。。👇

Note:

  • 如果拷贝的是基本数据类型,拷贝的就是基本数据类型的值 --- 已完全拷贝,老死不相往来
  • 如果拷贝的是引用数据类型,拷贝的就是内存地址 --- 引用关系仍然存在

b、扩展运算符

使用扩展运算符可以在构造字面量对象的时候,进行属性的拷贝。使用形式如下:

let cloneObj = { ...obj };

使用示例:

let obj1 = { a: 1, b: { c: 1 } }
let obj2 = { ...obj1 };
​
// 老死不相往来
obj1.a = 2;
console.log(obj1); // { a: 2, b: { c: 1 } }
console.log(obj2); // { a: 1, b: { c: 1 } }
// 藕断丝连
obj1.b.c = 2;
console.log(obj1); // { a: 1, b: { c: 2 } }
console.log(obj2); // { a: 1, b: { c: 2 } }

Note:

  • 还是那句话,基本类型拷贝后老死不相往来,引用类型则拷贝后引用关系仍然存在
  • 扩展运算符 和 Object.assign 实现的浅拷贝的功能差不多,如果属性都是基本类型的值,使用扩展运算符进行浅拷贝会更加方便

c、数组浅拷贝

(1)Array.prototype.slice

slice()方法是JavaScript数组方法,该方法可以从已有数组中返回选定的元素,不会改变原始数组。使用方式如下:

array.slice(start, end)

该方法有两个参数,两个参数都可选:

  • start

    • 规定从何处开始选取。如果是负数,那么它规定从数组尾部开始算起的位置。也就是说,-1 指最后一个元素,-2 指倒数第二个元素,以此类推。
  • end

    • 规定从何处结束选取。该参数是数组片断结束处的数组下标。如果没有指定该参数,那么切分的数组包含从 start 到数组结束的所有元素。如果这个参数是负数,那么它规定的是从数组尾部开始算起的元素。

看个🌰:

const animals = ['ant', 'bison', 'camel', 'duck', 'elephant'];
​
console.log(animals.slice(2));
// Expected output: Array ["camel", "duck", "elephant"]console.log(animals.slice(2, 4));
// Expected output: Array ["camel", "duck"]console.log(animals.slice(1, 5));
// Expected output: Array ["bison", "camel", "duck", "elephant"]console.log(animals.slice(-2));
// Expected output: Array ["duck", "elephant"]console.log(animals.slice(2, -1));
// Expected output: Array ["camel", "duck"]console.log(animals.slice());
// Expected output: Array ["ant", "bison", "camel", "duck", "elephant"]

详见:Array.prototype.slice()

slice方法不会修改原数组,只会返回一个浅拷贝了原数组中的元素的一个新数组。如果向两个数组任一中添加了新元素,则另一个不会受到影响

如果两个参数都不写,就可以实现一个数组的浅拷贝:

let arr = [1, 2, 3, 4];
let newArr = arr.slice()
​
console.log(newArr); // [1, 2, 3, 4]
console.log(arr.slice() === arr); // false
​
arr.push(5)
​
console.log(arr); // [1, 2, 3, 4, 5]
console.log(newArr); // [1, 2, 3, 4]
(2)Array.prototype.concat

concat() 方法用于合并两个或多个数组,此方法不会更改原始数组,而是返回一个新数组。使用方式如下:

arrayObject.concat(arrayA, arrayB, ......, arrayZ)

如果省略了所有参数,则会返回调用此方法的现存数组的一个浅拷贝(新数组):

let arr = [1, 2, 3, 4];
​
let newArr = arr.concat([5])
console.log(newArr); // [1, 2, 3, 4, 5]console.log(arr.concat()); // [1, 2, 3, 4]
console.log(arr.concat() === arr); // false

d、手写实现浅拷贝

根据以上对浅拷贝的理解,实现浅拷贝的思路:

  • 对基础类型做最基本的拷贝;
  • 对引用类型开辟新的存储,并且仅拷贝一层对象属性。

代码实现:

function shallowCopy (params) {
    // 基本类型直接返回
    if (!params || typeof params !== "object") return params;
​
    // 根据 params 的类型判断是新建一个数组还是对象
    let newObject = Array.isArray(params) ? [] : {};
​
    // 遍历 params 并判断是 params 的属性才拷贝
    for (let key in params) {
        if (params.hasOwnProperty(key)) {
            newObject[key] = params[key];
        }
    }
​
    return newObject;
}
​
let params = { a: 1, b: { c: 1 } }
​
let newObj = shallowCopy(params)
​
// 拷贝对象中---基本类型老死不相往来,引用类型藕断丝连
params.a = 222
params.b.c = 666
console.log(params); // { a: 222, b: { c: 666 } }
console.log(newObj); // { a: 1, b: { c: 666 } }

浅拷贝

  • 可以看到,所有的浅拷贝都只能拷贝一层对象
  • 如果存在对象的嵌套,那么浅拷贝就无能为力了
  • 深拷贝就是为了解决这个问题而生的,它能解决多层对象嵌套问题,实现彻底拷贝

3、深拷贝

3.1、 JSON.stringify

  • JSON.parse(JSON.stringify(obj))是比较常用的深拷贝方法之一

    • 它的原理就是利用JSON.stringifyJavaScript对象序列化成为JSON字符串,并将对象里面的内容转换成字符串,再使用JSON.parse来反序列化,将字符串生成一个新的JavaScript对象

这个方法是目前使用最多的深拷贝的方法,也是最简单的方法,使用示例:

let obj1 = {  
  a: 0,
  b: {
    c: 0
  }
};
let obj2 = JSON.parse(JSON.stringify(obj1));
obj1.a = 1;
obj1.b.c = 1;
console.log(obj1); // {a: 1, b: {c: 1}}
console.log(obj2); // {a: 0, b: {c: 0}}

这个方法虽然简单粗暴,但也存在一些问题,在使用该方法时需要注意:

  • 拷贝的对象中如果有 functionundefinedsymbol,当使用过JSON.stringify()进行处理之后,都会消失。

    const originObj = {
        name: 'test',
        age: undefined,
        func: function () {
            console.log('Hello World');
        },
        key: Symbol('一个独一无二的key')
    }
    const cloneObj = JSON.parse(JSON.stringify(originObj));
    console.log(cloneObj); // 只剩下 {name: "test"}
    
  • 无法拷贝不可枚举的属性;

  • 无法拷贝对象的原型链;

  • 拷贝 Date 引用类型会变成字符串;

  • 拷贝 RegExp 引用类型会变成空对象;

  • 对象中含有NaN、Infinity以及 -InfinityJSON 序列化的结果会变成null

  • 无法拷贝对象的循环应用,即对象成环 (obj[key] = obj)。

在日常开发中,上述几种情况一般很少出现,所以这种方法基本可以满足日常的开发需求。如果需要拷贝的对象中存在上述情况,还是要考虑使用下面的几种方法。

3.2、函数库lodash

该函数库也有提供_.cloneDeep用来做深拷贝,可以直接引入并使用:

var _ = require('lodash');
var obj1 = {
    a: 1,
    b: { f: { g: 1 } },
    c: [1, 2, 3]
};
var obj2 = _.cloneDeep(obj1);
console.log(obj1.b.f === obj2.b.f); // false

3.3、手写实现深拷贝

实现深拷贝的思路就是,使用for in来遍历传入参数的属性值

  • 如果值是基本类型就直接复制
  • 如果是引用类型就进行递归调用该函数

基础递归

function deepClone (source) {
    //判断source是不是对象
    if (source instanceof Object === false) return source;
​
    //根据source类型初始化结果变量
    let target = Array.isArray(source) ? [] : {};
​
    for (let i in source) {
        // 判断是否是自身属性
        if (source.hasOwnProperty(i)) {
            //判断数据i的类型
            if (typeof source[i] === 'object') {
                target[i] = deepClone(source[i]);
            } else {
                target[i] = source[i];
            }
        }
    }
    return target;
}
​
const obj = {
    info: { c: { d: 1 } },
    age: undefined,
    func: function () {
        console.log('Hello World');
    },
    key: Symbol('一个独一无二的key')
}
​
const resultA = deepClone(obj)
​
obj.info.c.d = 3
console.log(111111, obj); 
// { info: { c: { d: 3 } }, age: undefined, func: f, key: xxxxx}console.log(222222, resultA); 
// { info: { c: { d: 1 } }, age: undefined, func: f, key: xxxxx}
​
​
let resultB = [1, [2, 3], [4, [5]]]
let resultC = deepClone(resultB)
​
resultB[1][1] = 7
console.log(333333, resultB);  // [1, [2, 7], [4, [5]]]console.log(444444, resultC);  // [1, [2, 3], [4, [5]]]

这只是粗略的版本,这样虽然实现了深拷贝,但也存在一些问题:

  • 存在环引用问题(存在循环引用,拷贝会直接爆栈)

    • 【JS手写系列】手写实现深拷贝、浅拷贝
  • 对于Date、RegExp、Set、Map等引用类型不能正确拷贝

升级递归---解决环引用爆栈问题

function deepClone (source, map = new Map()) {
    //判断source是不是对象
    if (source instanceof Object === false) return source;
​
    //根据source类型初始化结果变量
    let target = Array.isArray(source) ? [] : {};
​
    /* ----------------新增👇---------------- */
    if (map.get(source)) {
        // 已存在则直接返回
        return map.get(source)
    }
    // 不存在则第一次设置
    map.set(source, target)
    /* ----------------新增👆---------------- */
​
    for (let i in source) {
        // 判断是否是自身属性
        if (source.hasOwnProperty(i)) {
            //判断数据i的类型
            if (typeof source[i] === 'object') {
                // 👇传递map
                target[i] = deepClone(source[i], map);
            } else {
                target[i] = source[i];
            }
        }
    }
    return target;
}
​
const obj = {
    info: { c: { d: 1 } },
    age: undefined,
    func: function () {
        console.log('Hello World')
    },
    key: Symbol('一个独一无二的key'),
}
​
// 形成环引用
obj.loop = obj
console.log(obj);
​
const resultA = deepClone(obj)
console.log(resultA); // 拷贝成功

王者递归---解决其余类型拷贝结果不准确问题

// 可遍历类型
const arrTag = '[object Array]';
const objTag = '[object Object]';
const mapTag = '[object Map]';
const setTag = '[object Set]';
const argTag = '[object Arguments]';
const strTag = '[object String]';
​
// 不可遍历类型
const boolTag = '[object Boolean]';
const numTag = '[object Number]';
const dateTag = '[object Date]';
const errTag = '[object Error]';
const regexpTag = '[object RegExp]';
const symbolTag = '[object Symbol]';
const funTag = '[object Function]';
​
// 将可遍历类型做个集合
const traversalArr = [arrTag, objTag, mapTag, setTag, argTag, strTag];
​
​
// 判断类型的函数(采用最全且无遗漏的判断方式)
function checkType (source) {
    return Object.prototype.toString.call(source)
}
​
// 拷贝RegExp的方法
function cloneReg (source) {
    const reFlags = /\w*$/;
    const result = new source.constructor(source.source, reFlags.exec(source));
    result.lastIndex = source.lastIndex;
    return result;
}
​
// 拷贝Date的方法
function cloneDate (source) {
    return new source.constructor(source.valueOf())
}
​
​
function deepClone (source, map = new Map()) {
    // 非对象直接返回
    if (source instanceof Object === false) return source
​
    // 根据source类型初始化结果变量
    let target = Array.isArray(source) ? [] : {};
​
​
    /* ----------------处理环引用问题👇---------------- */
    // 已存在则直接返回(仅仅在环引用之间生效)
    if (map.get(source)) return map.get(source)
​
    // 不存在则第一次设置
    map.set(source, target)
    /* ----------------处理环引用问题👆---------------- */
​
​
    /* ----------------处理Map、Set、Date、RegExp深拷贝失效问题👇---------------- */
    const type = checkType(source)
​
    console.log(type);
    let emptyObj
​
    // 如果是可遍历类型,直接创建空对象
    if (traversalArr.includes(type)) {
        emptyObj = new source.constructor()
    }
​
    // 处理Map类型
    if (type === mapTag) {
        source.forEach((value, key) => {
            emptyObj.set(key, deepClone(value, map))
        })
        return emptyObj
    }
​
    // 处理Set类型
    if (type === setTag) {
        source.forEach(value => {
            emptyObj.add(deepClone(value, map))
        })
        return emptyObj
    }
​
    // 处理Date类型
    if (type === dateTag) return cloneDate(source)
​
    // 处理Reg类型
    if (type === regexpTag) return cloneReg(source)
    /* ----------------处理Map、Set、Date、RegExp深拷贝失效问题👆---------------- */
​
​
    for (let item in source) {
        // 判断是否是自身属性
        if (source.hasOwnProperty(item)) {
            // 判断数据i的类型
            // if (source[item] instanceof Object) {
            if (typeof source[item] === 'obejct') {
                target[item] = deepClone(source[item], map);
            } else {
                target[item] = source[item];
            }
        }
    }
    return target;
}
​
const obj = {
    // 基本类型
    str: 'test',
    num: 18,
    boolean: true,
    sym: Symbol('独一无二key'),
​
    // 引用类型(以下8种数据对象均需进行真正意义上的深拷贝)
    obj_object: { name: 'squirrel' },
    arr: [123, '456'],
    func: (name, age) => console.log(`姓名:${name},年龄:${age}岁`),
​
    map: new Map([['t', 100], ['s', 200]]),
    set: new Set([1, 2, 3]),
    date: new Date(),
    reg: new RegExp(/test/g),
}
​
// 形成环引用
obj.loop = obj
​
const result = deepClone(obj)
console.log('手写deepClone结果:', result)

🚀🚀🚀搞定,看看结果:

【JS手写系列】手写实现深拷贝、浅拷贝

4、总结

  1. 赋值运算符=实现的是浅拷贝,拷贝的是对象的引用地址;

  2. JavaScript 中数组或者对象自带的拷贝方法都是首层浅拷贝;

  3. JSON.stringify实现的是深拷贝,但是对目标对象的部分拷贝结果存在问题;

  4. 本文主要是想深入理解深浅拷贝的区别和实现,记录手写深浅拷贝的过程;

    1. 官方loadsh库已经做得非常完美了,用起来不香?实际工作中建议直接使用;
  5. 若不用lodash,想实现相对精简的真正意义上的深拷贝,请手写递归!

🚀🚀🚀

都看到这儿了,可以点个赞再🏃‍♂️

优质参考链接👇:

juejin.cn/post/703328…

www.cnblogs.com/echolun/p/1…

juejin.cn/post/700351…

www.jb51.net/article/135…

转载自:https://juejin.cn/post/7188048010511810620
评论
请登录