前端 浅拷贝和深拷贝

作者: Wendy曹 | 来源:发表于2018-12-06 15:12 被阅读11次

    一、什么是浅拷贝、什么是深拷贝

    我们都知道js的数据类型分为基本类型和引用类型,一般讨论到浅拷贝和深拷贝的都是针对引用类型的,像Object和Array这样的复杂类型,

    1、浅拷贝:以Object为例
    var  a  =  {
        name:  'Wendy'
    };
    
    var  b  =  a;
    b.name  =  'Lily';
    console.log(b.name);    // Lily
    console.log(a.name);    // Lily
    

    可以看出,对于Object类型,当我们将a赋值给b,然后更改b中的属性,a也会随着变化。
    也就是说a和b指向了同一块内存,所以修改其中任意的值,另一个值都会随之变化,这就是浅拷贝。

    2、深拷贝

    如果给b放到新的内存中,将a的各个属性都复制到新内存里,就是深拷贝。
    也就是说,当b中的属性有变化的时候,a内的属性不会发生变化。

    1.png

    二、浅拷贝的实现

    这里说两个实现方式:

    1、Object.assign()

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

    var  target  =  {a:  1,  b:  1};
    var  obj1  =  {a:  2,  b:  2,  c: {ca:1}};
    var  obj2  =  {c:  {ca:  3,  cb:  2,  cd:  1}};
    var  result  =  Object.assign(target,  obj1,  obj2);
    
    console.log(target);    // {a: 2, b: 2, c: {ca: 31, cb: 32, cc: 33}}
    console.log(target  ===  result);    // true
    

    可以看到,Object.assign()拷贝的只是属性值,假如源对象的属性值是一个指向对象的引用,它也只拷贝那个引用值。所以Object.assign()只能用于浅拷贝或是合并对象。这是Object.assign()值得注意的地方。

    2、函数实现

    function shallowClone(source) {
        var target = {};
        for(var i in source) {
            if (source.hasOwnProperty(i)) {
                target[i] = source[i];
            }
        }
    
        return target;
    }
    
    var obj = {a:1, b:2, c:[1,2,3], d:{da:1}}
    var clone = shadowClone(obj) //{a:1, b:2, c:[1,2,3], d:{da:1}}
    

    三、深拷贝的实现

    1、JSON.parse和JSON.stringify

    对于 JSON 安全(也就是说可以被序列化为一个 JSON 字符串并且可以根据这个字符串解析出一个结构和值完全一样的对象)的对象来说,有一种巧妙的复制方法:

    var clone = JSON.parse(JSON.stringify(target))      
    

    当然,这种方法需要保证对象是 JSON 安全的,所以只适用于部分情况。

    2、浅拷贝+递归

    function clone(source) {
        var target = {};
        for(var i in source) {
            if (source.hasOwnProperty(i)) {
                if (typeof source[i] === 'object') {
                    target[i] = clone(source[i]); // 注意这里
                } else {
                    target[i] = source[i];
                }
            }
        }
    
        return target;
    }
    

    但是这样写还不够严谨,比如:

    • 没有对参数做校验
    • 判断是否是对象的逻辑不够严谨
    • 如果用了严谨的对象判断,那么就没有考虑到数组的情况

    先看第一个,函数需要校验参数,如果不是对象直接返回

    function clone(source) {    
      if (!isObject(source)) 
      return source;    // xxx
    }
    

    第二个typeof校验实际上只能区分基本类型和引用类型,其对于Date、RegExp、Array类型返回的是"object"。

    2.png

    目前判断一个对象类型的最好的办法是Object.prototype.toString.call()

    function isObject(x) {
      return Object.prototype.toString.call(x) === '[object Object]';
    }
    

    再抽象一些

    var types = ["Array", "Boolean", "Date", "Number", "Object", "RegExp", "String", "Window", "HTMLDocument"];
    for(var i = 0, c = types[i ];i<types.length;I++ ){
        is[c] = (function(type){
            return function(obj){
              return Object.prototype.toString.call(obj) == "[object " + type + "]";
            }
        )(c);
    }
    

    完善下第三个问题就是

    function isObject(x) {return Object.prototype.toString.call(x) === '[object Object]';}
    
    
    function clone(source) {
        var target = {};
        if(!isObject(source)) target = source;
        else{
          for(var i in source) {
            if (source.hasOwnProperty(i)) {
                if (isObject(source[i]) || Array.isArray(source[i])) {
                    target[i] = clone(source[i]); // 注意这里
                } else {
                    target[i] = source[i];
                }
            }
          }
        }
    
        return target;
    }
    

    其实递归方法最大的问题在于爆栈,当数据的层次很深,需要同时保存成千上百个调用记录,很容易发生"栈溢出"错误(stack overflow)

    我们用斐波拉契数列为例,普通递归写法:

    function f(n) {
      if (n === 0 || n === 1) return n 
      else return f(n - 1) + f(n - 2)
    }
    

    这种写法,简单粗暴,但是有个很严重的问题。调用栈随着n的增加而线性增加,当n为一个大数时,就会爆栈了(栈溢出,stack overflow)。这是因为这种递归操作中,同时保存了大量的栈帧,调用栈非常长,消耗了巨大的内存。

    三、破解递归爆栈

    其实破解递归爆栈的方法有两条路,第一种是消除尾递归,但在这个例子中貌似行不通,第二种方法就是干脆不用递归,改用循环,

    1、尾递归

    要说尾递归,就要先了解尾函数,尾函数就是指函数调用最后一步是调用另一个函数

    举个🌰:

    function f(x){  return g(x);}//属于尾调用
    function f(x){  let y = g(x);  return y;}// 不属于,因为调用函数g之后有其它操作
    function f(x){  return g(x) + 1;}//不属于,因为调用后还要其它操作,即使在同一行函数内
    

    递归函数是调用自身的函数,尾递归就是尾调用自身的函数,对尾递归来说,由于只存在一个调用记录,所以永远不会发生"栈溢出"错误。

    简单解释下栈溢出问题,由于函数调用会在内存形成一个"调用记录",又称"调用帧"(call frame),保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的调用记录上方,还会形成一个B的调用记录。等到B运行结束,将结果返回到A,B的调用记录才会消失。如果函数B内部还调用函数C,那就还有一个C的调用记录栈,以此类推。所有的调用记录,就形成一个"调用栈"(call stack)。

    3.png

    尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用记录,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用记录,取代外层函数的调用记录就可以了。

    接下来,把斐波拉契数列升级为尾递归看看

    function fTail(n, a=0, b=1){
      if(n===0) return a 
      else{
        return fTail(n-1, b, a+b)
      } 
    }
    fTail(5) => fTail(4, 1, 1) => fTail(3, 1, 2) => fTail(2, 2, 3) => fTail(1, 3, 5) => fTail(0, 5, 8) => return 5
    
    

    被尾递归改写之后的调用栈永远都是更新当前的栈帧而已,这样就完全避免了爆栈的危险

    但是,想法是好的,从尾调用优化到尾递归优化的出发点也没错,但是浏览器目前还没有支持


    4.png

    那么我们可以手动优化下:
    直接改函数内部,循环执行

    function fLoop(n, a = 0, b = 1) {
      while (n--) { 
        [a, b] = [b, a + b] 
      } 
      return a 
    }
    

    这个函数相对比较简单,我们把深拷贝代码用循环实现下:​​

    function cloneLoop(x) {
        const root = {};
    
        // 栈
        const loopList = [
            {
                parent: root,
                key: undefined,
                data: x,
            }
        ];
    
        while(loopList.length) {
            // 深度优先
            const node = loopList.pop();
            const parent = node.parent;
            const key = node.key;
            const data = node.data;
    
            // 初始化赋值目标,key为undefined则拷贝到父元素,否则拷贝到子元素
            let res = parent;
            if (typeof key !== 'undefined') {
                res = parent[key] = {};
            }
    
            for(let k in data) {
                if (data.hasOwnProperty(k)) {
                    if (typeof data[k] === 'object') {
                        // 下一次循环
                        loopList.push({
                            parent: res,
                            key: k,
                            data: data[k],
                        });
                    } else {
                        res[k] = data[k];
                    }
                }
            }
        }
    
        return root;
    }
    
    

    以上
    参考文章:
    You don't know JS(上)第109页
    深拷贝的终极探索
    JavaScript 调用栈、尾递归和手动优化
    尾调用优化

    在简书上发布相关文章是对自己不断学习的激励;如有什么写得不对的地方,欢迎批评指正;给我点赞的都是小可爱 ~_~

    相关文章

      网友评论

      • 苏星河:可以再讲讲深拷贝的循环引用问题

      本文标题:前端 浅拷贝和深拷贝

      本文链接:https://www.haomeiwen.com/subject/jrhjoftx.html