建立你的第一個JavaScript庫
我們其乎每天都在使用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; }); }; } }());複製代碼 就是這樣! 我希望你能試一試我們的小小的庫,並且能稍稍擴充一點點。 讓我再申明一下,這個教程的目的不是說建議你總是要寫一個自己的庫。 有專業的團隊在做一個龐大的,穩定的越來越好的庫。這裡我們只是想讓大家看看一個庫內部是什麼樣子的,希望你能在這學到一些東西。