構(gòu)建你第一個(gè)JavaScript框架
覺(jué)得Mootools不可思議?想知道Dojo是如何實(shí)現(xiàn)的?對(duì)JQuery的技巧感到好奇?在這篇教程里,我們將探尋框架背后的秘密,然后試著自己動(dòng)手建立一個(gè)你所喜愛(ài)的框架的簡(jiǎn)易版本。
我們幾乎每天都在使用各種各樣的JavaScript框架。當(dāng)你剛?cè)腴T的時(shí)候,方便的DOM(文檔對(duì)象模型)操作讓你覺(jué)得JQuery這樣的東西非常棒。這是因?yàn)椋菏紫?,?duì)于新手來(lái)說(shuō)DOM太難理解了;當(dāng)然,對(duì)于一個(gè)API來(lái)說(shuō)難以理解可不是什么好事。其次,瀏覽器間的兼容性問(wèn)題非常令人困擾。
我們將元素包裝成對(duì)象是因?yàn)槲覀兿胍軌驗(yàn)閷?duì)象添加方法。 |
在這個(gè)教程里,我們將試著從頭實(shí)現(xiàn)這些框架之一。是的,這會(huì)很有趣,不過(guò)在你太過(guò)興奮前我要澄清幾點(diǎn):
這不會(huì)是一個(gè)功能很完善的框架。的確,我們要寫(xiě)很多東西,但它還算不上JQuery??墒俏覀儗⒁龅氖虑闀?huì)讓你體驗(yàn)到在真正編寫(xiě)框架的感覺(jué)。
我們不打算保證全方位的兼容性。我們將要編寫(xiě)的框架能夠在 Internet Explorer 8+、Firefox 5+、Opera 10+、Chrome和Safari上工作。
我們的框架不會(huì)覆蓋到所有可能的功能。比如說(shuō),我們的append和preappend方法只有在你傳給它一個(gè)我們框架的實(shí)例時(shí)才能工作;我們不會(huì)用原生的DOM節(jié)點(diǎn)和節(jié)點(diǎn)列表。
另外:盡管在教程中我們不會(huì)為我們的框架編寫(xiě)測(cè)試用例,但是我已經(jīng)在第一次開(kāi)發(fā)它的時(shí)候做好了。你可以從 Github上獲取框架和測(cè)試用例的代碼。
第一步: 創(chuàng)建框架模板
我們將從一些包裝代碼開(kāi)始,它將容納我們的整個(gè)框架。這是典型的立即函數(shù)(IIFE).
- window.dome = (function () {
- function Dome (els) {
- }
- var dome = {
- get: function (selector) {
- }
- };
- return dome;
- }());
你可以看到,我們的框架叫做dome,因?yàn)樗且粋€(gè)基本的DOM框架。沒(méi)錯(cuò),基本(lame有“瘸子”、“不完整”的意思,dom加lame等于dome)的。
我們已經(jīng)有了一些東西。 首先,我們有了一個(gè)函數(shù);它將成為構(gòu)造框架的對(duì)象實(shí)例的構(gòu)造函數(shù);那些對(duì)象將會(huì)包含我們選擇和創(chuàng)建的元素。
然后,我們有了一個(gè)dome對(duì)象,它就是我們的框架對(duì)象;你可以看到它最終作為函數(shù)的返回值返回給了函數(shù)調(diào)用者(譯注:賦值給了window.dome)。這里還有一個(gè)空的get函數(shù),我們將用它從頁(yè)面里選取元素。那么,我們來(lái)填充代碼吧。
第二步: 獲取元素
dome的get函數(shù)只有一個(gè)參數(shù),但是它可以是很多東西。如果它一個(gè)string(字符串),我們將假定它是一個(gè)CSS(層疊樣式表)選擇器;不過(guò)我們也可能得到一個(gè)DOM節(jié)點(diǎn)或者DOM節(jié)點(diǎn)列表。
- 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來(lái)簡(jiǎn)單的選擇元素:當(dāng)然,這將限制我們的瀏覽器兼容性,不過(guò)對(duì)于這種情況還是可以接受的。如果 selector不是string類型,我們將檢查它的length屬性。如果存在,我們就知道我們得到的是一個(gè)節(jié)點(diǎn)列表;否則,就是一個(gè)單獨(dú)的元素,我們將它放到一個(gè)數(shù)組里。這是因?yàn)槲覀円谙旅嫦駾ome傳遞一個(gè)數(shù)組。你可以看到,我們返回了一個(gè)新的Dome對(duì)象。讓我們回到Dome函數(shù)并且為它填充代碼。
第三步: 創(chuàng)建Dome實(shí)例
這是Dome函數(shù):
- function Dome (els) {
- for(var i = 0; i < els.length; i++ ) {
- this[i] = els[i];
- }
- this.length = els.length;
- }
我強(qiáng)烈建議你去深入研究一些你喜歡的框架 |
這非常簡(jiǎn)單:我們只是遍歷了els的所有元素,并且把它們存儲(chǔ)在一個(gè)以數(shù)字為索引的新對(duì)象里。然后我們添加了一個(gè)length屬性。
但是這有什么意義呢?為什么不直接返回元素?因?yàn)椋何覀儗⒃匕b成對(duì)象是因?yàn)槲覀兿胍軌驗(yàn)閷?duì)象添加方法;這些方法能夠讓我們遍歷這些元素。實(shí)際上這正是JQuery的解決方案的濃縮版。
我們的Dome對(duì)象已經(jīng)返回了,現(xiàn)在讓我們來(lái)為它的原型(prototype)添加一些方法。我會(huì)直接把那些方法寫(xiě)在Dome函數(shù)下面。
第四步:添加幾個(gè)實(shí)用工具
要添加的第一批功能是些簡(jiǎn)單的工具函數(shù)。由于Dome對(duì)象可能包含至少一個(gè)DOM元素,那么我們需要在幾乎每一個(gè)方法里面都遍歷所有元素;這樣,這些工具才會(huì)給力。
我們從一個(gè)map函數(shù)開(kāi)始:
- Dome.prototype.map = function (callback) {
- var results = [], i = 0;
- for ( ; i < this.length; i++) {
- results.push(callback.call(this, this[i], i));
- }
- return results;
- };
當(dāng)然,這個(gè)map函數(shù)有一個(gè)入?yún)ⅲ粋€(gè)回調(diào)函數(shù)。我們遍歷Dome對(duì)象所有元素,收集回調(diào)函數(shù)的返回值到結(jié)果集中。注意我們是怎樣調(diào)用回調(diào)函數(shù)的:
- callback.call(this, this[i], i));
通過(guò)這種方式,函數(shù)將在Dome實(shí)例的上下文中被調(diào)用,并且函數(shù)接收到兩個(gè)參數(shù):當(dāng)前元素和元素序號(hào)。
我們也想要一個(gè)foreach函數(shù)。事實(shí)上這很簡(jiǎn)單:
- Dome.prototype.forEach(callback) {
- this.map(callback);
- return this;
- };
由于map函數(shù)和foreach函數(shù)之間的不同僅僅是map需要返回些東西,我們可以僅僅將回調(diào)傳給this.map然后忽略返回的數(shù)組;代替返回的是,我們將返回this,來(lái)使我們的庫(kù)呈鏈?zhǔn)?。foreach會(huì)被頻繁的調(diào)用,所以,注意當(dāng)一個(gè)函數(shù)的回調(diào)被返回,事實(shí)上,返回的是Dome實(shí)例。例如,下面的方法事實(shí)上就返回了Dome實(shí)例:
- Dome.prototype.someMethod1 = function (callback) {
- this.forEach(callback);
- return this;
- };
- Dome.prototype.someMethod2 = function (callback) {
- return this.forEach(callback);
- };
還有一個(gè):mapOne。很容易就知道這個(gè)函數(shù)是做什么的,但是真正的問(wèn)題是,為什么需要它?這就需要一些我們稱之為"庫(kù)哲學(xué)"的東西了。
一個(gè)簡(jiǎn)短的"哲學(xué)"闡釋
首先,對(duì)于一個(gè)初學(xué)者來(lái)說(shuō),DOM很讓人糾結(jié);它的API不完善。 |
如果構(gòu)建一個(gè)庫(kù)僅僅是寫(xiě)代碼,那就不是什么難事。但是當(dāng)我開(kāi)發(fā)這個(gè)庫(kù)時(shí),我發(fā)現(xiàn)那些不完善的部分決定了一定數(shù)量的方法的實(shí)現(xiàn)方式。
很快,我們要去構(gòu)建一個(gè)返回被選擇元素的文本的text方法。如果Dome對(duì)象包含多個(gè)DOM節(jié)點(diǎn)(比如dome.get("li")),返回什么?如果你就像jQuery那樣($("li").text())很簡(jiǎn)單的編寫(xiě),你將得到一個(gè)字符串,這個(gè)字符串是所有元素的文本的直接拼接。有用嗎?我認(rèn)為沒(méi)用,但是我不認(rèn)為沒(méi)有更好的辦法。
對(duì)于這個(gè)項(xiàng)目,我將以數(shù)組方式返回多個(gè)元素的文本,除非數(shù)組里只有一個(gè)元素,那么我僅僅返回一個(gè)文本字符串,而不是一個(gè)包含了一個(gè)元素的數(shù)組。我想你會(huì)經(jīng)常去獲取單個(gè)元素的文本,所以我們優(yōu)化了那種情況。但是,如果你想去獲取多個(gè)元素的文本,我們的返回你也會(huì)用著很爽。
回到代碼
那么,mapOne方法僅僅是簡(jiǎn)單的運(yùn)行map函數(shù),然后返回?cái)?shù)組,或者一個(gè)數(shù)組里的元素。如果你仍然不確定這是如何有用,堅(jiān)持一下,你就會(huì)看到!
- Dome.prototype.mapOne = function (callback) {
- var m = this.map(callback);
- return m.length > 1 ? m : m[0];
- };
第5步: 處理Text和HTML
接著,讓我們來(lái)添加文本方法。就像jQuery,我們可以傳遞一個(gè)string值,設(shè)置節(jié)點(diǎn)元素的text值,或者通過(guò)無(wú)參方法得到返回的text值。
- 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;
- });
- }
- };
如你所料,當(dāng)我們?cè)O(shè)置(setting)或者得到(getting)value值時(shí),需要檢查text的值。要注意的是如果justif(文本)方法不起作用,是因?yàn)閠ext為空字符串是一個(gè)錯(cuò)誤的值。
如果我們?cè)O(shè)置(setting)時(shí),可是使用一個(gè)forEach 遍歷元素,設(shè)置它們的innerText屬性。如果我們得到(getting)時(shí),返回元素的innerText屬性。在使用mapOne方法是要注意:如果我們正在處理多個(gè)元素,將返回一個(gè)數(shù)組;其他的則還是一個(gè)字符串。
如果html方法使用innerHTML屬性而不是innerText,它將會(huì)更優(yōu)雅的處理涉及text文本的事情。
- 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;
- });
- }
- };
就像我說(shuō)過(guò)的:幾乎相同的。
第六步: 修改類
下一步,我們想對(duì)class進(jìn)行操作,所以添加能addClass()和removeClass()。addClass()的參數(shù)是一個(gè)class名稱或者名稱的數(shù)組。為了實(shí)現(xiàn)動(dòng)態(tài)參數(shù),我們需要對(duì)參數(shù)的類型進(jìn)行判斷。如果參數(shù)是一個(gè)數(shù)組,那么遍歷這個(gè)數(shù)組,將元素添加上這些class名稱,如果參數(shù)是一個(gè)字符串,則直接加上這個(gè)class名稱。函數(shù)需要確保不將原來(lái)的class名稱弄亂。
- 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;
- });
- };
很直觀吧?嘿嘿
現(xiàn)在,寫(xiě)下removeClass(),同樣簡(jiǎn)單。不過(guò)每次只允許刪除一個(gè)class名稱。
- 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(" ");
- });
- };
對(duì)于每一個(gè)元素,我們都將el.className 分割成一個(gè)字符串?dāng)?shù)組。那么我們使用一個(gè)while循環(huán)連接,直到cs.indexOf(clazz)返回值大于-1。我們將得到的結(jié)果join成el.className。
第七步: 修復(fù)一個(gè)IE引起的BUG
我們處理的最糟瀏覽器是IE8.在這個(gè)小小的庫(kù)中,只有一個(gè)IE引起的BUG需要去修復(fù); 并且謝天謝地,修復(fù)它非常簡(jiǎn)單.IE8不支持Array的方法indexOf;我們需要在removeClass方法中使用到它, 下面讓我們來(lái)完成它:
- 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;
- };
- }
它看上去非常簡(jiǎn)單,并且它不是完整實(shí)現(xiàn)(不支持使用第二個(gè)參數(shù)),但是它能實(shí)現(xiàn)我們的目標(biāo).
第8步: 調(diào)整屬性
現(xiàn)在,我們想要一個(gè)attr函數(shù)。這將很容易,因?yàn)樗鼛缀鹾蛅ext方法或者h(yuǎn)tml方法是一樣的。像這些方法,我們都能夠設(shè)置和得到屬性:我們將設(shè)置一個(gè)屬性的名稱和值,同時(shí)只通過(guò)參數(shù)名來(lái)得到值。
- 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);
- });
- }
- };
如果形參有一個(gè)值,我們將遍歷元素并通過(guò)元素的setAttribute方法設(shè)置屬性值。另外,我們將使用mapOne返回通過(guò)getAttribute方法得到參數(shù)。
第9步: 創(chuàng)建元素
像任何一個(gè)優(yōu)秀的框架一樣,我們也應(yīng)該能夠創(chuàng)建元素。當(dāng)然,在Demo實(shí)例中沒(méi)有一個(gè)好的方法,所以讓我們來(lái)把方法加入到demo工程中。
- var dome = {
- // get method here
- create: function (tagName, attrs) {
- }
- };
正如你所看到的:我們需要兩個(gè)形參:元素名,和一個(gè)參數(shù)對(duì)象。大多數(shù)的屬性通過(guò)我們的arrt方法被使用,但是tagName和attrs卻有特殊待遇。我們?yōu)閏lassName屬性使用addClass方法,為text屬性使用text方法。當(dāng)然,我們首先要?jiǎng)?chuàng)建元素,和Demo對(duì)象。下面就是所有的作用:
- 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;
- }
如上,我們創(chuàng)建了元素,將他發(fā)送到新的Dmoe對(duì)象中。接著,我們處理所有屬性。注意:當(dāng)使用完className和text屬性后,我們不得不刪除他們。這將保證當(dāng)我們遍歷其他的鍵時(shí),它們還能被使用。當(dāng)然,我們最終通過(guò)返回這個(gè)新的Demo對(duì)象。
我們創(chuàng)建了新的元素,我們想要將這些元素插入到DOM,對(duì)吧?
第10步:尾部添加(Appending)與頭部添加(Prepending)元素
下一步,我們來(lái)實(shí)現(xiàn)尾部添加與頭部添加方法??紤]到多種場(chǎng)景,實(shí)現(xiàn)這些方法可能有些棘手。下面是我們的想要達(dá)到的效果:
- dome1.append(dome2);
- dome1.prepend(dome2);
IE8對(duì)我們來(lái)說(shuō)就是一奇葩。
尾部添加或頭部添加,包括以下幾種場(chǎng)景:
單個(gè)新元素添加至單個(gè)或多個(gè)已存在元素中
多個(gè)新元素添加至單個(gè)或多個(gè)已存在元素中
單個(gè)已存在元素添加至單個(gè)或多個(gè)已存在元素中
多個(gè)已存在元素添加至單個(gè)或多個(gè)已存在元素中
注意:這里的”新元素“表示還未加入DOM中節(jié)點(diǎn)元素,”已存在元素“指已存在于DOM中的節(jié)點(diǎn)元素。
現(xiàn)在讓我們一步步來(lái)實(shí)現(xiàn)之:
- Dome.prototype.append = function (els) {
- this.forEach(function (parEl, i) {
- els.forEach(function (childEl) {
- });
- });
- };
假設(shè)參數(shù)els是一個(gè)DOM對(duì)象。一個(gè)功能完備的DOM庫(kù)應(yīng)該能處理節(jié)點(diǎn)(node)或節(jié)點(diǎn)序列(nodelist),但現(xiàn)在我們不作要求。首先遍歷需要被添加進(jìn)的元素 (父元素),再在這個(gè)循環(huán)中遍歷將被添加的元素 (子元素)。
如果將一個(gè)子元素添加至多個(gè)父元素,需要克隆子元素(避免最后一次操作會(huì)移除上一次添加操作)??墒?,沒(méi)必要在初次添加的時(shí)候就克隆,只需要在其它循環(huán)中克隆就可以了。因此處理如下:
- if (i > 0) {
- childEl = childEl.cloneNode(true);
- }
變量i來(lái)自外層forEach循環(huán):它表示父級(jí)元素的序列號(hào)。第一個(gè)父元素添加的是子元素本身,而其他父元素添加的都是目標(biāo)子元素的克隆。因?yàn)樽鳛閰?shù)傳入的子元素是未被克隆的,所以,當(dāng)將單個(gè)子元素添加至單個(gè)父元素時(shí),所有的節(jié)點(diǎn)都是可響應(yīng)的。
最后,真正的添加元素操作:
- parEl.appendChild(childEl);
因此,組合起來(lái),我們得到以下實(shí)現(xiàn):
- 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方法
我們按照相同的邏輯實(shí)現(xiàn)prepend方法,其實(shí)也相當(dāng)簡(jiǎn)單。
- 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);
- }
- });
- };
不同點(diǎn)在于添加多個(gè)元素時(shí),添加后的順序會(huì)被反轉(zhuǎn)。所以不能采用forEach循環(huán),而是用倒序的for循環(huán)代替。同樣的,在添加至非第一個(gè)父元素時(shí)需克隆目標(biāo)子元素。
第十一步: 刪除節(jié)點(diǎn)
對(duì)于我們最后一個(gè)節(jié)點(diǎn)的操作方法,從dom中刪除這些節(jié)點(diǎn),很簡(jiǎn)單,只需要:
- Dome.prototype.remove = function () {
- return this.forEach(function (el) {
- return el.parentNode.removeChild(el);
- });
- };
只需要通過(guò)節(jié)點(diǎn)的迭代和在他們的父節(jié)點(diǎn)調(diào)用刪除子節(jié)點(diǎn)方法。比較好的是這個(gè)dom對(duì)象依然正常工作(感謝文檔對(duì)象模型吧)。我們可以在它上面使用我們想使用的方法,包括插入,預(yù)插回DOM,很漂亮,不是嗎?
第12步:事件處理
最后,卻是最重要的一環(huán),我們要寫(xiě)幾個(gè)事件處理函數(shù)。
如你所知,IE8依然使用舊的IE事件,因此我們需要為此作檢測(cè)。同時(shí),我們也要做好使用DOM 0 級(jí)事件的準(zhǔn)備。
查看下面的方法,我們稍后會(huì)討論:
- 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;
- });
- };
- }
- }());
在這里,我們用到了立即執(zhí)行函數(shù)(IIFE),在函數(shù)內(nèi)我們做了特性檢測(cè)。如果document.addEventListener方法存在,我們就使用它;另外我們也檢測(cè) document.attachEvent,如果沒(méi)有就使用DOM 0級(jí)方法。請(qǐng)注意我們?nèi)绾螐牧⒓磮?zhí)行函數(shù)中返回最終函數(shù):其最后會(huì)被分配到Dome.prototype.on。在做特性檢測(cè)時(shí),與每次運(yùn)行函數(shù)時(shí)檢測(cè)相比,這樣的方式分配適合的方法更加方便。
事件解綁方法off與on方法類似:.
- 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;
- });
- };
- }
- }());
就這樣!
我真心的希望你能夠試驗(yàn)一下我們的小框架,或者僅僅是繼承一點(diǎn)點(diǎn),就想前面我提到的,我已經(jīng)把這個(gè)框架放到github,帶著著我們已經(jīng)寫(xiě)的Jasmine 測(cè)試用例。可以自用的來(lái)fork代碼,發(fā)送pull請(qǐng)求。
我要重申:本文的觀點(diǎn)并是不推薦你一定要寫(xiě)你自己的框架
這里有樂(lè)于奉獻(xiàn)的人一起工作來(lái)使這個(gè)框架變大,盡可能的完善。這里只是簡(jiǎn)單講了一下框架的原理,我非常高興你能從中得到一些提示。
我真心的建議你能夠自己的研究你喜歡的框架,你會(huì)發(fā)現(xiàn)它們并沒(méi)有你想象的那么難懂,而且你很可能會(huì)從中學(xué)到很多東西,這里是一些很好的開(kāi)始文章,
10 Things I Learned from the jQuery Source (我從jquery源碼學(xué)到的10件事)( 作者 Paul Irish )
11 More Things I Learned from the jQuery Source (我從jquery源碼學(xué)到的11件事)(作者 Paul Irish)
Under jQuery’s Bonnet (作者 James Padolsey)
Backbone.js: Hacker’s Guide, part 1, part 2, part 3, part 4
了解其他的更好的框架? 請(qǐng)留言?
原文鏈接:http://www.oschina.net/translate/build-your-first-javascript-library