这是前端面试中「屡教不改」的一道题,所谓 屡教不改
的意思是:网上有超多教程,但是新手总是会写错,就算通知明天面试要考深拷贝,可能也做不好,或者说想做到 60 分都很难
因为深拷贝要牵扯到的知识点比较多,而且是平时业务代码中不怎么用到的
一、什么是深拷贝
简单解释:b
是 a
的一份拷贝,b
中没有对 a
中对象的引用
二、JSON 序列化
最简单的方法
1. 实现
假设现在有个对象 obj
const obj1 = {
a: 1,
b: [1, 2, 3],
c: { c1: 'cc1', c2: 'cc2' }
}
那么我们可以用最简单的方式 JSON 序列化
和 反序列化
:const obj2 = JSON.parse(JSON.stringify(obj1))
最终让我们来校验最终代码
const obj1 = {
a: 1,
b: [1, 2, 3],
c: { c1: 'cc1', c2: 'cc2' }
}
const obj2 = JSON.parse(JSON.stringify(obj1))\
// 修改属性值,用于校验
obj2.a = 2
console.log(obj1.a) // 1
obj2.b[1] = 222
console.log(obj1.b[1]) // 2
obj2.c.c1 = 'cccc'
console.log(obj1.c.c1) // cc1
运行代码,如果输出为
1
2
cc1
那么就成功深拷贝了
2. 缺点
以上方案有 3 个缺点:
-
不支持函数
const a1 = { fn: function () {}, name: 'a1' } const a2 = JSON.parse(JSON.stringify(a1)) console.log(a2) // 输出:{name: 'a1'}
如果被拷贝的对象有函数的话,js 会忽略这个函数(js 在以前在序列化的时候是会报错,再后来就改了,有函数就直接忽略)
-
不支持
undefined
const a1 = { fn: undefined, name: 'a1' } const a2 = JSON.parse(JSON.stringify(a1)) console.log(a2) // 输出:{name: 'a1'}
JSON 只支持
null
,不支持undefined
-
不支持引用
const a1 = { name: 'a1' } a1.self = a1 // 让 a 的 self 引用自己 const a2 = JSON.parse(JSON.stringify(a1)) console.log(a2) // 报错:TypeError: Converting circular structure to JSON
因为 JSON 只支持
树状结构
,不支持环状结构
-
不支持
Data
日期const a1 = { time: new Date(), name: 'a1' } const a2 = JSON.parse(JSON.stringify(a1)) console.log(a2) // 输出:{time: 'ISO 8601字符串', name: 'a1'}
此时输出的
time
并不是new Date()
,而是new Date()
的ISO 8601
格式 -
不支持正则
const a1 = { regex: /hi/, name: 'a1' } const a2 = JSON.parse(JSON.stringify(a1)) console.log(a2) // 输出:{regex: {}, name: 'a1'}
此时输出的
regex
是一个空对象,因为JSON
不支持正则表达式 -
不支持
symbol
const a1 = { symbol: Symbol(), name: 'a1' } const a2 = JSON.parse(JSON.stringify(a1)) console.log(a2) // 输出:{name: 'a1'}
此时输出直接忽略,因为
JSON
不支持symbol
-
更加具体可以在 json.org 上看
value
这一项,里面很清楚的写了 JSON 支持的值的类型只有string
array
string
number
"true"
"false"
"null"
3. 如何解决缺点
想要解决以上各种 JSON
不支持的数据类型,那么就需要使用 递归克隆
三、递归克隆
1. 需要注意的点
递归克隆的原理特别特别简单,但是有很多 需要注意的点
:
- JSON 克隆不支持函数、引用、undefined、Date、RegExp 等(上面已经提过
- 递归克隆要考虑环、爆栈
- 要考虑 Date、RegExp、Function 等特殊对象的克隆方式
- 要不要克隆
__proto__
,如果要克隆,就非常浪费内存;如果不克隆,就不是深克隆
2. 思路
- 递归
- 看节点的数据类型(8种)
- 如果是基本类型就直接拷贝
- 如果是
object
就分情况处理 - 使用
instanceof
判断对象类型,而不是typeof
,typeof
在判断引用类型的时候,除了function
会被识别出来之外,其余都会返回object
,而instanceof
用于检测构造函数的prototype
属性是否出现在某个实例对象的原型链上,可以准确的判断出引用类型
- object 分为
- 普通 object -
for in
如何处理?(for in
有个 bug 你知道吗?) - 数组 array - 数组如何初始化?而且数组上面还可以有其他对象
- 函数 function - 函数如何拷贝?闭包如何拷贝?
- 日期 Date - 日期如何拷贝?
- 普通 object -
3. 开始写代码前初始化
我分为以下步骤:
- 创建目录
在文件目录下新建src/index.js
和test/index.js
(这里用JS
而不用TS
写,是因为深拷贝的类型比较蛋疼) - 引入
chai
和sinon
- 如果不测试的话,根本不知道写得对还是错,要不停的手动测试会非常浪费时间,还不如直接自动化测试
yarn init -y
- 在
package.json
中添加"scripts": { "test": "mocha test/**/*.js" }, "devDependencies": { "chai": "^4.2.0", "mocha": "^6.2.0", "sinon": "^7.4.1", "sinon-chai": "^3.3.0" }
- 运行
yarn install
- 在
src/index.js
中写以下代码function deepClone() {} module.exports = deepClone
- 在
test/index.js
中写以下代码const chai = require('chai') const sinon = require('sinon') const sinonChai = require('sinon-chai') chai.use(sinonChai) const assert = chai.assert const deepClone = require('../src/index') describe('deepClone', () => { it('是一个函数', () => { assert.isFunction(deepClone) }) })
- 运行
yarn test
查看测试结果,接下来我们要做的就是不停的写测试用例,然后把失败的测试用例变成成功的测试用例
- 测试驱动开发
- 测试失败 -> 改代码 -> 测试成功 -> 添加测试 -> 测试失败...
- 这就是个「永动机」
4. 开始写代码
-
能够复制基本类型
// test/index.js it('能够复制基本类型', () => { const n = 123 const n2 = deepClone(n) assert(n === n2) })
// src/index.js function deepClone(source) { return source } module.exports = deepClone
-
能够复制普通对象 代码链接
-
能够复制数组对象 代码链接
-
能够复制函数 代码链接
function deepClone(source) {
if (source instanceof Object) {
if (source instanceof Array) {
const dist = new Array()
for (let key in source) {
dist[key] = deepClone(source[key])
}
return dist
} else if (source instanceof Function) {
const dist = function () {
return source.apply(this, arguments)
}
for (let key in source) {
dist[key] = deepClone(source[key])
}
return dist
} else {
const dist = new Object()
for (let key in source) {
dist[key] = deepClone(source[key])
}
return dist
}
}
return source
}
到了这里,以上的代码足够面试拿到 80 分了,但是还有不足,请看下面
四、环检测
这里是从 80 分到 90 分的过程
我们上面遇到了递归,但是递归总有一个出口,那么,如果一个对象里有个属性存的是这个对象的地址,那么就会从递归里出不来,这也叫 —— 环
那么如何解决呢?
如果发现这个对象已经找过,那么就直接用那个引用就可以了
五、考虑爆栈
如果一个对象特别 深
,比如一个对象里面有个属性,这个属性里面又有个属性,按照这样的规律重复2万次,就会爆栈
因为我们用到了递归,递归他会使用调用栈,如果这个栈的长度为2万,那么超过2万时就会爆栈
解决办法是对它的结构进行改造,用循环的方式把它们放进一个数组里
我这里不考虑写它,因为一般不用考虑它
六、拷贝 RegExp 和 Date
1. 可以复制正则 RegExp
我们首先要了解一下正则 —— MDN - RegExp(正则表达式)
我们应该拿到正则里的 文本
和 标志
,正则里面有两个很重要的属性,source
和 flags
2. 可以复制日期 Date
七、自动跳过原型属性
如果支持拷贝原型,那么会占用很多内存,所以一般是不拷贝,那么我们就让它自动跳过原型属性
我们在做遍历的时候用的是 for in
,for in
会默认原型上的属性,那么我们需要做一个判断:如果是本身的属性,再去复制他
八、解决 cache
被全局共享造成的污染问题
通过面向对象,或者使用闭包(我这里使用面向对象)
九、可以复制其它复杂类型
1. 可以复制 Set
2. 可以复制 Map
十、总结
深拷贝还是很考察基础知识的,但是实现起来并不难