从零构建轻量级JavaScript DOM库:原理与实战
本文深入解析主流JavaScript框架的设计原理,并指导读者从零开始构建一个名为dome的轻量级DOM库。教程涵盖IIFE封装、CSS选择器实现、元素遍历与链式调用、文本与HTML处理、类名管理、IE8兼容性修复、属性操作、元素创建与插入、节点删除及事件绑定等核心功能,帮助开发者理解框架底层逻辑。
引言:为什么要手写框架
你是否对Mootools的实现机制感到好奇?或者想了解Dojo框架背后的设计思路?又或许你对jQuery的高效技巧充满探究欲?本文将带你揭开这些流行框架的神秘面纱,并通过动手实践,从零开始构建一个简化版的自定义框架,让你亲身体验框架开发的乐趣。
在日常开发中,我们频繁使用各类JavaScript框架。对于初学者而言,jQuery等框架提供的便捷DOM(文档对象模型)操作令人印象深刻。这主要归因于两点:首先,原生DOM API对新手来说较为晦涩难懂,理解门槛较高;其次,不同浏览器之间的兼容性问题长期困扰着开发者,而框架有效解决了这一痛点。
- 我们将DOM元素封装为对象,核心目的是为这些对象扩展方法。
- 在本教程中,我们将尝试从零实现一个类似的框架。这过程充满趣味,但在你过度兴奋之前,需要先明确几个关键限制:
- 这并非一个功能完备的生产级框架。尽管我们需要编写大量代码,但它远不及jQuery完善。然而,通过这个过程,你将获得编写真实框架的直观体验。
- 我们不提供全浏览器的兼容性保证。该框架旨在支持Internet Explorer 8及以上版本、Firefox 5及以上版本、Opera 10及以上版本,以及Chrome和Safari浏览器。
- 我们的框架不会覆盖所有可能的功能场景。例如,append和prepend方法仅在传入框架实例时生效;我们不支持直接使用原生DOM节点或节点列表作为参数。
此外,虽然教程中未展示测试用例,但我在开发初期已编写了完整测试。你可以从Github获取框架源码及测试代码。
框架基础架构与初始化
第一步:创建框架模板
我们从一段包装代码开始,它将容纳整个框架。这是典型的立即执行函数表达式(IIFE)模式。
window.dome = (function () {
function Dome (els) {
}
var dome = {
get: function (selector) {
}
};
return dome;
}());
可以看到,我们的框架命名为dome,因为它是一个基础的DOM框架。没错,它很基础(lame意为“跛脚的”或“不完整的”,dom加lame即dome)。
目前我们定义了一个函数,它将成为构造框架对象实例的构造函数;这些对象将包含我们选中或创建的元素。
接着,我们定义了一个dome对象,即框架的主对象;你可以看到它最终作为函数的返回值返回给调用者(赋值给window.dome)。这里还有一个空的get函数,我们将用它从页面中选取元素。现在,让我们填充具体代码。
元素选择与实例化
第二步:获取元素
dome的get函数仅接收一个参数,但该参数可以是多种类型。如果它是字符串(string),我们假定它是一个CSS(层叠样式表)选择器;当然,它也可能是一个DOM节点或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属性。如果存在,说明我们得到的是一个节点列表;否则,它是一个单独的元素,我们将它放入数组中。这是因为我们需要向Dome构造函数传递一个数组。可以看到,我们返回了一个新的Dome对象。让我们回到Dome函数并填充其代码。
第三步:创建Dome实例
这是Dome函数的定义:
function Dome (els) {
for(var i = 0; i < els.length; i++ ) {
this[i] = els[i];
}
this.length = els.length;
}
我强烈建议你深入研究你喜欢的框架源码。
这非常简单:我们只是遍历了els数组的所有元素,并将它们存储在一个以数字为索引的新对象中。然后我们添加了一个length属性。
但这有什么意义呢?为什么不直接返回元素?因为:我们将元素包装成对象是为了能够为对象添加方法;这些方法使我们能够遍历这些元素。实际上,这正是jQuery解决方案的浓缩版。
我们的Dome对象已经返回,现在让我们为它的原型(prototype)添加一些方法。我会直接将那些方法写在Dome函数下方。
核心工具函数:Map与链式调用
第四步:添加几个实用工具
要添加的第一批功能是简单的工具函数。由于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函数有一个入参,即一个回调函数。我们遍历Dome对象的所有元素,收集回调函数的返回值到结果集中。注意我们是怎样调用回调函数的:
callback.call(this, this[i], i));
通过这种方式,函数将在Dome实例的上下文中被调用,并且函数接收到两个参数:当前元素和元素序号。
我们也想要一个foreach函数。事实上这很简单:
Dome.prototype.forEach(callback) {
this.map(callback);
return this;
};
由于map函数和foreach函数之间的不同仅仅是map需要返回些东西,我们可以仅仅将回调传给this.map然后忽略返回的数组;代替返回的是,我们将返回this,来使我们的库呈链式。foreach会被频繁的调用,所以,注意当一个函数的回调被返回,事实上,返回的是Dome实例。例如,下面的方法事实上就返回了Dome实例:
Dome.prototype.someMethod1 = function (callback) {
this.forEach(callback);
return this;
};
Dome.prototype.someMethod2 = function (callback) {
return this.forEach(callback);
};
还有一个:mapOne。很容易就知道这个函数是做什么的,但是真正的问题是,为什么需要它?这就需要一些我们称之为"库哲学"的东西了。 一个简短的"哲学"阐释
- 首先,对于一个初学者来说,DOM很让人纠结;它的API不完善。
如果构建一个库仅仅是写代码,那就不是什么难事。但是当我开发这个库时,我发现那些不完善的部分决定了一定数量的方法的实现方式。
很快,我们要去构建一个返回被选择元素的文本的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];
};
内容处理:Text与HTML方法
第5步: 处理Text和HTML
接着,让我们来添加文本方法。就像jQuery,我们可以传递一个string值,设置节点元素的text值,或者通过无参方法得到返回的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;
});
}
};
如你所料,当我们设置(setting)或者得到(getting)value值时,需要检查text的值。要注意的是如果justif(文本)方法不起作用,是因为text为空字符串是一个错误的值。
如果我们设置(setting)时,可是使用一个forEach 遍历元素,设置它们的innerText属性。如果我们得到(getting)时,返回元素的innerText属性。在使用mapOne方法是要注意:如果我们正在处理多个元素,将返回一个数组;其他的则还是一个字符串。
如果html方法使用innerHTML属性而不是innerText,它将会更优雅的处理涉及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;
});
}
};
就像我说过的:几乎相同的。
类名管理与IE8兼容性修复
第六步: 修改类
下一步,我们想对class进行操作,所以添加能addClass()和removeClass()。addClass()的参数是一个class名称或者名称的数组。为了实现动态参数,我们需要对参数的类型进行判断。如果参数是一个数组,那么遍历这个数组,将元素添加上这些class名称,如果参数是一个字符串,则直接加上这个class名称。函数需要确保不将原来的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;
});
};
很直观吧?嘿嘿
现在,写下removeClass(),同样简单。不过每次只允许删除一个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(" ");
});
};
对于每一个元素,我们都将el.className 分割成一个字符串数组。那么我们使用一个while循环连接,直到cs.indexOf(clazz)返回值大于-1。我们将得到的结果join成el.className。
第七步: 修复一个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);
});
}
};
如果形参有一个值,我们将遍历元素并通过元素的setAttribute方法设置属性值。另外,我们将使用mapOne返回通过getAttribute方法得到参数。
第9步: 创建元素
像任何一个优秀的框架一样,我们也应该能够创建元素。当然,在Demo实例中没有一个好的方法,所以让我们来把方法加入到demo工程中。
var dome = {
// get method here
create: function (tagName, attrs) {
}
};
正如你所看到的:我们需要两个形参:元素名,和一个参数对象。大多数的属性通过我们的arrt方法被使用,但是tagName和attrs却有特殊待遇。我们为className属性使用addClass方法,为text属性使用text方法。当然,我们首先要创建元素,和Demo对象。下面就是所有的作用:
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;
}
如上,我们创建了元素,将他发送到新的Dmoe对象中。接着,我们处理所有属性。注意:当使用完className和text属性后,我们不得不删除他们。这将保证当我们遍历其他的键时,它们还能被使用。当然,我们最终通过返回这个新的Demo对象。
我们创建了新的元素,我们想要将这些元素插入到DOM,对吧?
节点插入:Append与Prepend
第10步:尾部添加(Appending)与头部添加(Prepending)元素
下一步,我们来现尾部添加与头部添加方法。考虑到多种场景,实现这些方法可能有些棘手。下面是我们的想要达到的效果:
dome1.append(dome2);
dome1.prepend(dome2);
IE8对我们来说就是一奇葩。
尾部添加或头部添加,包括以下几种场景:
- 单个新元素添加至单个或多个已存在元素中
- 多个新元素添加至单个或多个已存在元素中
- 单个已存在元素添加至单个或多个已存在元素中
- 多个已存在元素添加至单个或多个已存在元素中
注意:这里的”新元素“表示还未加入DOM中节点元素,”已存在元素“指已存在于DOM中的节点元素。 现在让我们一步步来实现之:
Dome.prototype.append = function (els) {
this.forEach(function (parEl, i) {
els.forEach(function (childEl) {
});
});
};
假设参数els是一个DOM对象。一个功能完备的DOM库应该能处理节点(node)或节点序列(nodelist),但现在我们不作要求。首先遍历需要被添加进的元素 (父元素),再在这个循环中遍历将被添加的元素 (子元素)。 如果将一个子元素添加至多个父元素,需要克隆子元素(避免最后一次操作会移除上一次添加操作)。可是,没必要在初次添加的时候就克隆,只需要在其它循环中克隆就可以了。因此处理如下:
if (i > 0) {
childEl = childEl.cloneNode(true);
}
变量i来自外层forEach循环:它表示父级元素的序列号。第一个父元素添加的是子元素本身,而其他父元素添加的都是目标子元素的克隆。因为作为参数传入的子元素是未被克隆的,所以,当将单个子元素添加至单个父元素时,所有的节点都是可响应的。 最后,真正的添加元素操作:
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循环,而是用倒序的for循环代替。同样的,在添加至非第一个父元素时需克隆目标子元素。
节点删除与事件处理
第十一步: 删除节点
对于我们最后一个节点的操作方法,从dom中删除这些节点,很简单,只需要:
Dome.prototype.remove = function () {
return this.forEach(function (el) {
return el.parentNode.removeChild(el);
});
};
只需要通过节点的迭代和在他们的父节点调用删除子节点方法。比较好的是这个dom对象依然正常工作(感谢文档对象模型吧)。我们可以在它上面使用我们想使用的方法,包括插入,预插回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;
});
};
}
}());
在这里,我们用到了立即执行函数(IIFE),在函数内我们做了特性检测。如果document.addEventListener方法存在,我们就使用它;另外我们也检测document.attachEvent,如果没有就使用DOM 0级方法。请注意我们如何从立即执行函数中返回最终函数:其最后会被分配到Dome.prototype.on。在做特性检测时,与每次运行函数时检测相比,这样的方式分配适合的方法更加方便。
事件解绑方法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;
});
};
}
}());
vtk.js 渲染窗口黑屏或无内容显示的常见原因及排查
2026-11-05 17:30:30
js-cookie 安装与基础用法:set、get、remove 详解
2026-11-05 16:35:06
适合JavaScript初学者的实战项目:制作简易待办事项列表
2026-11-05 16:17:07
JS打开新标签页时怎么通过URL传递参数
2026-11-05 12:21:08
sigma.js 自定义节点样式:设置颜色、大小及标签显示的技巧
2026-11-05 09:55:00
