超詳細(xì)的JavaScript深拷貝實現(xiàn)

配圖源自 Feepik

此前寫過一篇文章:JavaScript深淺拷貝氯哮,其實沒那么難老速!贞绵,但里面的拷貝處理顯然不夠理想者祖。

今天再來詳細(xì)的講講...

一、JSON.stringify() 的缺陷

利用 JavaScript 內(nèi)置的 JSON 處理函數(shù)屁商,可以實現(xiàn)簡易的深拷貝:

const obj = {
  // ...
}
JSON.parse(JSON.stringify(obj)) // 序列化與反序列化

這個方法烟很,其實能適用于 90% 以上的應(yīng)用場景。畢竟多數(shù)項目下蜡镶,很少會去拷貝一個函數(shù)什么的雾袱。

但不得不說,這里面有“坑”官还,這些“坑”是 JSON.stringify() 方法本身實現(xiàn)邏輯產(chǎn)生的:

JSON.stringify(value[, replacer[, space]])

該方法有以下特點:

  • 布爾值芹橡、數(shù)值、字符串對應(yīng)的包裝對象望伦,在序列化過程會自動轉(zhuǎn)換成其原始值僻族。
  • undefined任意函數(shù)屡谐、Symbol 值,在序列化過程有兩種不同的情況蝌数。若出現(xiàn)在非數(shù)組對象的屬性值中愕掏,會被忽略;若出現(xiàn)在數(shù)組中顶伞,會轉(zhuǎn)換成 null饵撑。
  • 任意函數(shù)undefined 被單獨轉(zhuǎn)換時唆貌,會返回 undefined滑潘。
  • 所有以 Symbol 為屬性鍵的屬性都會被完全忽略,即便在該方法第二個參數(shù) replacer 中指定了該屬性锨咙。
  • Date 日期調(diào)用了其內(nèi)置的 toJSON() 方法轉(zhuǎn)換成字符串语卤,因此會被當(dāng)初字符串處理。
  • NaNInfinity 的數(shù)值及 null 都會當(dāng)做 null酪刀。
  • 這些對象 Map粹舵、SetWeakMap骂倘、WeakSet 僅會序列化可枚舉的屬性眼滤。
  • 被轉(zhuǎn)換值如果含有 toJSON() 方法,該方法定義什么值將被序列化历涝。
  • 對包含 循環(huán)引用 的對象進(jìn)行序列化诅需,會拋出錯誤漾唉。

二、深拷貝的邊界

其實堰塌,針對以上兩個內(nèi)置的全局方法赵刑,還有這么多情況不能處理,是不是很氣人蔫仙。其實不然料睛,我猜測 JSON.parse()JSON.stringify() 只是讓我們更方便地操作符合 JSON 格式的 JavaScript 對象或符合 JSON 格式的字符串。

至于上面提到的“坑”摇邦,很明顯是不符合作為跨平臺數(shù)據(jù)交換的格式要求的恤煞。在 JSON 中,它有 null施籍,是沒有 undefined居扒、Symbol 類型、函數(shù)等丑慎。

JSON 是一種數(shù)據(jù)格式喜喂,也可以說是一種規(guī)范。JSON 是用于跨平臺數(shù)據(jù)交流的竿裂,獨立于語言和平臺玉吁。而 JavaScript 對象是一個實例,存在于內(nèi)存中腻异。JavaScript 對象是沒辦法傳輸?shù)慕保挥性诒恍蛄谢癁?JSON 字符串后才能傳輸。

此前寫過一篇文章悔常,介紹了 JSON 和 JavaScript 的關(guān)系以及上述兩個方法的一些細(xì)節(jié)影斑。可看:詳談 JSON 與 JavaScript机打。

如果自己實現(xiàn)一個深拷貝的方法矫户,其實是有很多邊界問題要處理的,至于這些種種的邊界 Case残邀,要不要處理最好從實際情況出發(fā)皆辽。

常見的邊界 Case 有什么呢?

主要有循環(huán)引用芥挣、包裝對象膳汪、函數(shù)、原型鏈九秀、不可枚舉屬性遗嗽、Map/WeakMap、Set/WeakSet鼓蜒、RegExp痹换、Symbol征字、Date、ArrayBuffer娇豫、原生 DOM/BOM 對象等匙姜。

就目前而言,第三方最完善的深拷貝方法是 Lodash 庫的 _.cloneDeep() 方法了冯痢。在實際項目中氮昧,如需處理 JSON.stringify() 無法解決的 Case,我會推薦使用它浦楣。否則請使用內(nèi)置 JSON 方法即可袖肥,沒必要復(fù)雜化。

但如果為了學(xué)習(xí)深拷貝振劳,那應(yīng)該要每種情況都要去嘗試實現(xiàn)一下椎组,我想這也是你在看這篇文章的原意。這樣历恐,無論是實現(xiàn)特殊要求的深拷貝寸癌,還是面試,都可以從容應(yīng)對弱贼。

下面一起來學(xué)習(xí)吧蒸苇,如有不足,歡迎指出 ?? ~

三吮旅、自實現(xiàn)深拷貝方法

主要運用到遞歸的思路去實現(xiàn)一個深拷貝方法溪烤。

PS:完整的深拷貝方法會在文章最后放出。

先寫一個簡易版本:

const deepCopy = source => {
  // 判斷是否為數(shù)組
  const isArray = arr => Object.prototype.toString.call(arr) === '[object Array]'

  // 判斷是否為引用類型
  const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function')

  // 拷貝(遞歸思路)
  const copy = input => {
    if (typeof input === 'function' || !isObject(input)) return input

    const output = isArray(input) ? [] : {}
    for (let key in input) {
      if (input.hasOwnProperty(key)) {
        const value = input[key]
        output[key] = copy(value)
      }
    }

    return output
  }

  return copy(source)
}

以上簡易版本還存在很多情況要特殊處理鸟辅,接下來針對 JSON.stringify() 的缺陷,一點一點去完善它莺葫。

3.1 針對布爾值匪凉、數(shù)值、字符串的包裝對象的處理

需要注意的是捺檬,從 ES6 開始圍繞原始數(shù)據(jù)類型創(chuàng)建一個顯式包裝器對象不再被支持再层。但由于遺留原因,現(xiàn)有的原始包裝器對象(如 new Boolean堡纬、new Number聂受、new String)仍可使用。這也是 ES6+ 新增的 Symbol烤镐、BigInt 數(shù)據(jù)類型無法通過 new 關(guān)鍵字創(chuàng)建實例對象的原因蛋济。

由于 for...in 無法遍歷不可枚舉的屬性。例如炮叶,包裝對象的 [[PrimitiveValue]] 內(nèi)部屬性碗旅,因此需要我們特殊處理一下渡处。

以上結(jié)果,顯然不是預(yù)期結(jié)果祟辟。包裝對象的 [[PrimitiveValue]] 屬性可通過 valueOf() 方法獲取医瘫。

const deepCopy = source => {
  // 獲取數(shù)據(jù)類型(本次新增)
  const getClass = x => Object.prototype.toString.call(x)

  // 判斷是否為數(shù)組
  const isArray = arr => getClass(arr) === '[object Array]'

  // 判斷是否為引用類型
  const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function')

  // 判斷是否為包裝對象(本次新增)
  const isWrapperObject = obj => {
    const theClass = getClass(obj)
    const type = /^\[object (.*)\]$/.exec(theClass)[1]
    return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt'].includes(type)
  }

  // 處理包裝對象(本次新增)
  const handleWrapperObject = obj => {
    const type = getClass(obj)
    switch (type) {
      case '[object Boolean]':
        return Object(Boolean.prototype.valueOf.call(obj))
      case '[object Number]':
        return Object(Number.prototype.valueOf.call(obj))
      case '[object String]':
        return Object(String.prototype.valueOf.call(obj))
      case '[object Symbol]':
        return Object(Symbol.prototype.valueOf.call(obj))
      case '[object BigInt]':
        return Object(BigInt.prototype.valueOf.call(obj))
      default:
        return undefined
    }
  }

  // 拷貝(遞歸思路)
  const copy = input => {
    if (typeof input === 'function' || !isObject(input)) return input

    // 處理包裝對象(本次新增)
    if (isWrapperObject(input)) {
      return handleWrapperObject(input)
    }

    // 其余部分沒變,為了減少篇幅旧困,省略一萬字...
  }

  return copy(source)
}

我們在控制臺打印一下結(jié)果醇份,可以看到是符合預(yù)期結(jié)果的。

3.2 針對函數(shù)的處理

直接返回就好了吼具,一般不用處理僚纷。在實際應(yīng)用場景需要拷貝函數(shù)太少了...

const copy = input => {
  if (typeof input === 'function' || !isObject(input)) return input
}
3.3 針對以 Symbol 值作為屬性鍵的處理

由于以上 for...in 方法無法遍歷 Symbol 的屬性鍵,因此:

const sym = Symbol('desc')
const obj = {
  [sym]: 'This is symbol value'
}
console.log(deepCopy(obj)) // {}馍悟,拷貝結(jié)果沒有 [sym] 屬性

這里畔濒,我們需要用到兩個方法:

const copy = input => {
  // 其它不變
  for (let key in input) {
    // ...
  }

  // 處理以 Symbol 值作為屬性鍵的屬性(本次新增)
  const symbolArr = Object.getOwnPropertySymbols(input)
  if (symbolArr.length) {
    for (let i = 0, len = symbolArr.length; i < len; i++) {
      if (input.propertyIsEnumerable(symbolArr[i])) {
        const value = input[symbolArr[i]]
        output[symbolArr[i]] = copy(value)
      }
    }
  }

  // ...
}

下面我們對 source 對象做拷貝操作:

const source = {}
const sym1 = Symbol('1')
const sym2 = Symbol('2')
Object.defineProperties(source,
  {
    [sym1]: {
      value: 'This is symbol value.',
      enumerable: true
    },
    [sym2]: {
      value: 'This is a non-enumerable property.',
      enumerable: false
    }
  }
)

打印結(jié)果毅整,也符合預(yù)期結(jié)果:

3.4 針對 Date 對象的處理

其實趣兄,處理 Date 對象,跟上面提到的包裝對象的處理是差不多的悼嫉。暫時先放到 isWrapperObject()handleWrapperObject() 中處理艇潭。

const deepCopy = source => {
  // 其他不變...

  // 判斷是否為包裝對象(本次更新)
  const isWrapperObject = obj => {
    const theClass = getClass(obj)
    const type = /^\[object (.*)\]$/.exec(theClass)[1]
    return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date'].includes(type)
  }

  // 處理包裝對象
  const handleWrapperObject = obj => {
    const type = getClass(obj)
    switch (type) {
      // 其他 case 不變
      // ...
      case '[object Date]':
        return new Date(obj.valueOf()) // new Date(+obj)
      default:
        return undefined
    }
  }

  // 其他不變...
}
3.5 針對 Map、Set 對象的處理

同樣的戏蔑,暫時先放到 isWrapperObject()handleWrapperObject() 中處理蹋凝。

利用 Map、Set 對象的 Iterator 特性和自身的方法总棵,可以快速解決鳍寂。

const deepCopy = source => {
  // 其他不變...

  // 判斷是否為包裝對象(本次更新)
  const isWrapperObject = obj => {
    const theClass = getClass(obj)
    const type = /^\[object (.*)\]$/.exec(theClass)[1]
    return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date', 'Map', 'Set'].includes(type)
  }

  // 處理包裝對象
  const handleWrapperObject = obj => {
    const type = getClass(obj)
    switch (type) {
      // 其他 case 不變
      // ...
      case '[object Map]': {
        const map = new Map()
        obj.forEach((item, key) => {
          // 需要注意的是,這里的 key 不能深拷貝情龄,否則就會失去引用了
          // 具體原因可以思考一下迄汛,不難。想不明白再評論區(qū)吧
          map.set(key, copy(item))
        })
        return map
      }
      case '[object Set]': {
        const set = new Set()
        obj.forEach(item => {
          set.add(copy(item))
        })
        return set
      }
      default:
        return undefined
    }
  }

  // 其他不變...
}

打印下結(jié)果:

3.6 針對循環(huán)引用的問題

以下是一個循環(huán)引用(circular reference)的對象:

const foo = { name: 'Frankie' }
foo.bar = foo

上面提到 JSON.stringify() 無法處理循環(huán)引用的問題骤视,我們在控制臺打印一下:

從結(jié)果可以看到鞍爱,當(dāng)對循環(huán)引用的對象進(jìn)行序列化處理時,會拋出類型錯誤:Uncaught TypeError: Converting circular structure to JSON专酗。

接著睹逃,使用自行實現(xiàn)的 deepCopy() 方法,看下結(jié)果是什么:

我們看到祷肯,在拷貝循環(huán)引用的 foo 對象時唯卖,發(fā)生棧溢出了粱玲。

在另一篇文章,我提到過使用 JSON-js 可以處理循環(huán)引用的問題拜轨,具體用法是抽减,先引入其中的 cycle.js 腳本,然后 JSON.stringify(JSON.decycle(foo)) 就 OK 了橄碾。但究其根本卵沉,它使用了 WeakMap 去處理。

那我們?nèi)崿F(xiàn)一下:

const deepCopy = source => {
  // 創(chuàng)建一個 WeakMap 對象法牲,記錄已拷貝過的對象(本次新增)
  const weakmap = new WeakMap()

  // 中間這塊不變史汗,省略一萬字...

  // 拷貝(遞歸思路)
  const copy = input => {
    if (typeof input === 'function' || !isObject(input)) return input

    // 針對已拷貝過的對象,直接返回(本次新增拒垃,以解決循環(huán)引用的問題)
    if (weakmap.has(input)) {
      return weakmap.get(input)
    }

    // 處理包裝對象
    if (isWrapperObject(input)) {
      return handleWrapperObject(input)
    }

    const output = isArray(input) ? [] : {}

    // 記錄每次拷貝的對象
    weakmap.set(input, output)

    for (let key in input) {
      if (input.hasOwnProperty(key)) {
        const value = input[key]
        output[key] = copy(value)
      }
    }

    // 處理以 Symbol 值作為屬性鍵的屬性
    const symbolArr = Object.getOwnPropertySymbols(input)
    if (symbolArr.length) {
      for (let i = 0, len = symbolArr.length; i < len; i++) {
        if (input.propertyIsEnumerable(symbolArr[i])) {
          output[symbolArr[i]] = input[symbolArr[i]]
        }
      }
    }

    return output
  }

  return copy(source)
}

先看看打印結(jié)果停撞,不會像之前一樣溢出了。

需要注意的是悼瓮,這里不使用 Map 而是 WeakMap 的原因:

首先戈毒,Map 的鍵屬于強(qiáng)引用,而 WeakMap 的鍵則屬于弱引用横堡。且 WeakMap 的鍵必須是對象埋市,WeakMap 的值則是任意的。

由于它們的鍵與值的引用關(guān)系命贴,決定了 Map 不能確保其引用的對象不會被垃圾回收器回收的引用道宅。假設(shè)我們使用的 Map,那么圖中的 foo 對象和我們深拷貝內(nèi)部的 const map = new Map() 創(chuàng)建的 map 對象一直都是強(qiáng)引用關(guān)系胸蛛,那么在程序結(jié)束之前污茵,foo 不會被回收,其占用的內(nèi)存空間一直不會被釋放葬项。

相比之下泞当,原生的 WeakMap 持有的是每個鍵對象的“弱引用”,這意味著在沒有其他引用存在時垃圾回收能正確進(jìn)行玷室。原生 WeakMap 的結(jié)構(gòu)是特殊且有效的零蓉,其用于映射的 key 只有在其沒有被回收時才是有效的笤受。

基本上穷缤,如果你要往對象上添加數(shù)據(jù),又不想干擾垃圾回收機(jī)制箩兽,就可以使用 WeakMap津肛。

可看 Why WeakMap?

我們熟知的 Lodash 庫的深拷貝方法,自實現(xiàn)了一個類似 WeakMap 特性的構(gòu)造函數(shù)去處理循環(huán)引用的汗贫。(詳看

這里提供另一個思路身坐,也是可以的秸脱。

const deepCopy = source => {
  // 其他一樣,省略一萬字...

  // 創(chuàng)建一個數(shù)組部蛇,將每次拷貝的對象放進(jìn)去
  const copiedArr = []

  // 拷貝(遞歸思路)
  const copy = input => {
    if (typeof input === 'function' || !isObject(input)) return input

    // 循環(huán)遍歷摊唇,若有已拷貝過的對象,則直接放回涯鲁,以解決循環(huán)引用的問題
    for (let i = 0, len = copiedArr.length; i < len; i++) {
      if (input === copiedArr[i].key) return copiedArr[i].value
    }

    // 處理包裝對象
    if (isWrapperObject(input)) {
      return handleWrapperObject(input)
    }

    const output = isArray(input) ? [] : {}

    // 記錄每一次的對象
    copiedArr.push({ key: input, value: output })

    // 后面的流程不變...
  }

  return copy(source)
}

此前實現(xiàn)有個 bug巷查,感謝蝦蝦米指出,現(xiàn)已更正抹腿。

請在實現(xiàn)深拷貝之后測試以下示例:

const foo = { name: 'Frankie' }
foo.bar = foo

const cloneObj = deepCopy(foo) // 自實現(xiàn)深拷貝
const lodashObj = _.cloneDeep(foo) // Lodash 深拷貝

// 打印結(jié)果如下岛请,說明是正確的
console.log(lodashObj.bar === lodashObj) // true
console.log(lodashObj.bar === foo) // false
console.log(cloneObj.bar === cloneObj) // true
console.log(cloneObj.bar === foo) // false
3.7 針對正則表達(dá)式的處理

正則表達(dá)式里面,有兩個非常重要的屬性:

  • RegExp.prototype.source
    返回當(dāng)前正則表達(dá)式對象的模式文本的字符串警绩。注意崇败,這是 ES6 新增的屬性。
  • RegExp.prototype.flags
    返回當(dāng)前正則表達(dá)式對象標(biāo)志肩祥。
const { source, flags } = /\d/g
console.log(source) // "\\d"
console.log(flags) // "g"

有了以上兩個屬性后室,我們就可以使用 new RegExp(pattern, flags) 構(gòu)造函數(shù)去創(chuàng)建一個正則表達(dá)式了。

const { source, flags } = /\d/g
const newRegex = new RegExp(source, flags) // /\d/g

但需要注意的是搭幻,正則表達(dá)式有一個 lastIndex 屬性咧擂,該屬性可讀可寫,其值為整型檀蹋,用來指定下一次匹配的起始索引松申。在設(shè)置了 globalsticky 標(biāo)志位的情況下(如 /foo/g/foo/y)俯逾,JavaScript RegExp 對象是有狀態(tài)的贸桶。他們會將上次成功匹配后的位置記錄在 lastIndex 屬性中。

因此桌肴,上述拷貝正則表達(dá)式的方式是有缺陷的皇筛。看示例:

const re1 = /foo*/g
const str = 'table football, foosball'
let arr

while ((arr = re1.exec(str)) !== null) {
  console.log(`Found ${arr[0]}. Next starts at ${re1.lastIndex}.`)
}

// 以上語句會輸出坠七,以下結(jié)果:
// "Found foo. Next starts at 9."
// "Found foo. Next starts at 19."


// 當(dāng)我們修改 re1 的 lastIndex 屬性時水醋,輸出以下結(jié)果:
re1.lastIndex = 9
while ((arr = re1.exec(str)) !== null) {
  console.log(`Found ${arr[0]}. Next starts at ${re1.lastIndex}.`)
}
// "Found foo. Next starts at 19."

// 以上這些相信你們都都懂。

所以彪置,你可以發(fā)現(xiàn)以下示例拄踪,打印結(jié)果是不一致的,原因就是使用 RegExp 構(gòu)造函數(shù)去創(chuàng)建一個正則表達(dá)式時拳魁,lastIndex 會默認(rèn)設(shè)為 0惶桐。

const re1 = /foo*/g
const str = 'table football, foosball'
let arr

// 修改 lastIndex 屬性
re1.lastIndex = 9

// 基于 re1 拷貝一個正則表達(dá)式
const re2 = new RegExp(re1.source, re1.flags)

console.log('re1:')
while ((arr = re1.exec(str)) !== null) {
  console.log(`Found ${arr[0]}. Next starts at ${re1.lastIndex}.`)
}

console.log('re2:')
while ((arr = re2.exec(str)) !== null) {
  console.log(`Found ${arr[0]}. Next starts at ${re2.lastIndex}.`)
}

// re1:
// expected output: "Found foo. Next starts at 19."
// re2:
// expected output: "Found foo. Next starts at 9."
// expected output: "Found foo. Next starts at 19."

因此:

const deepCopy = source => {
  // 其他不變,省略...

  // 處理正則表達(dá)式
  const handleRegExp = regex => {
    const { source, flags, lastIndex } = regex
    const re = new RegExp(source, flags)
    re.lastIndex = lastIndex
    return re
  }

  // 拷貝(遞歸思路)
  const copy = input => {
    if (typeof input === 'function' || !isObject(input)) return input

    // 正則表達(dá)式
    if (getClass(input) === '[object RegExp]') {
      return handleRegExp(input)
    }

    // 后面不變,省略...
  }

  return copy(source)
}

打印結(jié)果也是符合預(yù)期的:

由于 RegExp.prototype.flags 是 ES6 新增屬性姚糊,我們可以看下 ES5 是如何實現(xiàn)的(源自 Lodash):

/** Used to match `RegExp` flags from their coerced string values. */
var reFlags = /\w*$/;

/**
 * Creates a clone of `regexp`.
 *
 * @private
 * @param {Object} regexp The regexp to clone.
 * @returns {Object} Returns the cloned regexp.
 */
function cloneRegExp(regexp) {
  var result = new regexp.constructor(regexp.source, reFlags.exec(regexp));
  result.lastIndex = regexp.lastIndex;
  return result;
}

但還是那句話贿衍,都 2021 年了,兼容 ES5 的問題就放心交給 Babel 吧救恨。

3.8 處理原型

注意贸辈,這里只實現(xiàn)類型為 "[object Object]" 的對象的原型拷貝。例如數(shù)組等不處理肠槽,因為這些情況實際場景太少了裙椭。

主要是修改以下這一步驟:

const output = isArray(input) ? [] : {}

主要利用 Object.create() 來創(chuàng)建 output 對象,改成這樣:

const initCloneObject = obj => {
  // 處理基于 Object.create(null) 或 Object.create(Object.prototype.__proto__) 的實例對象
  // 其中 Object.prototype.__proto__ 就是站在原型頂端的男人
  // 但我留意到 Lodash 庫的 clone 方法對以上兩種情況是不處理的
  if (obj.constructor === undefined) {
    return Object.create(null)
  }

  // 處理自定義構(gòu)造函數(shù)的實例對象
  if (typeof obj.constructor === 'function' && (obj !== obj.constructor || obj !== Object.prototype)) {
    const proto = Object.getPrototypeOf(obj)
    return Object.create(proto)
  }

  return {}
}

const output = isArray(input) ? [] : initCloneObject(input)

來看下打印結(jié)果署浩,可以看到 source 的原型對象已經(jīng)拷貝過來了:

再來看下 Object.create(null) 的情況揉燃,也是預(yù)期結(jié)果。

我們可以看到 Lodash 的 _.cloneDeep(Object.create(null)) 深拷貝方法并沒有處理這種情況筋栋。當(dāng)然了炊汤,要拷貝這種數(shù)據(jù)結(jié)構(gòu)在實際應(yīng)用場景,真的少之又少...

關(guān)于 Lodash 拷貝方法為什么不實現(xiàn)這種情況弊攘,我找到了一個相關(guān)的 Issue #588

A shallow clone won't do that as it's just _.assign({}, object) and a deep clone is loosely based on the structured cloning algorithm and doesn't attempt to clone inheritance or lack thereof.

四抢腐、優(yōu)化

綜上所述,完整但未優(yōu)化的深拷貝方法如下:

const deepCopy = source => {
  // 創(chuàng)建一個 WeakMap 對象襟交,記錄已拷貝過的對象
  const weakmap = new WeakMap()

  // 獲取數(shù)據(jù)類型
  const getClass = x => Object.prototype.toString.call(x)

  // 判斷是否為數(shù)組
  const isArray = arr => getClass(arr) === '[object Array]'

  // 判斷是否為引用類型
  const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function')

  // 判斷是否為包裝對象
  const isWrapperObject = obj => {
    const theClass = getClass(obj)
    const type = /^\[object (.*)\]$/.exec(theClass)[1]
    return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date', 'Map', 'Set'].includes(type)
  }

  // 處理包裝對象
  const handleWrapperObject = obj => {
    const type = getClass(obj)
    switch (type) {
      case '[object Boolean]':
        return Object(Boolean.prototype.valueOf.call(obj))
      case '[object Number]':
        return Object(Number.prototype.valueOf.call(obj))
      case '[object String]':
        return Object(String.prototype.valueOf.call(obj))
      case '[object Symbol]':
        return Object(Symbol.prototype.valueOf.call(obj))
      case '[object BigInt]':
        return Object(BigInt.prototype.valueOf.call(obj))
      case '[object Date]':
        return new Date(obj.valueOf()) // new Date(+obj)
      case '[object Map]': {
        const map = new Map()
        obj.forEach((item, key) => {
          map.set(key, copy(item))
        })
        return map
      }
      case '[object Set]': {
        const set = new Set()
        obj.forEach(item => {
          set.add(copy(item))
        })
        return set
      }
      default:
        return undefined
    }
  }

  // 處理正則表達(dá)式
  const handleRegExp = regex => {
    const { source, flags, lastIndex } = regex
    const re = new RegExp(source, flags)
    re.lastIndex = lastIndex
    return re
  }

  const initCloneObject = obj => {
    if (obj.constructor === undefined) {
      return Object.create(null)
    }

    if (typeof obj.constructor === 'function' && (obj !== obj.constructor || obj !== Object.prototype)) {
      const proto = Object.getPrototypeOf(obj)
      return Object.create(proto)
    }

    return {}
  }

  // 拷貝(遞歸思路)
  const copy = input => {
    if (typeof input === 'function' || !isObject(input)) return input

    // 正則表達(dá)式
    if (getClass(input) === '[object RegExp]') {
      return handleRegExp(input)
    }

    // 針對已拷貝過的對象迈倍,直接返回(解決循環(huán)引用的問題)
    if (weakmap.has(input)) {
      return weakmap.get(input)
    }

    // 處理包裝對象
    if (isWrapperObject(input)) {
      return handleWrapperObject(input)
    }

    const output = isArray(input) ? [] : initCloneObject(input)

    // 記錄每次拷貝的對象
    weakmap.set(input, output)

    for (let key in input) {
      if (input.hasOwnProperty(key)) {
        const value = input[key]
        output[key] = copy(value)
      }
    }

    // 處理以 Symbol 值作為屬性鍵的屬性
    const symbolArr = Object.getOwnPropertySymbols(input)
    if (symbolArr.length) {
      for (let i = 0, len = symbolArr.length; i < len; i++) {
        if (input.propertyIsEnumerable(symbolArr[i])) {
          const value = input[symbolArr[i]]
          output[symbolArr[i]] = copy(value)
        }
      }
    }

    return output
  }

  return copy(source)
}

接下來就是優(yōu)化工作了...

4.1 優(yōu)化一

我們上面使用到了 for...inObject.getOwnPropertySymbols() 方法去遍歷對象的屬性(包括字符串屬性和 Symbol 屬性),還涉及了可枚舉屬性和不可枚舉屬性。

  • for...in:遍歷自身繼承過來可枚舉屬性(不包括 Symbol 屬性)。
  • Object.keys:返回一個數(shù)組座柱,包含對象自身所有可枚舉屬性(不包括不可枚舉屬性和 Symbol 屬性)
  • Object.getOwnPropertyNames:返回一個數(shù)組,包含對象自身的屬性(包括不可枚舉屬性迹鹅,但不包括 Symbol 屬性)
  • Object.getOwnPropertySymbols:返回一個數(shù)組,包含對象自身的所有 Symbol 屬性(包括可枚舉和不可枚舉屬性)
  • Reflect.ownKeys:返回一個數(shù)組贞言,包含自身所有的屬性(包括 Symbol 屬性斜棚,不可枚舉屬性以及可枚舉屬性)

由于我們僅拷貝可枚舉的字符串屬性和可枚舉的 Symbol 屬性,因此我們將 Reflect.ownKeys()Object.prototype.propertyIsEnumerable() 結(jié)合使用即可该窗。

所以弟蚀,我們將以下這部分:

for (let key in input) {
  if (input.hasOwnProperty(key)) {
    const value = input[key]
    output[key] = copy(value)
  }
}

// 處理以 Symbol 值作為屬性鍵的屬性
const symbolArr = Object.getOwnPropertySymbols(input)
if (symbolArr.length) {
  for (let i = 0, len = symbolArr.length; i < len; i++) {
    if (input.propertyIsEnumerable(symbolArr[i])) {
      const value = input[symbolArr[i]]
      output[symbolArr[i]] = copy(value)
    }
  }
}

優(yōu)化成:

// 僅遍歷對象自身可枚舉的屬性(包括字符串屬性和 Symbol 屬性)
Reflect.ownKeys(input).forEach(key => {
  if (input.propertyIsEnumerable(key)) {
    output[key] = copy(input[key])
  }
})
4.2 優(yōu)化二

優(yōu)化 getClass()isWrapperObject()酗失、handleWrapperObject()义钉、handleRegExp() 及其相關(guān)的類型判斷方法。

由于 handleWrapperObject() 原意是處理包裝對象级零,但是隨著后面要處理的特殊對象越來越多断医,為了減少文章篇幅,暫時都寫在里面了奏纪,稍微有點亂鉴嗤。

因此下面我們來整合一下,部分處理函數(shù)可能會修改函數(shù)名序调。

五醉锅、最終

其實,上面提到的一些邊界 Case发绢、或者其他一些特殊對象(如 ArrayBuffer 等)硬耍,這里并沒有處理,但我認(rèn)為該完結(jié)了边酒,因為這些在實際應(yīng)用場景真的太少了经柴。

代碼已丟到 GitHub ?? toFrankie/Some-JavaScript-File

還是那句話:

如果生產(chǎn)環(huán)境使用 JSON.stringify() 無法解決你的需求墩朦,請使用 Lodash 庫的 _.cloneDeep() 方法坯认,那個才叫面面俱到。千萬別用我這方法氓涣,切記牛哺!

這篇文章主要面向?qū)W習(xí)、面試(手動狗頭)劳吠,或許也可以幫助你熟悉一些對象的特性引润。如有不足,歡迎指出痒玩,萬分感謝 ?? ~

終于終于終于......要寫完了淳附,吐了三斤血...

最終版本如下:

const deepCopy = source => {
  // 創(chuàng)建一個 WeakMap 對象,記錄已拷貝過的對象
  const weakmap = new WeakMap()

  // 獲取數(shù)據(jù)類型蠢古,返回值如:"Object"燃观、"Array"、"Symbol" 等
  const getClass = x => {
    const type = Object.prototype.toString.call(x)
    return /^\[object (.*)\]$/.exec(type)[1]
  }

  // 判斷是否為數(shù)組
  const isArray = arr => getClass(arr) === 'Array'

  // 判斷是否為引用類型
  const isObject = obj => obj !== null && (typeof obj === 'object' || typeof obj === 'function')

  // 判斷是否為“特殊”對象(需要特殊處理)
  const isSepcialObject = obj => {
    const type = getClass(obj)
    return ['Boolean', 'Number', 'String', 'Symbol', 'BigInt', 'Date', 'Map', 'Set', 'RegExp'].includes(type)
  }

  // 處理特殊對象
  const handleSepcialObject = obj => {
    const type = getClass(obj)
    const Ctor = obj.constructor // 對象的構(gòu)造函數(shù)
    const primitiveValue = obj.valueOf() // 獲取對象的原始值

    switch (type) {
      case 'Boolean':
      case 'Number':
      case 'String':
      case 'Symbol':
      case 'BigInt':
        // 處理包裝對象 Wrapper Object
        return Object(primitiveValue)
      case 'Date':
        return new Ctor(primitiveValue) // new Date(+obj)
      case 'RegExp': {
        const { source, flags, lastIndex } = obj
        const re = new RegExp(source, flags)
        re.lastIndex = lastIndex
        return re
      }
      case 'Map': {
        const map = new Ctor()
        obj.forEach((item, key) => {
          // 注意便瑟,即使 Map 對象的 key 為引用類型缆毁,這里也不能 copy(key),否則會失去引用到涂,導(dǎo)致該屬性無法訪問得到脊框。
          map.set(key, copy(item))
        })
        return map
      }
      case 'Set': {
        const set = new Ctor()
        obj.forEach(item => {
          set.add(copy(item))
        })
        return set
      }
      default:
        return undefined
    }
  }

  // 創(chuàng)建輸出對象(原型拷貝關(guān)鍵就在這一步)
  const initCloneObject = obj => {
    if (obj.constructor === undefined) {
      return Object.create(null)
    }

    if (typeof obj.constructor === 'function' && (obj !== obj.constructor || obj !== Object.prototype)) {
      const proto = Object.getPrototypeOf(obj)
      return Object.create(proto)
    }

    return {}
  }

  // 拷貝方法(遞歸思路)
  const copy = input => {
    if (typeof input === 'function' || !isObject(input)) return input

    // 針對已拷貝過的對象,直接返回(解決循環(huán)引用的問題)
    if (weakmap.has(input)) {
      return weakmap.get(input)
    }

    // 處理包裝對象
    if (isSepcialObject(input)) {
      return handleSepcialObject(input)
    }

    // 創(chuàng)建輸出對象
    const output = isArray(input) ? [] : initCloneObject(input)

    // 記錄每次拷貝的對象
    weakmap.set(input, output)

    // 僅遍歷對象自身可枚舉的屬性(包括字符串屬性和 Symbol 屬性)
    Reflect.ownKeys(input).forEach(key => {
      if (input.propertyIsEnumerable(key)) {
        output[key] = copy(input[key])
      }
    })

    return output
  }

  return copy(source)
}

六践啄、參考

The end.

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末屿讽,一起剝皮案震驚了整個濱河市昭灵,隨后出現(xiàn)的幾起案子吠裆,更是在濱河造成了極大的恐慌,老刑警劉巖烂完,帶你破解...
    沈念sama閱讀 219,270評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件试疙,死亡現(xiàn)場離奇詭異,居然都是意外死亡抠蚣,警方通過查閱死者的電腦和手機(jī)祝旷,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,489評論 3 395
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來嘶窄,“玉大人怀跛,你說我怎么就攤上這事”澹” “怎么了吻谋?”我有些...
    開封第一講書人閱讀 165,630評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長现横。 經(jīng)常有香客問我滨溉,道長,這世上最難降的妖魔是什么长赞? 我笑而不...
    開封第一講書人閱讀 58,906評論 1 295
  • 正文 為了忘掉前任晦攒,我火速辦了婚禮,結(jié)果婚禮上得哆,老公的妹妹穿的比我還像新娘脯颜。我一直安慰自己,他們只是感情好贩据,可當(dāng)我...
    茶點故事閱讀 67,928評論 6 392
  • 文/花漫 我一把揭開白布栋操。 她就那樣靜靜地躺著,像睡著了一般饱亮。 火紅的嫁衣襯著肌膚如雪矾芙。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,718評論 1 305
  • 那天近上,我揣著相機(jī)與錄音剔宪,去河邊找鬼。 笑死壹无,一個胖子當(dāng)著我的面吹牛葱绒,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播斗锭,決...
    沈念sama閱讀 40,442評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼地淀,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了岖是?” 一聲冷哼從身側(cè)響起帮毁,我...
    開封第一講書人閱讀 39,345評論 0 276
  • 序言:老撾萬榮一對情侶失蹤实苞,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后烈疚,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體黔牵,經(jīng)...
    沈念sama閱讀 45,802評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,984評論 3 337
  • 正文 我和宋清朗相戀三年胞得,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片屹电。...
    茶點故事閱讀 40,117評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡阶剑,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出危号,到底是詐尸還是另有隱情牧愁,我是刑警寧澤,帶...
    沈念sama閱讀 35,810評論 5 346
  • 正文 年R本政府宣布外莲,位于F島的核電站猪半,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏偷线。R本人自食惡果不足惜磨确,卻給世界環(huán)境...
    茶點故事閱讀 41,462評論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望声邦。 院中可真熱鬧乏奥,春花似錦、人聲如沸亥曹。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,011評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽媳瞪。三九已至骗炉,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間蛇受,已是汗流浹背句葵。 一陣腳步聲響...
    開封第一講書人閱讀 33,139評論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留兢仰,地道東北人笼呆。 一個月前我還...
    沈念sama閱讀 48,377評論 3 373
  • 正文 我出身青樓,卻偏偏與公主長得像旨别,于是被迫代替她去往敵國和親诗赌。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,060評論 2 355

推薦閱讀更多精彩內(nèi)容