編者:很早就使用jQuery的$.extend({},obj1,obj2)來合并兩個對象,原來jQuery的這個方法就是深度拷貝($.extend(true,{},obj1))和淺度拷貝的意思,這篇文章分享了 Underscore、lodash 和 jQuery 這些主流的第三方庫對于深度拷貝和淺度拷貝的實現與區分。
原文:http://jerryzou.com/posts/dive-into-deep-clone-in-javascript/
一年前我曾寫過一篇 Javascript 中的一種深復制實現,當時寫這篇文章的時候還比較稚嫩,有很多地方沒有考慮仔細。為了不誤人子弟,我決定結合 Underscore、lodash 和 jQuery 這些主流的第三方庫來重新談一談這個問題。
第三方庫的實現
講一句唯心主義的話,放之四海而皆準的方法是不存在的,不同的深復制實現方法和實現粒度有各自的優劣以及各自適合的應用場景,所以本文并不是在教大家改如何實現深復制,而是將一些在 JavaScript 中實現深復制所需要考慮的問題呈獻給大家。我們首先從較為簡單的 Underscore 開始:
Underscore —— _.clone()
在 Underscore 中有這樣一個方法:
_.clone()
,這個方法實際上是一種淺復制 (shallow-copy),所有嵌套的對象和數組都是直接復制引用而并沒有進行深復制。來看一下例子應該會更加直觀:
var x = {
a : 1 ,
b : { z : 0 }
};
var y = _ . clone ( x );
y === x // false
y . b === x . b // true
x . b . z = 100 ;
y . b . z // 100
讓我們來看一下
Underscore 的源碼 :
// Create a (shallow-cloned) duplicate of an object.
_ . clone = function ( obj ) {
if ( ! _ . isObject ( obj )) return obj ;
return _ . isArray ( obj ) ? obj . slice () : _ . extend ({}, obj );
};
如果目標對象是一個數組,則直接調用數組的
slice()
方法,否則就是用
_.extend()
方法。想必大家對
extend()
方法不會陌生,它的作用主要是將從第二個參數開始的所有對象,按鍵值逐個賦給第一個對象。而在 jQuery 中也有類似的方法。關于 Underscore 中的
_.extend()
方法的實現可以參考
underscore.js #L1006 。
Underscore 的
clone()
不能算作深復制,但它至少比直接賦值來得“深”一些,它創建了一個新的對象。另外,你也可以通過以下比較 tricky 的方法來完成
單層 嵌套的深復制:
var _ = require ( 'underscore' );
var a = [{ f : 1 }, { f : 5 }, { f : 10 }];
var b = _ . map ( a , _ . clone ); // <----
b [ 1 ]. f = 55 ;
console . log ( JSON . stringify ( a )); // [{"f":1},{"f":5},{"f":10}]
jQuery —— $.clone() / $.extend()
在 jQuery 中也有這么一個叫
$.clone()
的方法,可是它并不是用于一般的 JS 對象的深復制,而是用于 DOM 對象。這不是這篇文章的重點,所以感興趣的同學可以參考
jQuery的文檔 。與 Underscore 類似,我們也是可以通過
$.extend()
方法來完成深復制。值得慶幸的是,我們在 jQuery 中可以通過添加一個參數來實現
遞歸extend 。調用
$.extend(true, {}, ...)
就可以實現深復制啦,參考下面的例子:
var x = {
a : 1 ,
b : { f : { g : 1 } },
c : [ 1 , 2 , 3 ]
};
var y = $ . extend ({}, x ), //shallow copy
z = $ . extend ( true , {}, x ); //deep copy
y . b . f === x . b . f // true
z . b . f === x . b . f // false
在
jQuery的源碼 - src/core.js #L121 文件中我們可以找到
$.extend()
的實現,也是實現得比較簡潔,而且不太依賴于 jQuery 的內置函數,稍作修改就能拿出來單獨使用。
lodash —— _.clone() / _.cloneDeep()
在lodash中關于復制的方法有兩個,分別是
_.clone()
和
_.cloneDeep()
。其中
_.clone(obj, true)
等價于
_.cloneDeep(obj)
。使用上,lodash和前兩者并沒有太大的區別,但看了源碼會發現,Underscore 的實現只有30行左右,而 jQuery 也不過60多行???lodash 中與深復制相關的代碼卻有上百行,這是什么道理呢?
var $ = require ( "jquery" ),
_ = require ( "lodash" );
var arr = new Int16Array ( 5 ),
obj = { a : arr },
obj2 ;
arr [ 0 ] = 5 ;
arr [ 1 ] = 6 ;
// 1. jQuery
obj2 = $ . extend ( true , {}, obj );
console . log ( obj2 . a ); // [5, 6, 0, 0, 0]
Object . prototype . toString . call ( obj2 ); // [object Int16Array]
obj2 . a [ 0 ] = 100 ;
console . log ( obj ); // [100, 6, 0, 0, 0]
//此處jQuery不能正確處理Int16Array的深復制?。?!
// 2. lodash
obj2 = _ . cloneDeep ( obj );
console . log ( obj2 . a ); // [5, 6, 0, 0, 0]
Object . prototype . toString . call ( arr2 ); // [object Int16Array]
obj2 . a [ 0 ] = 100 ;
console . log ( obj ); // [5, 6, 0, 0, 0]
通過上面這個例子可以初見端倪,jQuery 無法正確深復制 JSON 對象以外的對象,而我們可以從下面這段代碼片段可以看出 lodash 花了大量的代碼來實現 ES6 引入的大量新的標準對象。更厲害的是,lodash 針對
存在環的對象 的處理也是非常出色的。因此相較而言,lodash 在深復制上的行為反饋比前兩個庫好很多,是更擁抱未來的一個第三方庫。
/** `Object#toString` result references. */
var argsTag = '[object Arguments]' ,
arrayTag = '[object Array]' ,
boolTag = '[object Boolean]' ,
dateTag = '[object Date]' ,
errorTag = '[object Error]' ,
funcTag = '[object Function]' ,
mapTag = '[object Map]' ,
numberTag = '[object Number]' ,
objectTag = '[object Object]' ,
regexpTag = '[object RegExp]' ,
setTag = '[object Set]' ,
stringTag = '[object String]' ,
weakMapTag = '[object WeakMap]' ;
var arrayBufferTag = '[object ArrayBuffer]' ,
float32Tag = '[object Float32Array]' ,
float64Tag = '[object Float64Array]' ,
int8Tag = '[object Int8Array]' ,
int16Tag = '[object Int16Array]' ,
int32Tag = '[object Int32Array]' ,
uint8Tag = '[object Uint8Array]' ,
uint8ClampedTag = '[object Uint8ClampedArray]' ,
uint16Tag = '[object Uint16Array]' ,
uint32Tag = '[object Uint32Array]' ;
借助 JSON 全局對象
相比于上面介紹的三個庫的做法,針對純 JSON 數據對象的深復制,使用 JSON 全局對象的
parse
和
stringify
方法來實現深復制也算是一個簡單討巧的方法。然而使用這種方法會有一些隱藏的坑,它能正確處理的對象只有 Number, String, Boolean, Array, 扁平對象,即那些能夠被 json 直接表示的數據結構。
function jsonClone ( obj ) {
return JSON . parse ( JSON . stringify ( obj ));
}
var clone = jsonClone ({ a : 1 });
擁抱未來的深復制方法
我自己實現了一個深復制的方法,因為用到了
Object.create
、
Object.isPrototypeOf
等比較新的方法,所以基本只能在 IE9+ 中使用。而且,我的實現是
直接定義在 prototype 上 的,很有可能引起大多數的前端同行們的不適。(關于這個我還曾在知乎上提問過:
為什么不要直接在Object.prototype上定義方法? )只是實驗性質的,大家參考一下就好,改成非 prototype 版本也是很容易的,不過就是要不斷地去
判斷對象的類型 了。~
這個實現方法具體可以看我寫的一個小玩意兒——
Cherry.js ,使用方法大概是這樣的:
function X () {
this . x = 5 ;
this . arr = [ 1 , 2 , 3 ];
}
var obj = { d : new Date (), r : /abc/ig , x : new X (), arr : [ 1 , 2 , 3 ] },
obj2 ,
clone ;
obj . x . xx = new X ();
obj . arr . testProp = "test" ;
clone = obj . $clone (); //<----
首先定義一個輔助函數,用于在預定義對象的 Prototype 上定義方法:
function defineMethods ( protoArray , nameToFunc ) {
protoArray . forEach ( function ( proto ) {
var names = Object . keys ( nameToFunc ),
i = 0 ;
for (; i < names . length ; i ++ ) {
Object . defineProperty ( proto , names [ i ], {
enumerable : false ,
configurable : true ,
writable : true ,
value : nameToFunc [ names [ i ]]
});
}
});
}
為了避免和源生方法沖突,我在方法名前加了一個
$
符號。而這個方法的具體實現很簡單,就是遞歸深復制。其中我需要解釋一下兩個參數:
srcStack
和
dstStack
。它們的主要用途是對存在環的對象進行深復制。比如源對象中的子對象
srcStack[7]
在深復制以后,對應于
dstStack[7]
。該實現方法參考了 lodash 的實現。關于遞歸最重要的就是 Object 和 Array 對象:
/*=====================================*
* Object.prototype
* - $clone()
*=====================================*/
defineMethods ([ Object . prototype ], {
'$clone' : function ( srcStack , dstStack ) {
var obj = Object . create ( Object . getPrototypeOf ( this )),
keys = Object . keys ( this ),
index ,
prop ;
srcStack = srcStack || [];
dstStack = dstStack || [];
srcStack . push ( this );
dstStack . push ( obj );
for ( var i = 0 ; i < keys . length ; i ++ ) {
prop = this [ keys [ i ]];
if ( prop === null || prop === undefined ) {
obj [ keys [ i ]] = prop ;
}
else if ( ! prop . $isFunction ()) {
if ( prop . $isPlainObject ()) {
index = srcStack . lastIndexOf ( prop );
if ( index > 0 ) {
obj [ keys [ i ]] = dstStack [ index ];
continue ;
}
}
obj [ keys [ i ]] = prop . $clone ( srcStack , dstStack );
}
}
return obj ;
}
});
/*=====================================*
* Array.prototype
* - $clone()
*=====================================*/
defineMethods ([ Array . prototype ], {
'$clone' : function ( srcStack , dstStack ) {
var thisArr = this . valueOf (),
newArr = [],
keys = Object . keys ( thisArr ),
index ,
element ;
srcStack = srcStack || [];
dstStack = dstStack || [];
srcStack . push ( this );
dstStack . push ( newArr );
for ( var i = 0 ; i < keys . length ; i ++ ) {
element = thisArr [ keys [ i ]];
if ( element === undefined || element === null ) {
newArr [ keys [ i ]] = element ;
} else if ( ! element . $isFunction ()) {
if ( element . $isPlainObject ()) {
index = srcStack . lastIndexOf ( element );
if ( index > 0 ) {
newArr [ keys [ i ]] = dstStack [ index ];
continue ;
}
}
}
newArr [ keys [ i ]] = element . $clone ( srcStack , dstStack );
}
return newArr ;
}
});
接下來要針對 Date 和 RegExp 對象的深復制進行一些特殊處理:
/*=====================================*
* Date.prototype
* - $clone
*=====================================*/
defineMethods ([ Date . prototype ], {
'$clone' : function () { return new Date ( this . valueOf ()); }
});
/*=====================================*
* RegExp.prototype
* - $clone
*=====================================*/
defineMethods ([ RegExp . prototype ], {
'$clone' : function () {
var pattern = this . valueOf ();
var flags = '' ;
flags += pattern . global ? 'g' : '' ;
flags += pattern . ignoreCase ? 'i' : '' ;
flags += pattern . multiline ? 'm' : '' ;
return new RegExp ( pattern . source , flags );
}
});
接下來就是 Number, Boolean 和 String 的
$clone
方法,雖然很簡單,但這也是必不可少的。這樣就能防止像單個字符串這樣的對象錯誤地去調用
Object.prototype.$clone
。
/*=====================================*
* Number / Boolean / String.prototype
* - $clone()
*=====================================*/
defineMethods ([
Number . prototype ,
Boolean . prototype ,
String . prototype
], {
'$clone' : function () { return this . valueOf (); }
});
比較各個深復制方法
特性
jQuery
lodash
JSON.parse
所謂“擁抱未來的深復制實現”
瀏覽器兼容性
IE6+ (1.x) & IE9+ (2.x)
IE6+ (Compatibility) & IE9+ (Modern)
IE8+
IE9+
能夠深復制存在環的對象
拋出異常 RangeError: Maximum call stack size exceeded
支持
拋出異常 TypeError: Converting circular structure to JSON
支持
對 Date, RegExp 的深復制支持
×
支持
×
支持
對 ES6 新引入的標準對象的深復制支持
×
支持
×
×
復制數組的屬性
×
僅支持RegExp#exec返回的數組結果
×
支持
是否保留非源生對象的類型
×
×
×
支持
復制不可枚舉元素
×
×
×
×
復制函數
×
×
×
×
執行效率
為了測試各種深復制方法的執行效率,我使用了如下的測試用例:
var x = {};
for ( var i = 0 ; i < 1000 ; i ++ ) {
x [ i ] = {};
for ( var j = 0 ; j < 1000 ; j ++ ) {
x [ i ][ j ] = Math . random ();
}
}
var start = Date . now ();
var y = clone ( x );
console . log ( Date . now () - start );
下面來看看各個實現方法的具體效率如何,我所使用的瀏覽器是 Mac 上的 Chrome 43.0.2357.81 (64-bit) 版本,可以看出來在3次的實驗中,我所實現的方法比 lodash 稍遜一籌,但比jQuery的效率也會高一些。希望這篇文章對你們有幫助~
深復制方法
jQuery
lodash
JSON.parse
所謂“擁抱未來的深復制實現”
Test 1
475
341
630
320
Test 2
505
270
690
345
Test 3
456
268
650
332
Average
478.7
293
656.7
332.3
參考資料