[JavaScript编码能力]浅拷贝和深拷贝

赋值(Copy)

赋值是将某一数值或对象赋给某个变量的过程,分为两种情况:

  • 基本数据类型:赋值,赋值之后两个变量互不影响。
  • 引用数据类型:赋值(引用地址),两个变量具有相同的引用,指向同一个对象,互相之间有影响。
//基本数据类型
let name = 'hello'
let name2 = name
console.log(name) // hello
console.log(name2) // hello
name2 = 'hello world'
console.log(name) // hello
console.log(name2) // hello world 修改了 name2 的值,不影响 name 的值
复制代码

内存中有一个变量name,值为hello。我们从变量name复制出一个变量name2,此时在内存中创建了一个块新的空间用于存储hello,虽然两者值是相同的,但是两者指向的内存空间完全不同,这两个变量参与任何操作都互不影响。

let obj = {
    name: '小刘',
    age: 18,
    info: {
        field: ['JS', 'CSS', 'HTML']
    }
}
let obj2 = obj
console.log(obj) // {name: "小刘", age: 18, info: {field: ["JS", "CSS", "HTML"]}}
console.log(obj2) // {name: "小刘", age: 18, info: {field: ["JS", "CSS", "HTML"]}}
obj2.name = '小孙'
obj2.info.field = ['JavaScript']
console.log(obj) // {name: "小孙", age: 18, info: {field: ["JavaScript"]}}
console.log(obj2) // {name: "小孙", age: 18, info: {field: ["JavaScript"]}}
复制代码

对引用类型进行赋值(引用地址)操作,两个变量指向同一个对象,改变变量 obj2 之后会影响变量 obj ,哪怕改变的只是对象 obj2 中的基本数据类型。

浅拷贝(Shallow Copy)

16ce894a1f1b5c32

创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。

  • 如果属性是基本类型,拷贝的就是基本类型的值;
  • 如果属性是引用类型,拷贝的就是内存地址 ;如果其中一个对象改变了这个地址,就会影响到另一个对象。

Object.assign() (ES6)

用于将所有可枚举属性的值从一个或多个源对象分配到目标对象。它将返回目标对象。

如果目标对象中的属性具有相同的键,则属性将被源对象中的属性覆盖。后面的源对象的属性将类似地覆盖前面的源对象的属性。

let target = {}
let source = { a: { b: 2 } }
Object.assign(target, source)
console.log(target) // 修改前 {a: {b: 2}} 修改后 {a: {b: 10}}
//如果我们修改了 b 的属性
source.a.b = 10
console.log(source) // {a: {b: 10}}
console.log(target) // {a: {b: 10}}
复制代码

由于修改后三个target里面的属性 b 都改变了,证明Objec.assign是一个浅拷贝。

注意:

  1. 拷贝的都是自有属性,不会拷贝对象继承的属性;
  2. 拷贝的都是可枚举属性;
  3. 可以拷贝symbol类型的属性;
  4. 原始类型会被包装为对象。

扩展运算符

实际上, 展开语法和 Object.assign() 行为一致, 执行的都是浅拷贝(只遍历一层)。

let obj = { a: 1, b: { c: 1 } }
let obj2 = { ...obj }

obj.a = 2
console.log(obj) // 修改二层属性前 {a: 2, b: {c: 1}} 修改二层属性后 {a: 2, b: {c: 2}}
console.log(obj2) // 修改二层属性前 {a: 1, b: {c: 1}} 修改二层属性后 {a: 2, b: {c: 2}}

obj.b.c = 2
console.log(obj) // {a: 2, b: {c: 2}}
console.log(obj2) // {a: 1, b: {c: 2}}
复制代码

Array.prototype.slice()

返回一个新的数组对象,这一对象是一个由 beginend 决定的原数组的浅拷贝(包括 begin,不包括end)。

let a = [0, "1", [2, 3]]
let b = a.slice(1)
console.log(b) // ["1", [4, 3]]

a[1] = '99'
a[2][0] = 4
console.log(a) // [0, "99", [4, 3]]
console.log(b) // ["1", [4, 3]]
复制代码

改变a[1]之后b[0]的值并没有发生变化,但是改变a[2][0]之后,相应的b[1][0]的值也发生变化。

说明slice()方法是浅拷贝。

Array.prototype.concat()

用于合并两个或多个数组。此方法不会更改现有数组,而是返回一个新数组。

let arr = [{ a: 1 }, { a: 1 }, { a: 1 }]
let arr2 = [{ b: 1 }, { b: 1 }, { b: 1 }]
let arr3 = arr.concat(arr2)
arr2[0].b = 123
console.log(arr3) // [{a: 1},{a: 1},{a: 1},{ b: 123},{b: 1},{b: 1}]
复制代码

改变arr2[0].b 之后,arr3的值也发生了变化,这说明concat也是浅拷贝。

手写浅拷贝

function shallowClone(source) {
    let target = {}
    for (const key in source) {
        if (source.hasOwnProperty(key)) {
            target[key] = source[key]
        }
    }
    return target
}
复制代码

深拷贝(Deep Copy)

16ce893a54f6c13d

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

JSON.parse(JSON.stringify())

  • JSON.stringify() 方法将一个 JavaScript 对象或值转换为 JSON 字符串。
  • JSON.parse() 方法用来解析 JSON 字符串,构造由字符串描述的 JavaScript 值或对象。

通过JSON.stringify()把一个对象序列化成为一个 JSON 字符串,将对象的内容转换成字符串的形式保存,再用JSON.parse() 反序列化将 JSON 字符串变成一个新对象。

const arr = [
    1, 6, {
        name: "小刘",
        age: 18
    }
]

let arr2 = JSON.parse(JSON.stringify(arr))
arr2[2].name = "小孙"
console.log(arr, arr2) // [1,6,{name: "小刘", age: 18}] [1,6,{name: "小孙", age: 18}] 
复制代码

需要注意的是:

let test = {
    num: 0,
    str: '',
    boolean: true,
    unf: undefined,
    nul: null,
    nan: NaN,
    infi: Infinity,
    infi1: -Infinity,
    obj: {
        name: '我是一个对象',
        id: 1
    },
    arr: [0, 1, 2],
    func: function () {
        console.log('我是一个函数')
    },
    date: new Date(0),
    reg: new RegExp('/我是一个正则/ig'),
    err: new Error('我是一个错误')
}

console.log(JSON.parse(JSON.stringify(test)))
复制代码

image-20210628132715522

//循环引用
let obj = {
    a: 1,
    b: {
        c: 2
    }
}
obj.a = obj.b
obj.b.c = obj.a

let test = JSON.parse(JSON.stringify(obj)) // Uncaught TypeError: Converting circular structure to JSON

//不可枚举属性被忽略
let test1 = JSON.stringify(
    Object.create(
        null,
        {
            x: { value: 'x', enumerable: false },
            y: { value: 'y', enumerable: true }
        }
    )
)
console.log(test1) // {"y":"y"}
复制代码
  1. JSON 会忽略undefiendsymbolfunction

  2. date对象Mon Jun 28 2021 13:28:59 GMT+0800 (中国标准时间)转变为字符串 “2021-06-28T05:28:49.680Z”。

    Date 日期调用了 toJSON() 将其转换为了 string 字符串(同Date.toISOString()),因此会被当做字符串处理。

  3. NaNInfinity-Infinity会转变为 null

  4. RegExpError会变成空对象 {}。

  5. 循环引用的情况下,会报Uncaught TypeError: Converting circular structure to JSON错误。

  6. 其他类型的对象,包括 Map/Set/WeakMap/WeakSet,仅会序列化可枚举的属性。

由此可见,使用 JSON 可以实现数组对象深拷贝,但是处理其它类型对象会有问题。

手写深拷贝(ConardLi大佬版)

深拷贝,考虑我们要拷贝的对象不知道有多少曾深度,我们可以用递归来解决问题:

  • 如果是原始类型,无需继续拷贝,直接返回;
  • 如果是引用类型,创建一个新的对象,遍历需要克隆的对象,将需要克隆的对象的属性执行深拷贝后依次添加到新对象上。

如果有更深层次的对象可以继续递归直到属性为原始类型,这样我们就完成了一个最简单的深拷贝:

function clone(source) {
    if (typeof source === 'object') {
        let target = {}
        for (const key in source) {
            if (source.hasOwnProperty(key)) {
                target[key] = clone(source[key])
            }
            return target
        }
    } else {
        return source
    }
}
复制代码

image-20210628181940498

考虑数组

我们可以看到,返回结果中数组的返回值是arr:{ 0:0, 1:1, 2:2 }。这说明我们没有判断对象是数组的情况。

function clone(source) {
    if (typeof source === 'object') {
        let target = Array.isArray(source) ? [] : {}
        for (const key in source) {
            target[key] = clone(source[key])
        }
        return target
    }
    return source
}
复制代码

image-20210628182603404

循环引用

const target = {
    field1: 1,
    field2: undefined,
    field3: {
        child: 'child'
    },
    field4: [2, 4, 8]
}
target.target = target
复制代码

image-20210628183602418

运行上面一段代码,我们发现栈内存溢出了。原因就是上面的对象存在循环引用的情况,即对象的属性间接或直接的引用了自身

解决循环引用的问题,我们可以额外开辟一个存储空间,来存储当前对象和拷贝对象的对应关系,当需要拷贝当前对象时,先去存储空间中寻找,有没有拷贝过这个对象,如果有的话直接返回,如果没有的话继续拷贝,这样就巧妙化解了循环引用的问题。

这个存储空间,需要可以存储key-value形式的数据,且key可以是一个引用类型,我们可以选择Map这种数据结构:

  • 检查map中有无克隆过的对象。
    • 有,直接返回。
    • 没有,将当前对象作为key,克隆对象作为value进行存储。
  • 继续克隆。
function clone(source, map = new Map()) {
    if (typeof source === 'object') {
        let target = Array.isArray(source) ? [] : {}
        if (map.get(source)) {
            return map.get(source)
        }
        map.set(source, target)
        for (const key in source) {
            target[key] = clone(source[key], map)
        }
        return target
    }
    return source
}
复制代码

image-20210628195335578

Map可以用WeakMap来代替,这样的好处是,不需要手动清除Map的属性,等下一次垃圾回收机制执行时,这块内存就会被释放掉。

坏处是,WeakMap是 ES6 新增的集合类型,兼容性没Map好。

性能优化

在上面的代码中,我们遍历数组和对象都使用了for in这种方式,实际上for in在遍历时效率是非常低的,下面我们来对比下常用的三种循环forwhilefor in的执行效率:

//先建立一个40000000级别的字符串数组
const array = new Array(40000000).fill('hello')
const length = array.length
let i = 0
let sum = 0
console.time('while')
while (i < length) {
    const element = array[i]
    sum += element
    i++
}
console.timeEnd('while') // while: 3408.120849609375 ms

console.time('for')
for (let i = 0; i < length; i++) {
    const element = array[i]
    sum += element
}
console.timeEnd('for') // for: 6242.840087890625 ms

console.time('for in')
sum = 0
for (const key in array) {
    const element = array[key]
    sum += element
}
console.timeEnd('for in') // for in: 29896.768310546875 ms
复制代码

由此可见,while的效率要高于forfor in。我们将for in遍历改写为while遍历。

我们先使用while来实现一个通用的foreach遍历,iteratee是遍历的回调函数,它可以接收每次遍历的valueindex两个参数:

function forEach(array, iteratee) {
    let index = -1
    const length = array.length
    while (++index < length) {
        iteratee(array[index], index)
    }
    return array
}
复制代码

下面我们对clone函数进行改写:

  • 当遍历数组时,直接使用forEach进行遍历;
  • 当遍历对象时,使用Object.keys取出所有的key进行遍历;
  • 然后在遍历时把forEach回调函数的value当做key使用。
function clone(source, map = new WeakMap()) {
    //是对象的情况下
    if (typeof source === 'object') {
        //判定是数组还是对象
        const isArray = Array.isArray(source)
        let target = isArray ? [] : {}

        //map中有克隆过的对象直接返回
        if (map.get(source)) {
            return map.get(source)
        }
        //map中没有克隆过的对象进行存储
        map.set(source, target)

        //遍历数组时,使用forEach遍历;遍历对象时,使用 Object.keys (返回 key 组成的数组)取出所用的 key 进行遍历
        const keys = isArray ? undefined : Object.keys(source)
        // undefined || array -> array
        // array || object -> array
        forEach(keys || source, (value, key) => {
            //如果是数组
            if (keys) {
                //将回调函数的 value 当做 key 使用
                key = value
            }
            //将对象储存在 target 中 ,多层对象递归
            target[key] = clone(source[key], map)
        })
        //返回对象
        return target

    } else {
        //返回原始类型
        return source
    }
}
复制代码

合理的判断引用类型

上面,我们只考虑了objectarray两种数据类型,实际上所用的引用类型还有很多。

我们还需要考虑functionnull两种特殊的数据类型。

function isObject(source) {
    const type = typeof source
    return source !== null && (type === 'object' || type === 'function')
}

if(!isObject(source)){
    return source
}
复制代码

获取数据类型

我们可以使用toString()来获取准确的引用类型:

每一个引用类型都有toString方法,默认情况下,toString()方法被每个Object对象继承。如果此方法在自定义对象中未被覆盖,toString() 返回 "[object type]",其中type是对象的类型。

注意,上面提到了如果此方法在自定义对象中未被覆盖,toString才会达到预想的效果,事实上,大部分引用类型比如Array、Date、RegExp等都重写了toString方法。

我们可以直接调用Object原型上未被覆盖的toString()方法,使用call来改变this指向来达到我们想要的效果。

function getType(source){
    return Object.prototype.toString.call(source)
}
复制代码

下面我们抽离出一些常用的数据类型以便后面使用:

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

const boolTag = '[object Boolean]'
const dateTag = '[object Date]'
const errorTag = '[object Error]'
const numberTag = '[object Number]'
const regexpTag = '[object RegExp]'
const stringTag = '[object String]'
const symbolTag = '[object Symbol]'
复制代码

在上面的集中类型中,我们简单将他们分为两类:

  • 可以继续遍历的类型。
  • 不可以继续遍历的类型。

我们分别为它们做不同的拷贝。

可继续遍历的类型

objectarrayMapSet这几种类型都属于可持续遍历的类型,需要进行递归。

我们首先要获得它们的初始化数据,例如上面的[]{},我们可以通过拿到constructor的方式来通用的获取。

这种方法有一个好处:因为我们还使用了原对象的构造方法,所以他可以保留对象原型上的数据,如果直接使用普通的{},那么原型必然会丢失的。

function clone(source, map = new WeakMap()) {
    //克隆原始类型
    if (!isObject(source)) {
        return source
    }
    //初始化
    const type = getType(source)
    let target
    if (deepTag.includes(type)) {
        target = getInit(source, type)
    }

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

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

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

    //克隆对象和数组
    //运算符优先级 === > ...? :... > = 
    const keys = type === arrayTag ? undefined : Object.keys(source)
    forEach(keys || source, (value, key) => {
        if (keys) {
            key = value
        }
        target[key] = clone(target[key], map)
    })
    return target
}
复制代码

不可继续遍历的类型

其他剩余的类型我们把它们统一归类成不可处理的数据类型,我们依次进行处理:

BooleanNumberStringStringDateError这几种类型我们都可以直接用构造函数和原始数据创建一个新对象:

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
    }
}
复制代码

克隆Symbol类型:

function cloneSymbol(targe) {
    return Object(Symbol.prototype.valueOf.call(targe))
}
复制代码

克隆Regexp类型:

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

克隆function类型:

实际上克隆函数是没有实际应用场景的,两个对象使用一个在内存中处于同一个地址的函数也是没有任何问题的,lodash对函数的处理是直接返回:

const isFunc = typeof value == 'function'
 if (isFunc || !cloneableTags[tag]) {
        return object ? value : {}
 }
复制代码

下面来分析一下函数类型怎么克隆:

  1. 首先,我们可以通过protptype来区分箭头函数和普通函数,箭头函数是没有prototype的。
  2. 我们可以直接使用eval和函数字符串来重新生成一个箭头函数。
    • 这种方法不适用于普通函数。
  3. 我们可以用正则来处理普通函数。
    • 分别使用正则取出函数体和函数参数,然后使用new Function([arg1,[arg2,[...argN]]],functionBody)构造函数重新构造一个新的函数。
function cloneFunction(func) {
    const bodyReg = /(?<={)(.|\n)+(?=})/m
    const paramReg = /(?<=\().+(?=\)\s+{)/

    const funcString = func.toString()
    if (func.prototype) {
        console.log('普通函数')
        const param = paramReg.exec(funcString)
        const body = bodyReg.exec(funcString)
        if (body) {
            console.log('匹配到函数体:', body[0])
            if (param) {
                const paramArr = param[0].split[',']
                console.log('匹配到参数:', paramArr)
                return new Function(...paramArr, body[0])
            } else {
                return new Function(body[0])
            }
        } else {
            return null
        }
    } else {
        return eval(funcString)
    }
}
复制代码

综合

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

//通用 while 循环
function forEach(array, iteratee) {
    let index = -1
    const length = array.length
    while (++index < length) {
        iteratee(array[index], index)
    }
    return array
}
//判断是否为引用类型
function isObject(source) {
    const type = typeof source
    return source !== null && (type === 'object' || type === 'function')
}
//获取实际类型
function getType(source) {
    return Object.prototype.toString.call(source)
}
//初始化被克隆的对象
function getInit(source) {
    const Ctor = source.constructor
    return new Ctor()
}
//克隆Symbol
function cloneSymbol(targe) {
    return Object(Symbol.prototype.valueOf.call(targe))
}
//克隆正则
function cloneReg(targe) {
    //意思是匹配字符串尾部字母
    const reFlags = /\w*$/
    //targe.constructor 就是 RegExp 构造函数
    //正则分为源码(source)和修饰符(flags),targe.source 获取源码,也就是//里面的数据,reFlags.exec(targe) 获取修饰符,也就是 //后面的gim
    const result = new targe.constructor(targe.source, reFlags.exec(targe))
    //克隆lastIndex,lastIndex 表示每次匹配时的开始位置。
    result.lastIndex = targe.lastIndex
    return result
}
//克隆函数
function cloneFunction(func) {
    //后行断言 匹配 { + (非\n\r的所有字符或\r) + 先行断言 匹配 } ,也就是匹配函数体
    //后行断言 匹配 ( + 非\n\r的所有字符 + 先行断言 匹配 ) + 空格 + { ,也就是匹配函数参数
    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:
            return Object(Boolean.prototype.valueOf.call(targe)) //为了修正Boolean(false)判定为true
        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(source, map = new WeakMap()) {

    // 原始类型直接返回
    if (!isObject(source)) {
        return source
    }

    // 初始化
    const type = getType(source)
    let target
    if (deepTag.includes(type)) {
        target = getInit(source, type)
    } else {
        return cloneOtherType(source, type)
    }

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

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

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

    // 克隆对象和数组
    // 优先级 === > ?: > =
    const keys = type === arrayTag ? undefined : Object.keys(source)
    forEach(keys || source, (value, key) => {
        //对象的情况下,value 当做 key 使用
        if (keys) {
            key = value
        }
        //递归
        target[key] = clone(source[key], map)
    })

    return target
}
复制代码

测试:

const map = new Map()
map.set('key', 'value')
map.set('xiaoliu', 'hello world')

const set = new Set()
set.add('xiaoliu')
set.add('hello world')

const source = {
    field1: 1,
    field2: undefined,
    field3: {
        child: 'child'
    },
    field4: [2, 4, 8],
    empty: null,
    map,
    set,
    bool: new Boolean(false),
    num: new Number(2),
    str: new String(2),
    symbol: Object(Symbol(1)),
    date: new Date(),
    reg: /\d+/,
    error: new Error(),
    func1: () => {
        console.log('hello world')
    },
    func2: function (a, b) {
        return a + b
    }
}
复制代码

image-20210630091816645

参考:

如何写出一个惊艳面试官的深拷贝?

js 深拷贝 vs 浅拷贝

【进阶4-1期】详细解析赋值、浅拷贝和深拷贝的区别

浅拷贝和深拷贝(较为完整的探索)

Object.assign()

聊聊对象深拷贝和浅拷贝

这一次彻底掌握深拷贝

深拷贝的终极探索(90%的人都不知道)

头条面试官:你知道如何实现高性能版本的深拷贝嘛?

JavaScript深拷贝的一些坑

低门槛彻底理解JavaScript中的深拷贝和浅拷贝

深入深入再深入 js 深拷贝对象

这一次彻底掌握深拷贝

如何 clone 一个正则?

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享