背景
上周組長給看了一下校招生的一道筆試題,題目是實現一個深拷貝的函數,實現方式各有不同,但是大多數還是只實現成了淺拷貝,作為一個剛入職的小校招生,也沒有多少高深莫測的技術沉淀,就拿這個小小的點作一個總結吧,畢竟再牛逼的技術還是要足夠扎實的基礎才能理解與運用。
標准的工科生一枚,不善文筆,有什么不對的地方還需要大佬們指出。
數據類型
為什么要作深淺拷貝呢?什么樣的數據類型存在深淺拷貝的問題?這還得從數據類型說起。在js中,變量的類型主要分為基本數據類型(string、number、boolean、null、undefined)和引用數據類型(比如Array、Date、RegExp、Function),基本類型保存在棧內存中,它們都是簡單的數據段,大小固定,而引用類型可能是由多個值構成的對象,大小不固定,保存在堆內存中。
基本類型是按值訪問的,可以操作保存在變量中的實際的值,而引用類型的值是保存在內存中的對象,js不能直接操作直接訪問內存中的位置,也就是不能直接操作對象的內存空間,在操作對象時,實際上操作的是對象的引用,所以,引用類型的值是按引用訪問的。
當從一個變量賦值另一個變量的引用類型的值的時候,復制的副本實際上是一個指針,指向儲存在堆中的同一個對象,因此改變其中一個變量就會影響另一個變量。
var person_1 = { name: 'max', age: 22 }; var person_2 = person_1; person_2.name = 'caesar'; console.log(person_1, person_2); //{name: "caesar", age: 22} //{name: "caesar", age: 22}
在這里,我們只是簡單的復制了一次引用類型的地址而已,因為兩者引用的是同一個地址,對新變量操作后,之前的變量也就跟着變了,但是往往我們並不想這樣。
Good!有這些就夠了,現在我們進入正題,所以,基本類型不存在深淺拷貝的問題,我們要拷貝的類型肯定就是引用類型了,比如Array和Object。
淺拷貝
好,這個時候我想拷貝一個數組,我大腦里快速的閃現了幾個原生的數組copy方法,以及我飛快寫出的一個循環賦值法。
- Array.prototype.slice
- Array.prototype.concat
- ES6 copyWithin()
- function copyArr(arr) { let res = [] for (let i = 0; i < arr.length; i++) { res.push(arr[i]) } return res }
但是它們是深拷貝嗎?我小試了一下。
我只改變了新數組里面對象的一個屬性,為什么之前的數組里的對象也跟着變了!!

要知道為什么,還得看看實現它們的原理,於是我搜了一下v8源碼(就不分析其他詳情功能啦- -,我們只看關鍵),舉個栗子:Array.prototype.slice:
ArraySliceFallback 主函數
function ArraySliceFallback(start, end) { CHECK_OBJECT_COERCIBLE(this, "Array.prototype.slice"); ... //最終返回結果的值,可以從這里看到創建了一個新的真正的數組 var result = ArraySpeciesCreate(array, MaxSimple(end_i - start_i, 0)); if (end_i < start_i) return result; //走這里的邏輯大概處理的數組長度相對較大,但是拷貝的數與數組的總體大小相比,處理元素的數量相對較小,但是這和我們的目的無關,不細究。 if (UseSparseVariant(array, len, IS_ARRAY(array), end_i - start_i)) { %NormalizeElements(array); if (IS_ARRAY(result)) %NormalizeElements(result); //看這里看這里,處理值的地方 SparseSlice(array, start_i, end_i - start_i, len, result); } else { //還有這里這里 SimpleSlice(array, start_i, end_i - start_i, len, result); } result.length = end_i - start_i; return result; }
slice方法的主函數。前面對start,end輸入的一些判斷處理就忽略了,主要看兩個分支處理SparseSlice和SimpleSlice
SparseSlice
function SparseSlice(array, start_i, del_count, len, deleted_elements) { var indices = %GetArrayKeys(array, start_i + del_count); if (IS_NUMBER(indices)) { var limit = indices; //循環 for (var i = start_i; i < limit; ++i) { var current = array[i]; //賦值 if (!IS_UNDEFINED(current) || i in array) { %CreateDataProperty(deleted_elements, i - start_i, current); } } } else { var length = indices.length; //循環 for (var k = 0; k < length; ++k) { var key = indices[k]; if (key >= start_i) { var current = array[key]; //賦值 if (!IS_UNDEFINED(current) || key in array) { %CreateDataProperty(deleted_elements, key - start_i, current); } } } } }
可以看到該函數兩個分支都只是循環了數組,然后進行直接的賦值操作。
SimpleSlice
function SimpleSlice(array, start_i, del_count, len, deleted_elements) { //直接循環 for (var i = 0; i < del_count; i++) { var index = start_i + i; if (index in array) { //賦值 var current = array[index]; %CreateDataProperty(deleted_elements, i, current); } } }
所以slice方法並沒有對循環的每一項是基本類型還是引用類型作區分,並針對引用元素對其內部屬性再作判斷,所以,Array.prototype.slice是淺拷貝無疑。后面2個原生方法就不拿出來看了,其實也類似,感興趣和不信的小伙伴可以點擊這個鏈接去看看(https://github.com/v8/v8/blob/master/src/js/array.js)
小結
我們可以用=直接復制基本類型,復制引用類型時,循環遍歷對象,對每個屬性使用=完成復制,所以以上的這些拷貝都只是復制了第一層的屬性,這就是淺拷貝,雖然我們確實得到了一個新的與復制源獨立的對象,但是其內部包含的引用屬性值仍然指向同一個地址(也就是我們只復制了地址)。
假如數組中保存的對象的某個屬性還是一個對象呢?(然后一層一層這樣下去呢?雖然肯定沒人設計這么深的數據結構),但是為了解決這個問題,就有了深拷貝的實現方式:對屬性中所有引用類型的值,一直遍歷到基本類型為止,要實現深拷貝,也能想到用遞歸了。
深拷貝
所以深拷貝並不是簡單的復制引用,而是在堆中重新分配內存,並且把源對象實例的所有屬性都新建復制,以保證復制的對象的引用不指向任何原有對象上或其屬性內的任何對象,復制后的對象與原來的對象是完全隔離的。
其他類庫的深拷貝實現(jquery)
我們先來看看jquery是怎么實現深拷貝的。jquery是使用extend函數來實現對象的深拷貝。
jQuery.extend = jQuery.fn.extend = function() { ... //前面都不重要,我們直接看拷貝的核心代碼 for ( ; i < length; i++ ) { // 只處理非空參數 if ( (options = arguments[ i ]) != null ) { for ( name in options ) { //遍歷源對象的屬性名 src = target[ name ]; //獲取目標對象上,屬性名對應的屬性 copy = options[ name ]; //獲取源對象上,屬性名對應的屬性 // jq這里做的比較好,為了避免深度遍歷時死循環,jq不會覆蓋目標對象的同名屬性,也就是避免對象環的問題(對象的某個屬性值是對象本身) if ( target === copy ) { continue; } // 深度拷貝且值是普通象或數組,則遞歸,也就是說,jquery對{}或者new Object創建的對象以及數組作了遞歸的深拷貝處理 if ( deep && copy && ( jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)) ) ) { // 如果copy是數組 if ( copyIsArray ) { copyIsArray = false; // clone為src的修正值 clone = src && jQuery.isArray(src) ? src : []; // 如果copy的是對象 } else { // clone為src的修正值 clone = src && jQuery.isPlainObject(src) ? src : {}; } // 遞歸調用jQuery.extend target[ name ] = jQuery.extend( deep, clone, copy ); // 否則,如果不是純對象或者數組jquery最后會直接把源對象的屬性,賦給源對象(淺拷貝) } else if ( copy !== undefined ) { target[ name ] = copy; } } } } // 返回更改后的對象 return target; };
但是jquery的深拷貝不能實現函數對象的拷貝,看下面的這段代碼。
我們寫了一個函數類,雖然它是函數,但是它也是函數對象並且是引用類型呀,但是jquery並不會對函數對象作深拷貝處理,它會走到上面最后的else if里面,直接把源對象的屬性賦值給對象,也就是,還是淺拷貝,不過函數更多的是用來完成某些功能,有輸入值和返回值,所以也不是很需要對函數對象作深拷貝。
JSON全局對象
針對JSON數據對象的復制,有一種簡單取巧的方法,那就是使用JSON的全局對象的parse和stringify來實現深復 制。
function jsonClone(obj) { return JSON.parse(JSON.stringify(obj)); }
但是它能正確處理的只有Number、String、Boolean,Aarry,扁平對象,即那些能夠被json直接表示的數據結構,如下。
比較好的實現深拷貝的方法
本來想自己寫一個深拷貝,嘗試寫了一下,在看了前人們寫的后感覺多少都會有抄襲的感覺,而且細節考慮不周到(其實就是自己水平還不夠- -),還是給大家分享一個我覺得的前人寫的比較好的深拷貝吧
//定義一個輔助函數,用於在預定義對象的 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]] }); } }); } //Object對象的處理 defineMethods([ Object.prototype ], { //主要解釋兩個參數,srcStack和dstStack。它們的主要用途是對存在環的對象進行深復制。比如源對象中的子對象srcStack[7]在深復制以后,對應於dstStack[7] '$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的處理 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類型的處理 defineMethods([ Date.prototype ], { '$clone': function() { return new Date(this.valueOf()); } }); //RegExp的處理 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 的處理,這樣能防止像單個字符串這樣的對象錯誤地去調用Object.prototype.$clone defineMethods([ Number.prototype, Boolean.prototype, String.prototype ], { '$clone': function() { return this.valueOf(); } });
考慮得周到多了,不僅能實現Object和Array的深拷貝,還考慮了Date個RegExp這兩個引用類型,我這個前端小萌新還需要多多努力呀,多多學習努力,多多學習努力,多多學習努力。
結語
並不是所有變量的賦值都需要深拷貝,在大多數情況下,我們也只用得到淺拷貝,按需所取吧,像jquery針對是否需要深拷貝會加上供開發者選擇的第一個參數。
第一次寫文章,很多地方都不到位,但是最后感覺收獲頗豐,在深淺拷貝上有了更深的印象與認識。
如果你喜歡我們的文章,關注我們的公眾號和我們互動吧。