為什麼要用原生 JavaScript 代替 jQuery?

邊城發表於2017-02-01

隨著 JavaScript 本身的完善,越來越多的人開始喜歡使用原生 JavaScript 開發代替各種庫,其中不少人發出了用原生 JavaScript 代替 jQuery 的聲音。這並不是什麼壞事,但也不見得就是好事。如果你真的想把 jQuery 從前端依賴庫中移除掉,我建議你慎重考慮。

首先 jQuery 是一個第三方庫。庫存在的價值之一在於它能極大地簡化開發。一般情況下,第三方庫都是由原生語言特性和基礎 API 庫實現的。因此,理論上來說,任何庫第三方庫都是可以用原生語言特性代替的,問題在於是否值得?

jQuery 的作用

引用一段 jQuery 官網的話:

jQuery is a fast, small, and feature-rich JavaScript library. It makes things like HTML document traversal and manipulation, event handling, animation, and Ajax much simpler with an easy-to-use API that works across a multitude of browsers.

這一段話很謙虛的介紹了 jQuery 在處理 DOM 和跨瀏覽器方面做出的貢獻。而事實上,這也正是我們選用 jQuery 的主要原因,並順帶使用了它帶來的一些工具,比如陣列工具,Deferred 等。

對於我來說,最常用的功能包括

  • 在 DOM 樹中進行查詢
  • 修改 DOM 樹及 DOM 相關操作
  • 事件處理
  • Ajax
  • Deferred 和 Promise
  • 物件和陣列處理
  • 還有一個一直在用卻很難在列清單時想到的——跨瀏覽器

到底是誰在替代誰?

上面提到的所有功能都能用原生程式碼來實現。從本質上來說,jQuery 就是用來代替原生實現,以達到減少程式碼,增強可讀性的目的的——所以,到底是用 jQuery 代替原生程式碼,還是用原生程式碼代替 jQuery?這個先後因果關係可否搞明白?

我看到說用 querySelectorAll() 代替 $() 的時候,不禁在想,用 jQuery 一個字元就能解決的,為什麼要寫十六個字元?大部分瀏覽器是有實現 $(),但是寫原生程式碼的時候你會考慮 $() 的瀏覽器相容性嗎?jQuery 已經考慮了!

我看到一大堆建立 DOM 結構的原生 JavaScript 程式碼的時候,不禁在想,用 jQuery 只需要一個方法鏈就解決了,我甚至可以用和 HTML 結構類似的程式碼(包含縮排),比如

// 建立一個 ul 列表並加在 #container 中
$("<ul>").append(
    $("<li>").append(
        $("<a>").attr("href", "#").text("first")),
    $("<li>").append(
        $("<a>").attr("href", "#").text("second")),
    $("<li>").append(
        $("<a>").attr("href", "#").text("third"))
).appendTo($("#container"));

這段程式碼用 document.createElement() 來實現完全沒有問題,只不過程式碼量要大得多,而且會出現大量重複(或類似)的程式碼。當然是可以把這些重複程式碼提取出來寫成函式的……不過 jQuery 已經做了。

注,拼 HTML 的方法實在弱爆了,既容易出錯,又不易閱讀。如果有 ES6 的字串模板之後,用它來寫 HTML 也是個不錯的主意。

就 DOM 操作這一部分來說,jQuery 仍然是一個非常好用的工具。這是 jQuery 替代了原生 JavaScript,以前如此,現在仍然如此。

沒落的 jQuery 工具函式

jQuery 2006 年被發明出來的時候,還沒有 ES5(2011年6月釋出)。即使在 ES5 釋出之後很長一段時間裡,也不是所有瀏覽器都支援。因此在這一時期,除 DOM 操作外,jQuery 的巨大貢獻在於解決跨瀏覽器的問題,以及提供了方便的物件和陣列操作工具,比如 each()index()filter 等。

如今 ECMAScript 剛剛釋出了 2017 的標準,瀏覽器標準混亂的問題也已經得到了很好的解決,前端界還出現了 Babel 這樣的轉譯工具和 TypeScript 之類的新語言。所以現在大家都儘可放心的使用各種新的語言特性,哪怕 ECMAScript 的相關標準還在制定中。在這一時期,jQuery 提供的大量工具方法都已經有了原生替代品——在使用上差別不大的情況下,確實寧願用原生實現。

事實上,jQuery 也在極盡可能地採用原生實現,以提高執行效率。jQuery 沒有放棄這些已有原生實現的工具函式/方法,主要還是因為向下相容,以及一如既往的提供瀏覽器相容性——畢竟不是每一個使用 jQuery 的開發者都會使用轉譯工具。

那麼,對於 JavaScript 開發者而言,jQuery 確實有很多工具方法可以被原生 JavaScript 函式/方法替代。比如

  • $.parseJSON() 可以用 JSON.parse() 替代,而且 JSON.stringify() 還彌補了 jQuery 沒有 $.toJSON() 的不足;
  • $.extend() 的部分功能可以由 Object.assign() 替代`
  • $.fn 的一些資料處理工具方法,比如 each()index() 等都可以用 Array.prototype 中相應的工具方法替代,比如 forEach()indexOf() 等。
  • $.Deferred() 和 jQuery Promise 在某些情況下可以用原生 Promise 替代。它們在沒有 ES6 之前也算是個不錯的 Promise 實現。
  • ......

$.fn 就是 jQuery.prototype,也就是 jQuery 物件的原型。所以在其上定義的方法就是 jQuery 物件的方法。

這些工具方法在原生 JavaScript 中已經逐漸補充完善,但它們仍然只是在某些情況下可以被替代……因為 jQuery 物件是一個特有的資料結構,針對 jQuery 自身建立的工具方法在作用於 jQuery 物件的時候會有一些針對性的實現——既然 DOM 操作仍然不能把 jQuery 拋開,那這些方法也就不可能被完全替換掉。

jQuery 與原生 JavaScript 的結合

有時候需要用 jQuery,有時候不需要用,該如何分辨?

jQuery 的優勢在於它的 DOM 處理、Ajax,以及跨瀏覽器。如果在專案中引入 jQuery,多半是因為對這些功能的需求。而對於不操作 DOM,也不需要考慮跨瀏覽器(比如用於轉譯工具)的部分,則考慮儘可能的用原生 JavaScript 實現。

如此以來,一定會存在 jQuery 和原生 JavaScript 的交集,那麼,就不得不說說需要注意的地方。

jQuery 物件實現了部分陣列功能的偽陣列

首先要注意的一點,就是 jQuery 物件是一個偽陣列,它是對原生陣列或偽陣列(比如 DOM 節點列表)的封裝。

如果要獲得某個元素,可以用 [] 運算子或 get(index) 方法;如果要獲得包含所有元素的陣列,可以使用 toArray() 方法,或者通過 ES6 中引入的 Array.from() 來轉換。

// 將普通陣列轉換成 jQuery 物件
const jo = $([1, 2, 3]);
jo instanceof jQuery;   // true
Array.isArray(jo);      // false

// 從 jQuery 物件獲取元素值
const a1 = jo[0];       // 1
const a2 = jo.get(1);   // 2

// 將 jQuery 物件轉換成普通陣列
const arr1 = jo.toArray();      // [1, 2, 3]
Array.isArray(arr1);            // true
const arr2 = Array.from(jo);    // [1, 2, 3]
Array.isArray(arr2);            // true

注意 each/mapforEach/map 回撥函式的引數順序

jQuery 定義在 $.fn 上的 each()map() 方法與定義在 Array.prototype 上的原生方法 forEach()map() 對應,它們的引數都是回撥函式,但它們的回撥函式定義有一些細節上的差別。

$.fn.each() 的回撥定義如下:

Function(Integer index, Element element )

回撥的第一個引數是陣列元素所在的位置(序號,從 0 開始),第二個引數是元素本身。

Array.prototype.forEach() 的回撥定義是

Function(currentValue, index, array)

回撥的第一個引數是陣列元素本身,第二個引數才是元素所有的位置(序號)。而且這個回撥有第三個引數,即整個陣列的引用。

請特別注意這兩個回撥定義的第一個引數和第二個引數,所表示的意義正好交換,這在混用 jQuery 和原生程式碼的時候很容易發生失誤。

對於 $.fn.map()Array.prototype.map() 的回撥也是如此,而且由於這兩個方法同名,發生失誤的概率會更大。

注意 each()/map() 中的 this

$.fn.each()$.fn.map() 回撥中經常會使用 this,這個 this 指向的就是當前陣列元素。正是因為有這個便利,所以 jQuery 在定義回請販時候沒有把元素本身作為第一個引數,而是把序號作為第一個引數。

不過 ES6 帶來了箭頭函式。箭頭函式最常見的作用就是用於回撥。箭頭函式中的 this 與箭頭函式定義的上下文相關,而不像普通函式中的 this 是與呼叫者相關。

現在問題來了,如果把箭頭函式作為 $.fn.each()$.fn.map() 的回撥,需要特別注意 this 的使用——箭頭函式中的 this 不再是元素本身。鑑於這個問題,建議若非必要,仍然使用函式表示式作為 $.fn.each()$.fn.map() 的回撥,以保持原有的 jQuery 程式設計習慣。實在需要使用箭頭函式來引用上下文 this 的情況下,千萬記得用其回撥定義的第二個引數作為元素引用,而不是 this

// 將所有輸入控制的 name 設定為其 id
$(":input").each((index, input) => {
    // const $input = $(this) 這是錯誤的!!!
    const $input = $(input);
    $input.prop("name", $input.prop("id"));
});

$.fn.map() 返回的並不是陣列

Array.prototype.map() 不同,$.fn.map() 返回的不是陣列,而是 jQuery 物件,是偽陣列。如果需要得到原生陣列,可以採用 toArray()Array.from() 輸出。

const codes = $([97, 98, 99]);
const chars = codes.map(function() {
    return String.fromCharCode(this);
});     // ["a", "b", "c"]

chars instanceof jQuery;    // true
Array.isArray(chars);       // false

const chars2 = chars.toArray();
Array.isArray(chars2);      // true

jQuery Promise

jQuery 是通過 $.Deferred() 來實現的 Promise 功能。在 ES6 以前,如果引用了 jQuery,基本上不需要再專門引用一個 Promise 庫,jQuery 已經實現了 Promise 的基本功能。

不過 jQuery Promise 雖然實現了 then(),卻沒有實現 catch(),所以它不能相容原生的 Promise,不過用於 co 或者 ES2017 的 async/await 毫無壓力。

// 模擬非同步操作
function mock(value, ms = 200) {
    const d = $.Deferred();
    setTimeout(() => {
        d.resolve(value);
    }, ms);
    return d.promise();
}
// co 實現
co(function* () {
    const r1 = yield mock(["first"]);
    const r2 = yield mock([...r1, "second"]);
    const r3 = yield mock([...r2, "third"]);
    console.log(r1, r2, r3);
});

// [`first`]
// [`first`, `second`]
// [`first`, `second`, `third`]
// async/await 實現,需要 Chrome 55 以上版本測試
(async () => {
    const r1 = await mock(["first"]);
    const r2 = await mock([...r1, "second"]);
    const r3 = await mock([...r2, "third"]);
    console.log(r1, r2, r3);
})();

// [`first`]
// [`first`, `second`]
// [`first`, `second`, `third`]

雖然 jQuery 的 Promise 沒有 catch(),但是提供了 fail 事件處理,這個事件在 Deferred reject() 的時候觸發。相應的還有 done 事件,在 Deferred resovle() 的時候觸發,以及 always 事件,不論什麼情況都會觸發。

與一次性的 then() 不同,事件可以註冊多個處理函式,在事件觸發的時候,相應的處理函式會依次執行。另外,事件不具備傳遞性,所以 fail() 不能在寫在 then() 鏈的最後。

結語

總的來說,在大量操作 DOM 的前端程式碼中使用 jQuery 可以帶來極大的便利,也使 DOM 操作的相關程式碼更易讀。另一方面,原生 JavaScript 帶來的新特性確實可以替代 jQuery 的部分工具函式/方法,以降低專案對 jQuery 的依賴程式。

jQuery 和原生 JavaScript 應該是共生關係,而不是互斥關係。應該在合適的時候選用合適的方法,而不是那麼絕對的非要用誰代替誰。

相關文章