【進階4-4期】Lodash是如何實現深拷貝的

木易楊說發表於2019-02-12

引言

在上一篇文章中介紹瞭如何實現一個深拷貝,分別說明了物件、陣列、迴圈引用、引用丟失、Symbol 和遞迴爆棧等情況下的深拷貝實踐,今天我們來看看 Lodash 如何實現上述之外的函式、正則、Date、Buffer、Map、Set、原型鏈等情況下的深拷貝實踐。本篇文章原始碼基於 Lodash 4.17.11 版本。

更多內容請檢視 GitHub

整體流程

入口

入口檔案是 cloneDeep.js,直接呼叫核心檔案 baseClone.js 的方法。

// 木易楊
const CLONE_DEEP_FLAG = 1
const CLONE_SYMBOLS_FLAG = 4

function cloneDeep(value) {
    return baseClone(value, CLONE_DEEP_FLAG | CLONE_SYMBOLS_FLAG)
}
複製程式碼

第一個引數是需要拷貝的物件,第二個是位掩碼(Bitwise),關於位掩碼的詳細介紹請看下面擴充部分。

baseClone 方法

然後我們進入 ./.internal/baseClone.js 路徑檢視具體方法,主要實現邏輯都在這個方法裡。

先介紹下該方法的引數 baseClone(value, bitmask, customizer, key, object, stack)

  • value:需要拷貝的物件

  • bitmask:位掩碼,其中 1 是深拷貝,2 拷貝原型鏈上的屬性,4 是拷貝 Symbols 屬性

  • customizer:定製的 clone 函式

  • key:傳入 value 值的 key

  • object:傳入 value 值的父物件

  • stack:Stack 棧,用來處理迴圈引用

我將分成以下幾部分進行講解,可以選擇自己感興趣的部分閱讀。

  • 位掩碼
  • 定製 clone 函式
  • 非物件
  • 陣列 & 正則
  • 物件 & 函式
  • 迴圈引用
  • Map & Set
  • Symbol & 原型鏈

baseClone 完整程式碼

這部分就是核心程式碼了,各功能分割如下,詳細功能實現部分將對各個功能詳細解讀。

// 木易楊
function baseClone(value, bitmask, customizer, key, object, stack) {
    let result

    // 標誌位
    const isDeep = bitmask & CLONE_DEEP_FLAG		// 深拷貝,true
    const isFlat = bitmask & CLONE_FLAT_FLAG		// 拷貝原型鏈,false
    const isFull = bitmask & CLONE_SYMBOLS_FLAG	// 拷貝 Symbol,true

    // 自定義 clone 函式
    if (customizer) {
        result = object ? customizer(value, key, object, stack) : customizer(value)
    }
    if (result !== undefined) {
        return result
    }

    // 非物件  
    if (!isObject(value)) {
        return value
    }
    
    const isArr = Array.isArray(value)
    const tag = getTag(value)
    if (isArr) {
        // 陣列
        result = initCloneArray(value)
        if (!isDeep) {
            return copyArray(value, result)
        }
    } else {
        // 物件
        const isFunc = typeof value == 'function'

        if (isBuffer(value)) {
            return cloneBuffer(value, isDeep)
        }
        if (tag == objectTag || tag == argsTag || (isFunc && !object)) {
            result = (isFlat || isFunc) ? {} : initCloneObject(value)
            if (!isDeep) {
                return isFlat
                    ? copySymbolsIn(value, copyObject(value, keysIn(value), result))
                	: copySymbols(value, Object.assign(result, value))
            }
        } else {
            if (isFunc || !cloneableTags[tag]) {
                return object ? value : {}
            }
            result = initCloneByTag(value, tag, isDeep)
        }
    }
    // 迴圈引用
    stack || (stack = new Stack)
    const stacked = stack.get(value)
    if (stacked) {
        return stacked
    }
    stack.set(value, result)

    // Map
    if (tag == mapTag) {
        value.forEach((subValue, key) => {
            result.set(key, baseClone(subValue, bitmask, customizer, key, value, stack))
        })
        return result
    }

    // Set
    if (tag == setTag) {
        value.forEach((subValue) => {
            result.add(baseClone(subValue, bitmask, customizer, subValue, value, stack))
        })
        return result
    }

    // TypedArray
    if (isTypedArray(value)) {
        return result
    }

    // Symbol & 原型鏈
    const keysFunc = isFull
    	? (isFlat ? getAllKeysIn : getAllKeys)
    	: (isFlat ? keysIn : keys)

    const props = isArr ? undefined : keysFunc(value)
    
    // 遍歷賦值
    arrayEach(props || value, (subValue, key) => {
        if (props) {
            key = subValue
            subValue = value[key]
        }
        assignValue(result, key, baseClone(subValue, bitmask, customizer, key, value, stack))
    })
    
    // 返回結果
    return result
}
複製程式碼

詳細功能實現

位掩碼

上面簡單介紹了位掩碼,引數定義如下。

// 木易楊
// 主線程式碼
const CLONE_DEEP_FLAG = 1		// 1 即 0001,深拷貝標誌位
const CLONE_FLAT_FLAG = 2		// 2 即 0010,拷貝原型鏈標誌位,
const CLONE_SYMBOLS_FLAG = 4	// 4 即 0100,拷貝 Symbols 標誌位
複製程式碼

位掩碼用於處理同時存在多個布林選項的情況,其中掩碼中的每個選項的值都等於 2 的冪。相比直接使用變數來說,優點是可以節省記憶體(1/32)(來自MDN

// 木易楊
// 主線程式碼
// cloneDeep.js 新增標誌位,1 | 4 即 0001 | 0100 即 0101 即 5
CLONE_DEEP_FLAG | CLONE_SYMBOLS_FLAG

// baseClone.js 取出標誌位
let result // 初始化返回結果,後續程式碼需要,和位掩碼無關
const isDeep = bitmask & CLONE_DEEP_FLAG 	// 5 & 1 即 1 即 true
const isFlat = bitmask & CLONE_FLAT_FLAG	// 5 & 2 即 0 即 false
const isFull = bitmask & CLONE_SYMBOLS_FLAG // 5 & 4 即 4 即 true
複製程式碼

常用的基本操作如下

  • a | b:新增標誌位 a 和 b
  • mask & a:取出標誌位 a
  • mask & ~a:清除標誌位 a
  • mask ^ a:取出與 a 的不同部分
// 木易楊
var FLAG_A = 1; // 0001
var FLAG_B = 4; // 0100

// 新增標誌位 a 和 b => a | b
var mask = FLAG_A | FLAG_B => 0101 => 5

// 取出標誌位 a => mask & a
mask & FLAG_A => 0001 => 1
mask & FLAG_B => 0100 => 4

// 清除標記位 a => mask & ~a
mask & ~FLAG_A => 0100 => 4

// 取出與 a 的不同部分 => mask ^ a
mask ^ FLAG_A => 0100 => 4
mask ^ FLAG_B => 0001 => 1
FLAG_A ^ FLAG_B => 0101 => 5
複製程式碼

定製 clone 函式

// 木易楊
// 主線程式碼
if (customizer) {
	result = object ? customizer(value, key, object, stack) : customizer(value)
}
if (result !== undefined) {
    return result
}
複製程式碼

上面程式碼比較清晰,存在定製 clone 函式時,如果存在 value 值的父物件,就傳入 value、key、object、stack 這些值,不存在父物件直接傳入 value 執行定製函式。函式返回值 result 不為空則返回執行結果。

這部分是為了定製 clone 函式暴露出來的方法。

非物件

// 木易楊
// 主線程式碼
//判斷要拷貝的值是否是物件,非物件直接返回本來的值
if (!isObject(value)) {
    return value;
}

// ../isObject.js
function isObject(value) {
    const type = typeof value;
    return value != null && (type == 'object' || type ='function');
}
複製程式碼

這裡的處理和我在【進階3-3】的處理一樣,有一點不同在於物件的判斷中加入了 function,對於函式的拷貝詳見下面函式部分。

陣列 & 正則

// 木易楊
// 主線程式碼
const isArr = Array.isArray(value)
const hasOwnProperty = Object.prototype.hasOwnProperty

if (isArr) {
    // 陣列
    result = initCloneArray(value)
    if (!isDeep) {
        return copyArray(value, result)
    }
} else {
    ... // 非陣列,後面解析
}

// 初始化一個陣列
function initCloneArray(array) {
  	const { length } = array
    // 構造相同長度的新陣列
  	const result = new array.constructor(length)

  	// 正則 `RegExp#exec` 返回的陣列
  	if (length && typeof array[0] == 'string' && hasOwnProperty.call(array, 'index')) {
   	 	result.index = array.index
    	result.input = array.input
  	}
  	return result
}
    
// ... 未完待續,最後部分有陣列遍歷賦值    
複製程式碼

傳入的物件是陣列時,構造一個相同長度的陣列 new array.constructor(length),這裡相當於 new Array(length),因為 array.constructor === Array

// 木易楊
var a = [];
a.constructor === Array; // true

var a = new Array;
a.constructor === Array // true
複製程式碼

如果存在正則 RegExp#exec 返回的陣列,拷貝屬性 indexinput。判斷邏輯是 1、陣列長度大於 0,2、陣列第一個元素是字串型別,3、陣列存在 index 屬性。

// 木易楊
if (length && typeof array[0] == 'string' && hasOwnProperty.call(array, 'index')) {
    result.index = array.index
    result.input = array.input
}
複製程式碼

其中正規表示式 regexObj.exec(str) 匹配成功時,返回一個陣列,並更新正規表示式物件的屬性。返回的陣列將完全匹配成功的文字作為第一項,將正則括號裡匹配成功的作為陣列填充到後面。匹配失敗時返回 null

// 木易楊
var re = /quick\s(brown).+?(jumps)/ig;
var result = re.exec('The Quick Brown Fox Jumps Over The Lazy Dog');
console.log(result);
// [
//	0: "Quick Brown Fox Jumps" 	// 匹配的全部字串
//	1: "Brown"					// 括號中的分組捕獲
//	2: "Jumps"
//	groups: undefined
//	index: 4					// 匹配到的字元位於原始字串的基於0的索引值
//	input: "The Quick Brown Fox Jumps Over The Lazy Dog" // 原始字串
//	length: 3
// ]
複製程式碼

如果不是深拷貝,傳入valueresult,直接返回淺拷貝後的陣列。這裡的淺拷貝方式就是迴圈然後複製。

// 木易楊
if (!isDeep) {
	return copyArray(value, result)
}

// 淺拷貝陣列
function copyArray(source, array) {
  let index = -1
  const length = source.length
  array || (array = new Array(length))
  while (++index < length) {
    array[index] = source[index]
  }
  return array
}
複製程式碼

物件 & 函式

// 木易楊
// 主線程式碼
const isArr = Array.isArray(value)
const tag = getTag(value)
if (isArr) {
    ... // 陣列情況,詳見上面解析
} else {
    // 函式
    const isFunc = typeof value == 'function'

    // 如果是 Buffer 物件,拷貝並返回
    if (isBuffer(value)) {
        return cloneBuffer(value, isDeep)
    }
    
    // Object 物件、類陣列、或者是函式但沒有父物件
    if (tag == objectTag || tag == argsTag || (isFunc && !object)) {
        // 拷貝原型鏈或者 value 是函式時,返回 {},不然初始化物件
        result = (isFlat || isFunc) ? {} : initCloneObject(value)
        if (!isDeep) {
            return isFlat
                ? copySymbolsIn(value, copyObject(value, keysIn(value), result))
            	: copySymbols(value, Object.assign(result, value))
        }
    } else {
        // 在 cloneableTags 中,只有 error 和 weakmap 返回 false
        // 函式或者 error 或者 weakmap 時,
        if (isFunc || !cloneableTags[tag]) {
            // 存在父物件返回value,不然返回空物件 {}
            return object ? value : {}
        }
        // 初始化非常規型別
        result = initCloneByTag(value, tag, isDeep)
    }
}
複製程式碼

通過上面程式碼可以發現,函式、errorweakmap 時返回空物件 {},並不會真正拷貝函式。

value 型別是 Object 物件和類陣列時,呼叫 initCloneObject 初始化物件,最終呼叫 Object.create 生成新物件。

// 木易楊
function initCloneObject(object) {
    // 建構函式並且自己不在自己的原型鏈上
    return (typeof object.constructor == 'function' && !isPrototype(object))
        ? Object.create(Object.getPrototypeOf(object))
    	: {}
}

// 本質上實現了一個instanceof,用來測試自己是否在自己的原型鏈上
function isPrototype(value) {
    const Ctor = value && value.constructor
    // 尋找對應原型
    const proto = (typeof Ctor == 'function' && Ctor.prototype) || Object.prototype
    return value === proto
}
複製程式碼

其中 Object 的建構函式是一個函式物件。

// 木易楊
var obj = new Object();
typeof obj.constructor; 
// 'function'

var obj2 = {};
typeof obj2.constructor;
// 'function'
複製程式碼

對於非常規型別物件,通過各自型別分別進行初始化。

// 木易楊
function initCloneByTag(object, tag, isDeep) {
    const Ctor = object.constructor
    switch (tag) {
        case arrayBufferTag:
            return cloneArrayBuffer(object)

        case boolTag: // 布林與時間型別
        case dateTag:
            return new Ctor(+object) // + 轉換為數字

        case dataViewTag:
            return cloneDataView(object, isDeep)

        case float32Tag: case float64Tag:
        case int8Tag: case int16Tag: case int32Tag:
        case uint8Tag: case uint8ClampedTag: case uint16Tag: case uint32Tag:
            return cloneTypedArray(object, isDeep)

        case mapTag: // Map 型別
            return new Ctor

        case numberTag: // 數字和字串型別
        case stringTag:
            return new Ctor(object)

        case regexpTag: // 正則
            return cloneRegExp(object)

        case setTag: // Set 型別
            return new Ctor

        case symbolTag: // Symbol 型別
            return cloneSymbol(object)
    }
}
複製程式碼

拷貝正則型別

// 木易楊
// \w 用於匹配字母,數字或下劃線字元,相當於[A-Za-z0-9_]
const reFlags = /\w*$/
function cloneRegExp(regexp) {
    // 返回當前匹配的文字
    const result = new regexp.constructor(regexp.source, reFlags.exec(regexp))
    // 下一次匹配的起始索引
    result.lastIndex = regexp.lastIndex
    return result
}
複製程式碼

初始化 Symbol 型別

// 木易楊
const symbolValueOf = Symbol.prototype.valueOf
function cloneSymbol(symbol) {
    return Object(symbolValueOf.call(symbol))
}
複製程式碼

迴圈引用

構造了一個棧用來解決迴圈引用的問題。

// 木易楊
// 主線程式碼
stack || (stack = new Stack)
const stacked = stack.get(value)
// 已存在
if (stacked) {
    return stacked
}
stack.set(value, result)
複製程式碼

如果當前需要拷貝的值已存在於棧中,說明有環,直接返回即可。棧中沒有該值時儲存到棧中,傳入 valueresult。這裡的 result 是一個物件引用,後續對 result 的修改也會反應到棧中。

Map & Set

value 值是 Map 型別時,遍歷 value 並遞迴其 subValue,遍歷完成返回 result 結果。

// 木易楊
// 主線程式碼
if (tag == mapTag) {
    value.forEach((subValue, key) => {
        result.set(key, baseClone(subValue, bitmask, customizer, key, value, stack))
    })
    return result
}
複製程式碼

value 值是 Set 型別時,遍歷 value 並遞迴其 subValue,遍歷完成返回 result 結果。

// 木易楊
// 主線程式碼
if (tag == setTag) {
    value.forEach((subValue) => {
        result.add(baseClone(subValue, bitmask, customizer, subValue, value, stack))
    })
    return result
}
複製程式碼

上面的區別在於新增元素的 API 不同,即 Map.setSet.add

Symbol & 原型鏈

這裡我們介紹下 Symbol 和 原型鏈屬性的拷貝,通過標誌位 isFullisFlat 來控制是否拷貝。

// 木易楊
// 主線程式碼
// 型別化陣列物件
if (isTypedArray(value)) {
    return result
}

const keysFunc = isFull // 拷貝 Symbol 標誌位
	? (isFlat 			// 拷貝原型鏈屬性標誌位
       ? getAllKeysIn 	// 包含自身和原型鏈上可列舉屬性名以及 Symbol
       : getAllKeys)	// 僅包含自身可列舉屬性名以及 Symbol
	: (isFlat 
       ? keysIn 		// 包含自身和原型鏈上可列舉屬性名的陣列
       : keys)			// 僅包含自身可列舉屬性名的陣列

const props = isArr ? undefined : keysFunc(value)
arrayEach(props || value, (subValue, key) => {
    if (props) {
        key = subValue
        subValue = value[key]
    }
    // 遞迴拷貝(易受呼叫堆疊限制)
    assignValue(result, key, baseClone(subValue, bitmask, customizer, key, value, stack))
})
return result
複製程式碼

我們先來看下怎麼獲取自身、原型鏈、Symbol 這幾種屬性名組成的陣列 keys

// 木易楊
// 建立一個包含自身和原型鏈上可列舉屬性名以及 Symbol 的陣列
// 使用 for...in 遍歷
function getAllKeysIn(object) {
    const result = keysIn(object)
    if (!Array.isArray(object)) {
        result.push(...getSymbolsIn(object))
    }
    return result
}

// 建立一個僅包含自身可列舉屬性名以及 Symbol 的陣列
// 非 ArrayLike 陣列使用 Object.keys
function getAllKeys(object) {
    const result = keys(object)
    if (!Array.isArray(object)) {
        result.push(...getSymbols(object))
    }
    return result
}
複製程式碼

上面通過 keysInkeys 獲取常規可列舉屬性,通過 getSymbolsIngetSymbols 獲取 Symbol 可列舉屬性。

// 木易楊
// 建立一個包含自身和原型鏈上可列舉屬性名的陣列
// 使用 for...in 遍歷
function keysIn(object) {
    const result = []
    for (const key in object) {
        result.push(key)
    }
    return result
}

// 建立一個僅包含自身可列舉屬性名的陣列
// 非 ArrayLike 陣列使用 Object.keys
function keys(object) {
    return isArrayLike(object)
        ? arrayLikeKeys(object)
    	: Object.keys(Object(object))
}

// 測試程式碼
function Foo() {
  this.a = 1
  this.b = 2
}
Foo.prototype.c = 3

keysIn(new Foo)
// ['a', 'b', 'c'] (迭代順序無法保證)
     
keys(new Foo)
// ['a', 'b'] (迭代順序無法保證)
複製程式碼

常規屬性遍歷原型鏈用的是 for.. in,那麼 Symbol 是如何遍歷原型鏈的呢,這裡通過迴圈以及使用 Object.getPrototypeOf 獲取原型鏈上的 Symbol

// 木易楊
// 建立一個包含自身和原型鏈上可列舉 Symbol 的陣列
// 通過迴圈和使用 Object.getPrototypeOf 獲取原型鏈上的 Symbol
function getSymbolsIn (object) {
    const result = []
    while (object) { // 迴圈
        result.push(...getSymbols(object))
        object = Object.getPrototypeOf(Object(object))
    }
    return result
}

// 建立一個僅包含自身可列舉 Symbol 的陣列
// 通過 Object.getOwnPropertySymbols 獲取 Symbol 屬性
const nativeGetSymbols = Object.getOwnPropertySymbols
const propertyIsEnumerable = Object.prototype.propertyIsEnumerable

function getSymbols (object) {
    if (object == null) { // 判空
        return []
    }
    object = Object(object)
    return nativeGetSymbols(object)
        .filter((symbol) => propertyIsEnumerable.call(object, symbol))
}
複製程式碼

我們回到主線程式碼,獲取到 keys 組成的 props 陣列之後,遍歷並遞迴。

// 木易楊
// 主線程式碼
const props = isArr ? undefined : keysFunc(value)
arrayEach(props || value, (subValue, key) => {
    // props 時替換 key 和 subValue,因為 props 裡面的 subValue 只是 value 的 key
    if (props) { 
        key = subValue
        subValue = value[key]
    }
    // 遞迴拷貝(易受呼叫堆疊限制)
    assignValue(result, key, baseClone(subValue, bitmask, customizer, key, value, stack))
})

// 返回結果,主線結束
return result
複製程式碼

我們看下 arrayEach 的實現,主要實現了一個遍歷,並在 iteratee 返回為 false 時退出。

// 木易楊
// 迭代陣列
// iteratee 是每次迭代呼叫的函式
function arrayEach(array, iteratee) {
    let index = -1
    const length = array.length

    while (++index < length) {
        if (iteratee(array[index], index, array) === false) {
            break
        }
    }
    return array
}
複製程式碼

我們看下 assignValue 的實現,在值不相等情況下,將 value 分配給 object[key]

// 木易楊
const hasOwnProperty = Object.prototype.hasOwnProperty

// 如果現有值不相等,則將 value 分配給 object[key]。
function assignValue(object, key, value) {
    const objValue = object[key]

    // 不相等
    if (! (hasOwnProperty.call(object, key) && eq(objValue, value)) ) {
        // 值可用
        if (value !== 0 || (1 / value) == (1 / objValue)) {
            baseAssignValue(object, key, value)
        }
    // 值未定義而且鍵 key 不在物件中    
    } else if (value === undefined && !(key in object)) {
        baseAssignValue(object, key, value)
    }
}

// 賦值基本實現,其中沒有值檢查。
function baseAssignValue(object, key, value) {
    if (key == '__proto__') {
        Object.defineProperty(object, key, {
            'configurable': true,
            'enumerable': true,
            'value': value,
            'writable': true
        })
    } else {
        object[key] = value
    }
}

// 比較兩個值是否相等
// (value !== value && other !== other) 是為了判斷 NaN
function eq(value, other) {
  return value === other || (value !== value && other !== other)
}
複製程式碼

參考

lodash

lodash深拷貝原始碼探究

按位操作符

RegExp.prototype.exec()

進階系列目錄

  • 【進階1期】 呼叫堆疊
  • 【進階2期】 作用域閉包
  • 【進階3期】 this全面解析
  • 【進階4期】 深淺拷貝原理
  • 【進階5期】 原型Prototype
  • 【進階6期】 高階函式
  • 【進階7期】 事件機制
  • 【進階8期】 Event Loop原理
  • 【進階9期】 Promise原理
  • 【進階10期】Async/Await原理
  • 【進階11期】防抖/節流原理
  • 【進階12期】模組化詳解
  • 【進階13期】ES6重難點
  • 【進階14期】計算機網路概述
  • 【進階15期】瀏覽器渲染原理
  • 【進階16期】webpack配置
  • 【進階17期】webpack原理
  • 【進階18期】前端監控
  • 【進階19期】跨域和安全
  • 【進階20期】效能優化
  • 【進階21期】VirtualDom原理
  • 【進階22期】Diff演算法
  • 【進階23期】MVVM雙向繫結
  • 【進階24期】Vuex原理
  • 【進階25期】Redux原理
  • 【進階26期】路由原理
  • 【進階27期】VueRouter原始碼解析
  • 【進階28期】ReactRouter原始碼解析

交流

進階系列文章彙總如下,內有優質前端資料,覺得不錯點個star。

github.com/yygmind/blo…

我是木易楊,網易高階前端工程師,跟著我每週重點攻克一個前端面試重難點。接下來讓我帶你走進高階前端的世界,在進階的路上,共勉!

【進階4-4期】Lodash是如何實現深拷貝的

相關文章