jQuery整體架構原始碼解析

ChokCoco發表於2016-03-15

最近一直在研讀 jQuery 原始碼,初看原始碼一頭霧水毫無頭緒,真正靜下心來細看寫的真是精妙,讓你感嘆程式碼之美。

其結構明晰,高內聚、低耦合,兼具優秀的效能與便利的擴充套件性,在瀏覽器的相容性(功能缺陷、漸進增強)優雅的處理能力以及 Ajax 等方面周到而強大的定製功能無不令人驚歎。

另外,閱讀原始碼讓我接觸到了大量底層的知識。對原生JS 、框架設計、程式碼優化有了全新的認識,接下來將會寫一系列關於 jQuery 解析的文章。

我在 github 上關於 jQuery 原始碼的全文註解,感興趣的可以圍觀一下。jQuery v1.10.2 原始碼註解 

網上已經有很多解讀 jQuery 原始碼的文章了,作為系列開篇的第一篇,思前想去起了個【深入淺出jQuery】的標題,資歷尚淺,無法對 jQuery 分析的頭頭是道,但是 jQuery 原始碼當中確實有著大量巧妙的設計,不同層次水平的閱讀者都能有收穫,所以打算厚著臉皮將自己從中學到的一些知識點共享出來。打算從整體及分支,分章節剖析。本篇主要講 jQuery 的整體架構及一些前期準備,先來看看 jQuery 的整體結構:

整體架構

不同於 jQuery 程式碼各個模組細節實現的晦澀難懂,jQuery 整體框架的結構十分清晰,按程式碼行文大致分為如上圖所示的模組。

初看 jQuery 原始碼可能很容易一頭霧水,因為 9000 行的程式碼感覺沒有盡頭,所以瞭解作者的行文思路十分重要。

整體而言,我覺得 jQuery 採用的是總–分的結構,雖然JavaScript有著作用域的提升機制,但是 9000 多行的程式碼為了相互的關聯性,並不代表所有的變數都要定義在最頂部。在 jQuery 中,只有全域性都會用到的變數、正規表示式定義在了程式碼最開頭,而每個模組一開始,又會定義一些只在本模組會使用到的變數、正則、方法等。所以在一開始的閱讀的過程中會有很多看不懂其作用的變數,正則,方法。

所以,我覺得閱讀原始碼很重要的一點是,摒棄程式導向的思維方式,不要刻意去追求從上至下每一句都要在一開始弄明白。很有可能一開始你在一個奇怪的方法或者變數處卡殼了,很想知道這個方法或變數的作用,然而可能它要到幾千行處才被呼叫到。如果去追求這種逐字逐句弄清楚的方式,很有可能在碰壁幾次之後閱讀的積極性大受打擊。

道理說了很多,接來下進入真正的正文,對 jQurey 的一些前期準備,小的細節進行分析:

閉包結構

// 用一個函式域包起來,就是所謂的沙箱
// 在這裡邊 var 定義的變數,屬於這個函式域內的區域性變數,避免汙染全域性
// 把當前沙箱需要的外部變數通過函式引數引入進來
// 只要保證引數對內提供的介面的一致性,你還可以隨意替換傳進來的這個引數
(function(window, undefined) {
   // jQuery 程式碼
})(window);

jQuery 具體的實現,都被包含在了一個立即執行的函式閉包裡面,為了不汙染全域性作用域,只在後面暴露 $ 和 jQuery 這 2 個變數給外界,儘量的避開變數衝突。常用的還有另一種寫法:

(function(window) {
   // JS程式碼
})(window, undefined);

比較推崇的的第一種寫法,也就是 jQuery 的寫法。二者有何不同呢,當我們的程式碼執行在更早期的環境當中(pre-ES5,eg. Internet Explorer 8),undefined 僅是一個變數且它的值是可以被覆蓋的。意味著你可以做這樣的操作:

undefined = 42
console.log(undefined) // 42

當使用第一種方式,可以確保你需要的 undefined 確實就是 undefined。

另外不得不提出的是,jQuery 在這裡有一個針對壓縮優化細節,使用第一種方式,在程式碼壓縮的時候,window 和 undefined 都可以壓縮為 1 個字母並且確保它們就是 window 和 undefined。

// 壓縮策略
// w -> windwow , u -> undefined
(function(w, u) {

})(window);

無 new 構造

嘿,回想一下使用 jQuery 的時候,例項化一個 jQuery 物件的方法:

// 無 new 構造
$('#test').text('Test');

// 當然也可以使用 new
var test = new $('#test');
test.text('Test');

大部分人使用 jQuery 的時候都是使用第一種無 new 的構造方式,直接 $(”) 進行構造,這也是 jQuery 十分便捷的一個地方。當我們使用第一種無 new 構造方式的時候,其本質就是相當於 new jQuery(),那麼在 jQuery 內部是如何實現的呢?看看:

(function(window, undefined) {
    var
    // ...
    jQuery = function(selector, context) {
        // The jQuery object is actually just the init constructor 'enhanced'
        // 看這裡,例項化方法 jQuery() 實際上是呼叫了其擴充的原型方法 jQuery.fn.init
        return new jQuery.fn.init(selector, context, rootjQuery);
    },

    // jQuery.prototype 即是 jQuery 的原型,掛載在上面的方法,即可讓所有生成的 jQuery 物件使用
    jQuery.fn = jQuery.prototype = {
        // 例項化化方法,這個方法可以稱作 jQuery 物件構造器
        init: function(selector, context, rootjQuery) {
            // ...
        }
    }
    // 這一句很關鍵,也很繞
    // jQuery 沒有使用 new 運算子將 jQuery 例項化,而是直接呼叫其函式
    // 要實現這樣,那麼 jQuery 就要看成一個類,且返回一個正確的例項
    // 且例項還要能正確訪問 jQuery 類原型上的屬性與方法
    // jQuery 的方式是通過原型傳遞解決問題,把 jQuery 的原型傳遞給jQuery.prototype.init.prototype
    // 所以通過這個方法生成的例項 this 所指向的仍然是 jQuery.fn,所以能正確訪問 jQuery 類原型上的屬性與方法
    jQuery.fn.init.prototype = jQuery.fn;

})(window);

大部分人初看 jQuery.fn.init.prototype = jQuery.fn 這一句都會被卡主,很是不解。但是這句真的算是 jQuery 的絕妙之處。理解這幾句很重要,分點解析一下:

1)首先要明確,使用 $(‘xxx’) 這種例項化方式,其內部呼叫的是 return new jQuery.fn.init(selector, context, rootjQuery) 這一句話,也就是構造例項是交給了 jQuery.fn.init() 方法取完成。

2)將 jQuery.fn.init 的 prototype 屬性設定為 jQuery.fn,那麼使用 new jQuery.fn.init() 生成的物件的原型物件就是 jQuery.fn ,所以掛載到 jQuery.fn 上面的函式就相當於掛載到 jQuery.fn.init() 生成的 jQuery 物件上,所有使用 new jQuery.fn.init() 生成的物件也能夠訪問到 jQuery.fn 上的所有原型方法。

3)也就是例項化方法存在這麼一個關係鏈

  • jQuery.fn.init.prototype = jQuery.fn = jQuery.prototype ;
  • new jQuery.fn.init() 相當於 new jQuery() ;
  • jQuery() 返回的是 new jQuery.fn.init(),而 var obj = new jQuery(),所以這 2 者是相當的,所以我們可以無 new 例項化 jQuery 物件。

方法的過載

jQuery 原始碼晦澀難讀的另一個原因是,使用了大量的方法過載,但是用起來卻很方便:

// 獲取 title 屬性的值
$('#id').attr('title');
// 設定 title 屬性的值
$('#id').attr('title','jQuery');

// 獲取 css 某個屬性的值
$('#id').css('title');
// 設定 css 某個屬性的值
$('#id').css('width','200px');

方法的過載即是一個方法實現多種功能,經常又是 get 又是 set,雖然閱讀起來十分不易,但是從實用性的角度考慮,這也是為什麼 jQuery 如此受歡迎的原因,大多數人使用 jQuery() 構造方法使用的最多的就是直接例項化一個 jQuery 物件,但其實在它的內部實現中,有著 9 種不同的方法過載場景:

// 接受一個字串,其中包含了用於匹配元素集合的 CSS 選擇器
jQuery([selector,[context]])
// 傳入單個 DOM
jQuery(element)
// 傳入 DOM 陣列
jQuery(elementArray)
// 傳入 JS 物件
jQuery(object)
// 傳入 jQuery 物件
jQuery(jQuery object)
// 傳入原始 HTML 的字串來建立 DOM 元素
jQuery(html,[ownerDocument])
jQuery(html,[attributes])
// 傳入空引數
jQuery()
// 繫結一個在 DOM 文件載入完成後執行的函式
jQuery(callback)

所以讀原始碼的時候,很重要的一點是結合 jQuery API 進行閱讀,去了解方法過載了多少種功能,同時我想說的是,jQuery 原始碼有些方法的實現特別長且繁瑣,因為 jQuery 本身作為一個通用性特別強的框架,一個方法相容了許多情況,也允許使用者傳入各種不同的引數,導致內部處理的邏輯十分複雜,所以當解讀一個方法的時候感覺到了明顯的困難,嘗試著跳出卡殼的那段程式碼本身,站在更高的維度去思考這些複雜的邏輯是為了處理或相容什麼,是否是過載,為什麼要這樣寫,一定會有不一樣的收穫。其次,也是因為這個原因,jQuery 原始碼存在許多相容低版本的 HACK 或者邏輯十分晦澀繁瑣的程式碼片段,瀏覽器相容這樣的大坑極其容易讓一個前端工程師不能學到程式設計的精髓,所以不要太執著於一些邊角料,即使相容性很重要,也應該適度學習理解,適可而止。

jQuery.fn.extend 與 jQuery.extend

extend 方法在 jQuery 中是一個很重要的方法,jQuey 內部用它來擴充套件靜態方法或例項方法,而且我們開發 jQuery 外掛開發的時候也會用到它。但是在內部,是存在 jQuery.fn.extend 和 jQuery.extend 兩個 extend 方法的,而區分這兩個 extend 方法是理解 jQuery 的很關鍵的一部分。先看結論:

1)jQuery.extend(object) 為擴充套件 jQuery 類本身,為類新增新的靜態方法;

2)jQuery.fn.extend(object) 給 jQuery 物件新增例項方法,也就是通過這個 extend 新增的新方法,例項化的 jQuery 物件都能使用,因為它是掛載在 jQuery.fn 上的方法(上文有提到,jQuery.fn = jQuery.prototype )。

它們的官方解釋是:

1)jQuery.extend(): 把兩個或者更多的物件合併到第一個當中,

2)jQuery.fn.extend():把物件掛載到 jQuery 的 prototype 屬性,來擴充套件一個新的 jQuery 例項方法。

也就是說,使用 jQuery.extend() 擴充的靜態方法,我們可以直接使用 $.xxx 進行呼叫(xxx是擴充的方法名),

而使用 jQuery.fn.extend() 擴充的例項方法,需要使用 $().xxx 呼叫。

原始碼解析較長,也可以去這裡閱讀

// 擴充套件合併函式
// 合併兩個或更多物件的屬性到第一個物件中,jQuery 後續的大部分功能都通過該函式擴充套件
// 雖然實現方式一樣,但是要注意區分用法的不一樣,那麼為什麼兩個方法指向同一個函式實現,但是卻實現不同的功能呢,
// 閱讀原始碼就能發現這歸功於 this 的強大力量
// 如果傳入兩個或多個物件,所有物件的屬性會被新增到第一個物件 target
// 如果只傳入一個物件,則將物件的屬性新增到 jQuery 物件中,也就是新增靜態方法
// 用這種方式,我們可以為 jQuery 名稱空間增加新的方法,可以用於編寫 jQuery 外掛
// 如果不想改變傳入的物件,可以傳入一個空物件:$.extend({}, object1, object2);
// 預設合併操作是不迭代的,即便 target 的某個屬性是物件或屬性,也會被完全覆蓋而不是合併
// 如果第一個引數是 true,則是深拷貝
// 從 object 原型繼承的屬性會被拷貝,值為 undefined 的屬性不會被拷貝
// 因為效能原因,JavaScript 自帶型別的屬性不會合並
jQuery.extend = jQuery.fn.extend = function() {
    var src, copyIsArray, copy, name, options, clone,
        target = arguments[0] || {},
        i = 1,
        length = arguments.length,
        deep = false;

    // Handle a deep copy situation
    // target 是傳入的第一個引數
    // 如果第一個引數是布林型別,則表示是否要深遞迴,
    if (typeof target === "boolean") {
        deep = target;
        target = arguments[1] || {};
        // skip the boolean and the target
        // 如果傳了型別為 boolean 的第一個引數,i 則從 2 開始
        i = 2;
    }

    // Handle case when target is a string or something (possible in deep copy)
    // 如果傳入的第一個引數是 字串或者其他
    if (typeof target !== "object" && !jQuery.isFunction(target)) {
        target = {};
    }

    // extend jQuery itself if only one argument is passed
    // 如果引數的長度為 1 ,表示是 jQuery 靜態方法
    if (length === i) {
        target = this;
        --i;
    }

    // 可以傳入多個複製源
    // i 是從 1或2 開始的
    for (; i < length; i++) {
        // Only deal with non-null/undefined values
        // 將每個源的屬性全部複製到 target 上
        if ((options = arguments[i]) != null) {
            // Extend the base object
            for (name in options) {
                // src 是源(即本身)的值
                // copy 是即將要複製過去的值
                src = target[name];
                copy = options[name];

                // Prevent never-ending loop
                // 防止有環,例如 extend(true, target, {'target':target});
                if (target === copy) {
                    continue;
                }

                // Recurse if we're merging plain objects or arrays
                // 這裡是遞迴呼叫,最終都會到下面的 else if 分支
                // jQuery.isPlainObject 用於測試是否為純粹的物件
                // 純粹的物件指的是 通過 "{}" 或者 "new Object" 建立的
                // 如果是深複製
                if (deep && copy && (jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)))) {
                    // 陣列
                    if (copyIsArray) {
                        copyIsArray = false;
                        clone = src && jQuery.isArray(src) ? src : [];

                        // 物件
                    } else {
                        clone = src && jQuery.isPlainObject(src) ? src : {};
                    }

                    // Never move original objects, clone them
                    // 遞迴
                    target[name] = jQuery.extend(deep, clone, copy);

                    // Don't bring in undefined values
                    // 最終都會到這條分支
                    // 簡單的值覆蓋
                } else if (copy !== undefined) {
                    target[name] = copy;
                }
            }
        }
    }

    // Return the modified object
    // 返回新的 target
    // 如果 i < length ,是直接返回沒經過處理的 target,也就是 arguments[0]
    // 也就是如果不傳需要覆蓋的源,呼叫 $.extend 其實是增加 jQuery 的靜態方法
    return target;
};

需要注意的是這一句 jQuery.extend = jQuery.fn.extend = function() {} ,也就是 jQuery.extend 的實現和 jQuery.fn.extend 的實現共用了同一個方法,但是為什麼能夠實現不同的功能了,這就要歸功於 Javascript 強大(怪異?)的 this 了。

1)在 jQuery.extend() 中,this 的指向是 jQuery 物件(或者說是 jQuery 類),所以這裡擴充套件在 jQuery 上;

2)在 jQuery.fn.extend() 中,this 的指向是 fn 物件,前面有提到 jQuery.fn = jQuery.prototype ,也就是這裡增加的是原型方法,也就是物件方法。

jQuery 的鏈式呼叫及回溯

另一個讓大家喜愛使用 jQuery 的原因是它的鏈式呼叫,這一點的實現其實很簡單,只需要在要實現鏈式呼叫的方法的返回結果裡,返回 this ,就能夠實現鏈式呼叫了。

當然,除了鏈式呼叫,jQuery 甚至還允許回溯,看看:

// 通過 end() 方法終止在當前鏈的最新過濾操作,返回上一個物件集合
$('div').eq(0).show().end().eq(1).hide();

當選擇了 (‘div’).eq(0) 之後使用 end() 可以回溯到上一步選中的 jQuery 物件 $(‘div’),其內部實現其實是依靠新增了 prevObject 這個屬性:

jQuery 完整的鏈式呼叫、增棧、回溯通過 return this 、 return this.pushStack() 、return this.prevObject 實現,看看原始碼實現:

jQuery.fn = jQuery.prototype = {
    // 將一個 DOM 元素集合加入到 jQuery 棧
    // 此方法在 jQuery 的 DOM 操作中被頻繁的使用, 如在 parent(), find(), filter() 中
    // pushStack() 方法通過改變一個 jQuery 物件的 prevObject 屬性來跟蹤鏈式呼叫中前一個方法返回的 DOM 結果集合
    // 當我們在鏈式呼叫 end() 方法後, 內部就返回當前 jQuery 物件的 prevObject 屬性
    pushStack: function(elems) {
        // 構建一個新的jQuery物件,無參的 this.constructor(),只是返回引用this
        // jQuery.merge 把 elems 節點合併到新的 jQuery 物件
        // this.constructor 就是 jQuery 的建構函式 jQuery.fn.init,所以 this.constructor() 返回一個 jQuery 物件
        // 由於 jQuery.merge 函式返回的物件是第二個函式附加到第一個上面,所以 ret 也是一個 jQuery 物件,這裡可以解釋為什麼 pushStack 出入的 DOM 物件也可以用 CSS 方法進行操作
        var ret = jQuery.merge(this.constructor(), elems);

        // 給返回的新 jQuery 物件新增屬性 prevObject
        // 所以也就是為什麼通過 prevObject 能取到上一個合集的引用了
        ret.prevObject = this;
        ret.context = this.context;

        // Return the newly-formed element set
        return ret;
    },
    // 回溯鏈式呼叫的上一個物件
    end: function() {
        // 回溯的關鍵是返回 prevObject 屬性
        // 而 prevObject 屬性儲存了上一步操作的 jQuery 物件集合
        return this.prevObject || this.constructor(null);
    },
    // 取當前 jQuery 物件的第 i 個
    eq: function(i) {
        // jQuery 物件集合的長度
        var len = this.length,
            j = +i + (i < 0 ? len : 0);

        // 利用 pushStack 返回
        return this.pushStack(j >= 0 && j < len ? [this[j]] : []);
    }, 
}

總的來說,

1)end() 方法返回 prevObject 屬性,這個屬性記錄了上一步操作的 jQuery 物件合集;

2)而 prevObject 屬性由 pushStack() 方法生成,該方法將一個 DOM 元素集合加入到 jQuery 內部管理的一個棧中,通過改變 jQuery 物件的 prevObject 屬性來跟蹤鏈式呼叫中前一個方法返回的 DOM 結果集合

3)當我們在鏈式呼叫 end() 方法後,內部就返回當前 jQuery 物件的 prevObject 屬性,完成回溯。

正則與細節優化

不得不提 jQuery 在細節優化上做的很好。也存在很多值得學習的小技巧,下一篇將會以 jQuery 中的一些程式設計技巧為主題行文,這裡就不再贅述。

然後想談談正規表示式,jQuery 當中用了大量的正規表示式,我覺得如果研讀 jQuery ,正則水平一定能夠大大提升,如果是個正則小白,我建議在閱讀之前先去了解以下幾點:

1)瞭解並嘗試使用 Javascript 正則相關 API,包括了 test() 、replace() 、match() 、exec() 的用法;

2)區分上面 4 個方法,哪個是 RegExp 物件方法,哪個是 String 物件方法;

3)瞭解簡單的零寬斷言,瞭解什麼是捕獲但不匹配以及捕獲並且匹配。

變數衝突處理

最後想提一提 jQuery 變數的衝突處理,通過一開始儲存全域性變數的 window.jQuery 以及 windw.$ 。

當需要處理衝突的時候,呼叫靜態方法 noConflict(),讓出變數的控制權,原始碼如下:

(function(window, undefined) {
    var
        // Map over jQuery in case of overwrite
        // 設定別名,通過兩個私有變數對映了 window 環境下的 jQuery 和 $ 兩個物件,以防止變數被強行覆蓋
        _jQuery = window.jQuery,
        _$ = window.$;

    jQuery.extend({
        // noConflict() 方法讓出變數 $ 的 jQuery 控制權,這樣其他指令碼就可以使用它了
        // 通過全名替代簡寫的方式來使用 jQuery
        // deep -- 布林值,指示是否允許徹底將 jQuery 變數還原(移交 $ 引用的同時是否移交 jQuery 物件本身)
        noConflict: function(deep) {
            // 判斷全域性 $ 變數是否等於 jQuery 變數
            // 如果等於,則重新還原全域性變數 $ 為 jQuery 執行之前的變數(儲存在內部變數 _$ 中)
            if (window.$ === jQuery) {
                // 此時 jQuery 別名 $ 失效
                window.$ = _$;
            }
            // 當開啟深度衝突處理並且全域性變數 jQuery 等於內部 jQuery,則把全域性 jQuery 還原成之前的狀況
            if (deep && window.jQuery === jQuery) {
                // 如果 deep 為 true,此時 jQuery 失效
                window.jQuery = _jQuery;
            }

            // 這裡返回的是 jQuery 庫內部的 jQuery 建構函式(new jQuery.fn.init())
            // 像使用 $ 一樣盡情使用它吧
            return jQuery;
        }
    })
}(window)

畫了一幅簡單的流程圖幫助理解:

jQuery衝突處理流程圖

那麼讓出了這兩個符號之後,是否就不能在我們的程式碼中使用 jQuery 或者呢 $ 呢?莫慌,還是可以使用的:

// 讓出 jQuery 、$ 的控制權不代表不能使用 jQuery 和 $ ,方法如下:
var query = jQuery.noConflict(true);

(function($) {

// 外掛或其他形式的程式碼,也可以將引數設為 jQuery
})(query);

//  ... 其他用 $ 作為別名的庫的程式碼

結束語

對 jQuery 整體架構的一些解析就到這裡,下一篇將會剖析一下 jQuery 中的一些優化小技巧,一些對程式設計有所提高的地方。

原創文章,文筆有限,才疏學淺,文中若有不正之處,萬望告知。

最後,我在 github 上關於 jQuery 原始碼的全文註解,感興趣的可以圍觀一下,給顆星星。jQuery v1.10.2 原始碼註解 

相關文章