最近一直在研讀 jQuery 源碼,初看源碼一頭霧水毫無頭緒,真正靜下心來細看寫的真是精妙,讓你感嘆代碼之美。
其結構明晰,高內聚、低耦合,兼具優秀的性能與便利的擴展性,在瀏覽器的兼容性(功能缺陷、漸進增強)優雅的處理能力以及 Ajax 等方面周到而強大的定製功能無不令人驚歎。
另外,閱讀源碼讓我接觸到了大量底層的知識。對原生JS 、框架設計、代碼優化有了全新的認識,接下來將會寫一系列關於 jQuery 解析的文章。
我在 github 上關於 jQuery 源碼的全文註解,感興趣的可以圍觀一下。
網上已經有很多解讀 jQuery 源碼的文章了,作爲系列開篇的第一篇,思前想去起了個【深入淺出jQuery】的標題,資歷尚淺,無法對 jQuery 分析的頭頭是道,但是 jQuery 源碼當中確實有着大量巧妙的設計,不同層次水平的閱讀者都能有收穫,所以打算厚着臉皮將自己從中學到的一些知識點共享出來。打算從整體及分支,分章節剖析。本篇主要講 jQuery 的整體架構及一些前期準備,先來看看 jQuery 的整體結構:
一、jQuery 整體架構
不同於 jQuery 代碼各個模塊細節實現的晦澀難懂,jQuery 整體框架的結構十分清晰,按代碼行文大致分爲如上圖所示的模塊。
初看 jQuery 源碼可能很容易一頭霧水,因爲 9000 行的代碼感覺沒有盡頭,所以瞭解作者的行文思路十分重要。
整體而言,我覺得 jQuery 採用的是總--分的結構,雖然JavaScript有着作用域的提升機制,但是 9000 多行的代碼爲了相互的關聯性,並不代表所有的變量都要定義在最頂部。在 jQuery 中,只有全局都會用到的變量、正則表達式定義在了代碼最開頭,而每個模塊一開始,又會定義一些只在本模塊會使用到的變量、正則、方法等。所以在一開始的閱讀的過程中會有很多看不懂其作用的變量,正則,方法。
所以,我覺得閱讀源碼很重要的一點是,摒棄面向過程的思維方式,不要刻意去追求從上至下每一句都要在一開始弄明白。很有可能一開始你在一個奇怪的方法或者變量處卡殼了,很想知道這個方法或變量的作用,然而可能它要到幾千行處才被調用到。如果去追求這種逐字逐句弄清楚的方式,很有可能在碰壁幾次之後閱讀的積極性大受打擊。
道理說了很多,接來下進入真正的正文,對 jQurey 的一些前期準備,小的細節進行分析:
二、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);
三、jQuery 無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 的絕妙之處。理解這幾句很重要,分點解析一下:
- 首先要明確,使用 $('xxx') 這種實例化方式,其內部調用的是 return new jQuery.fn.init(selector, context, rootjQuery) 這一句話,也就是構造實例是交給了 jQuery.fn.init() 方法去完成。
- 將 jQuery.fn.init 的 prototype 屬性設置爲 jQuery.fn,那麼使用 new jQuery.fn.init() 生成的對象的原型對象就是 jQuery.fn ,所以掛載到 jQuery.fn 上面的函數就相當於掛載到 jQuery.fn.init() 生成的 jQuery 對象上,所有使用 new jQuery.fn.init() 生成的對象也能夠訪問到 jQuery.fn 上的所有原型方法。
- 也就是實例化方法存在這麼一個關係鏈
- 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 方法的重載
1、方法重載基本介紹
jQuery 源碼晦澀難讀的另一個原因是,使用了大量的方法重載,但是用起來卻很方便:
// 獲取 title 屬性的值
$('#id').attr('title');
// 設置 title 屬性的值
$('#id').attr('title','jQuery');
// 獲取 css 某個屬性的值
$('#id').css('title');
// 設置 css 某個屬性的值
$('#id').css('width','200px');
2、應用場景
方法的重載即是一個方法實現多種功能,經常又是 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
1、基本分析
extend 方法在 jQuery 中是一個很重要的方法,jQuey 內部用它來擴展靜態方法或實例方法,而且我們開發 jQuery 插件開發的時候也會用到它。但是在內部,是存在 jQuery.fn.extend 和 jQuery.extend 兩個 extend 方法的,而區分這兩個 extend 方法是理解 jQuery 的很關鍵的一部分。先看結論:
- jQuery.extend(object) 爲擴展 jQuery 類本身,爲類添加新的靜態方法;
- jQuery.fn.extend(object) 給 jQuery 對象添加實例方法,也就是通過這個 extend 添加的新方法,實例化的 jQuery 對象都能使用,因爲它是掛載在 jQuery.fn 上的方法(上文有提到,jQuery.fn = jQuery.prototype )。
2、官方解釋
它們的官方解釋是:
- jQuery.extend(): 把兩個或者更多的對象合併到第一個當中,
- 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 了。
- 在 jQuery.extend() 中,this 的指向是 jQuery 對象(或者說是 jQuery 類),所以這裏擴展在 jQuery 上;
- 在 jQuery.fn.extend() 中,this 的指向是 fn 對象,前面有提到 jQuery.fn = jQuery.prototype ,也就是這裏增加的是原型方法,也就是對象方法。
六、jQuery 的鏈式調用及回溯
另一個讓大家喜愛使用 jQuery 的原因是它的鏈式調用,這一點的實現其實很簡單,只需要在要實現鏈式調用的方法的返回結果裏,返回 this ,就能夠實現鏈式調用了。
當然,除了鏈式調用,jQuery 甚至還允許回溯,看看:
// 通過 end() 方法終止在當前鏈的最新過濾操作,返回上一個對象集合
$('div').eq(0).show().end().eq(1).hide();
jQuery 完整的鏈式調用、增棧、回溯通過 return this 、 return this.pushStack() 、return this.prevObject 實現,看看源碼實現:
總的來說,
- end() 方法返回 prevObject 屬性,這個屬性記錄了上一步操作的 jQuery 對象合集;
- 而 prevObject 屬性由 pushStack() 方法生成,該方法將一個 DOM 元素集合加入到 jQuery 內部管理的一個棧中,通過改變 jQuery 對象的 prevObject 屬性來跟蹤鏈式調用中前一個方法返回的 DOM 結果集合
- 當我們在鏈式調用 end() 方法後,內部就返回當前 jQuery 對象的 prevObject 屬性,完成回溯。
七、jQuery 正則與細節優化
不得不提 jQuery 在細節優化上做的很好。也存在很多值得學習的小技巧,下一篇將會以 jQuery 中的一些編程技巧爲主題行文,這裏就不再贅述。
然後想談談正則表達式,jQuery 當中用了大量的正則表達式,我覺得如果研讀 jQuery ,正則水平一定能夠大大提升,如果是個正則小白,我建議在閱讀之前先去了解以下幾點:
- 瞭解並嘗試使用 Javascript 正則相關 API,包括了 test() 、replace() 、match() 、exec() 的用法;
- 區分上面 4 個方法,哪個是 RegExp 對象方法,哪個是 String 對象方法;
- 瞭解簡單的零寬斷言,瞭解什麼是匹配但是不捕獲以及匹配並且捕獲。
八、jQuery 變量衝突處理
1、源碼分析
最後想提一提 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)
2、流程圖
畫了一幅簡單的流程圖幫助理解:
那麼讓出了這兩個符號之後,是否就不能在我們的代碼中使用 jQuery 或者呢 $ 呢?莫慌,還是可以使用的:
// 讓出 jQuery 、$ 的控制權不代表不能使用 jQuery 和 $ ,方法如下:
var query = jQuery.noConflict(true);
(function($) {
// 插件或其他形式的代碼,也可以將參數設爲 jQuery
})(query);
// ... 其他用 $ 作爲別名的庫的代碼
九、結束語
對 jQuery 整體架構的一些解析就到這裏,下一篇將會剖析一下 jQuery 中的一些優化小技巧,一些對編程有所提高的地方。
原創文章,文筆有限,才疏學淺,文中若有不正之處,萬望告知。