# 重新思考 jQuery

在這年頭或許使用 jQuery 的人越來越少,更甚者有可能連 jQuery 都沒有聽過。

不過在好幾年前,前端工程、SPA 的需求還沒有那麼高、瀏覽器的實作五花八門、可怕的 IE 仍是主流,各種建構工具發展還沒有那麼成熟時,jQuery 可以說是開發網站必備的神器。

許多人詬病 jQuery 寫出來的程式碼雜亂無章,不好模組化,甚至排斥去使用它,卻不知道其實是自己缺乏把程式寫好的能力而已,jQuery 當中還是有許多值得我們學習的典範。

要知道在那個年代還沒有成熟的工具,要實作模組化需要良好的程式風格,以及對 JavaScript 的掌握,兼容各種瀏覽器(尤其是 IE),而 jQuery 卻做到了,而且提供了非常好用的介面跟擴充性,讓它盛名將近 10 多年。

我希望介紹一下我從 jQuery 學到的事情。當然隨著時代演進,逐漸汰舊換新是一件必要的事情,選擇符合時宜的工具也是開發的重點之一,但是從過去的典範學習,也能發現很多寶藏。

# Selector

jQuery 當中,我們可以用像是 CSS 的選擇器語法來選擇元素。

$('.links:first-child > input ~ label')
$('.links[data-action="gotoNext"] + input[type="text"]')

那是個連用 document.querySelector 都嫌奢侈的年代,只能靠這幾個 API getElementByIdgetElementByClasNamegetElementByTagName 來獲取需要的元素,一旦 DOM 結構變得複雜,要匹配起來也就變得格外困難。

不妨思考一下,如果沒有 querySelector 這個 API,你要怎麼實作用 css selector 來選擇元素呢?是不是光用想的就覺得麻煩的要命?

這是我覺得相當強大的概念,我們把調用 API 的方式簡化成字串來描述(就是 CSS 的 selector 啦),寫起來相當直覺,背後實作的理論仍是老派的正規表達式、詞法分析、語法分析、抽象語法樹。

jQuery 內部使用 Sizzle (opens new window) 為選擇器引擎。這個引擎實作了 CSS selector 語法並回傳對應的元素,在 querySelectorAll 還沒普及前簡直就是前端救星,整份程式碼並不長,整個實作總共 2000 多行程式碼而已。

這個引擎簡單來講實作了:

  • 透過詞法解析器將選擇器語法轉為 token 可以透過 Sizzle.tokenize('#test > p.last > span:last-child') 看看被解析後的 token。

    [
    	{
    		"value": "#test",
    		"type": "ID",
    		"matches": [
    			"test"
    		]
    	},
    	{
    		"value": " > ",
    		"type": ">"
    	},
    	{
    		"value": "p",
    		"type": "TAG",
    		"matches": [
    			"p"
    		]
    	},
    	{
    		"value": ".last",
    		"type": "CLASS",
    		"matches": [
    			"last"
    		]
    	},
    	{
    		"value": " > ",
    		"type": ">"
    	},
    	{
    		"value": "span",
    		"type": "TAG",
    		"matches": [
    			"span"
    		]
    	},
    	{
    		"value": ":last-child",
    		"type": "CHILD",
    		"matches": [
    			"last",
    			"child",
    			null,
    			null,
    			null,
    			null,
    			null,
    			null
    		]
    	}
    ]
    
  • 根據 token 中的類型與值做對應的處理(例如呼叫原生 API 等等)

  • 將匹配後的結果回傳

實際上 Sizzle 內部的實現比上述複雜很多,有機會的話再放到進階篇介紹。

但這個概念非常實用,像是 jsx 語法、css 選擇器都是類似的概念,透過抽象化的語法來簡化 API 呼叫,更容易從語法上知道程式在做什麼。

Sizzle 提供了強大的解析引擎來做這件事,同時為了兼顧效能,內部也做了許多優化。

想要做到的事情很簡單,就是可以讓你無腦用 $('.class > p > div + input[type="checkbox"]') 的語法來選擇元素,但實作起來完全就是一件吃力不討好的事情。

關於 Sizzle 的實作,大概也可以寫成一篇鐵人賽系列的吧,在這裏並沒有辦法描述太多細節,有興趣的話可以參考看看 How jQuery selects elements using Sizzle (opens new window)

知道 Sizzle 是怎麼實作的對前端開發有幾個好處:

  • 你可以學習用簡單的語法來描述複雜的規則。
  • 用抽象化的語法來簡化一些 strcuctral 的資料結構及 API 呼叫
  • 基本上 sizzle 就算是一個小型的編譯器,可以從中學習怎麼實作語法解析器,但也不用到那麼複雜。(例如直接解析一門語言的語法等)
  • 有了語法樹,你甚至可以用 CSS 選擇器的語法來做其他事情,例如透過 CSS 語法選擇資料夾中的某些檔案。
  • 像是 ESLint Babel 背後幾乎都是同一個概念,都是先分析詞法、tokenize、建立語法樹,再用語法樹來做到比較複雜的事情。

# 為什麼要從右至左分析選擇器?

假設我想要匹配 #test > p.last > span:last-child,從左到右解析是:

  1. 用 document.getElementById('') 找到 div#test
  2. 使用 getElementsByClassName,確認是否為 p tag
  3. 確認父元素是否為 #test
  4. 對 p 的子元素 span 做遍歷,確認 span 是否為最後一個。

如果將選擇器變成 #test p.last span:last-child 會需要更多回溯。


從右到左解析是:

  1. 找到所有 span 並判斷是否為 last-child
  2. 對符合條件的 span 尋找上層是否為 p.last
  3. 找到匹配的 p 後,向上尋找是否為 #test

其中最大的差別是,如果從左到右解析,很容易遇到失敗而必須最上層開始匹配一次的回溯過程,當選擇器或 DOM 結構變得複雜時,這會相當耗費效能。但仔細思考一下就可以發現從右到左是比較有效率的,在大部分的情況下我們預期大部分的元素都不會被匹配,所以從最右邊開始,只有符合當前條件的元素才去匹配上一層。

# 事件註冊、事件委託

jQuery 提供了一個統一的介面 on 來監聽事件:

$(document).on('click', e => {})

如果要做事件委託,用原生的 JavaScript 大概會長這樣子:

document.addEventListener('click', e => {
  if (e.target.className === '.link') {
    // do something...
  }
});

這樣寫有幾個不好的地方,1. 我們將判斷的邏輯寫在處理器裡頭,未來不好維護 2. 每次都要寫 e.target.className 來做判斷,未來可能想用其他方式(例如 data attribute 等等)

用 jQuery 只要這樣寫:

$(document).on('click', '.link', e => {})

就能做到事件委託了,也不需要在裡頭判斷 e.target.className 我覺得這樣的 API 設計很棒,你可以在參數中彈性選擇你想要委託的節點是什麼,而不用直接寫在處理器裡頭。

你可能會問,這麼簡單的事情,我只要包裝一下就好了呀。

function delegate(type, target, delegationNode, handler) {
  target.addEventListner(type, e => {
    if (e.target === delegationNode) {
      handler(e);
    }
  })
}

但 jQuery 在這裏提供了相當簡潔的介面直接支援一般事件註冊與委託,而且第二個參數可以做到直接用選擇器的語法來選擇元素,比起我們的抽象又多了份彈性。

而且 jQuery 原生支持複數的事件處理器:

$('.links').on('click', e => {});

但用一般的 JavaScript 寫起來就沒有那麼直覺:

function addEvents(type, handler) {
  document.querySelectorAll('.links')
    .forEach(elm => {
  	  elm.addEventListener(type, handler);
    })  
}

這些都是在設計 API 時你可能需要考量的事情,像是參數省略、簡潔的介面、一目瞭然的名稱及用途,最重要的就是保持適度的彈性,這些都是值得學習的地方,也是我認為 jQuery 盛名數十年的原因之一。

# 立即執行函數(IIFE)

jQuery 的執行,是用一個立即函數 (funcion(window){})(window) 給包起來的。

除了可以讓 code 立即執行之外,最大的好處在於可以消除作用域。例如在裡頭使用的變數等,希望在使用後可以不要洩漏給外部操作,就可以使用立即執行函數。

另外這樣子傳入 window 參數有幾個好處:

第一個是可以明確知道這個函數需要 window 變數;第二個是作用域的查找,因為函數會先向上一個作用域找變數,找不到才往更上一層找;再來就是做最小化時,當作參數傳入後所有使用 window 的變數都可以被最小化。例如:

(function(window){
  window.innerWidth = ...
 
  // ...
 
  window.onclick = ...
})(window);

透過最小化可以變成:

(function(w){
  w.innerWidth = ...
 
  // ...
 
  w.onclick = ...
})(window);

因為參數化的關係可以讓所有在內部使用到 window 的呼叫都改為 w。如果不用參數而直接使用 window 的話:

(function(){
  window.innerWidth = ...
 
  // ...
 
  window.onclick = ...
})();

因為 window 是全域變數,就沒有辦法使用最小化來壓縮了。

# $().width() / $().height()

這邊的 width 跟 height 是一個函數,是在 jquery 當中隨處可見的 API 設計,像是 attr data 都是。

如果沒有給參數,會回傳這個元素的高/寬,如果有定義參數的話,則會設定元素的高/寬。

我自己是蠻喜歡這樣的設計巧思的。不過現在你也可以用 setter, getter,甚至是用 proxy 來實作。

# $.ajax 的抽象

jQuery 的 ajax 提供了許多抽象,所以我們可以很簡單的呼叫 ajax 而不用設定可怕的 XMLHttpRequest 物件(也沒有那麼可怕,就是沒那麼直覺)

$.ajax({
  dataType: "json",
  url: url,
  data: data,
  success,
});

# 重載

jQuery 的函數,大量引用重載(overloadding)的概念,這也是為什麼讓人用起來覺得很容易的原因之一。

重載的意思是,根據函式的簽名來決定要怎麼執行這個函數。例如我們傳入 $(document),jQuery 知道他是一個 Document 物件,要轉換成 jQuery 需要用 A 方法實作;而傳入 $('.class') 的時候是字串,jQuery 知道要先去找匹配的元素,再把他們轉成 jQuery 物件。

重載在靜態語言如 Java C++ 是很常見的手法,你可以用同一個函數名稱,根據函數的簽名來決定要執行哪一項實作。

當然 JavaScript 和靜態語言的實作是完全不同的,JavaScript 是個動態語言,當然沒有你寫好函數簽名就自動幫你進行對應時做這種好康的事,但你或許可以參考一下 jQuery 是怎麼做到這件事的。(額外一提,TypeScript 有類似重載的機制)

最大的差異在於在你實作重載的時候,靜態語言會建立一個 virtual table,並且根據函數簽名來查表,但 JavaScript 則是全部靠自己判斷參數型別了。

$() 提供了九個重載,雖然我們通常只用 $(selector) 或是 $(element) 而已。

當然,也有人覺得函數就應該只做一件事而已,提供不同的參數長度與型別會更容易產生 bug,也不好除錯(例如我是要使用這個重載,還是單純傳錯參數)

# $().fn

在寫 jQuery 套件的時候,一定會用到這個函數。這個函數的設計很特別:

jQuery.fn = jQuery.prototype = {
  // a lot of methods
};

其實只是指向 jQuery 的 prototype 而已,也就是每次寫 $.fn.hello 的時候,都是在 jQuery 當中加入新的方法,也就可以被全部的 jQuery 物件給使用,這個設計也讓許多開發者可以撰寫自己的 plugin 到 jQuery 而不用修改程式碼。

不過為什麼 jQuery 要特別幫它取名呢?我覺得有幾個原因:

  • fn 這個名字比較短,比起 prototype 更加語意化
  • 比較有「我在寫 jQuery 套件的感覺」

# $().remove() / $().detatch

you might not need jQuery (opens new window) 當中,remove 可以直接替換成 removeChild。不過其實在 remove 當中做了更多事:

function remove(selector, keepData) {
	var elem,
		i = 0;

	for (; (elem = this[i]) != null; i++) {
		if (!selector || jQuery.filter(selector, [elem]).length) {
			if (!keepData && elem.nodeType === 1) {
				jQuery.cleanData(elem.getElementsByTagName('*'));
				jQuery.cleanData([elem]);
			}

			if (elem.parentNode) {
				elem.parentNode.removeChild(elem);
			}
		}
	}

	return this;
}

cleanData 會幫你把裡頭的 event 跟 data 都刪除,來避免一些舊瀏覽器裡無法被垃圾回收的問題。如果不想要 jquery 幫你刪除的話就要用 detatch 這個函數。

# 小結

雖然現在的開發當中,透過 React 或是 Vue 開發的話,或許不再需要 jQuery 也說不定,但 jQuery 發展至今已經相當成熟,而且原始碼當中有許多值得我們借鏡的 API 設計模式,以上提出一些我覺得不錯的設計,事實上還有很多(非常多!),再寫下去可能就變成 jQuery 佈道大會了,有興趣了解的話,網路上有許多文章,大家可以參考看看。

有些盛行許久的函式庫裡頭有許多值得我們學習的事物,像是 jQuery 易用的 API 以及強大的 DOM 操作背後的原因是什麼,是一個相當有趣的問題,這個章節試著解釋一些 jQuery 的設計,並沒有辦法涵蓋到全部,不過希望讓大家體會到觀察開源函式庫,可以從中學到什麼。