JavaScript中的深拷贝与浅拷贝

本文同步发表在我的个人博客牛牛和旺财

个人博客的评论系统加了邮件通知,有什么想和作者说的建议在个人博客留言哦

后续若有变化,将只在个人博客更新,恕此处不再修改

深拷贝与浅拷贝也是js中常见的问题了,再次总结回顾一下,顺便复习了js中的数据类型及存储方式。

JavaScript中的数据类型

最新的 ECMAScript 标准定义了 8 种数据类型,包括7种原始类型和 Object :

原始类型(primitive values)

在 js 中除 Object 以外的所有类型都是不可变的,我们称这些类型的值为原始值。js中的原始类型共有如下7种:

  • Boolean
  • Null
  • Undefined
  • Number
  • BigInt(ES10新增)
  • String
  • Symbol(ES6新增)

什么意思?啥叫不可变?

我们在js中对基本数据类型的任何操作,都不会影响原有的值,而是返回一个新的值。举例如下:

let str = 'nba';
str[1] = 'c';
console.log(str); // 输出是nba

运行上述代码我们会看到输出结果仍然是 nba ,而不是 cba,这就是原始类型的值不可变

基本数据类型是存放在栈内存中的简单数据段,数据大小确定,内存空间大小可以分配,是直接按值存放的,所以可以直接访问。

对象

在计算机科学中, 对象是指内存中的可以被 标识符引用的一块区域。在 Javascript 里,对象可以被看作是一组属性的集合。

js中的对象是存放在堆内存中的,当我们定义一个对象时候,变量实际上是一个存放在栈内存的指针。

js中对象的话题可以写一本书,这里不再赘述。

浅拷贝

定义

创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值如果属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。

实现

ES5版本

这里提供一个简单的实现:

/**
 * 浅拷贝对象
 * @param {Object} source 拷贝的源对象
 */
function copyObj(source) {
    // 如果源对象是个数组我们需要返回数组而不是对象
    let result = Object.prototype.toString.call(source) === '[object Array]' ? [] : {};

    for (let key in source) {
        // for in会遍历原型链上的属性,通常只需要拷贝对象自身的属性,所以用hasOwnProperty做判断
        if (source.hasOwnProperty(key)) {
            result[key] = source[key];
        }
    }
    return result;
}

很多项目里也都是这么用的,但是这么做在ES6时代真的OK吗?

一切都要从ES6的Symbol类型说起。ES6中规定了对象的属性名可以是字符串或者 Symbol 类型,Symbol 类型的特点就是独一无二,避免与其它属性重复。但是随之而来的问题是:

Symbol 作为属性名,该属性不会出现在for...in、for...of循环中,也不会被Object.keys()、Object.getOwnPropertyNames()、JSON.stringify()返回。

ES6版本

Object.assign() 和 展开运算符

这两种方法都能解决上面提到的Symbol 作为属性名的问题。

Object.assign() 方法用于将所有可枚举属性的值从一个或多个源对象复制到目标对象。它将返回目标对象。

let source = { a: 1, b: {c: 2} };

let result = Object.assign({}, source);

console.log(result);

通过展开运算符也能实现浅拷贝:

let source = { a: 1, b: {c: 2} };

let result = {...source};

console.log(result);

Object.keys()

Object.keys()方法会返回一个由一个给定对象的自身可枚举属性组成的数组,数组中属性名的排列顺序和使用 for...in 循环遍历该对象时返回的顺序一致。

相比于上面ES5中通过for...in 循环遍历的方法,这个方法可以避免hasOwnProperty的判断。把ES5浅拷贝对象的方法中遍历对象用 Object.keys() 实现即可。

不可枚举属性的问题

什么?还有问题?是的,还有问题。什么问题?

不可枚举属性

我们知道js中对象的属性有个叫可枚举的标志,通常情况下它的值是true。但是当通过Object.defineProperty定义的对象属性,这个标志的默认值是false

绝大多数情况下,遍历对象是不需要处理不可枚举属性的。如果有特殊需求想处理如何做呢?

  • Object.getOwnPropertyNames()返回一个由指定对象的所有自身属性的属性名(包括不可枚举属性但不包括Symbol值作为名称的属性)组成的数组。
  • Object.getOwnPropertySymbols()方法返回一个给定对象自身的所有 Symbol 属性的数组。

通过这两个方法联用,就能获得一个对象自身包含Symbol属性在内的、可枚举和不可枚举的所有属性。除了这两个方法之外:

  • 静态方法Reflect.ownKeys()返回一个由目标对象自身的属性键组成的数组。

深拷贝

定义

将一个对象从内存中完整的拷贝一份出来, 从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象。

实现

破产版

let source = { a: 1, b: {c: 2} };

let result = JSON.parse(JSON.stringify());

console.log(result);

破产版的深拷贝也不是不可以用,但是遇到一些特殊场合它就不够用了,例如:

  • 如果源对象中有时间对象,反序列化后得到的是个字符串而非时间对象
  • 如果源对象中有RegExp、Error对象,则序列化的结果是空对象
  • 如果源对象中有函数、undefined,序列化时候会丢掉它们
  • 如果源对象里有NaN、Infinity和-Infinity,则序列化的结果会变成null
  • JSON.stringify()只能序列化对象的可枚举的自有属性
  • 对象中存在循环引用的情况也无法正确实现深拷贝

完美版

以下代码来自 ConardLi.github.io/demo/deepClone/src/clone_6.js,阅读代码可以直接从clone方法开始,我为代码添加了注释。


// 在对象上调用toString方法之后不同的数据会得到不同的字符串

// 可以继续遍历的类型
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];

// 更快的迭代数组的forEach方法
function forEach(array, iteratee) {
    let index = -1;
    const length = array.length;
    while (++index < length) {
        iteratee(array[index], index);
    }
    return array;
}

// 是否是对象
function isObject(target) {
    const type = typeof target;
    return target !== null && (type === 'object' || type === 'function');
}

// 获取类型
function getType(target) {
    return Object.prototype.toString.call(target);
}

// 初始化被克隆的对象-用要拷贝对象的constructor新创建一个对象
function getInit(target) {
    const Ctor = target.constructor;
    return new Ctor();
}

// 克隆Symbol类型
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();
    // 通过是否含有prototype判断是否是箭头函数
    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 clone(target, map = new WeakMap()) {

    // 对于基本类型直接返回自身即可
    if (!isObject(target)) {
        return target;
    }

    // 获得要复制的对象类型,不同类型的对象复制方法不同
    const type = getType(target);
    let cloneTarget;
    if (deepTag.includes(type)) {
        cloneTarget = getInit(target, type);
    } else {
        return cloneOtherType(target, type);
    }

    // 防止循环引用
    if (map.get(target)) {
        return map.get(target);
    }
    map.set(target, cloneTarget);

    // 克隆set
    if (type === setTag) {
        target.forEach(value => {
            cloneTarget.add(clone(value, map));
        });
        return cloneTarget;
    }

    // 克隆map
    if (type === mapTag) {
        target.forEach((value, key) => {
            cloneTarget.set(key, clone(value, map));
        });
        return cloneTarget;
    }

    // 克隆对象和数组
    const keys = type === arrayTag ? undefined : Object.keys(target);
    forEach(keys || target, (value, key) => {
        if (keys) {
            key = value;
        }
        cloneTarget[key] = clone(target[key], map);
    });

    return cloneTarget;
}

写在后面的话

拷贝对象有多种方式,实际业务中我们要根据业务场景选择最适合的那一种。比如如果对象只有一层,且key和value都是简单的字符串,那么本文提到的大多数方法都是有效的。如果你的场景要深拷贝一个复杂的对象,那就要好好思考一下了。

本文提到的深拷贝再深入研究的话,可以衍生出来好多值得探究的问题,例如:

  • 对象类型判断
  • 如何克隆函数
  • 如何克隆正则

-……
后面有时间再深入研究这几点吧。

参考资料

你可能感兴趣的:(javascript,深拷贝,面试)