JavaScript 專題之從零實現 jQuery 的 extend

冴羽發表於2017-07-14

JavaScritp 專題系列第七篇,講解如何從零實現一個 jQuery 的 extend 函式

前言

jQuery 的 extend 是 jQuery 中應用非常多的一個函式,今天我們一邊看 jQuery 的 extend 的特性,一邊實現一個 extend!

extend 基本用法

先來看看 extend 的功能,引用 jQuery 官網:

Merge the contents of two or more objects together into the first object.

翻譯過來就是,合併兩個或者更多的物件的內容到第一個物件中。

讓我們看看 extend 的用法:

jQuery.extend( target [, object1 ] [, objectN ] )複製程式碼

第一個引數 target,表示要擴充的目標,我們就稱它為目標物件吧。

後面的引數,都傳入物件,內容都會複製到目標物件中,我們就稱它們為待複製物件吧。

舉個例子:

var obj1 = {
    a: 1,
    b: { b1: 1, b2: 2 }
};

var obj2 = {
    b: { b1: 3, b3: 4 },
    c: 3
};

var obj3 = {
    d: 4
}

console.log($.extend(obj1, obj2, obj3));

// {
//    a: 1,
//    b: { b1: 3, b3: 4 },
//    c: 3,
//    d: 4
// }複製程式碼

當兩個物件出現相同欄位的時候,後者會覆蓋前者,而不會進行深層次的覆蓋。

extend 第一版

結合著上篇寫得 《JavaScript專題之深淺拷貝》,我們嘗試著自己寫一個 extend 函式:

// 第一版
function extend() {
    var name, options, src, copy;
    var length = arguments.length;
    var i = 1;
    var target = arguments[0];

    for (; i < length; i++) {
        options = arguments[i];
        if (options != null) {
            for (name in options) {
                src = target[name];
                copy = options[name];
                if (copy !== undefined){
                    target[name] = copy;
                }
            }
        }
    }

    return target;
};複製程式碼

extend 深拷貝

那如何進行深層次的複製呢?jQuery v1.1.4 加入了一個新的用法:

jQuery.extend( [deep], target, object1 [, objectN ] )複製程式碼

也就是說,函式的第一個引數可以傳一個布林值,如果為 true,我們就會進行深拷貝,false 依然當做淺拷貝,這個時候,target 就往後移動到第二個引數。

還是舉這個例子:

var obj1 = {
    a: 1,
    b: { b1: 1, b2: 2 }
};

var obj2 = {
    b: { b1: 3, b3: 4 },
    c: 3
};

var obj3 = {
    d: 4
}

console.log($.extend(true, obj1, obj2, obj3));

// {
//    a: 1,
//    b: { b1: 3, b2: 2, b3: 4 },
//    c: 3,
//    d: 4
// }複製程式碼

因為採用了深拷貝,會遍歷到更深的層次進行新增和覆蓋。

extend 第二版

我們來實現深拷貝的功能,值得注意的是:

  1. 需要根據第一個引數的型別,確定 target 和要合併的物件的下標起始值。
  2. 如果是深拷貝,根據 copy 的型別遞迴 extend。
// 第二版
function extend() {
    // 預設不進行深拷貝
    var deep = false;
    var name, options, src, copy;
    var length = arguments.length;
    // 記錄要複製的物件的下標
    var i = 1;
    // 第一個引數不傳佈爾值的情況下,target預設是第一個引數
    var target = arguments[0] || {};
    // 如果第一個引數是布林值,第二個引數是才是target
    if (typeof target == 'boolean') {
        deep = target;
        target = arguments[i] || {};
        i++;
    }
    // 如果target不是物件,我們是無法進行復制的,所以設為{}
    if (typeof target !== 'object') {
        target = {}
    }

    // 迴圈遍歷要複製的物件們
    for (; i < length; i++) {
        // 獲取當前物件
        options = arguments[i];
        // 要求不能為空 避免extend(a,,b)這種情況
        if (options != null) {
            for (name in options) {
                // 目標屬性值
                src = target[name];
                // 要複製的物件的屬性值
                copy = options[name];

                if (deep && copy && typeof copy == 'object') {
                    // 遞迴呼叫
                    target[name] = extend(deep, src, copy);
                }
                else if (copy !== undefined){
                    target[name] = copy;
                }
            }
        }
    }

    return target;
};複製程式碼

在實現上,核心的部分還是跟上篇實現的深淺拷貝函式一致,如果要複製的物件的屬性值是一個物件,就遞迴呼叫 extend。不過 extend 的實現中,多了很多細節上的判斷,比如第一個引數是否是布林值,target 是否是一個物件,不傳引數時的預設值等。

接下來,我們看幾個 jQuery 的 extend 使用效果:

target 是函式

在我們的實現中,typeof target 必須等於 object,我們才會在這個 target 基礎上進行擴充,然而我們用 typeof 判斷一個函式時,會返回function,也就是說,我們無法在一個函式上進行擴充!

什麼,我們還能在一個函式上進行擴充!!

當然啦,畢竟函式也是一種物件嘛,讓我們看個例子:

function a() {}

a.target = 'b';

console.log(a.target); // b複製程式碼

實際上,在 underscore 的實現中,underscore 的各種方法便是掛在了函式上!

所以在這裡我們還要判斷是不是函式,這時候我們便可以使用《JavaScript專題之型別判斷(上)》中寫得 isFunction 函式

我們這樣修改:

if (typeof target !== "object" && !isFunction(target)) {
    target = {};
}複製程式碼

型別不一致

其實我們實現的方法有個小 bug ,不信我們寫個 demo:

var obj1 = {
    a: 1,
    b: {
        c: 2
    }
}

var obj2 = {
    b: {
        c: [5],

    }
}

var d = extend(true, obj1, obj2)
console.log(d);複製程式碼

我們預期會返回這樣一個物件:

{
    a: 1,
    b: {
        c: [5]
    }
}複製程式碼

然而返回了這樣一個物件:

{
    a: 1,
    b: {
        c: {
            0: 5
        }
    }
}複製程式碼

讓我們細細分析為什麼會導致這種情況:

首先我們在函式的開始寫一個 console 函式比如:console.log(1),然後以上面這個 demo 為例,執行一下,我們會發現 1 列印了三次,這就是說 extend 函式執行了三遍,讓我們捋一捋這三遍傳入的引數:

第一遍執行到遞迴呼叫時:

var src = { c: 2 };
var copy = { c: [5]};

target[name] = extend(true, src, copy);複製程式碼

第二遍執行到遞迴呼叫時:

var src = 2;
var copy = [5];

target[name] = extend(true, src, copy);複製程式碼

第三遍進行最終的賦值,因為 src 是一個基本型別,我們預設使用一個空物件作為目標值,所以最終的結果就變成了物件的屬性!

為了解決這個問題,我們需要對目標屬性值和待複製物件的屬性值進行判斷:

判斷目標屬性值跟要複製的物件的屬性值型別是否一致:

  • 如果待複製物件屬性值型別為陣列,目標屬性值型別不為陣列的話,目標屬性值就設為 []

  • 如果待複製物件屬性值型別為物件,目標屬性值型別不為物件的話,目標屬性值就設為 {}

結合著《JavaScript專題之型別判斷(下)》中的 isPlainObject 函式,我們可以對型別進行更細緻的劃分:


var clone, copyIsArray;

...

if (deep && copy && (isPlainObject(copy) ||
        (copyIsArray = Array.isArray(copy)))) {

    if (copyIsArray) {
        copyIsArray = false;
        clone = src && Array.isArray(src) ? src : [];

    } else {
        clone = src && isPlainObject(src) ? src : {};
    }

    target[name] = extend(deep, clone, copy);

} else if (copy !== undefined) {
    target[name] = copy;
}複製程式碼

迴圈引用

實際上,我們還可能遇到一個迴圈引用的問題,舉個例子:

var a = {name : b};
var b = {name : a}
var c = extend(a, b);
console.log(c);複製程式碼

我們會得到一個可以無限展開的物件,類似於這樣:

迴圈引用物件
迴圈引用物件

為了避免這個問題,我們需要判斷要複製的物件屬性是否等於 target,如果等於,我們就跳過:

...
src = target[name];
copy = options[name];

if (target === copy) {
    continue;
}
...複製程式碼

如果加上這句,結果就會是:

{name: undefined}複製程式碼

最終程式碼

function extend() {
    // 預設不進行深拷貝
    var deep = false;
    var name, options, src, copy, clone, copyIsArray;
    var length = arguments.length;
    // 記錄要複製的物件的下標
    var i = 1;
    // 第一個引數不傳佈爾值的情況下,target 預設是第一個引數
    var target = arguments[0] || {};
    // 如果第一個引數是布林值,第二個引數是 target
    if (typeof target == 'boolean') {
        deep = target;
        target = arguments[i] || {};
        i++;
    }
    // 如果target不是物件,我們是無法進行復制的,所以設為 {}
    if (typeof target !== "object" && !isFunction(target)) {
        target = {};
    }

    // 迴圈遍歷要複製的物件們
    for (; i < length; i++) {
        // 獲取當前物件
        options = arguments[i];
        // 要求不能為空 避免 extend(a,,b) 這種情況
        if (options != null) {
            for (name in options) {
                // 目標屬性值
                src = target[name];
                // 要複製的物件的屬性值
                copy = options[name];

                // 解決迴圈引用
                if (target === copy) {
                    continue;
                }

                // 要遞迴的物件必須是 plainObject 或者陣列
                if (deep && copy && (isPlainObject(copy) ||
                        (copyIsArray = Array.isArray(copy)))) {
                    // 要複製的物件屬性值型別需要與目標屬性值相同
                    if (copyIsArray) {
                        copyIsArray = false;
                        clone = src && Array.isArray(src) ? src : [];

                    } else {
                        clone = src && isPlainObject(src) ? src : {};
                    }

                    target[name] = extend(deep, clone, copy);

                } else if (copy !== undefined) {
                    target[name] = copy;
                }
            }
        }
    }

    return target;
};複製程式碼

思考題

如果覺得看明白了上面的程式碼,想想下面兩個 demo 的結果:

var a = extend(true, [4, 5, 6, 7, 8, 9], [1, 2, 3]);
console.log(a) // ???複製程式碼
var obj1 = {
    value: {
        3: 1
    }
}

var obj2 = {
    value: [5, 6, 7],

}

var b = extend(true, obj1, obj2) // ???
var c = extend(true, obj2, obj1) // ???複製程式碼

專題系列

JavaScript專題系列目錄地址:github.com/mqyqingfeng…

JavaScript專題系列預計寫二十篇左右,主要研究日常開發中一些功能點的實現,比如防抖、節流、去重、型別判斷、拷貝、最值、扁平、柯里、遞迴、亂序、排序等,特點是研(chao)究(xi) underscore 和 jQuery 的實現方式。

如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。

相關文章