从另外一个角度看待——JS深浅复制

简介: 1. 浅复制 VS 深复制2. 浅谈浅复制• 扩展运算符(...)复制对象和数组• Object.assign()• Object.getOwnPropertyDescriptors()和Object.defineProperties()3. 深复制4. 掘金的文章


焦虑很多时候就是因为想的太多

简明扼要

  1. JS在语言层面仅支持浅复制,深复制需要手动实现
  2. instanceof 判断的是 a和A是否有血缘关系
  3. 扩展运算符在副本中直接定义新的属性
  4. Object.assign()通过赋值的方式来处理副本中对应属性
  5. 赋值操作调用自己或者继承的setter函数,而定义属性不是
  6. __proto__是由Object类通过一个getter和一个setter实现的
  7. '__proto__' in {} // true
    '__proto__' in { __proto__: null }
  8. 通过JSON对数据进行深复制,只能处理JSON所能识别的keyvalue
  9. 通过循环处理来解决深复制爆栈问题
  10. 遍历树结构,
    1. 深度优先非递归遍历 用栈(stack)实现
    2. 广度优先非递归遍历(层序遍历)用队列(queue)来实现的

文章概要

  1. 浅复制 VS 深复制
  2. 浅谈浅复制
  • 扩展运算符(...)复制对象和数组
  • Object.assign()
  • Object.getOwnPropertyDescriptors()Object.defineProperties()
  1. 深复制
  2. 掘金的文章

1. 浅复制 VS 深复制

针对JS引用类型数据(复杂数据)的复制,有两种处理模式。

  • 浅复制(Shallow Copying): 仅仅复制对象或数组类型的顶层变量,而变量的值和原数据的值是同一份
  • 深复制(Deep Copying):复制原数据的所有条目(key-value),它遍历完整的数据树(其根是要复制的值),并复制所有节点。

JS在语言层面仅支持浅复制,深复制需要手动实现


2. 浅谈浅复制

在JS中,存在几个内置属性天然支持数据浅复制,但是每个属性都有一定的适用条件和范围。

2.1 扩展运算符(...)复制对象和数组

const copyOfObject = {...originalObject};
const copyOfArray = [...originalArray];
复制代码

让我们简单描述一下,扩展运算符不足和特性。 在开始讲述之前,我们先做一个简单的总结:

不足&特性
扩展运算符不能复制普通对象的prototype属性
扩展运算符不能复制内置对象特殊属性(internal slots)
扩展运算符只复制对象的本身的属性(非继承)
扩展运算符只复制对象的可枚举属性(enumerable)
扩展运算符复制的数据属性都是可写的(writable)和可配置的(configurable)

扩展运算符不能复制普通对象的prototype属性

class MyClass {}
const original = new MyClass();
original instanceof MyClass // true
const copy = {...original};
copy instanceof MyClass  //false
复制代码

友情提示:a instanceof A 用于判断实例a的原型链中出现过相应的构造函数A,如果存在,则 instanceof 返回 true 。 instanceof 判断的是 a和A是否有血缘关系,而不是仅仅根据是否是父子关系。

let ar = [];
ar instanceof Array // true
ar instanceof Object // true
复制代码

我们在JS篇之数据类型那些事儿中有过对这方面的介绍,感兴趣可以自行查阅。

同时,还需要额外的唠叨一下,下面的语句是等价的。

obj instanceof SomeClass
SomeClass.prototype.isPrototypeOf(obj)
复制代码

通过上面的分析,如果将复制对象的prototype属性设置为同原始数据一样,就能解决扩展运算符不能复制对象prototype的问题。

const original = new MyClass();
const copy = {
  __proto__: Object.getPrototypeOf(original),
  ...original,
};
copy instanceof MyClass //true
复制代码

也可以先复制生成copy对象,然后通过Object.setPrototypeOf()来指定修改对象的__proto__属性。

扩展运算符不能复制内置对象的特殊属性

我们在前面介绍JS数据类型的时候,介绍了在浏览器宿主环境下,JS = ECMAScript + DOM + BOM。 而ECMAScript是语言核心,其中包含了一些内置对象:如Date/RegExp

而针对这些内置对象,扩展运算符无法复制它们特殊属性(这些属性在语言标准中也叫内部槽[internal slots])

let originalReg = new RegExp('789', 'g'); 
let copyReg = {...originalReg} // {} 
复制代码

originalReg,其值为/789/g,是一种表达文本模式(即字符串结构),有点像字符串的模板。但是通过.来访问其属性,发现该属性类型存在很多内部属性copyReg没有复制成功originalReg的内部属性。

扩展运算符只复制对象的本身的属性(非继承)

在下面的例子中,original的继承的属性inheritedProp没有出现在copy中。

其实,这最后都归结于通过扩展运算符复制的对象copy不能复制原数据的__proto__。(原型链方向)

const proto = { inheritedProp: 'a' };
const original = {
  __proto__: proto, 
  ownProp: 'b' 
};
const copy = {...original};
copy.inheritedProp// undefined
copy.ownProp // 'b'
复制代码

扩展运算符只复制对象的可枚举属性(enumerable)

虽然一些属性属于对象的自身属性,但是它是不可枚举的,这些属性也不能被复制。

例如:数组实例的length属于自身属性,但是不可枚举。

const arr = ['a', 'b'];
arr.length // 2
({}.hasOwnProperty).call(arr, 'length') // true
const copy = {...arr}; // (A)
({}.hasOwnProperty).call(copy, 'length') // false
复制代码

这是一个很少出现的限制条件,因为对象的大多数属性都是可枚举的。

如果我们想要复制一个不可枚举的属性,可以同时使用

  1. Object.getOwnPropertyDescriptors()
  2. Object.defineProperties()

Object.getOwnPropertyDescriptors()无论可枚举属性还是不可枚举属性都可以访问。并且,不仅仅是访问值,还可以访问getter/setter函数还有只读属性。

扩展运算符的默认行为

通过扩展运算符进行复制对象的时候,所复制的数据属性都是可写的(writable)和可配置的(configurable)。

属性的数据属性

内部属性 解释 默认值
Configurable 1. 属性是否可以通过 delete 删除并重新定义
2. 是否可以修改它的特性
3. 是否可以把它改为访问器属性
true
Enumerable 属性是否可以通过for-in循环返回 true
Writable 属性的值是否可以被修改 true
Value 包含属性实际的值 undefined

例如:通过Object.defineProperties()将属性prop设置为不可写和不可配置。

const original = Object.defineProperties(
  {}, {
    prop: {
      value: 1,
      writable: false,
      configurable: false,
      enumerable: true,
    },
  });
  original.prop = 789; // 赋值失败 (writable :false)
  delete original.prop; //删除失败 (configurable: false)
复制代码

但是,如果我们通过扩展运算符进行对象复制,其 writableconfigurable被重置为true

const copy = {...original};
Object.getOwnPropertyDescriptors(copy)
/* {
    prop: {
      value: 1,
      writable: true,
      configurable: true,
      enumerable: true,
    },
  }
*/
copy.prop = 789; // 赋值成功 (writable :true)
delete copy.prop; //删除成功 (configurable: true)
复制代码

同时,针对访问器属性(getter/setter)也是有类似的效果。

2.2 Object.assign()

Object.assign()的工作方式和扩展运算符类似。

const copy1 = {...original};
const copy2 = Object.assign({}, original);
复制代码

Object.assign()并非完全和扩展运算符等同,他们之间存在一些细微的差别。

  • 扩展运算符在副本中直接定义新的属性
  • Object.assign()通过赋值的方式来处理副本中对应属性

赋值操作调用自己或者继承的setter函数,而定义属性不是。

const original = {['__proto__']: null}; // (A)
const copy1 = {...original};
//copy1拥有属于自己的属性 (__proto__)
Object.keys(copy1) // ['__proto__']
const copy2 = Object.assign({}, original);
// copy2 prototype的值为null
Object.getPrototypeOf(copy2)// null
复制代码

在A行用表达式作为属性名,创建了一个__proto__的属性并且没有调用继承的setter函数。然后,通过Object.assign()方式复制的属性,是调用了setter函数(设置__proto__)。

这里再多说一句: __proto__是由Object类通过一个getter和一个setter实现的。

class Object {
  get __proto__() {
    return Object.getPrototypeOf(this);
  }
  set __proto__(other) {
    Object.setPrototypeOf(this, other);
  }
  // ···
}
复制代码

也就意味着,可以通过手动配置对象的__proto__:null,创建一个在原型链上没有Object.prototype存在的对象。

换句话说,就是通过手动配置__proto__:null切断对象的原型链

'__proto__' in {} // true
'__proto__' in { __proto__: null } //false 手动关闭连接
复制代码

2.3 Object.getOwnPropertyDescriptors()Object.defineProperties()

JavaScript允许我们通过属性描述符来创建属性。

function copyAllOwnProperties(original) {
  return Object.defineProperties(
    {}, Object.getOwnPropertyDescriptors(original));
}
复制代码

解决了通过扩展运算符复制对象的两个问题

1. 能够复制所有自有属性

解决扩展运算符无法复制原对象的setter/getter函数

const original = {
  get myGetter() { return 789 },
  set mySetter(x) {},
};
copy = copyAllOwnProperties(original);
copy.myGetter //789  
复制代码

2. 能够复制非枚举属性

const arr = ['a', 'b'];
arr.length //2 
({}.hasOwnProperty).call(arr, 'length') // true
const copy = copyAllOwnProperties(arr);
({}.hasOwnProperty).call(copy, 'length') // true
复制代码

3. 深复制

JS中深复制需要手动实现、

3.1 通过嵌套扩展运算符实现深复制

const original = {name: '789', work: {address: 'BeiJing'}};
const copy = {name: original.name, work: {...original.work}};
original.work !== copy.work // 指向不同的引用地址
复制代码

使用嵌套扩展运算符实现深复制,有一个很重要的前提条件就是:模板数据简单并且你对在何处使用扩展运算符了然于心。而对于复杂数据,就不太适用了。

3.2 使用JSON实现数据的深复制

我们先将普通对象,先转换为JSON串(stringify),然后再解析(parse)该串。

function jsonDeepCopy(original) {
  return JSON.parse(JSON.stringify(original));
}
const original = {name: '789', work: {address: 'BeiJing'}};
const copy = jsonDeepCopy(original);
original.work !== copy.work // 指向不同的引用地址
复制代码

而通过这种方式有一个很明显的缺点就是:

只能处理JSON所能识别的keyvalue。对于不支持的类型,会被直接忽略掉。

jsonDeepCopy({
    // 不支持 Symbols类型的值作为 key
    [Symbol('a')]: 'abc',
    // 不支持的值
    b: function () {},
    // 不支持的值
    c: undefined,
  }) // 返回一个空对象
复制代码

更有甚者,JSON不能识别一些新的数据类型,会直接报错。

jsonDeepCopy({a: 123n}) 
//Uncaught TypeError: Do not know how to serialize a BigInt
复制代码

3.3 手动实现

递归函数实现深复制

function clone(source) {
    let target = {};
    for(let i in source) {
        if (source.hasOwnProperty(i)) {
            if (typeof source[i] === 'object') {
                target[i] = clone(source[i]); // 递归处理
            } else {
                target[i] = source[i];
            }
        }
    }
    return target;
}
复制代码

这段代码想必大家不会太陌生。实现逻辑就是

  1. 利用 for-in对对象的属性进行遍历(自身属性+继承属性)
  2. source.hasOwnProperty(i)判断是否是非继承可枚举属性
  3. typeof source[i] === 'object'判断值的类型,如果是对象,递归处理

而上述代码,只能说是深复制的一个基础版本,其中还存在一些漏洞。

  • 没有对参数进行校验
  • 没有考虑数组的兼容
  • 判断是否对象的逻辑不够严谨

我们就简单的把上面的代码做一下简单的优化处理。(遍历对象的方式有很多,我们采用Object.entries())

function deepCopy(original) {
  if (Array.isArray(original)) {
     // 处理数组
    const copy = [];
    for (const [index, value] of original.entries()) {
      copy[index] = deepCopy(value);
    }
    return copy;
  } else if (typeof original === 'object' && original !== null) {
     // 处理对象
    const copy = {};
    for (const [key, value] of Object.entries(original)) {
      //使用Object.entries返回的是自身可枚举的键值对
      copy[key] = deepCopy(value);
    }
    return copy;
  } else {
    // 基本数据类型,直接返回
    return original;
  }
}
复制代码

然后,我们还可以搞一个更简约的版本:使用map()来更换for-of。使用Object.fromEntries()包装处理的对象。

function deepCopy(original) {
  if (Array.isArray(original)) {
    return original.map(elem => deepCopy(elem));
  } else if (typeof original === 'object' && original !== null) {
    return Object.fromEntries(
      Object.entries(original)
        .map(([k, v]) => [k, deepCopy(v)]));
  } else {
    // 基本数据类型,直接返回
    return original;
  }
}
复制代码

循环函数实现深复制

通过递归实现对象复制的方式,其实有一个很棘手的问题需要处理:递归爆栈

而解决递归爆栈,有两种方式

  1. 消除尾递归
  2. 改用循环处理

很明显,我们的递归处理函数不适合第一种方式,那就采用第二种,将递归函数改成循环函数。

此时,我们需要维护一个简单的数据机构,用于追踪数据直接的关联关系。

字段 解释
parent 保存数据直接关联
默认值 root = {}
key 当前遍历的key
默认值 undefined
data 当前遍历的value
默认值 x (初始数据)
function deepCopyWithLoop(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') {
                    // 如果对应k的值为对象,需要更新loopList
                    // parent:res 保存数据关联关系
                    loopList.push({
                        parent: res,
                        key: k,
                        data: data[k],
                    });
                } else {
                    // 简单数据类型:直接赋值
                    res[k] = data[k];
                }
            }
        }
    }
    return root;
}
复制代码

针对用循环函数改造递归函数还有一点简单聊聊。

如何遍历一个树结构的数据类型。想必大家肯定会脱口而出。用BFS/DFS。而BFS又分三类,前序(Preorder)/中序(Inorder)/后序(Postorder)。

树结构/深度优先/前序遍历/递归方式

function preOrderTraverseR(node) {
    if (node == null)  return;    // 基线条件(跳出递归的条件)
    console.log(node.data + " ");
    preOrderTraverse(node.left);
    preOrderTraverse(node.right);
}
复制代码

而如果说,现在不让用递归,让你对一个树进行遍历处理。你该如何处理呢。 记住一点。

遍历树结构,

1. 深度优先非递归遍历 用栈(stack)实现

2. 广度优先非递归遍历(层序遍历)用队列(queue)来实现的

树结构/深度优先/前序遍历/非递归方式

function preOrderTraverser(nodes) {
    let result = [];
    let stack = [nodes];
    while(stack.length) { 
        let node = stack.pop(); 
        result.push(node.value);
        //  左右树的入栈顺序和遍历顺序相反
        if(node.right) stack.push(node.right); 
        if(node.left) stack.push(node.left);
    }
    return result;
}
复制代码

note: 左右树的入栈顺序和遍历顺序相反


相关文章
|
5月前
|
JavaScript 前端开发 小程序
老程序员分享:js中自然日的计算
老程序员分享:js中自然日的计算
48 0
|
自然语言处理 JavaScript 前端开发
JavaScript深度剖析之变量、函数提升:从表面到本质
JavaScript深度剖析之变量、函数提升:从表面到本质
|
JavaScript 索引
面试官:怎样实现JS数组扁平化?(一)
面试官:怎样实现JS数组扁平化?(一)
|
存储 JavaScript 前端开发
20个JS精简代码无形装逼集合,最为致命,记得收藏好
20个JS精简代码无形装逼集合,最为致命,记得收藏好
|
设计模式 JavaScript 前端开发
【再来亿遍 温故知新】—— 关于 JS 原型你必须要知道的二三
本瓜一向认为:学习不是一蹴而就的事情。一定是要求学习者对知识点进行反复咀嚼拿捏、不断打破重塑,长此以往,才以期达到融会贯通、为我所用的程度。所谓:温故知新,不亦乐乎?
|
设计模式 编解码 前端开发
换一个角度来审视React
a. React是什么 • 前端场景下MVC架构 • Flux设计模式 • Redux + React = MVC b. JSX • 手动实现一个JSX转换器
117 0
|
JavaScript 前端开发 安全
|
JavaScript 前端开发
JS原始值创建背后发生的故事
Js初学者又或者是使用者都会产生这么一个疑惑: “我们声明的字符串变量为什么可以以类似对象的形式来调用方法,比如str.toString()” ,当然不只是字符串类型,还有布尔,数值类型,他们都属于原始值类型,本文将带你了解这三种原始值的创建,背后发生了什么,为什么可以以对象形式来调用方法,又或者是属性。
66 0
|
存储 前端开发 JavaScript
JavaScript数据深浅拷贝的深度解析(一文明白!)
JavaScript数据深浅拷贝的深度解析(一文明白!)