轻松拿下 JS 浅拷贝、深拷贝

Offer 驾到,掘友接招!我正在参与2022春招打卡活动,点击查看活动详情

本文将由浅入深地讲解浅拷贝和深拷贝,知识图谱如下:

image.png

深拷贝和浅拷贝的区别?

答:

浅拷贝和深拷贝都是创建一份数据的拷贝。

JS 分为原始类型和引用类型,对于原始类型的拷贝,并没有深浅拷贝的区别,我们讨论的深浅拷贝都只针对引用类型。

  • 浅拷贝和深拷贝都复制了值和地址,都是为了解决引用类型赋值后互相影响的问题。

  • 但是浅拷贝只进行一层复制,深层次的引用类型还是共享内存地址,原对象和拷贝对象还是会互相影响。

  • 深拷贝就是无限层级拷贝,深拷贝后的原对象不会和拷贝对象互相影响。

网络上的很多文章觉得引用类型赋值就是浅拷贝,误导了很多人,但 lodash 中的浅拷贝和深拷贝总不会错吧,这么多项目都在用。

为了验证上述理论的正确性,我们就用 lodash 来测试一下,lodash 中浅拷贝方法为 clone,深拷贝方法为 cloneDeep。

前置知识

两个对象指向同一地址, 用 == 运算符作比较会返回 true。

两个对象指向不同地址, 用 == 运算符作比较会返回 false。

const obj = {}
const newObj = obj

console.log(obj == newObj) // true
复制代码
const obj = {}
const newObj = {}

console.log(obj == newObj) // false
复制代码

引用类型互相赋值

直接赋值,两个对象指向同一地址,就会造成引用类型之间互相影响的问题:

const obj = {
  name: 'lin'
}

const newObj = obj
obj.name = 'xxx' // 改变原来的对象

console.log('原来的对象', obj)
console.log('新的对象', newObj)

console.log('两者指向同一地址', obj == newObj) 
复制代码

image.png

使用浅拷贝

使用 lodash 浅拷贝 clone 方法,让他们俩指向不同地址,即可解决这个问题:

import { clone } from 'lodash'

const obj = {
  name: 'lin'
}

const newObj = clone(obj)
obj.name = 'xxx'     // 改变原来的对象

console.log('原来的对象', obj)
console.log('新的对象', newObj)

console.log('两者指向同一地址', obj == newObj)
复制代码

image.png

但是浅拷贝只能解决一层,更深层的对象还是会指向同一地址,互相影响:

import { clone } from 'lodash'

const obj = {
  person: {
    name: 'lin'
  }
}

const newObj = clone(obj)
obj.person.name = 'xxx'    // 改变原来的对象

console.log('原来的对象', obj)
console.log('新的对象', newObj)

console.log('更深层的指向同一地址', obj.person == newObj.person)
复制代码

image.png

使用深拷贝

这个时候,就需要使用深拷贝来解决:

import { cloneDeep } from 'lodash'

const obj = {
  person: {
    name: 'lin'
  }
}

const newObj = cloneDeep(obj)
obj.person.name = 'xxx' // 改变原来的对象

console.log('原来的对象', obj)
console.log('新的对象', newObj)

console.log('更深层的对象指向同一地址', obj.person == newObj.person)
复制代码

image.png

理论验证完了,接下来我们就来实现浅拷贝和深拷贝。

实现浅拷贝

Object.assign

const obj = {
  name: 'lin'
}

const newObj = Object.assign({}, obj)

obj.name = 'xxx' // 改变原来的对象

console.log(newObj) // { name: 'lin' } 新对象不变

console.log(obj == newObj) // false 两者指向不同地址
复制代码

数组的 slice 和 concat 方法

const arr = ['lin', 'is', 'handsome']
const newArr = arr.slice(0)

arr[2] = 'rich' // 改变原来的数组

console.log(newArr) // ['lin', 'is', 'handsome']

console.log(arr == newArr) // false 两者指向不同地址
复制代码
const arr = ['lin', 'is', 'handsome']
const newArr = [].concat(arr)

arr[2] = 'rich' // 改变原来的数组

console.log(newArr) // ['lin', 'is', 'handsome'] // 新数组不变

console.log(arr == newArr) // false 两者指向不同地址
复制代码

数组静态方法 Array.from

const arr = ['lin', 'is', 'handsome']
const newArr = Array.from(arr)

arr[2] = 'rich' // 改变原来的数组

console.log(newArr) // ['lin', 'is', 'handsome']

console.log(arr == newArr) // false 两者指向不同地址
复制代码

扩展运算符

const arr = ['lin', 'is', 'handsome']
const newArr = [...arr]

arr[2] = 'rich' // 改变原来的数组

console.log(newArr) // ['lin', 'is', 'handsome'] // 新数组不变

console.log(arr == newArr) // false 两者指向不同地址
复制代码
const obj = {
  name: 'lin'
}

const newObj = { ...obj }

obj.name = 'xxx' // 改变原来的对象

console.log(newObj) // { name: 'lin' } // 新对象不变

console.log(obj == newObj) // false 两者指向不同地址
复制代码

实现深拷贝

要求:

  • 支持对象、数组、日期、正则的拷贝。
  • 处理原始类型(原始类型直接返回,只有引用类型才有深拷贝这个概念)。
  • 处理 Symbol 作为键名的情况。
  • 处理函数(函数直接返回,拷贝函数没有意义,两个对象使用内存中同一个地址的函数,问题不大)。
  • 处理 DOM 元素(DOM 元素直接返回,拷贝 DOM 元素没有意义,都是指向页面中同一个)。
  • 额外开辟一个储存空间 WeakMap,解决循环引用递归爆栈问题(引入 WeakMap 的另一个意义,配合垃圾回收机制,防止内存泄漏)。

先把答案贴出来:

function deepClone (target, hash = new WeakMap()) { // 额外开辟一个存储空间WeakMap来存储当前对象
  if (target === null) return target // 如果是 null 就不进行拷贝操作
  if (target instanceof Date) return new Date(target) // 处理日期
  if (target instanceof RegExp) return new RegExp(target) // 处理正则
  if (target instanceof HTMLElement) return target // 处理 DOM元素

  if (typeof target !== 'object') return target // 处理原始类型和函数 不需要深拷贝,直接返回

  // 是引用类型的话就要进行深拷贝
  if (hash.get(target)) return hash.get(target) // 当需要拷贝当前对象时,先去存储空间中找,如果有的话直接返回
  const cloneTarget = new target.constructor() // 创建一个新的克隆对象或克隆数组
  hash.set(target, cloneTarget) // 如果存储空间中没有就存进 hash 里

  Reflect.ownKeys(target).forEach(key => { // 引入 Reflect.ownKeys,处理 Symbol 作为键名的情况
    cloneTarget[key] = deepClone(target[key], hash) // 递归拷贝每一层
  })
  return cloneTarget // 返回克隆的对象
}
复制代码

测试一下:

const obj = {
  a: true,
  b: 100,
  c: 'str',
  d: undefined,
  e: null,
  f: Symbol('f'),
  g: {
    g1: {} // 深层对象
  },
  h: [], // 数组
  i: new Date(), // Date
  j: /abc/, // 正则
  k: function () {}, // 函数
  l: [document.getElementById('foo')] // 引入 WeakMap 的意义,处理可能被清除的 DOM 元素
}

obj.obj = obj // 循环引用

const name = Symbol('name')
obj[name] = 'lin'  // Symbol 作为键

const newObj = deepClone(obj)

console.log(newObj)
复制代码

image.png

接下来,我们一步一步拆解,如何写出这个深拷贝。

前置知识

要手写出一个还算不错的深拷贝,下面这些知识都可以用到。

typeof 与 instanceof 有什么区别?

typeof null 的结果是什么?为什么?

for in

Object.prototype.constructor

WeakMap

Reflect.ownKeys()

觉得概念多,没关系,阿林会带你一步一步慢慢熟悉的。

一行代码版本

首先是一行代码版本:

JSON.parse(JSON.stringify(obj))
复制代码
const obj = {
  person: {
    name: 'lin'
  }
}

const newObj = JSON.parse(JSON.stringify(obj))
obj.person.name = 'xxx' // 改变原来的深层对象

console.log(newObj) // { person: { name: 'lin' } } 新的深层对象不变
复制代码

但是这种方式存在弊端,会忽略undefinedsymbol函数

const obj = {
  a: undefined,
  b: Symbol('b'),
  c: function () {}
}

const newObj = JSON.parse(JSON.stringify(obj))

console.log(newObj) // {} 
复制代码

NaNInfinity-Infinity 会被序列化为 null

const obj = {
  a: NaN,
  b: Infinity,
  c: -Infinity
}

const newObj = JSON.parse(JSON.stringify(obj))

console.log(newObj)
复制代码

image.png

而且还不能解决循环引用的问题:

const obj = {
  a: 1
}

obj.obj = obj

const newObj = JSON.parse(JSON.stringify(obj)) // 报错
复制代码

image.png

这种一行代码版本适用于日常开发中深拷贝一些简单的对象,接下来,我们试着一步步深入手写一个深拷贝,处理各种边界问题。

先实现一个浅拷贝

function clone (obj) {
  const cloneObj = {} // 创建一个新的对象
  for (const key in obj) { // 遍历需克隆的对象
    cloneObj[key] = obj[key] // 将需要克隆对象的属性依次添加到新对象上
  }
  return cloneObj
}
复制代码

浅拷贝之后,原对象和克隆对象更深层的对象指向同一地址,会互相影响。

测试一下,

const obj = {
  person: {
    name: 'lin'
  }
}

const newObj = clone(obj)
obj.person.name = 'xxx'    // 改变原来的对象

console.log('原来的对象', obj)
console.log('新的对象', newObj)

console.log('更深层的指向同一地址', obj.person == newObj.person)
复制代码

image.png

简单版本

现在用递归来实现深拷贝,让原对象和克隆对象不互相影响。

function deepClone (target) {
  if (typeof target !== 'object') { // 如果是原始类型,无需继续拷贝,直接返回
    return target
  }
  // 如果是引用类型,递归实现每一层的拷贝
  const cloneTarget = {} // 定义一个克隆对象
  for (const key in target) { // 遍历原对象
    cloneTarget[key] = deepClone(target[key]) // 递归拷贝每一层
  }
  return cloneTarget // 返回克隆对象
}
复制代码

测试一下:

const obj = {
  person: {
    name: 'lin'
  }
}

const newObj = deepClone(obj)
obj.person.name = 'xxx' // 改变原来的对象

console.log('原来的对象', obj)
console.log('新的对象', newObj)

console.log('更深层的指向同一地址', obj.person == newObj.person)
复制代码

image.png

处理数组、日期、正则、null

上文的方法实现了最简单版本的深拷贝,但是没有处理 null 这种原始类型,也没有处理数组、日期和正则这种比较常用的引用类型。

测试一下,

const obj = {
  a: [],
  b: new Date(),
  c: /abc/,
  d: null
}

const newObj = deepClone(obj)

console.log('原来的对象', obj)
console.log('新的对象', newObj)
复制代码

image.png

现在来处理一下:

function deepClone (target) {
  if (target === null) return target // 处理 null
  if (target instanceof Date) return new Date(target) // 处理日期
  if (target instanceof RegExp) return new RegExp(target) // 处理正则
  
  if (typeof target !== 'object') return target // 处理原始类型
  
  // 处理对象和数组
  const cloneTarget = new target.constructor() // 创建一个新的克隆对象或克隆数组
  for (const key in target) { // 递归拷贝每一层
    cloneTarget[key] = deepClone(target[key]) 
  }
  return cloneTarget
}
复制代码

测试一下:

const obj = {
  a: [1, 2, 3],
  b: new Date(),
  c: /abc/,
  d: null
}

const newObj = deepClone(obj)

console.log('原来的对象', obj)
console.log('新的对象', newObj)
复制代码

image.png

new 实例.constructor()

你可能注意到了这样一行代码,它是怎样处理数组的呢?

const cloneTarget = new target.constructor() // 创建一个新的克隆对象或克隆数组
复制代码

实例的 constructor 其实就是构造函数,

class Person {}

const p1 = new Person()

console.log(p1.constructor === Person) // true
console.log([].constructor === Array)  // true
console.log({}.constructor === Object) // true
复制代码
console.log(new {}.constructor())  // {}
等价于
console.log(new Object()) // {}
复制代码
console.log(new [].constructor())  // {}
等价于
console.log(new Array()) // []
复制代码

运用在我们的深拷贝函数里,就不用在拷贝时去判断数组类型了,原对象是对象,就创建一个新的克隆对象,原对象是数组,就创建一个新的克隆数组。

处理 Symbol

上面的方法无法处理 Symbol 作为键,测试一下。

const obj = {}
const name = Symbol('name')
obj[name] = 'lin' // Symbol 作为键

const newObj = deepClone(obj)

console.log(newObj) // {}
复制代码

可以把 for in 换成 Reflect.ownKeys 来解决

Reflect.ownKeys 方法返回一个由目标对象自身的属性键组成的数组。它的返回值等同于Object.getOwnPropertyNames(target).concat(Object.getOwnPropertySymbols(target))。

继续改造克隆函数,

function deepClone (target) {
  if (target === null) return target 
  if (target instanceof Date) return new Date(target)
  if (target instanceof RegExp) return new RegExp(target) 

  if (typeof target !== 'object') return target 

  
  const cloneTarget = new target.constructor() 
  
  // 换成 Reflect.ownKeys
  Reflect.ownKeys(target).forEach(key => { 
    cloneTarget[key] = deepClone(target[key]) // 递归拷贝每一层
  })
  return cloneTarget
}
复制代码

测试一下,

const obj = {}
const name = Symbol('name')
obj[name] = 'lin'

const newObj = deepClone(obj)

console.log(newObj)
复制代码

image.png

处理循环引用

上面的方法无法处理循环引用,测试一下。

const obj = {
  a: 1
}
obj.obj = obj

const newObj = deepClone(obj)
复制代码

image.png

原因是对象存在循环引用的情况,递归进入死循环导致栈内存溢出了。

解决循环引用问题,可以额外开辟一个存储空间来存储当前对象和拷贝对象的对应关系。

当需要拷贝当前对象时,先去存储空间中找,有没有拷贝过这个对象,如果有的话直接返回,就不会一直递归导致栈内存溢出了。

function deepClone (target, hash = {}) { // 额外开辟一个存储空间来存储当前对象和拷贝对象的对应关系
  if (target === null) return target
  if (target instanceof Date) return new Date(target)
  if (target instanceof RegExp) return new RegExp(target)

  if (typeof target !== 'object') return target

  if (hash[target]) return hash[target] // 当需要拷贝当前对象时,先去存储空间中找,如果有的话直接返回
  const cloneTarget = new target.constructor()
  hash[target] = cloneTarget // 如果存储空间中没有就存进存储空间 hash 里

  Reflect.ownKeys(target).forEach(key => {
    cloneTarget[key] = deepClone(target[key], hash) // 递归拷贝每一层
  })
  return cloneTarget
}
复制代码

测试一下,

const obj = {
  a: 1
}
obj.obj = obj

const newObj = deepClone(obj)

console.log('原来的对象', obj)
console.log('新的对象', newObj)
复制代码

image.png

上面的方法我们使用的是对象来创建的存储空间,这个存储空间还可以用 MapWeakMap,这里优化一下,使用 WeakMap,配合垃圾回收机制,防止内存泄漏。

WeakMap

WeakMap结构与Map结构类似,用于生成键值对的集合,除了以下两点和 Map 不同,其他都和 Map 相同

  • WeakMap只接受对象作为键名(null除外),不接受其他类型的值作为键名。
const map = new WeakMap()
map.set(1, 2)        // TypeError: 1 is not an object!
map.set(Symbol(), 2) // TypeError: Invalid value used as weak map key
map.set(null, 2)     // TypeError: Invalid value used as weak map key
复制代码
  • WeakMap的键名所指向的对象,不计入垃圾回收机制。

WeakMap的设计目的在于,有时我们想在某个对象上面存放一些数据,但是这会形成对于这个对象的引用。请看下面的例子。

const e1 = document.getElementById('foo')
const e2 = document.getElementById('bar')
const arr = [
  [e1, 'foo 元素'],
  [e2, 'bar 元素']
]
复制代码

上面代码中,e1e2是两个对象,我们通过arr数组对这两个对象添加一些文字说明。这就形成了arre1e2的引用。

一旦不再需要这两个对象,我们就必须手动删除这个引用,否则垃圾回收机制就不会释放e1e2占用的内存。

// 不需要 e1 和 e2 的时候
// 必须手动删除引用
arr[0] = null
arr[1] = null
复制代码

上面这样的写法显然很不方便。一旦忘了写,就会造成内存泄露

WeakMap 就是为了解决这个问题而诞生的,它的键名所引用的对象都是弱引用,即垃圾回收机制不将该引用考虑在内。因此,只要所引用的对象的其他引用都被清除,垃圾回收机制就会释放该对象所占用的内存。也就是说,一旦不再需要,WeakMap 里面的键名对象和所对应的键值对会自动消失,不用手动删除引用。

基本上,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap。一个典型应用场景是,在网页的 DOM 元素上添加数据,就可以使用WeakMap结构。当该 DOM 元素被清除,其所对应的WeakMap记录就会自动被移除。

const wm = new WeakMap()
const element = document.getElementById('example')
wm.set(element, 'some information')
wm.get(element) // "some information"
复制代码

上面代码中,先新建一个 WeakMap 实例。然后,将一个 DOM 节点作为键名存入该实例,并将一些附加信息作为键值,一起存放在 WeakMap 里面。这时,WeakMap 里面对element的引用就是弱引用,不会被计入垃圾回收机制。

也就是说,上面的 DOM 节点对象的引用计数是1,而不是2。这时,一旦消除对该节点的引用,它占用的内存就会被垃圾回收机制释放WeakMap 保存的这个键值对,也会自动消失。

总之,WeakMap的专用场合就是,它的键所对应的对象,可能会在将来消失。WeakMap结构有助于防止内存泄漏。

引入 WeakMap

了解了 WeakMap 的作用,我们来继续优化深拷贝函数,

存储空间把对象改成 WeakMap,WeakMap 主要是为了处理经常被删除的 DOM 元素,在深拷贝函数里也加入对 DOM 元素的处理。

如果拷贝对象是 DOM 元素就直接返回,拷贝 DOM 元素没有意义,都是指向页面中同一个。

function deepClone (target, hash = new WeakMap()) { // 额外开辟一个存储空间WeakMap来存储当前对象
  if (target === null) return target
  if (target instanceof Date) return new Date(target)
  if (target instanceof RegExp) return new RegExp(target)
  if (target instanceof HTMLElement) return target // 处理 DOM元素

  if (typeof target !== 'object') return target

  if (hash.get(target)) return hash.get(target) // 当需要拷贝当前对象时,先去存储空间中找,如果有的话直接返回
  const cloneTarget = new target.constructor()
  hash.set(target, cloneTarget) // 如果存储空间中没有就存进 hash 里

  Reflect.ownKeys(target).forEach(key => {
    cloneTarget[key] = deepClone(target[key], hash) // 递归拷贝每一层
  })
  return cloneTarget
}
复制代码

测试一下,

const obj = {
  domArr: [document.getElementById('foo')]
}

const newObj = deepClone(obj)

console.log(newObj)
复制代码

image.png

至此,在面试场景中手写一个深拷贝,差不多到天花板了,毕竟面试的环境10分钟写一段代码,能把上面的功能写出来,已经很厉害了。

至于其他边界情况,就靠嘴皮子去说吧。

更多边界情况

其实上面的深拷贝方法还有很多缺陷,有很多类型对象都没有实现拷贝,毕竟 JS 的标准内置对象实在太多了,要考虑所有的边界情况,就会让深拷贝函数变得特别复杂。

JavaScript 标准内置对象

我们花了 14 行实现了一个还算不错的深拷贝,但lodash 里的拷贝函数光是定义数据类型就超过 14 行了。

/** `Object#toString` result references. */
const argsTag = '[object Arguments]'
const arrayTag = '[object Array]'
const boolTag = '[object Boolean]'
const dateTag = '[object Date]'
const errorTag = '[object Error]'
const mapTag = '[object Map]'
const numberTag = '[object Number]'
const objectTag = '[object Object]'
const regexpTag = '[object RegExp]'
const setTag = '[object Set]'
const stringTag = '[object String]'
const symbolTag = '[object Symbol]'
const weakMapTag = '[object WeakMap]'

const arrayBufferTag = '[object ArrayBuffer]'
const dataViewTag = '[object DataView]'
const float32Tag = '[object Float32Array]'
const float64Tag = '[object Float64Array]'
const int8Tag = '[object Int8Array]'
const int16Tag = '[object Int16Array]'
const int32Tag = '[object Int32Array]'
const uint8Tag = '[object Uint8Array]'
const uint8ClampedTag = '[object Uint8ClampedArray]'
const uint16Tag = '[object Uint16Array]'
const uint32Tag = '[object Uint32Array]'
复制代码

工具函数方法,就封装了二十多个,一个深拷贝的函数代码加起来可能有接近千行了。

import Stack from './Stack.js'
import arrayEach from './arrayEach.js'
import assignValue from './assignValue.js'
import cloneBuffer from './cloneBuffer.js'
import copyArray from './copyArray.js'
import copyObject from './copyObject.js'
import cloneArrayBuffer from './cloneArrayBuffer.js'
import cloneDataView from './cloneDataView.js'
import cloneRegExp from './cloneRegExp.js'
import cloneSymbol from './cloneSymbol.js'
import cloneTypedArray from './cloneTypedArray.js'
import copySymbols from './copySymbols.js'
import copySymbolsIn from './copySymbolsIn.js'
import getAllKeys from './getAllKeys.js'
import getAllKeysIn from './getAllKeysIn.js'
import getTag from './getTag.js'
import initCloneObject from './initCloneObject.js'
import isBuffer from '../isBuffer.js'
import isObject from '../isObject.js'
import isTypedArray from '../isTypedArray.js'
import keys from '../keys.js'
import keysIn from '../keysIn.js'
复制代码

甚至为了提高性能,lodash内部不用 for in,也不用 Reflect.ownKeys来遍历,还专门重写了遍历的方法。

// arrayEach.js
function arrayEach(array, iteratee) {
  let index = -1
  const length = array.length

  while (++index < length) {
    if (iteratee(array[index], index, array) === false) {
      break
    }
  }
  return array
}
复制代码

阿林也只是看得懂,但讲不好,就贴一些大佬们的文章吧。

Lodash是如何实现深拷贝的

如何写出一个惊艳面试官的深拷贝?

日常开发中,如果要使用深拷贝,为了兼容各种边界情况,一般是使用三方库,推荐两个:

未来的深拷贝

其实,浏览器自己实现了深拷贝函数,想不到吧。

这个 Web API 名称叫 structuredClone(),详情可访问 MDN 和最新的 HTML5 规范

我们来尝尝鲜,试用一下:

const obj = {
  person: {
    name: 'lin'
  }
}

const newObj = structuredClone(obj) // 
obj.person.name = 'xxx' // 改变原来的对象

console.log('原来的对象', obj)
console.log('新的对象', newObj)

console.log('更深层的对象指向同一地址', obj.person == newObj.person)
复制代码

image.png

深拷贝生效了,那是不是说以后再也不用 lodash 的 cloneDeep 了呢?

很显然,不能,毕竟这是一个新的 API,从兼容性来考虑,很多浏览器应该都不会支持。

caniuse 一查,果然如此。

image.png

另外, MDN 也介绍了实现这个 API 用到的算法,阿林粗略地看了一下,似乎实现得没有 lodash 全面,链接贴在下面,感兴趣的可以去看看。

Structured_clone_algorithm

可能再过一段时间大家就都使用这个深拷贝了吧,谁知道呢?就跟几年前还在兼容 ie 浏览器,现在没有特殊需求,傻子才去做兼容🙈

总结

关于浅拷贝和深拷贝的使用选择,保险的做法是所有的拷贝都用深拷贝,而且一般是直接引三方库,毕竟自己写深拷贝,各种边界情况有时候考虑不到。

像手写深拷贝这种卷王行为也只会出现在面试场景中了😅,面试场景中能把本文的深拷贝手写出来,并且能说出 lodash 源码的实现思路,也差不多了。

其实,如果只是拷贝一层对象,只要能解决引用类型赋值后相互影响的问题,用浅拷贝又怎么了?

另外,如果 JSON.parse(JSON.stringify(object))能实现你的功能,你却非要去引入 lodashcloneDeep 方法,那不就徒增了项目的打包体积了吗?自己平时写着玩的项目,用 JSON.parse(JSON.stringify(object)) 又怎么了?

当然,如果团队有规范,为了代码风格统一或者说为了避免潜在的风险,统一全部用三方库的方法,增加一些打包的体积也没关系,毕竟企业级的项目还是要严谨一点。

黑猫白猫,能抓到耗子就是好猫,各取所长就行。

如果我的文章对你有帮助,你的👍就是对我的最大支持^_^

我是阿林,输出洞见技术,再会!

来源:https://juejin.cn/post/7072528644739956773
posted @ 2022-10-05 19:40  程序员小明1024  阅读(130)  评论(0编辑  收藏  举报