# 重新思考 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 getElementById
、getElementByClasName
、getElementByTagName
來獲取需要的元素,一旦 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
,從左到右解析是:
- 用 document.getElementById('') 找到 div#test
- 使用
getElementsByClassName
,確認是否為 p tag - 確認父元素是否為
#test
- 對 p 的子元素 span 做遍歷,確認 span 是否為最後一個。
如果將選擇器變成 #test p.last span:last-child
會需要更多回溯。
從右到左解析是:
- 找到所有 span 並判斷是否為
last-child
- 對符合條件的 span 尋找上層是否為
p.last
- 找到匹配的 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 的設計,並沒有辦法涵蓋到全部,不過希望讓大家體會到觀察開源函式庫,可以從中學到什麼。