iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 7
0
Modern Web

深入現代前端開發系列 第 7

Day7 jQuery 真的如此醜陋不堪嗎?重新思考 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 為選擇器引擎。這個引擎實作了 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 提供了強大的解析引擎來做這件事,同時為了兼顧效能,內部也做了許多優化。

想要做到的事情很簡單,,但實作起來完全就是一件吃力不討好的事情。

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

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

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

事件註冊、事件委託

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

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

為了防止事件監聽器過多,或是新加入的 node 可能無法順利註冊事件監聽器,我們可以利用事件冒泡機制來實作事件委託機制。如果要做事件委託,用原生的 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 的函數,大量引用重載(overload)的概念,這也是為什麼讓人用起來覺得很容易的原因之一。

重載的意思是,根據函式的簽名來決定要怎麼執行這個函數。例如我們傳入 $(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 當中,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 的設計,並沒有辦法涵蓋到全部,不過希望讓大家體會到觀察開源函式庫,可以從中學到什麼。


上一篇
Day6 [JavaScript 基礎] 垃圾回收機制
下一篇
Day8 為什麼前端需要工程化? — webpack
系列文
深入現代前端開發32

尚未有邦友留言

立即登入留言