来!跟我一起手写「深拷贝」

96 阅读6分钟

源码仓库

这是前端面试中「屡教不改」的一道题,所谓 屡教不改 的意思是:网上有超多教程,但是新手总是会写错,就算通知明天面试要考深拷贝,可能也做不好,或者说想做到 60 分都很难

因为深拷贝要牵扯到的知识点比较多,而且是平时业务代码中不怎么用到的

一、什么是深拷贝

简单解释:ba 的一份拷贝,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 个缺点:

  1. 不支持函数

    const a1 = {
      fn: function () {},
      name: 'a1'
    }
    
    const a2 = JSON.parse(JSON.stringify(a1))
    console.log(a2) // 输出:{name: 'a1'}
    

    如果被拷贝的对象有函数的话,js 会忽略这个函数(js 在以前在序列化的时候是会报错,再后来就改了,有函数就直接忽略)

  2. 不支持 undefined

    const a1 = {
      fn: undefined,
      name: 'a1'
    }
    
    const a2 = JSON.parse(JSON.stringify(a1))
    console.log(a2) // 输出:{name: 'a1'}
    

    JSON 只支持 null,不支持 undefined

  3. 不支持引用

    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 只支持 树状结构,不支持 环状结构

  4. 不支持 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 格式

  5. 不支持正则

    const a1 = {
      regex: /hi/,
      name: 'a1'
    }
    
    const a2 = JSON.parse(JSON.stringify(a1))
    console.log(a2) // 输出:{regex: {}, name: 'a1'}
    

    此时输出的 regex 是一个空对象,因为 JSON 不支持正则表达式

  6. 不支持 symbol

    const a1 = {
      symbol: Symbol(),
      name: 'a1'
    }
    
    const a2 = JSON.parse(JSON.stringify(a1))
    console.log(a2) // 输出:{name: 'a1'}
    

    此时输出直接忽略,因为 JSON 不支持 symbol

  7. 更加具体可以在 json.org 上看 value 这一项,里面很清楚的写了 JSON 支持的值的类型只有

    • string
    • array
    • string
    • number
    • "true"
    • "false"
    • "null"

3. 如何解决缺点

想要解决以上各种 JSON 不支持的数据类型,那么就需要使用 递归克隆

三、递归克隆

1. 需要注意的点

递归克隆的原理特别特别简单,但是有很多 需要注意的点

  1. JSON 克隆不支持函数、引用、undefined、Date、RegExp 等(上面已经提过
  2. 递归克隆要考虑环、爆栈
  3. 要考虑 DateRegExpFunction 等特殊对象的克隆方式
  4. 要不要克隆 __proto__,如果要克隆,就非常浪费内存;如果不克隆,就不是深克隆

2. 思路

  • 递归
    • 看节点的数据类型(8种)
    • 如果是基本类型就直接拷贝
    • 如果是 object 就分情况处理
    • 使用 instanceof 判断对象类型,而不是 typeoftypeof 在判断引用类型的时候,除了 function 会被识别出来之外,其余都会返回 object,而 instanceof 用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上,可以准确的判断出引用类型
  • object 分为
    • 普通 object - for in 如何处理?(for in 有个 bug 你知道吗?)
    • 数组 array - 数组如何初始化?而且数组上面还可以有其他对象
    • 函数 function - 函数如何拷贝?闭包如何拷贝?
    • 日期 Date - 日期如何拷贝?

3. 开始写代码前初始化

我分为以下步骤:

  1. 创建目录
    在文件目录下新建 src/index.jstest/index.js
    (这里用 JS 而不用 TS 写,是因为深拷贝的类型比较蛋疼)
  2. 引入 chaisinon
    • 如果不测试的话,根本不知道写得对还是错,要不停的手动测试会非常浪费时间,还不如直接自动化测试
    • 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 查看测试结果,接下来我们要做的就是不停的写测试用例,然后把失败的测试用例变成成功的测试用例
  3. 测试驱动开发
  4. 测试失败 -> 改代码 -> 测试成功 -> 添加测试 -> 测试失败...
  5. 这就是个「永动机」

4. 开始写代码

  1. 能够复制基本类型

    代码链接

    // 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
    
  2. 能够复制普通对象 代码链接

  3. 能够复制数组对象 代码链接

  4. 能够复制函数 代码链接

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(正则表达式)

我们应该拿到正则里的 文本标志,正则里面有两个很重要的属性,sourceflags

2. 可以复制日期 Date

代码链接

七、自动跳过原型属性

代码链接

如果支持拷贝原型,那么会占用很多内存,所以一般是不拷贝,那么我们就让它自动跳过原型属性

我们在做遍历的时候用的是 for infor in 会默认原型上的属性,那么我们需要做一个判断:如果是本身的属性,再去复制他

八、解决 cache 被全局共享造成的污染问题

代码链接

通过面向对象,或者使用闭包(我这里使用面向对象)

九、可以复制其它复杂类型

1. 可以复制 Set

代码链接

2. 可以复制 Map

代码链接

十、总结

深拷贝还是很考察基础知识的,但是实现起来并不难