建立你的第一個JavaScript庫

SuperApple發表於2012-10-22

  是否曾對Mootools的魔力感到驚奇?是否有想知道Dojo如何做到那樣的?是否對jQuery感到好奇?在這個教程中,我們將瞭解它們背後的東西並且動手建立一個超級簡單的你最喜歡的庫。

  我們其乎每天都在使用JavaScript庫。當你剛入門時,利用jQuery是一件非常奇妙的事,主要是因為它的DOM操作。首先,DOM對於入門者來說可能是相對困難的事情;其次用它我們幾乎可以不用考慮跨瀏覽器相容的問題。

  在這個教程中,我們將試著從頭開始實現一個很簡單的庫。是的,它非常有意思,但是在你高興之前讓我申明幾點:

  • 這不會是全功能的庫。我們有很多方法要寫,但是它不是jQuery。我們將會做足工作來讓你感受到在你建立一個庫時會遇到的各種問題。

  • 我們不會完全解決所有瀏覽器的相容性問題。我們寫的程式碼能支援IE8+,Firefox 5+,Opera 10+,Chrome和Safari。

  • 我們不會覆蓋使用我們庫的所有可能性。比如我們的append和prepend方法只在你傳入一個我們庫的例項時才有效,它們不支援原生的DOM節點或節點集合。

 步驟1: 建立庫樣板檔案Creating the Library Boilerplate

  我們以一些封裝程式碼開始,它將會包含我們整個庫。它就是你經常用到的立即執行函式表示式

window.dome = (function () {
        function Dome (els) {
        }
        var dome = {
                get: function (selector) {
                }
        };
        return dome;
}());

  如你所見,我們把我們的庫叫Dome,因為它主要就是一個針對DOM的庫,是的,它很不完整。

  到此我們做了兩件事。首先,我們定義了一個函式,它最終會是例項化我們庫的建構函式,這些物件將會封裝我們選擇或建立的元素。

  接下來我們建立了dome物件,它是我們實際的庫物件;你能看到,它在最後被返回。它有一個空的get函式,我們將用它來從頁面中選擇元素。所以,讓我們現在來填充它的程式碼。


 步驟2: 獲取元素

  dome.get函式傳入一個引數,但是它可以有好幾種情況。如果它是一個字串,我們假定它是一個CSS選擇器;但是我們也可以傳入單個DOM節點或是一個NodeList。

get: function (selector) {
        var els;
        if (typeof selector === "string") {
        els = document.querySelectorAll(selector);
        } else if (selector.length) {
                els = selector;
        } else {
                els = [selector];
        }
        return new Dome(els);
}

  我們使用document.querySelectorAll來簡化元素的查詢:當然這有瀏覽器相容性問題,但是對於我們的例子來說它是ok的。如果selector不是字串,我們將檢查它的length屬性。如果它存在,我們就知道它是一個NodeList;否則它是單個元素然後我們將它放到一個陣列中。這就是我們下面需要將呼叫Dome的結果傳給一個陣列的原因;你可以看到我們返回一個新的Dome物件。所以讓我們回頭看看Dome函式並填充它。


 步驟3: 建立Dome例項

  下面是Dome函式:

function Dome (els) {
        for(var i = 0; i < els.length; i++ ) {
                this[i] = els[i];
        }
        this.length = els.length;
}

  它確實很簡單:我們只是遍歷我們選擇的元素並把它們附到帶有數字索引的新物件中。然後我們新增一個length屬性。

  但是這的關鍵是什麼呢?為什麼不直接返回元素?我們將元素封裝到一個物件因為我們想為這個物件建立方法;這些方法可以讓我們與這些元素互動。這實際上就是jQuery採用的方法的簡化版本。

  所以,我們返回了Dome物件,讓我們在它的原型上新增一些方法。我把這些方法直接寫在Dome函式中。


 步驟4: 新增一些常用工具函式

  我們要寫的第一個方法是一個簡單的工具函式。因為我們的Dome物件可以封裝多個DOM元素,幾乎每個方法都需要遍歷每個元素;所以,這些工具函式會非常便利。

  讓我們以一個map函式開始:

Dome.prototype.map = function (callback) {
        var results = [], i = 0;
        for ( ; i < this.length; i++) {
                results.push(callback.call(this, this[i], i));
        }
        return results;
};

  當然,map函式傳入單個引數,一個回撥函式。我們遍歷陣列中的每一項,收集回撥函式返回的所有內容放到results陣列中。注意我們如何呼叫回撥函式:

callback.call(this, this[i], i));

  這樣函式就會在我們的Dome例項的上下文中被呼叫,它接受兩個引數:當前元素,以及索引號。

  我們也想要一個forEach函式。它確實非常簡單:

Dome.prototype.forEach(callback) {
        this.map(callback);
        return this;
};

  map和forEach間的唯一區別是map需要返回一些東西,因此我們也可以只傳入我們的回撥函式給this.map並忽略返回的陣列,我們將返回this來使得我們的庫支援鏈式操作。我們將經常使用forEach。所以,注意當返回我們的this.forEach對函式的呼叫時,我們事實上是返回了this。例如,下面的方法實際上返回相同的東西:

Dome.prototype.someMethod1 = function (callback) {
        this.forEach(callback);
        return this;
};
Dome.prototype.someMethod2 = function (callback) {
        return this.forEach(callback);
};

  另外:mapOne。很容易看出這個函式是幹什麼的,但是問題是為什麼我們需要它?它需要一些你可以叫做“庫哲學”的東西來解釋。

 一個簡單的“哲學的”迂迴

  如果建立一個庫只是寫程式碼,那就不是什麼難的工作了。但是我正在做這個專案,我發現困難的部分是決定一些方法應該如何工作。

  很快,我們將建一個text方法,它返回我們選擇元素的文字。如果我們的Dome物件封裝幾個DOM節點(如dome.get("li")),它會返回什麼呢?如果你在jQuery做類似的事情($("li").text()),你將會得到一個所有元素的文字拼起來的字串。它有用嗎?我認為沒用,但是我不知道更好的返回是什麼。

  在這個專案中,我將以陣列形式返回多個元素的文字,除非陣列中只有一個元素,那我們就返回一個文字字串,而不是隻有一個元素的陣列。我想你最常用的是獲取單個元素的文字,所以我們對這個情況進行優化。然而,如果你獲取多個元素的文字,我們也會返回一些你能操作的東西。

 回到程式碼

  所以,mapOne方法只是簡單的執行map,然後要麼返回陣列,要麼返回單元素陣列中的元素。如果你還是不確定這有什麼用,等一會你會發現的!

Dome.prototype.mapOne = function (callback) {
        var m = this.map(callback);
        return m.length > 1 ? m : m[0];
};

 步驟5: 處理文字和HTML

  接下來,讓我們新增text方法。就像jQuery一樣,我們可以給它傳入一個字串並設定元素的文字,或不傳引數來獲取元素的文字。

Dome.prototype.text = function (text) {
        if (typeof text !== "undefined") {
                return this.forEach(function (el) {
                        el.innerText = text;
                });
        } else {
                return this.mapOne(function (el) {
                        return el.innerText;
                });
        }
};
Dome.prototype.text = function (text) {
        if (typeof text !== "undefined") {
                return this.forEach(function (el) {
                        el.innerText = text;
                });
        } else {
                return this.mapOne(function (el) {
                        return el.innerText;
                });
        }
};

  你可能也想到了,我們需要檢查text的值來看它是要設定還是要獲取。注意如果只是用if(text)會有問題,因為空字串會被判斷為false。

  如果我們在設定值,我們將對元素呼叫forEach並且設定它們的innerText屬性為text。如果我們要獲取,我們將返回元素的 innerText屬性。注意我們使用mapOne方法:如果我們在處理多個元素,它將返回一個陣列,否則它將就是一個字串。

  html方法幾乎與text一樣,除了它使用innerHTML屬性而不是innerText。

Dome.prototype.html = function (html) {
        if (typeof html !== "undefined") {
                this.forEach(function (el) {
                        el.innerHTML = html;
                });
                return this;
        } else {
                return this.mapOne(function (el) {
                        return el.innerHTML;
                });
        }
};

  就像我說的:幾乎完全一樣。


 步驟6: 調整樣式

  再接下來,我們希望能新增和刪除樣式,因此讓我們來寫一個addClass和removeClass方法。

  我們的addClass方法將接收一個字串或是樣式名稱的陣列。為了做到這點,我們需要檢查引數的型別。如果是陣列,我們將遍歷它並建立一個樣式名的字串。否則,我們就簡單的在樣式名前加一個空格,這樣它就不會和元素已有的樣式混在一些。然後我們遍歷元素並且將新的樣式附加到className屬性後面。

Dome.prototype.addClass = function (classes) {
        var className = "";
        if (typeof classes !== "string") {
                for (var i = 0; i < classes.length; i++) {
                        className += " " + classes[i];
                }
        } else {
                className = " " + classes;
        }
        return this.forEach(function (el) {
                el.className += className;
        });
};

  很直接,對嗎?

  那如何刪除樣式呢?為了保持簡單,我們只允許一次刪除一個樣式。

Dome.prototype.removeClass = function (clazz) {
        return this.forEach(function (el) {
                var cs = el.className.split(" "), i;
                while ( (i = cs.indexOf(clazz)) > -1) {
                        cs = cs.slice(0, i).concat(cs.slice(++i));
                }
                el.className = cs.join(" ");
        });
};

  對每個元素,我們將el.className分隔成一個陣列。然後,我們使用一個while迴圈來剔除我們傳入的樣式,直到cs.indexOf(clazz)返回-1。我們這樣做是為了處理同樣的樣式在一個元素中出現的不止一次的特殊情況:我們必須保證它真的被刪除了。一旦我們確保刪除每個樣式的例項,我們用空格連線陣列的每一項並把它設定到el.className。


 步驟7: 修正一個IE的Bug

  我們正在處理的最糟糕的瀏覽器是IE8。在我們的小小的庫中,只有一個IE bug需要我們處理,很幸運它很簡單。IE8不支援Array的indexOf方法;我們在removeClass中使用到它,所以讓我們修復它:

if (typeof Array.prototype.indexOf !== "function") {
        Array.prototype.indexOf = function (item) {
                for(var i = 0; i < this.length; i++) {
                        if (this[i] === item) {
                                return i;
                        }
                }
                return -1;
        };
}

  它非常簡單,並且這不是一個完全的實現(不支援第二個引數),但是能達到我們的目的。


 步驟8: 調節屬性

  現在,我們想要一個attr函式。這很容易,因為它與我們的text或html方法非常類似。像那些方法一樣,我們能夠獲取或設定屬性值:我們可以傳入元素名和值來設定,也可以只傳入屬性名來獲取。

Dome.prototype.attr = function (attr, val) {
        if (typeof val !== "undefined") {
                return this.forEach(function(el) {
                        el.setAttribute(attr, val);
                });
        } else {
                return this.mapOne(function (el) {
                        return el.getAttribute(attr);
                });
        }
};

  如果val有一個值,我們將遍歷這些元素並且將選擇的屬性設定為這個值,使用元素的setAttribute方法。否則,我們使用mapOne通過getAttribute方法來返回屬性值。


 步驟9: 建立元素

  像很多好的庫一樣,我們應該能夠建立新的元素。當然它作為一個Dome例項的一個方法不是很好,所以讓我們直接把它掛到dome物件上去。

var dome = {
        // get method here
        create: function (tagName, attrs) {
        }
};

  你已經看到,我們使用兩個引數:元素的名字,和屬性值物件。大部分屬效能過attr方法賦值,但是兩種方法可以做特殊處理。我們使用addClass方法操作className屬性,以及text方法操作text屬性。當然,我們首先需要建立元素和Dome物件。下面是整個操作的程式碼:

create: function (tagName, attrs) {
        var el = new Dome([document.createElement(tagName)]);
                if (attrs) {
                        if (attrs.className) {
                                el.addClass(attrs.className);
                                delete attrs.className;
                        }
                if (attrs.text) {
                        el.text(attrs.text);
                        delete attrs.text;
                }
                for (var key in attrs) {
                        if (attrs.hasOwnProperty(key)) {
                                el.attr(key, attrs[key]);
                        }
                }
        }
        return el;
}

  我們建立元素並將它傳給一個新的Dome物件。然後中我們處理屬性。注意在操作完它們後我們必須刪除className和text屬性。這樣可以避免當我們在attrs中遍歷剩下的key值時被應用為屬性。當然我們最後要返回這個新建的Dome物件。

  但是現在只是建立了新的元素,我們希望把它插入到DOM中對嗎?


 步驟10: 附加元素

  下一步,我們將寫append和prepend方法。這些確實是有點難搞的函式,主要是因為有很多種使用情況。以下是我們希望能做到的:

dome1.append(dome2);
dome1.prepend(dome2);

  使用情況如下:我們可能想要append或prepend

  • 一個新的元素到一個或多個已存在的元素

  • 多個新元素到一個或多個已存在的元素

  • 一個已存在的元素到一個或多個已存在的元素

  • 多個已存在的元素到一個或多個已存在的元素

  注意:我使用“新”來表示元素還沒有在DOM中;已存在的元素是已經在DOM中有的。

  讓我們一步一步來:

Dome.prototype.append = function (els) {
        this.forEach(function (parEl, i) {
                els.forEach(function (childEl) {
                });
        });
};

  我們期望els引數是一個Dome物件。一個完整的DOM庫可以接受一個節點或nodelist作為引數,但是我們暫時不這樣做。我們必須遍歷我們每一個元素,並且在它裡面,我們還要遍歷每個我們需要append的元素。

  如果我們將els到多個元素,我們需要克隆它們。然而,我們不想在他們第一次被附加的時候克隆節點,而時隨後再說。所以我們這樣:

if (i > 0) {
        childEl = childEl.cloneNode(true);
}

  這個i來自外層的forEach迴圈:它是當前父元素的索引。如果我們不是附加到第一個父元素,我們將克隆節點。這樣,真正的節點將會放到第一個父節點中,其它父節點將獲得一個拷貝。這樣很好用,因為傳入的Dome物件將只會擁有原始的節點。所以如果我們只是附加單個元素到單個元素,使用的所有節點都將是各自Dome物件的一部分。

  最後,我們終於可以附加元素:

parEl.appendChild(childEl);

  所以,彙總起來是這樣

Dome.prototype.append = function (els) {
        return this.forEach(function (parEl, i) {
                els.forEach(function (childEl) {
                        if (i > 0) {
                                childEl = childEl.cloneNode(true);
                        }
                        parEl.appendChild(childEl);
                });
        });
};

 prepend方法

  我們想要prepend方法也滿足同樣的情況,所以這個方法非常類似:

Dome.prototype.prepend = function (els) {
        return this.forEach(function (parEl, i) {
                for (var j = els.length -1; j > -1; j--) {
                        childEl = (i > 0) ? els[j].cloneNode(true) : els[j];
                        parEl.insertBefore(childEl, parEl.firstChild);
                }
        });
};

  當prepend時所不同的是如果你順次prepend一系列元素到另外一個元素時,它們是倒序的。因為我們不能反向forEach,我將使用for迴圈反向遍歷。同樣,我們將克隆節點如果它不是我們第一個要附件到的父節點。


 步驟11: 移除節點

  對於我們最後一個節點處理方法,我們想要從DOM中刪除節點。其實很簡單:

Dome.prototype.remove = function () {
        return this.forEach(function (el) {
                return el.parentNode.removeChild(el);
        });
};

  就是遍歷節點並在每個元素的parentNode上呼叫removeChild方法。這裡漂亮的地方在於這個Dome物件還將正常工作;我們可以在它上面使用任何方法,包括重新放回到DOM中去。


 步驟12: 處理事件

  最後,但是肯定不是用得最少的,我們將寫一些函式處理事件。你可以知道,IE8使用老式的IE事件,所以我們需要檢查它。同時,我們將丟擲DOM 0事件,就因為我們可以。

簽出方法,然後中我們將討論它:

Dome.prototype.on = (function () {
        if (document.addEventListener) {
                return function (evt, fn) {
                        return this.forEach(function (el) {
                                el.addEventListener(evt, fn, false);
                        });
                };
        } else if (document.attachEvent)  {
                return function (evt, fn) {
                        return this.forEach(function (el) {
                                el.attachEvent("on" + evt, fn);
                        });
                };
        } else {
                return function (evt, fn) {
                        return this.forEach(function (el) {
                                el["on" + evt] = fn;
                        });
                };
        }
}());

  在這,我們使用了一個立即執行函式表示式,在函式裡面我們做了特徵檢查。如果document.addEventListener存在,我們將使用它;否則我們檢查document.attachEvent或者求助於DOM 0事件。注意我們如何返回最後的函式:它將在結束時被賦給Dome.prototype.on。當做特徵檢測時,非常方便地像這樣賦給合適的函式,而不是每次函式執行時都得檢查一次。

  off函式用於解除安裝事件,它與前面非常類似。

Dome.prototype.off = (function () {
        if (document.removeEventListener) {
                return function (evt, fn) {
                        return this.forEach(function (el) {
                                el.removeEventListener(evt, fn, false);
                        });
                };
        } else if (document.detachEvent)  {
                return function (evt, fn) {
                        return this.forEach(function (el) {
                                el.detachEvent("on" + evt, fn);
                        });
                };
        } else {
                return function (evt, fn) {
                        return this.forEach(function (el) {
                                el["on" + evt] = null;
                        });
                };
        }
}());

 就是這樣!

  我希望你能試一試我們的小小的庫,並且能稍稍擴充套件一點點。就像我之前是提到的一樣,我把它放到Github上了。可以免費fork,玩一玩,並且傳送一個pull請求。

  讓我再申明一下,這個教程的目的不是說建議你總是要寫一個自己的庫。

  有專業的團隊在做一個龐大的,穩定的越來越好的庫。這裡我們只是想讓大家看看一個庫內部是什麼樣子的,希望你能在這學到一些東西。

原文連結:Build Your First JavaScript Library

相關文章