原文:Build Your First JavaScript Library
你是否曾对魔幻般的Mootools感到惊奇,是否曾想知道Dojo的内部机制,亦是否曾好奇于jQuery的巧妙?在本课程,我们将去了解它背后的原理,并尝试动手去创建一个非常简单的库。
我们几乎每天都在使用JavaScript库。当你刚开始的时候,像用jQuery是非常爽的,主要是因为DOM。首先对新手来讲DOM是比较难操作的,因为它是非常简劣的API,其次它没有兼容所有浏览器。
在本课程,我们将从零开始创建一个库。感觉非常有趣吧,但你先不要激动,让我申明几点:
- 这不是功能完整的库。我们是有一套方法要写,但它不是jQuery,不过我们做的足够使你体验到你将来在创建库时会遇到的各种问题。
- 这个项目不会兼容所有浏览器。我们写的代码会在以下浏览器中正常运行:IE8+、Firefox 5+、Opera 10+、Chrome和Safari。
- 不会涵盖所有使用我们库的情况。例如,我们的append和prepend方法只能接受我们库的实例,如果传递它原生DOM节点或节点列表它不会执行。
- 我们也不会为这库写测试用例,因为在我第一次开发它的时候已经做了这个工作。你可以通过Github获得库和测试用例。
Step1 创建库样板
开始,我们写些封装代码,这代码将包含整个库。
window.dome = (function () { function Dome (els) { } var dome = { get: function (selector) { } }; return dome; }());
我们把库命名为Dome,因为他只主要是一个DOM库,是的,它并不完整。
这里我们做了两件事。首先我们命名了一个函数,它最终是我们库实例的构造函数;这些对象会封装我们选择或者创建的元素。
然后,我们定义了dome对象,它是我们真正的库对象;可以看到,它在最后被返回。这对象有一个空的get函数,它将用于从页面选择元素。现在,我们来填充它吧。
Step2 获取元素
dome.get()接收一个参数,它可以是各种类型的值。如果参数是字符串,我们假定它是css选择器。也可以接收单个DOM节点或者一个节点列表。
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来简化查找元素:当然这限制了一些浏览 器的支持,不过对这例子来说,没关系。如果selector 不是字符串,我们检查它是否存在length属性,如果存在我们接收到的是NodeList,否则,我们接收到是单一的元素,我们会将其放入数组。这是因 为当我们调用底部 Dom时需要传递给它一个数组。可以看到,我们返回了一个新的Dome对象。现在我们返回到Dome函数,并填充它。
Step3 创建Dome实例
这是函数Dome:
function Dome (els) { for(var i = 0; i < els.length; i++ ) { this[i] = els[i]; } this.length = els.length; }
非常简单,我们遍历了选择的元素,并将它放入带有数字索引的新对象,然后给这个对象添加了length属性。
注意点,为什么不直接返回元素?我们把这些元素封装在对象里是因为想要能为对象创建方法,这些方法使我们可以和这些元素发生交互操作。
现在,返回了一个Dome对象,我们给它的原型添加些方法,我将这些方法写在Dome函数的正下方。
Step4 添加一些工具函数
首先,我们来添加一些简单的工具函数 ,由于我们的Dome对象可以包含多个Dom元素,我们几乎在每个方法中遍历每个元素,所以有了这些方法将非常方便。
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接收一个回调函数。我们将遍历数组中的每一项,将callback返回的任何值存入results数组,注意我们是如何调用callback的:
callback.call(this, this[i], i));
通过这种方式,回调函数将在Dome实例上下文中调用,它接收两个参数:元素和索引数。
我们还需要一个函数forEach:
Dome.prototype.forEach = function(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。很容易看出这个function是做什么的,但问题是,我们为什么需要它?这需要一些你可以称为"库哲学"的东西来解释。
简短的"哲学"绕道
如果创建一个库只是写代码,那并不是什么难事。但是我在做这项工程时,我发现艰难的是考虑这些方法的工作方式。
我 们马上要创建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]; };
step5处理Text和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; }); } };
如你可能预见的,在text里我们需要检验值来看看我们是要设置值还是获取值。注意不能写if(text),因为空字符串是一个错值。
如果我们要设置,我们会对每个元素执行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; }); } };
step6:调整 Class
下一步,我们想要添加和移除样式,所以让我们来写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循环去切割 出不合法的class直到 cs.indexOf('clazz)返回-1。我们这样做覆盖了边缘情况当同样的类曾被重复添加到一个元素里。一旦我们确定我们已经切割出样式的每个情 况,我们用空格连接这个数组,并把它设置到el.className。
step7:处理一处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; }; }
相当简单吧,它并不是完整的实现(不支持第二个参数),不过我们的目的达到了。
step8:调整属性
现在我们想要一个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方法设置所选属性为那个值。否则我j我们用mapOne通过getAttribute方法来返回那个属性值。
step9:创建元素
我们应该能创建新元素,像任何优秀的库一样。当然,把它作为一个Dome实例的方法不好,所以我们把他放到我们的dome对象里。
var dome = { // get method here create: function (tagName, attrs) { } };
你可以看到,我们要接收两个参数,一个是元素的名字,一个是属性对象。大部分属性能通过方法attr应用,但两个需要特殊处理。我们用方法addClass来处理className属性。当然我们首先需要创建元素和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中剩下的键时又被调用。当然,我们以返回新的Dome对象结束。
我们现在在创建新元素,我们想把他们插入到DOM,对吧?
step10:添加元素
接着,我们写append 和prepend方法。现在需要写一些巧妙的函数,主要因为有多种使用情况。这是我们想要能做到的:
dome1.append(dome2); dome1.prepend(dome2);
使用情况如下,我们可能想追加或向前添加
-- 一个新元素到一个或多个现有元素
-- 多个新元素到一个或多个现有元素
-- 一个现有元素到一个或多个现有元素
-- 多个现有元素到一个或多个现有元素
注意:我这里说的新是指不存在DOM的元素,现有元素是指已经在DOM的元素。
我们开始吧:
Dome.prototype.append = function (els) { this.forEach(function (parEl, i) { els.forEach(function (childEl) { }); }); };
我们希望参数els是一个Dome对象,一个完整的DOM库应该将它作为节点或节点列表接收,我们不那样做。我们循环每个元素,然后在那里面继续循环想要插入的每个元素。
如果我们想添加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); } }); };
向前添加的不同点是当你按需向前添加一列元素到另一个元素,他们会按相反的顺序结束。当然我们不能用forEach反过来。
step11:移除节点
我们最后的操作节点方法是希望能从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级事件。注意我们是如何从IIFE返回最终函数:在将其指派给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,包括对以上代码的Jasmine单元测试,你可以frok他,运行它。
”再次申明:本课程的目的不是建议你总应该写自己的库。“
这里有专门的团队一起工作让它强大,使库建的尽可能强大。这里的要点只是提了几点可能在库里发生的,我希望你有所获得。
我建议你在你喜欢的一些库里研究。你会发现他们不再如此神秘,可能你已经想到。
【这是第一篇翻译文章,接着会继续翻译前端相关的一些有趣的文章,欢迎一起讨论。】