焦虑很多时候就是因为想的太多
简明扼要
- JS在语言层面仅支持浅复制,深复制需要手动实现
- instanceof 判断的是 a和A是否有血缘关系
- 扩展运算符在副本中直接定义新的属性
Object.assign()
通过赋值的方式来处理副本中对应属性- 赋值操作调用自己或者继承的
setter
函数,而定义属性不是 __proto__
是由Object
类通过一个getter
和一个setter
实现的'__proto__' in {}
// true'__proto__' in { __proto__: null }
- 通过JSON对数据进行深复制,只能处理JSON所能识别的
key
和value
- 通过循环处理来解决深复制爆栈问题
- 遍历树结构,
1. 深度优先非递归遍历 用栈(stack
)实现
2. 广度优先非递归遍历(层序遍历)用队列(queue
)来实现的
文章概要
- 浅复制 VS 深复制
- 浅谈浅复制
- 扩展运算符(
...
)复制对象和数组 Object.assign()
Object.getOwnPropertyDescriptors()
和Object.defineProperties()
- 深复制
- 掘金的文章
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 复制代码
这是一个很少出现的限制条件,因为对象的大多数属性都是可枚举的。
如果我们想要复制一个不可枚举的属性,可以同时使用
Object.getOwnPropertyDescriptors()
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) 复制代码
但是,如果我们通过扩展运算符进行对象复制,其 writable
和configurable
被重置为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所能识别的
key
和value
。对于不支持的类型,会被直接忽略掉。
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; } 复制代码
这段代码想必大家不会太陌生。实现逻辑就是
- 利用
for-in
对对象的属性进行遍历(自身属性+继承属性) source.hasOwnProperty(i)
判断是否是非继承的可枚举属性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; } } 复制代码
循环函数实现深复制
通过递归实现对象复制的方式,其实有一个很棘手的问题需要处理:递归爆栈。
而解决递归爆栈,有两种方式
- 消除尾递归
- 改用循环处理
很明显,我们的递归处理函数不适合第一种方式,那就采用第二种,将递归函数改成循环函数。
此时,我们需要维护一个简单的数据机构,用于追踪数据直接的关联关系。
字段 | 解释 |
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: 左右树的入栈顺序和遍历顺序相反