最近在 vue 框架下寫業務程式碼,不可避免地涉及到物件深淺拷貝的問題,趁機會總結記錄一下。
由於微信文章平臺只能再重新編輯一次,以後文章有更新的話,會更新到我自己的個人部落格,有興趣的可以圍觀下: 個人部落格地址:blog.ironmaxi.com
記憶體的堆區與棧區
首先要講一下大家耳熟能詳的「堆疊」,要區分一下資料結構和記憶體中的「堆疊」定義。
資料結構中的堆和棧是兩種不同的、資料項按序排列的資料結構。
而我們重點要講的是記憶體中的堆區與棧區。
在 C 語言中,棧區分配區域性變數空間,而堆區是地址向上增長的用於分配程式猿申請的記憶體空間,另外還有靜態區是分配靜態變數、全域性變數空間的;只讀區是分配常量和程式程式碼空間的。以下舉個簡單的例子:
int a = 0; // 全域性初始化區
char *p1; // 全域性未初始化區
main()
{
int b; // 棧
char s[] = "abc"; // 棧
char *p2; // 棧
char *p3 = "123456"; // 在常量區,p3在棧上。
static int c =0; // 全域性(靜態)初始化區
p1 = (char *)malloc(10); // 堆
p2 = (char *)malloc(20); // 堆
}
複製程式碼
而 JavaScript 是高階語言,底層依舊依靠 C/C++ 來編譯實現,其變數劃分為基本資料型別和引用型別。 基本資料型別包括:
- undefined
- null
- boolean
- number
- string
這些型別在記憶體中分別佔有固定大小的空間,他們的值儲存在棧空間,通過按值訪問、拷貝和比較。
引用型別包括:
- object
- array
- function
- error
- date
這些型別的值大小不固定,棧記憶體中存放地址指向堆記憶體中的物件,是按引用訪問的,說白了就跟 C 語言的指標一樣的道理。
對於引用型別變數,棧記憶體中存放的知識該物件的訪問地址,在堆記憶體中為該值分配空間,由於這種值的大小不固定,因此不能把他們儲存到棧記憶體中;但記憶體地址大小是固定的,因此可以將堆記憶體地址儲存到棧記憶體中。這樣,當查詢引用型別的變數時,就先從棧中讀取堆記憶體地址,然後再根據該地址取出對應的值。
很顯而易見的一點就是,JavaScript 中所有引用型別建立例項時,都是顯式或隱式地 new 出對應型別的例項,實際上就是對應 C 語言的 malloc
分配記憶體函式。
JavaScript 中變數的賦值
js 中變數的賦值分為「傳值」與「傳址」。
給變數賦基本資料型別的值,就是「傳值」;而給變數賦引用資料型別的值,實際上是「傳址」。
基本資料型別變數的賦值、比較,只是值的賦值和比較,也即棧記憶體中的資料的拷貝和比較,參見如下直觀的程式碼:
var num1 = 123;
var num2 = 123;
var num3 = num1;
num1 === num2; // true
num1 === num3; // true
num1 = 456;
num1 === num2; // false
num1 === num3; // false
複製程式碼
引用資料型別變數的賦值、比較,只是存於棧記憶體中的堆記憶體地址的拷貝、比較,參加如下直觀的程式碼:
var arr1 = [1, 2, 3];
var arr2 = [1, 2, 3];
var arr3 = arr1;
arr1 === arr2; // false
arr1 === arr3; // true
arr1 = [1, 2, 3];
arr1 === arr2; // false
arr1 === arr3; // false
複製程式碼
再提及一個要點,js 中所有引用資料型別的頂級原型,都是 Object
,也就都是物件。
JavaScript 中變數的拷貝
js 中的拷貝區分為「淺拷貝」與「深拷貝」。
淺拷貝
淺拷貝只會將物件的各個屬性進行依次複製,並不會進行遞迴複製,也就是說只會賦值目標物件的第一層屬性。
對於目標物件第一層為基本資料型別的資料,就是直接賦值,即「傳值」; 而對於目標物件第一層為引用資料型別的資料,就是直接賦存於棧記憶體中的堆記憶體地址,即「傳址」。
深拷貝
深拷貝不同於淺拷貝,它不只拷貝目標物件的第一層屬性,而是遞迴拷貝目標物件的所有屬性。
一般來說,在JavaScript中考慮複合型別的深層複製的時候,往往就是指對於 Date
、Object
與 Array
這三個複合型別的處理。我們能想到的最常用的方法就是先建立一個空的新物件,然後遞迴遍歷舊物件,直到發現基礎型別的子節點才賦予到新物件對應的位置。
不過這種方法會存在一個問題,就是 JavaScript 中存在著神奇的原型機制,並且這個原型會在遍歷的時候出現,然後需要考慮原型應不應該被賦予給新物件。那麼在遍歷的過程中,我們可以考慮使用 hasOwnProperty
方法來判斷是否過濾掉那些繼承自原型鏈上的屬性。
動手實現一份淺拷貝加擴充套件的函式
function _isPlainObject(target) {
return (typeof target === 'object' && !!target && !Array.isArray(target));
}
function shallowExtend() {
var args = Array.prototype.slice.call(arguments);
// 第一個引數作為target
var target = args[0];
var src;
target = _isPlainObject(target) ? target : {};
for (var i=1;i<args.length;i++) {
src = args[i];
if (!_isPlainObject(src)) {
continue;
}
for(var key in src) {
if (src.hasOwnProperty(key)) {
if (src[key] != undefined) {
target[key] = src[key];
}
}
}
}
return target;
}
複製程式碼
測試用例:
// 初始化引用資料型別變數
var target = {
key: 'value',
num: 1,
bool: false,
arr: [1, 2, 3],
obj: {
objKey: 'objValue'
},
};
// 拷貝+擴充套件
var result = shallowExtend({}, target, {
key: 'valueChanged',
num: 2,
bool: true,
});
// 對原引用型別資料做修改
target.arr.push(4);
target.obj['objKey2'] = 'objValue2';
// 比較基本資料型別的屬性值
result === target; // false
result.key === target.key; // false
result.num === target.num; // false
result.bool === target.bool;// false
// 比較引用資料型別的屬性值
result.arr === target.arr; // true
result.obj === target.obj; // true
複製程式碼
jQuery.extend 實現深淺拷貝加擴充套件功能
貼下 jQuery@3.3.1 中 jQuery.extend
的實現:
jQuery.extend = jQuery.fn.extend = function() {
var options,
name,
src,
copy,
copyIsArray,
clone,
target = arguments[0] || {},
i = 1,
length = arguments.length,
deep = false;
// 如果第一個引數是布林值,則為判斷是否深拷貝的標誌變數
if (typeof target === "boolean") {
deep = target;
// 跳過 deep 標誌變數,留意上面 i 的初始值為1
target = arguments[i] || {};
// i 自增1
i++;
}
// 判斷 target 是否為 object / array / function 以外的型別變數
if (typeof target !== "object" && !isFunction(target)) {
// 如果是其它型別變數,則強制重新賦值為新的空物件
target = {};
}
// 如果只傳入1個引數;或者是傳入2個引數,第一個引數為 deep 變數,第二個為 target
// 所以 length 的值可能為 1 或 2,但無論是 1 或 2,下段 for 迴圈只會執行一次
if (i === length) {
// 將 jQuery 本身賦值給 target
target = this;
// i 自減1,可能的值為 0 或 1
i--;
}
for (; i < length; i++) {
// 以下拷貝操作,只針對非 null 或 undefined 的 arguments[i] 進行
if ((options = arguments[i]) != null) {
// Extend the base object
for (name in options) {
src = target[name];
copy = options[name];
// 避免死迴圈的情況
if (target === copy) {
continue;
}
// Recurse if we're merging plain objects or arrays
// 如果是深拷貝,且copy值有效,且copy值為純object或純array
if (deep && copy && (jQuery.isPlainObject(copy) || (copyIsArray = Array.isArray(copy)))) {
if (copyIsArray) {
// 陣列情況
copyIsArray = false;
clone = src && Array.isArray(src)
? src
: [];
} else {
// 物件情況
clone = src && jQuery.isPlainObject(src)
? src
: {};
}
// 克隆copy物件到原物件並賦值回原屬性,而不是重新賦值
// 遞迴呼叫
target[name] = jQuery.extend(deep, clone, copy);
// Don't bring in undefined values
} else if (copy !== undefined) {
target[name] = copy;
}
}
}
}
// Return the modified object
return target;
};
複製程式碼
該方法的作用是用一個或多個其他物件來擴充套件一個物件,返回被擴充套件的物件。
如果不指定target,則給jQuery名稱空間本身進行擴充套件。這有助於外掛作者為jQuery增加新方法。
如果第一個引數設定為true,則jQuery返回一個深層次的副本,遞迴地複製找到的任何物件;否則的話,副本會與原物件共享結構。 未定義的屬性將不會被複制,然而從物件的原型繼承的屬性將會被複制。
ES6 實現深淺拷貝
Object.assign
Object.assign
方法可以把 任意多個的源物件所擁有的自身可列舉屬性 拷貝給目標物件,然後返回目標物件。
注意:
- 對於訪問器屬性,該方法會執行那個訪問器屬性的
getter
函式,然後把得到的值拷貝給目標物件,如果你想拷貝訪問器屬性本身,請使用Object.getOwnPropertyDescriptor()
和Object.defineProperties()
方法; - 字串型別和 symbol 型別的屬性都會被拷貝;
- 在屬性拷貝過程中可能會產生異常,比如目標物件的某個只讀屬性和源物件的某個屬性同名,這時該方法會丟擲一個
TypeError
異常,拷貝過程中斷,已經拷貝成功的屬性不會受到影響,還未拷貝的屬性將不會再被拷貝; - 該方法會跳過那些值為
null
或undefined
的源物件;
利用 JSON 進行忽略原型鏈的深拷貝
var dest = JSON.parse(JSON.stringify(target));
複製程式碼
同樣的它也有缺點:
該方法會忽略掉值為 undefined
的屬性以及函式表示式,但不會忽略值為 null
的屬性。
再談原型鏈屬性
在專案實踐中,發現有起碼有以下兩種方式可以來規避原型鏈屬性上的拷貝。
方式1
最常用的方式:
for (let key in targetObj) {
if (targetObj.hasOwnProperty(key)) {
// 相關操作
}
}
複製程式碼
缺點:遍歷了原型鏈上的所有屬性,效率不高;
方式2
以下都是 ES6 的方式:
const keys = Object.keys(targetObj);
keys.map((key)=>{
// 相關操作
});
複製程式碼
注意:只會返回引數物件自身的(不含繼承的)所有可遍歷(enumerable)屬性的鍵名所組成的陣列。
方式3
另闢蹊徑:
const obj = Object.create(null);
target.__proto__ = Object.create(null);
for (let key in target) {
// 相關操作
}
複製程式碼