2019 JS內功修煉之讀jQuery源碼

引言:2019年,react hooks成功上位,vue3.0發佈alpha版,TS使用率的飛速增長,以及大量前端開發工具使用體驗的大幅優化和提高等等讓越來越多的開發者吐槽前端學不動了的時候,最好的應對方式便是對基礎概念的掌握。內功足夠強大,才能做到不被別人牽着鼻子走。閱讀開源代碼是一個很好的方式,首先率選擇了jQuery便是裏面的內容沒有太多足夠抽象的設計思想。更多的是對於基礎內容的覆蓋。同時也包含一些不錯但設計模式在裏面,因此具有不錯的性價比。

jQuery是早期前端開發中佔比很重的一個庫。在手動操作DOM和瀏覽器差異較大的時代,jQuery通過統一和簡化不同瀏覽器之間的API,爲程序開發帶了極大的便利。所以jQuery的設計思路也是圍繞這兩點展開的。

ps: 不做特殊說明,$在源碼示例中等效jQuery

jQuery做的主要工作

  1. DOM查詢及操作
  2. ajax請求
  3. animation動畫
  4. promise(deferred)
  5. event handle
  6. css style
  7. 兼容性問題,抹平不同瀏覽器的間差異

jQuery的特點

  1. 面向對象 ————> prototype
  2. API設計的特點 ————> 函數重載
    1. jQuery對於DOM的操作是命令式的,那麼相對就要要求使用成本是相對較低的,沒有特別複雜的API設計,數量少,參數簡單。
  3. 內部封裝,爲了實現jQuery的幾大功能,做了大量的內部封裝例如類型判斷方法class2type,拆分夠細,爲以後的模塊化加載和性能優化預留口子;
  4. 選擇器引擎:Sizzle, 將查找函數當做數據緩存起來,如果下次碰見相同的selector,可以跳過selector的解析過程,直接執行元素的查找工作;

jQuery的面向對象

jQuery實際採用面向對象的方式進行程序開發。jQuery本身是構造函數。

jQuery('body').constructor === jQuery // true
jQuery('body').addClass === jQuery.prototype.addClass // true

// 因爲 jQuery('body')的constructor和 addClass方法分別指向 jQuery本身和jQuery.prototype上的addClass方法,
// 所以jQuery('body')返回的對象實際上就是jQuery構造函數生成的實例

但是在js中生成實例一般使用new操作符,而jQuery一般的寫法是$()。這裏其實是通過某種技巧省略了new操作符。首先有無new,生成的實例都是等效的。

(new jQuery('body')).constructor === jQuery('body').constructor // true
(new jQuery('body')).__proto__ === jQuery('body').__proto__ // true
// 這就證實了 有無new操作符,返回的結果是等效的。

這樣設計有一個好處是讓構造jQuery對象更加方便。

那它的實現方式呢, 看一眼jQuery函數的定義:

jQuery = function( selector, context ) {
	return new jQuery.fn.init( selector, context );
};

我們發現jQuery方法返回的實際是 jQuery.fn.init的實例。同時,我們爲了讓生成的實例繼承jQuery.prototype上的方法,還需要添加一行代碼:

 jQuery.fn.init.prototype = jQuery.prototype;

關於js中構造函數和prototype的更多內容可以查閱其它資料。

關於new操作符,我們都知道在構造 函數沒有指定return對象的時候,會返回this本身。如果我們在無new時,顯式指定return對象爲this(return this;),是不是也等效於new呢?

答案: 不是

這裏和函數中this的指向有關。一個函數或方法在執行時,內部的this指向分爲四個來源

  1. func() : this指向全局
  2. obj.func(): this指向obj
  3. call/apply: this指向傳給call/apply的第一個參數
  4. new操作符: this指向在函數內新創建的一個Object對象

封裝與繼承

已經確定jQuery的開發採用面向對象的方式。而面向對象的兩個基本要素: 封裝繼承
封裝定義一個實例如何組裝完成,繼承定義多個實例間會共享的內容(行爲)。

封裝

$.fn.init方法做了jQuery對象的封裝工作,通過一個簡單的$()工廠函數調用。在init方法中,將一切可能的輸入源封裝爲jQuery對象。
有個需要特別說明的地方是,$()方法除了接收普通的DOM對象或HTML字符串作爲輸入源返回一個jQuery對象外,還支持接收函數。這也是個語法糖,意思是在document ready的時候,調用這個函數。這麼沒有什麼特別的目的,就是爲了非常方便地定義一些在document ready執行的邏輯。因爲在實際業務中,你的代碼執行的時候可能還有很多元素未加載。

繼承

jQuery對象的繼承基於js的原型對象完成。所有的jQuery對象都共享$.prototype對象上的方法。同時jQuery給自身添加了extend()方法用於對象的擴展。那麼,也同樣可以用於擴展自身的prototype對象,從而實現功能的擴展。這也是jQuery插件實現的基本原理。

需要預先明確的點: jQuery.prototype === jQuery.fn 。 這有什麼用? 手敲代碼的時候快一些。

hooks

jQuery中許多地方用到了鉤子思想,主要是用於處理瀏覽器的兼容性問題。在事件處理和css樣式設置中的體現尤爲明顯。

jQuery的事件處理

事件處理包含綁定,分發和刪除三部分業務。jQuery中所有的事件(包括自定義事件)都會通過這三個方法進行處理。如果遇到自定義事件或者需要兼容性處理等特殊情況,會通過jQuery.event.special處理。

jQuery.event.special實現的基礎是jQuery對瀏覽器的事件做了代理,所有在業務上需要綁定到元素事件的邏輯,最終都會交給一個統一的方法。這個方法通過原生API綁定到元素上,然後在事件被觸發時,此方法根據事件的上下文進行業務邏輯的分發。

jQuery對事件的綁定最終都收縮到jQuery.event.add方法中,不論對外暴露的API是on()或者one()。 同時在模塊內部也有一個on方法,這個方法同樣起到函數重載的作用,將參數處理成規範形式然後提交給jQuery.event.add方法進行事件綁定操作。

jQuery.event.add這個方法很有意思,它並沒有直接把處理方法直接通過原生綁定方法綁定處理事件到元素上面。而是將EventHandler作爲數據存到元素本身(存儲的實現參考Data.js),如果元素對同一類型綁定了多個事件,這些事件會以數組的形式存在。如果沒有把handler直接幫到元素的事件上面,那麼如何在事件觸發時,調起這些邏輯?其實是綁定了一個調度器,這個調度器會在事件觸發時,將存儲元素本身的方法逐一取出執行。

這是對於瀏覽器支持的普通事件的處理方式,如果是自定義事件呢?

答案就是 jQuery.event.special

假如在執行自定義事件customEvent綁定的邏輯時,jQuery首先檢查jQuery.event.special.customEvent是否存在。如果存在的話,會走jQuery.event.special.customEvent中定義的邏輯。 這個對象一般包含四個方法: setup, add, teardown, ‘remove’。作用於事件處理中不同生命週期。通過special對事件處理邏輯做攔截,在此基礎上可以實現對原生事件行爲的重寫或者添加自定義事件。

如果不使用special,那麼如何處理兼容性問題。if…else ? 寫出來的邏輯成了麪條式的代碼。在事件處理中,包含三個基本要素: 綁定解綁分發。針對同一個事件兼容性處理,可能需要在這三個處理方法中分別添加兼容性業務的處理。這樣一來寫出來的邏輯必然十分繁雜。如果我們以事件爲單位,定義各自的三種邏輯,然後交給程序在合適的時間調起。這樣一來,業務會清晰很多。

jQuery.event.special

setup: 給該元素第一次綁定該事件時調用;
teardown: 給該元素解綁該事件最後一個handler時調用;
add: 給該元素添加handler;
remove: 給該元素移除handler;
handler: 當dispatch該事件的時候調用;
_default: 給該事件添加默認行爲;

如果setup/teardown 返回false,那麼會執行jQuery的bind/unbind方法(通過DOM native API)

$.fn.css()方法

關於css樣式相關方法的hooks是以jQuery.cssHooks存在的,分爲 getset

ajax與dataType

.ajaxdataType:jsonpjsonp<script>XMLHttpRequest.ajax允許接收`dataType:jsonp`,但是我們知道`jsonp`是通過`<script>`腳本實現的跨域請求,它不能通過XMLHttpRequest發送。那麼.ajax有什麼特別的處理麼?

prefilterstransports

這也是ajax可以自定義dataType的關鍵點,原理跟event.special 類似。

jQuery.Callbacks

jQuery自己實現了一個Callbacks方法,用於管理回調,主要是爲了提供給自己的defferred、ajax和animation使用。

實現基於觀察者模式,對外暴露 add,remove,fire這幾個API方法。 除了這三個方法,是無法在外部直接修改回調list和執行狀態firing等數據的,通過閉包來實現。

同時提供了回調函數上下文的設置接口(fireWith)。

函數式編程與Sizzle

jQuery的設計思路就是找到頁面上的一些元素並執行一些操作。其中負責“找”的便是selector。而這一部分最終成爲一個獨立項目Sizzle。

Sizzle作爲查找器引擎,基於函數式編程的思路進行開發。基本的思路是將輸入(selector字符串)轉化爲輸出結果(與selector match的元素),不對輸入數據做任何變更,通過不同的輸入數據生成不同的函數然後執行最終函數獲得目標數據。

Sizzle在轉換selector的中間過程中,還對生成的函數進行緩存,進而在下次遇到相同的輸入時,可以直接返回之前已經生成過的函數,從而獲得性能的提升。

Sizzle本身實現了一個小型的compilor。爲什麼這麼說,在早先瀏覽器不支持querySelector/querySelectorAll的時代,想一想’:first’, " p ~ p"等之類的元素查找。這種寫法暗含了上下文相關。傳統的getElementsByTagName方法必然包含了大量的回溯操作。這對於開發者是極爲不便利的,jQuery封裝了這些操作。這可能也是爲什麼當時可以快速流行併成爲js中最流行的庫的原因。

鏈式操作

在通過查找引擎Sizzle找到目標元素後,就可以對元素執行一些操作。
在jQuery中,我們都知道進行DOM操作可以採用鏈式寫法,比如像下面這樣對document.body進行操作:

$('body').addClass('foo').find('div').remove().end().addClass('bar')

那麼如果不採用鏈式寫法呢,會有什麼樣的結果,看下面

$('body').addClass('foo');
$('body').find('div').remove();
$('body').addClass('bar');

所以,一目瞭然~~~

這樣在進行DOM操作時,手寫代碼帶來的便利性是顯而易見的。實現這種寫法的機制也很簡單,就是在每一次操作之後,都返回對象自身return this

但是,如果某個方法需要返回操作結果或者其它數據,那麼這時候鏈式操作就無法滿足了。

函數重載

jQuery中存在許多函數重載。我們知道函數重載是在函數名相同的前提下,根據參數類型或個數來區分不同的處理。那麼函數重載在jQuery中有什麼意義呢?

$('body').css('width')
$('body').css('width', '800px')
$('body').css({
    'color': 'red',
    'border': '10px solid blue'
})

很明顯,css()是方法是被重載的函數。那如果不對css進行重載呢,想像一下,如果實現上面的功能應該怎麼設計程序。可能需要設計get/set方法或者針對每種參數類型都寫一個方法。那麼對外暴露的API就不僅僅是一個css()了。API的繁多是會增加使用者的學習成本的。

但是函數重載也不是隻好不壞,增加了程序的複雜性。在jQuery中,存在一些單純的normalize參數的方法。這樣讓開發者無法第一眼就知道最終調用的是哪個方法。這是對開發者而言,對於計算機,函數重載也可能會增加程序的消費。

關於函數重載,在jQuery的event處理中,得到了更明顯的使用。綁定實踐最終會調用jQuery.event.add方法,但是在這之前,會先走on()方法,這個方法主要的作用就是規範函數的參數。

函數重載最終的效果的通過參數個數或者參數類型,類區分不同的處理方案, 減少了對外暴露的API數量。但是函數重載的基礎是在不同方案之間概念相近的情況下,才建議採用函數重載。這樣對於使用者而言,也是清晰明確的。如果你把jQuery的find的方法重載到jQuery.css中,那誰可以一眼看出find方法在哪個API中呢。

收緊口子

舉幾個例子:

  1. 所有的樣式set 都走 style()方法;
  2. 所有的樣式get 都走 css()方法;
  3. 所有的動畫 都走 animate()方法;
  4. 元素的追加和替換都走 domManip()方法;

那有個問題:什麼時候該收,什麼時候該放? 答:找到業務中的關鍵節點,然後在關鍵節點上做好覆蓋面比較廣的把控。

隊列

動畫都會以隊列的形式執行,默認隊列是fx,那麼fx是如何實現的? 一個隊列應該具有自執行的特點,將處理方法以數組的形式存儲,然後再執行出棧時,給每個方法添加一個鉤子,鉤住下一個要執行的方法,在執行完後調用下一個方法。

這個模式跟compose很像,將多個函數合併成一個函數執行。Koa和Redux的核心概念實現便是基於函數的compose。

總結

jQuery從2005年發行至今(2019年12月),仍然在生產環境中佔據一席之地的原因?

  1. 短小精悍的API設計,上手成本低;
  2. 較小的運行時負載, 足夠好的性能;
  3. 在API設計簡單,功能強大基礎上,代碼量足夠小;
  4. 強大的擴展性,導致了非常豐富的生態;

關於運行時負載

運行時負載是現在React/Vue等框架隨着業務功能的逐漸強大,也難以避免,最終總會有一個天花板存在。也因此,有人搞出了無運行時負載的框架Svelte。Vue3更是強調自己的運行時性能是2.x的一倍,一部分提升得益於用Proxy替換了Object.defineProperty,另一部分則是靜態編譯時做的性能優化。所以對於框架或者庫的設計,這也是應該考慮的一方面問題。

參考

jQuery.event實現的基本原理demo special-events

jquery-edge-new-special-event-hooks

發佈了41 篇原創文章 · 獲贊 7 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章