[译] 创建你的第一个javascript库

原文: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存在,我们就用它,否则,我们检查<code>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他,运行它。

”再次申明:本课程的目的不是建议你总应该写自己的库。“

这里有专门的团队一起工作让它强大,使库建的尽可能强大。这里的要点只是提了几点可能在库里发生的,我希望你有所获得。

我建议你在你喜欢的一些库里研究。你会发现他们不再如此神秘,可能你已经想到。

【这是第一篇翻译文章,接着会继续翻译前端相关的一些有趣的文章,欢迎一起讨论。】