目前,jQuery是事实上的操作文档对象模型(DOM)的库。它可以与流行的客户端MV*框架结合使用,并且拥有大量的插件与大型的社区。开发者对于Javascript的兴趣与日俱增的同时,很多人开始好奇,原生的API是如何工作的,以及我们何时应该直接使用它们而不是引用一个额外的库。
最近,我开始发现越来越多的jQuery的问题,至少是在我的使用中是这样的。其中的绝大多数涉及到jQuery的核心,在不取消向后兼容的情况下无法解决——而向后兼容又非常重要。与很多人一样,我继续使用了它一段时间,每天浏览所有讨厌的浏览器怪异模式。
后来, Daniel Buchner 创造了 SelectorListener,于是有了“live扩展(live extensions)”的概念。我开始考虑创造一系列的函数,使得我们可以使用比迄今为止用过的方法都更好的方式来创建非干扰性的DOM组件。目标是回顾已有的API与解决方案,并创造一个更干净、可测试且轻量级的库。
向库添加有用的特性
是live扩展的想法鼓励我开发了better-dom项目,不过,还有一些其他的有趣的特性使得它成为一个独特的库。我们快速地看一下:
- live扩展
- 原生的动画
- 内置的微模板
- 国际化的支持
live扩展
jQuery有一个叫做“live事件(live events)”的概念。借助事件代理,它使得开发者可以处理现有的以及未来的元素。但是许多情况会要求更大的灵活度。比如为了初始化一个部件而需要对DOM进行转换,事件代理就会力不从心。故而,live扩展。
目标是,只需定义一次扩展并使得所有未来的元素快速略过初始化函数,而无论部件的复杂度。这个很重要,因为它使得我们可以声明式地开发web页面,从而在AJAX应用中表现优异。
Live扩展使得你无需调用初始化方法就可以操作未来的元素
我们来看一个简单的例子。假设我们的任务是实现一个完全自定义的提示框。:hover 伪类选择器并无帮助,因为提示框的位置随着鼠标移动而变化。事件代理也不是很合适;监听文档树中所有元素的mouseover 及mouseleave 事件代价很大。live扩展将拯救你!
DOM.extend("[title]", {
constructor: function() {
var tooltip = DOM.create("span.custom-title");
// set the title's textContent and hide it initially
tooltip.set("textContent", this.get("title")).hide();
this
// remove legacy title
.set("title", null)
// store reference for quicker access
.data("tooltip", tooltip)
// register event handlers
.on("mouseenter", this.onMouseEnter, ["clientX", "clientY"])
.on("mouseleave", this.onMouseLeave)
// insert the title element into DOM
.append(tooltip);
},
onMouseEnter: function(x, y) {
this.data("tooltip").style({left: x, top: y}).show();
},
onMouseLeave: function() {
this.data("tooltip").hide();
}
});
我们可以在CSS中定义 .custom-title 元素的样式:
.custom-title {
position: fixed; /* required */
border: 1px solid #faebcc;
background: #faf8f0;
}
当你向页面中插入一个带title 属性的新元素时,最有趣的部分发生了。自定义的提示框无需调用任何初始化方法即可生效。
live扩展是独立的;这样它们并不需要为了使得未来的内容生效去调用一个初始化方法。因此它们可以与任何DOM库结合使用,将UI代码分割成许多小的独立的块,从而简化应用的逻辑。
最后,同样很重要的,一些关于Web组件的内容。规范的一部分,“装饰器” ,意在解决类似的问题。目前,它使用了一种基于标记的实现,通过特殊的语法,将事件监听者绑定到子元素上。但它仍只是早期的草案:
“装饰器,与Web组件的其它部分不同的是,它还没有一个规范。”
原生动画
多亏了 Apple, CSS现在拥有了对动画的良好支持。过去动画通常使用Javascript的setInterval 及setTimeout实现。这曾经是很酷的特性——但是现在看来,它更像是坏的实践。原生的动画总是更平滑:常常更快,开销更小,并且在浏览器不支持的情况下可以很好地降级。
better-dom中,没有animate方法:只有show, hide 以及toggle。库使用基于标准的aria-hidden属性来在CSS中获取一个隐藏元素的状态。
为了说明它是如何工作的,我们来为先前介绍的提示框添加一个简单的动画效果:
.custom-title {
position: fixed; /* required */
border: 1px solid #faebcc;
background: #faf8f0;
/* animation code */
opacity: 1;
-webkit-transition: opacity 0.5s;
transition: opacity 0.5s;
}
.custom-title[aria-hidden=true] {
opacity: 0;
}
show() 以及hide() 在内部将 aria-hidden 属性值设置为false或true。这使得CSS可以处理动画与转换。
你可以在这个demo点击预览中看到更多使用了better-dom的动画。
内置的微模板
HTML字符串冗长而繁琐。寻找替代的过程中我发现了超棒的Emmet。如今Emmet已经是一个非常流行的文本编辑器插件,它拥有漂亮而简洁的语法。比如这段HTML:
body.append("<ul><liclass='list-item'></li><liclass='list-item'></li><liclass='list-item'></li></ul>");
与对应的微模板比较:
body.append("ul>li.list-item*3");
在better-dom中,任何接受HTML的方法同样接受Emmet表达式。缩写解析器很快,所以不用担心付出性能代价。如果需要,还有一个模板预编译函数可用。
国际化支持
开发一个UI组件经常会需要本地化——这并不轻松。多年来,很多人使用不同的方法解决这个问题。在better-dom中,我相信改变CSS选择器的状态,就如同转换语言。
从概念上说,转换语言正是改变内容的“表现”。在CSS2中,有几个伪类选择器可用于描述这样一个模型::lang 以及:before。我们来看下边的代码:
[data-i18n="hello"]:before {
content: "Hello Maksim!";
}
[data-i18n="hello"]:lang(ru):before {
content: "Привет Максим!";
}
这是个很简单的把戏:html 元素的lang 属性控制当前语言,而content 属性值根据当前的语言变化。通过使用如data-i18n这样的属性,我们可以在HTML中维护文本内容。
[data-i18n]:before {
content: attr(data-i18n);
}
[data-i18n="Hello Maksim!"]:lang(ru):before {
content: "Привет Максим!";
}
当然,这样的CSS并不吸引人,所以better-com提供了两个帮助方法:i18n 及DOM.importStrings。前者用于更新 data-i18n 属性为合适的值,后者为特定的语言本地化字符串。
label.i18n("Hello Maksim!");
// the label displays "Hello Maksim!"
DOM.importStrings("ru", "Hello Maksim!", "Привет Максим!");
// now if the page is set to ru language,
// the label will display "Привет Максим!"
label.set("lang", "ru");
// now the label will display "Привет Максим!"
// despite the web page's language
还可以使用参数化的字符串。直接向关键字符串中添加${param} 变量:
label.i18n("Hello ${user}!", {user: "Maksim"});
// the label will display"Hello Maksim!"
让原生的APIs 更加优雅
通常我们都希望遵从标准。但是有时候标准对用户并不友好。DOM就是一团糟 ,为了将其变得好用,我们不得不把它包装到一个方便的API中。尽管开源的库已经作了很多改进,仍有一些部分可以做得更好:
- getter 及setter
- 事件处理
- 功能性的方法支持
GETTER 及SETTER
原生的 DOM 元素有attributes 及properties的概念,但他们的行为并不完全一致。假设我们在一个web页面中有如下的标记:
<a href="/chemerisuk/better-dom"id="foo"data-test="test">better-dom</a>
为了解释为什么“DOM就是一团糟”,我们来看这:
var link = document.getElementById("foo");
link.href; // => "https://github.com/chemerisuk/better-dom"
link.getAttribute("href"); // => "/chemerisuk/better-dom"
link["data-test"]; // => undefined
link.getAttribute("data-test"); // => "test"
link.href = "abc";
link.href; // => "https://github.com/abc"
link.getAttribute("href"); // => "abc"
一个attribute与其在HTML中对应的字符串相等,但元素的同名property可能会有一些奇怪的行为,比如在上边列出来的,生成完全合格的URL。这些区别有时会导致混淆。
在实际使用中,很难想像一个这样的区别有用的场景。除此之外,开发者必须总是牢记哪些值(attribute 或property)被使用了,这会引入没必要的复杂度。
在better-dom中,事情要清楚得多。每个元素都只有智能的getter与setter。
varlink = DOM.find("#foo");
link.get("href"); // => "https://github.com/chemerisuk/better-dom"
link.set("href", "abc");
link.get("href"); // => "https://github.com/abc"
link.get("data-attr"); // => "test"
首先,它做一次属性(property)查找,如果是已定义的,则返回供操作。不然,getter 及setter 作用于对应的元素属性(attribute)。对于boolean值(checked, selected, 这些), 可以直接使用 true 或 false 来更新值:改变元素的该属性(property)将触发对应的attibute(原生行为)的更新。
改良的事件处理
事件处理是DOM中很重要的一部分,然而,我发现一个根本性的问题:将event对象传入元素监听者的行为导致关心可测试性的开发者不得不伪造第一个参数(事件对象),或是创建一个额外的函数来传入事件处理函数仅需的事件属性。
var button = document.getElementById("foo");
button.addEventListener("click", function(e) {
handleButtonClick(e.button);
}, false);
这很烦人。不过如果我们将可变部分抽象为一个参数,我们就可以摆脱额外的函数:
var button = DOM.find("#foo");
button.on("click", handleButtonClick, ["button"]);
默认地,事件处理函数会被传入["target", "defaultPrevented"] 数组,所以不用为了获得这些属性添加最后一个参数。
button.on("click", function(target, canceled) {
// handle button click here
});
延时绑定也得到了支持(我建议读一下Peter Michaux关于这个主题的回顾)。它是W3C的标准中常规事件绑定的更加灵活的替换物。它在你需要频繁进行启用和关闭方法调用时非常有用。
button._handleButtonClick = function() { alert("click!"); };
button.on("click", "_handleButtonClick");
button.fire("click"); // shows "clicked" message
button._handleButtonClick = null;
button.fire("click"); // shows nothing
最后,同样很重要的,better-dom不提供任何对于遗留的或不同浏览器中表现不一致的API的调用,比如click(), focus() 和submit()。 调用他们的唯一方式是使用fire 方法,它在没有监听者返回false的情况下执行默认行为:
link.fire("click"); // clicks on the link
link.on("click", function() { returnfalse; });
link.fire("click"); // triggers the handler above but doesn't do a click
功能性方法的支持
ES5规范了一些的有用的数组方法,包括 map, filter 以及some。它们允许我们以符合标准的方式使用通用的集合操作。因此现在我们有了诸如 Underscore 和 Lo-Dash 这样的项目,它们在老的浏览器上实现这些方法。
better-dom中的每个元素(或集合)都内置了如下的方法:
- each (它与 forEach 的区别在于返回this 而不是 undefined)
- some
- every
- map
- filter
- reduce[Right]
varurls, activeLi, linkText;
urls=menu.findAll("a").map(function(el) {
returnel.get("href");
});
activeLi=menu.children().filter(function(el) {
returnel.hasClass("active");
});
linkText=menu.children().reduce(function(memo, el) {
returnmemo||el.hasClass("active") &&el.find("a").get()
}, false);
避免jQuery的问题
在不放弃向后兼容的情况下,以下的绝大多数问题无法在jQuery中得到解决。这是为什么创造一个新的库看起来是合乎逻辑的解决途径。
- “神奇的” $ 函数
- [] 操作符的值
- 关于 return false的问题
- find 以及findAll
“神奇的” $ 函数
每个人都或多或少听说过$ (美元) 函数的神奇。一个单字符的名字并不具有描述性,所以它看起来像是一个内置的语言操作符。这也正是缺乏经验的开发者的代码中$的调用随处可见的原因。
在背后的实现中,$是个极其复杂的函数。经常地执行,尤其是 mousemove 、scroll这样的频繁事件中,会导致较差的UI性能。
尽管有很多文章建议将jQuery对象缓存下来,开发者依旧在将$大量嵌入在代码中,因为jQuery库的语法鼓励了这样的代码风格。
$函数的另一个问题是,它可以被用来做完全不同的两件事。人们已经喜欢了这样的语法,但通常来说,这是一个失败的函数设计:
$("a"); // => searches all elements that match “a” selector
$("<a>"); // => creates a <a> element with jQuery wrapper
better-dom 使用了几个函数来承担jQuery中$函数的职责:find[All] 以及DOM.create。find[All] 被用来依据CSS选择器来获取元素。 DOM.create 在内存中创建一个新的节点树。它们的名字就可以清晰地表明它们的职责。
[]操作符的值
导致$函数被频繁调用的另一个原因正是方括号操作符。当一个新的jQuery对象被创建的时候,所有相关的节点都被存储在数值型属性中。但是请注意,这样一个数值属性的值包含了一个原生的元素实例(而非经jQuery包装过的对象):
var links = $("a");
links[0].on("click", function() { ... }); // throws an error
$(links[0]).on("click", function() { ... }); // works fine
正因为这样的特性,jQuery或是其它库(比如Underscore)中的每一个功能方法都要求当前元素在回调函数中使用$() 包起来。因此,开发者必须时刻牢记他们正在操作的对象类型——一个原生元素或是一个包装过的对象——尽管事实上他们正在使用一个操作DOM的库。
在better-dom中,方括号操作符返回一个库对象,所以开发者可以忘记原生元素。只有一种可接受的方式来获取原生元素:使用一个特别的 legacy方法。
varfoo=DOM.find("#foo");
foo.legacy(function(node) {
// use Hammer library to bind a swipe listener
Hammer(node).on("swipe", function(e) {
// handle swipe gesture here
});
});
事实上,只有非常少见的情况会需要这个方法,比如兼容一个原生的方法,或是另一个DOM库(比如上边例子中的Hammer)。
关于RETURN FALSE的问题
jQuery事件处理函数中返回false后的奇怪的拦截行为让我一直很纠结。依据W3C的标准,它应该在大多数情况下取消默认行为。在jQuery中,return false 还会阻止事件代理。
这样的捕获会导致问题:
- 自行调用stopPropagation() 可能导致兼容性问题,因为它阻止了其他任务相关的监听者的执行。
- 大部分开发者(即使是一些有经验的)并没有意识到这样的行为
尚不清楚为什么jQuery社区决定不遵循标准。但better-dom并不会重蹈覆辙。 所以,正如每个人所预期的,事件句柄中的return false 只会阻止浏览器默认行为,而不会干扰事件冒泡。
FIND 以及FINDALL
元素查找是在浏览器中代价最大的操作之一。两个原生的方法可以用来实现它:querySelector以及querySelectorAll。区别在于前者在匹配到第一个结果后即停止查找。
这个特性使得我们可以显著减少特定情形下的迭代次数。在我的测试中,速度提升到了二十倍!而且,可以预见,依据文档树的规模,提升可能达到更多。
jQuery提供了一个find 方法,使用querySelectorAll ,用于一般的情形。目前还没有函数使用querySelector 来只获取第一个匹配的元素。
better-dom 库有两个单独的方法:find 及findAll。它们允许我们使用querySelector 优化。为了评估潜在的性能提升,我在我上一个商业项目的所有源代码中搜索了这些方法的使用:
- find:在11个文件中匹配103次
- findAll:在4个文件中匹配14次
很明显find 方法要受欢迎得多。这说明querySelector 优化在大多数情况下是有意义的,并能推动相当的性能提升。
结论
live扩展确实使得解决前端问题简单不少。将UI分割为许多小块可以带来更加独立、可维护的解决方案。不过正如我们所展示的,一个框架不仅仅是关于这些(尽管这是主要目标)。
我在开发过程中学习到的一件事是,如果你不喜欢某个标准,或者你对该如何做某件事情有自己不同看法,那么就去实现它,证明你的方法可行。这也很有趣!
更多关于better-dom 项目的信息可以在GitHub找到。