第15章 CSS选择引擎 {#15-css}
随着web开发越来越专业,所有流行的浏览器都包含了W3C选择器API。这个API提供了querySelectorAll()和querySelector()两个方法,在我们应用中结合其它一些好的工具可以写出满足跨浏览器要求的可以快速遍历DOM的代码。
注意 想要得到更多有关API信息么?请查看W3C Level1(www.w3.org/TR/selectors-api/)和W3C Level2(www.w3.org/TR/selectors-api2/)。
你可能会问,几乎在所有的浏览器中都实现了W3C选择器API,为什么我们还要花时间来讨论如何实现纯JavaScript的CSS选择器引擎呢?
虽然存在这些标准的API是件好事,但是大多数浏览器只是生硬地实现了。这样的做法就与一个好的API不沾边了。例如,这样方法不能利用已经创建好的DOM缓存,它们不能提供错误报告,它们也不能处理任何形式的扩展。
流行的JavaScript类库中的CSS选择器考虑到了这些因素。他们使用DOM缓存提供更高的性能,它们提供了错误报告,它们拥有很高的扩展性。
提示 CSS选择器引擎是一个功能性的术语,它通过CSS表达式来匹配DOM元素,例如,.ninja的表达式可以匹配所有class为ninja的元素。
说了这么多,还是没有回答问题:为什么需要理解纯JavaScript的CSS选择器引擎是如何工作的呢?理解这些当然是为了惊人的性能收益了。这样做不只是可以实现更快、更好的遍历方法,也可以让我了解到如何创建适应CSS选择器引擎的更高性能的CSS选择器。
CSS选择器引擎如今是日常开发中的一部分,理解它们如何工作、如何使它们工作更快可以在开发为我们提供基本的帮助。如果你在捉摸我们要如何做,主要遵循以下模式:
- 查找DOM元素。
- 对它们做一些事情。
除了新的选择器API,查找DOM元素从来都不是重点。这些API对于使用ID或者标签名查找API有限制。不管怎么说,第一步做起来是比较简单的,让我们把重点放在第二步上。
CSS3选择器引擎标准被W3C定义在www.w3.org/TR/css3-selectors/。
有三种重要的实现CSS选择器引擎的方式:
- 使用之前提到的W3C选择器API。
- 使用许多浏览器中内置的XPath语言
- 使用纯粹的DOM,如果前两个功能不存在,它就是主要的CSS选择器引擎
本章将会深入地探讨这些策略,可以允许我们决定去实现还是理解一个JavaScript CSS选择器引擎。
我们将从W3C的方法开始。
15.1 W3C选择器API {#15-1-w3c-api}
W3C选择器API是一个比较新的API,在JavaScript中使用它实现完整的CSS选择器引擎可以减少许多工作量。
浏览器供应商抓住了这个新的API,并且在大多数浏览器中实现了它(safari 3,IE8,Chrome,和Opera10)。API一般情况下支持所有CSS引擎实现的选择器,因此一个浏览器是否完全支持CSS3,从它的选择器的实现上就可以反映出来。
API提供了一些有用的方法,它们其中的两个在流行的浏览器中已经实现了:
- querySelector() 接收一个CSS选择器字符串并且返回第一个元素,如果没有找到返回null。
- querySelectorAll() 接收一个CSS选择器字符串并且返回一个静态的NodeList,其中包含查找到的所有元素。
在两个方法在所有的DOM元素,DOM文档,DOM框架中存在。
下面的代码展示如何使用这些API。
Listing 15.1 Examples of the Selectors API in action
<div id="test">
<b>Hello</b>, I'm a ninja!
</div>
<div id="test2"></div>
<script type="text/javascript">
window.onload = function () {
//Finds <div> elements that are children of the body
var divs = document.querySelectorAll("body > div");
assert(divs.length === 2, "Two divs found using a CSS selector.");
//Finds only children who are bold!
var b = document.getElementById("test")
.querySelector("b:only-child");
assert(b,
"The bold element was found relative to another element.");
};
</script>
对比其它JavaScript类库的实现,浏览器实现的API只在有限的程度上支持了CSS选择器。这个问题可以在根元素的查询规则中体现出来(调用querySelector()和querySelectorAll()都可以)。
Listing 15.2 Element-rooted queries
<div id="test">
<b>Hello</b>, I'm a ninja!
</div>
<script type="text/javascript">
window.onload = function () {
var b = document.getElementById("test").querySelector("div b");
assert(b, "Only the last part of the selector matters.");
};
</script>
注意:执行这个查询时,选择器只会在元素内部查找与表达式中最后一部分匹配的元素。这个看起来与直觉想违背的。在listing 15.2中,我们可以看到在id为test的元素中并没有div,但是这个选择器却是成功的。
这种情况与大多数人想像的都不一样,所以我们需要提供一个解决方案。最常见的方法是为根节点元素提供一个新的id,更改它的上下文。
Listing 15.3 Enforcing the element root
<div id="test">
<b>Hello</b>, I'm a ninja!
</div>
<script type="text/javascript">
(function() {
//使用立即执行方法将计数器变量绑定到rootedQuerySelectorAll()方法上
var count = 1;
// 将方法定义到全局定义域上
this.rootedQuerySelectorAll = function (elem, query) {
// 保存原始的id,之后我们再将它设置回来
var oldID = elem.id;
// 分配唯一的临时
elem.id = "rooted" + (count++);
try {
return elem.querySelectorAll("#" + elem.id + " " + query);
}
catch (e) {
throw e;
}
finally {
//在finally模块中重置原始的id,这样可以保证代码永远会执行
elem.id = oldID;
}
};
})();
window.onload = function () {
var b = rootedQuerySelectorAll(
document.getElementById("test"), "div b");
assert(b.length === 0, "The selector is now rooted properly.");
};
</script>
在listing15.3中有两点比较重要。
一开始,我们必须为元素分配一个唯一的id,并且存储之前的id。这样是为了保证在我们创建选择器的时候得到的结果不会产生冲突。然后我们将这个id预置到选择器之前(形式是“#id”,id是唯一值)。
程序会简单地移除id并且返回查询结果。但是注意一点:选择器API可以抛出异常(通常是选择器语法错误或者不支持选择器)。基于这个原因,我们选择try/catch语法。由于我们想要重设id,我们可以添加一个额外的finally模块。这是语言本身的特性:即便我们在try中返回或者在catch中抛出异常,代码最终都会执行finally模块(执行会早于当前方法的返回)。通过这种方法id总是被设置正确的。
选择器API是W3C提出最有前途的API之一。在支持这种API的浏览器主导市场后,它就可以通过很简单的方法替代大部分JavaScript类库中的大部分工作了。
转移注意力,让我们以XML的方式解决这个问题。
15.2 使用XPath查找元素 {#15-2-xpath}
对于那些不支持选择器API的浏览器,统一地使用XPath查询。
XPath是在文档中查找元素的语言。它明显地要强于传统的CSS选择器,大多数流行的浏览器(Firefox, Safari 3+,Opera 9+,Chrome)都支持了XPath,它可以应付基于HTML的DOM文档。IE6及以上提供支持XML文档的XPath(并不是HTML文档——它是我们共同的目标)。
使用XPath表达式替代纯粹的DOM操作能够得到的收益并没有一个确定的标准。由于编程的方式这个标准被确定后,依然需要知道一些事情:通过纯粹的DOM方法使用id或者标签来查找元素仍然是比较快的(使用getElementById()和getElementByTagName())。
如果我们面对的观众可以舒服地使用XPath表达式(并且很欣然地将自己限制在主流的浏览器上),我们就可以下面这段代码(Prototype类库提供)并且完全忽略任何关于CSS选择器引擎的东西。
Listing 15.4 A method for executing an XPath expression on an HTML document
if (typeof document.evaluate === "function") {
function getElementsByXPath(expression, parentElement) {
var results = [];
var query = document.evaluate(expression, parentElement || document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
for (var i = 0, length = query.snapshotLength; i < length; i++)
results.push(query.snapshotItem(i));
return results;
}
}
使用XPath可以很好的处理所有事情,但是实际上根本不太可行。XPath封装的这些功能是为开发人员使用的,并且相对于CSS选择器而言它过于复杂了。我们在这里不能看到完整的XPath,但是table 15.1提供了最常用的XPath表达式,并且提供了与它们对应的CSS选择器。
Table 15.1 CSS selectors and their equivalent XPath expressions
Goal | XPath | CSS3 |
---|---|---|
All elements | //* | * |
All elements named p | //p | P |
All immediate child elements of p | //p/* | P > * |
Element by ID | //*[@id=’foo’] | #foo |
Element by Class | //*[contains(concat(“”,@class,””),”foo”)] | .foo |
Element with attribute | //*[@title] | *[title] |
First child of all p | //p/*[0] | P > *:first-child |
All p with an a descendant | //p[a] | Not possible |
Next element | //p/following-slibling::*[0] | P + * |
相对于使用纯粹的DOM选择器引擎,我可以使用XPath表达式创建选择器引擎,并且使用正则转换选择器。不同之处是将CSS选择器映射为与之对应的XPath表达式,然后执行。
因为大量的DOM CSS选择器引擎的实现,这个方法并没有带来太多有利于我们的东西。许多开发者不使用XPath是为了减少他们引擎的复杂程度。你需要权衡使用XPath引擎带来的收益(特别是对比选择器API)。
现在开始卷起我们的衣袖。
15.3 纯DOM实现 {#15-3-dom}
在每个CSS选择器引擎的核心都是一个纯粹的DOM实现。它的要求是转换CSS选择器并且使用已存在的DOM方法(getElementById()
与getElementByTagName()
)查找符合的元素。
注意 HTML5在标准方法中添加了getElementsByClassName()
方法。
使用DOM实现CSS选择器有一些重要的原因:
- IE6、7——IE8和9已经支持了
querySelectorAll()
,IE6和IE7却漏掉了XPath或者选择器API,所以使用DOM实现就变得必须了。 - 向后兼容——如果你想要你的代码优雅降级并且支持那些不支持选择器API或者XPath的浏览器(比如Safari 2),你就需要使用DOM形式来实现。
- 速度——有许多纯粹的DOM选择器性能非常好(例如使用ID查找元素)。
- 完全覆盖——并不是所有的浏览器都支持相同集合的CSS选择器。如果我们想要在所有浏览器中支持所有这些CSS选择器集合——或者至少是那些最常用的,我们就需要凭借自己的力量了。
牢记这些,我们可以想到两个可能的CSS选择器引擎的实现:从上到下和从下到上。
从上到下的选择器引擎将CSS选择器从左至右进行解析,然后根据每个选择器片段匹配元素。在大多数JavaScript类库中都可以找到这种引擎,查找元素时优先使用这种工具。
看下面这个简单的例子:
<body>
<div></div>
<div class='ninja'>
<span>Please </span><a href='/ninja'><span>Click me!<span></a>
</div>
<body>
如果我们想要找到包含“Click me!”的<span>,我们可以使用下面这个选择器:
div.ninja a span
从上到下的方法在Figure 15.1中已经描绘了。
第一步,div.ninja标识了document中的子树。下一步,在子树中使用a标识了锚点元素。最后span标识了最终的目标元素。注意,这是一个简单的例子。在任何阶段都可以标识复杂的子树。
当开发选择器引擎时需要考虑两个重要的注意事项:
- 这些结果需要按照文档的顺序(它们被定义时的顺序)。
- 这些结果应该是唯一的(不应该返回重复的元素)。
出于这些方面的考虑,开发从上到下的引擎是比较复杂的。
来看一个简化的实现,使用标签名查找元素。
Listing 15.5 A limited, top-down selector engine
<div>
<div>
<span>Span</span>
</div>
</div>
<script type="text/javascript">
window.onload = function(){
function find(selector, root){
//If no root provided, starts at the top of the document
root = root || document;
//Splits the selector on spaces, grabs the first term, collects the remainder, finds the element matching the first term, and initializes an array to gather the results within
var parts = selector.split(" "), query = parts[0], rest = parts.slice(1).join(" "), elems = root.getElementsByTagName(query), results = [];
for (var i = 0; i < elems.length; i++) {
if (rest) {
//Calls find() recursively until all the selectors are consumed
results = results.concat(find(rest, elems[i]));
}
else {
//Pushes found elements onto results array
restesults.push(elems[i]);
}
}
//Returns list of matched elements
return results;
};
var divs = find("div");
assert(divs.length === 2, "Correct number of divs found.");
var divs = find("div");
assert(divs.length === 2, "Correct number of divs found.");
var divs = find("div", document.body);
assert(divs.length === 2, "Correct number of divs found in body.");
var divs = find("body div");
assert(divs.length === 2, "Correct number of divs found in body.");
var spans = find("div span");
assert(spans.length === 2, "A duplicate span was found.");
};
</script>
在这个实现中我们做了一个限制,这个引擎只能查找标签名。这个引擎可以分解为几个步骤:解析过滤器,查找元素,过滤,检索和合并元素。
我将近距离观察每个步骤。
15.3.1 解析选择器 {#15-3-1}
在我们简单的例子中,我们只将标签名形式的CSS选择器转换为字符串数组,例如“div span”转换为[“div”, “span”]。
这个简单的例子使用空白分隔符将字符串隔断,但是CSS2和CSS3有能力使用属性或者属性值来查找元素,因此可能在选择器中会多出其它一些空格。这就使得我们之前使用的方法看起来过于简化了。
为了完整的实现,我们想得到一系列可靠的解析规则,用来处理那此抛过来的任何表达式;这个规则最可能的就是使用正则表达式。下面的例子利用正则表达式展示非常健壮的解析能力,它可以捕获选择器的每个部分并将它们截断(如果有必要的话用逗号分隔)。
Listing 15.6 A regular expression for breaking apart a CSS selector
<script type="text/javascript">
var selector = "div.class > span:not(:first-child) a[href]"
var chunker = /((?:\([^\)]+\)|\[[^\]]+\]|[^ ,\(\[]+)+)(\s*,\s*)?/g;
var parts = [];
//Resets the position of the chunker regexp (start from beginning)
chunker.lastIndex = 0;
//Collects the pieces
while ((m = chunker.exec(selector)) !== null) {
parts.push(m[1]);
//Stops on encountering a comma
if (m[2]) {
extra = RegExp.rightContext;
break;
}
}
assert(parts.length == 4, "Our selector is broken into 4 unique parts.");
assert(parts[0] === "div.class", "div selector");
assert(parts[1] === ">", "child selector");
assert(parts[2] === "span:not(:first-child)", "span selector");
assert(parts[3] === "a[href]", "a selector");
</script>
很明显,这个选择器只是拼图中的一部分。我们需要为每个表达式添加额外的解析规则。大多数选择器引擎都包含一个正则表达式与方法的映射,当选择器中某部分匹配上时,关联的方法就会执行。
研究这些表达式的细节会花费很多时间。如果你真的想研究它是如何做的,我们鼓励你去看jQuery的源代码或者其它你感兴趣的类库的源代码,研究选择器解析那部分。
下面,我们需要找到正则表达式匹配的元素。
15.3.2 查找元素 {#15-3-2}
有许多方法可以找到正确的元素。这些技术依赖于浏览器支持哪些技术以及哪些是有效的。不过,还是有许多明显的方法的。
getElementById()只在HTML文档的根节点上是有效的,这方法会在文档中查找指定id的第一个元素(正是这个原因这个id的元素应该只有一个),因此CSS ID选择器是有效的。让人恼火的是IE和Opera还会将name一样的第一个元素查找出来。如果我们只希望查找ie,我们就需要一个额外的验证步骤来去除那些不需要的元素。
如果我们想要找到与指定id匹配的所有元素(这是CSS选择器的习惯,即使每个页面被规定只有一个指定id的元素),我需要遍历所有元素或者使用document.all[“id”]。document.all[“id”]将所有匹配的元素以数组的形式返回,支持此方法的浏览器包括IE,Opera,和Safari。
getElementByTagName()方法根据标签名匹配元素,另外一种用法就是通过*做为标签名可以查找文档或者元素中的所有元素。在处理基于属性的选择器(例如:.class或者[attr])的时候这种方法是非常有用的。
使用*的时候有一个警告,IE返回的结果中包括注释节点(因为标签的节点名是!,因此会被返回)。所以需要过滤注释节点。
getElementsByName()是一个非常容易实现的方法,它的目的是查找指定名称的所有元素(例如<input>标签和那些拥有name属性的标签)。实现这个[name=name]选择器是非常有用的。
getElementByClassName()是一个新的HTML5方法。它是基于class属性来查找元素的。这个方法巨大地提升了类选择器的速度。
虽然有许多技术可以被用来实现选择器,但是上述我们所说的方法是查找元素时最主要的方法。
使用这些方法的结果,我们移步到过滤部分。
15.3.3 过滤集合 {#15-3-3}
一个CSS表达式通常包含许多独立的部分。例如div.class[id]包含三个部分:它会查找那些类名为class并且拥有一个属性名为id的div元素。
第一步是标识根选择器。例如,我们看到使用了div,所以我们使用getElementsByTagName()方法遍历页面上所有的div元素。然后,我们过滤出这些结果中只包含指定类名和拥有id属性的元素。
这个过滤的功能常见于所有的选择器中。过滤器中的内容最主要的是处理元素的属性和根据元素的兄弟节点和其它关系确定位置。
- 属性过滤——这个方法的实现是访问DOM的属性(一般使用getAttribute方法)和验证它们的值。Class过滤是这个方法的一种形式(访问className属性并检查它的值)。
- 位置过滤——例如选择器:nth-child(event)或者:last-child,它们是使用在父元素上的多种方法的结合体。在那些支持它的浏览器中使用children(IE,Safari,Chrome,Opera,和Firefox3.1),children中包含所有的child元素。所有的浏览器都包含childNodes,它包含子元素列表,其中也包含text节点和注释。使用这两种方法可以做到各种形式的元素位置过滤。
构建一个过滤器方法有两个目的:我们可以为用户提供一个简单的方法用来测试它们的元素,并且我们可以快速的检查一个元素是否与指定选择器匹配。
现在让我们关注细化结果的工具。
15.3.4 遍历与合并
选择器引擎需要有能力去遍历(查找后裔元素)和合并结果。但是像我们listing那样,我们的初始实现还差得很远。我们最终的结果是得到两个<span>元素,而不是一个。我们需要引入一个额外的检查来确保返回的数组中有唯一的结果。大多数从上到下的选择器引擎都拥有保持一致性的工具。
不走运的是,没有一个简单的方式可以确保DOM元素的一致性,所以我们需要自己来实现它。我们会遍历这些元素并为它们分配临时的标识,因此我们可以验证我们是否遇到过它们。
Listing 15.7 Finding the unique elements in an array
//Sets up our willing test subjects.
<div id="test">
<b>Hello</b>, I'm a ninja!
</div>
<div id="test2"></div>
<script type="text/javascript">
(function(){
//Defines the unique() function inside an immediate function to create a closure that will include the run variable but hide it from the outside world.
var run = 0;
//Accepts an array of elements and returns a new array containing only unique elements from the original array.
this.unique = function(array) {
var ret = [];
//Keeps track of which run we’re on. By incrementing this value each time the function is called, a unique identifier value will be used for testing for uniqueness.
run++;
for (var i = 0, length = array.length; i < length; i++) {
var elem = array[i];
//Runs through the original array, copying elements that we haven’t “seen” yet, and marking them so that we’ll know whether we’ve “seen” them or not.
if (elem.uniqueID !== run) {
elem.uniqueID = run;
ret.push(array[i]);
}
}
//Returns the resulting array, containing only references to unique elements.
return ret;
};
})();
window.onload = function(){
//Tests it! The first test shouldn’t result in any removals (as there are no duplicates passed), but the second should collapse down to a single element.
var divs = unique(document.getElementsByTagName("div"));
assert(divs.length === 2, "No duplicates removed.");
var body = unique([document.body, document.body]);
assert(body.length === 1, "body duplicate removed.");
};
</script>
unique()方法在检验数组中的元素时为它们添加了额外的属性,标记它们是被看过的。遍历完成后,只有唯一的元素被复制到结果的数组当中。在所有的类库中都会发现这种验证方法。
想要了解属性绑定那部分可以回顾13章中涉及到事件的那部分。
上面我们考虑的都是从上到下的方法。让我们快速粗略地了解一下另外一种方式。
15.3.5 从下到上的选择器引擎 {#15-3-5}
如果你不关心元素唯一性的话,从下到上的选择器引擎也是一个选择。从下到上的选择器引擎与从上到下是相反的。
如果选择器是div span,选择器引擎会先找出所有的<span>元素,然后寻找每个元素的<div>祖先元素。这种选择器引擎在大多数浏览器引擎中都会找到。
这种引擎并不像从上到下的方法那样流行。虽然对于简单的选择器(子选择器)它运行起来非常良好,遍历祖先开销非常大。但是它的简单性值得我的丑爹产权衡。
这个引擎的构造非常简单。在CSS选择器中我们查找最后的表达式,然后遍历合适的元素(就像从上到下引擎,但是使用的是最后的表达式)。从此开始,所有的操作都是一系列的过滤操作和一些在过程中删除元素的操作(看下面的代码)。
Listing 15.8 A simple bottom-up selector engine
<div>
<div>
<span>Span</span>
</div>
</div>
<script type="text/javascript">
window.onload = function(){
function find(selector, root){
root = root || document;
var parts = selector.split(" "),
query = parts[parts.length - 1],
rest = parts.slice(0,-1).join(""),
elems = root.getElementsByTagName(query),
results = [];
for (var i = 0; i < elems.length; i++) {
if (rest) {
var parent = elems[i].parentNode;
while (parent && parent.nodeName != rest) {
parent = parent.parentNode;
}
if (parent) {
results.push(elems[i]);
}
} else {
results.push(elems[i]);
}
}
return results;
};
var divs = find("div");
assert(divs.length === 2, "Correct number of divs found.");
var divs = find("div", document.body);
assert(divs.length === 2, "Correct number of divs found in body.");
var divs = find("body div");
assert(divs.length === 2, "Correct number of divs found in body.");
var spans = find("div span");
assert(spans.length === 1, "No duplicate span was found.");
};
</script>
Listing 15.8 展示了从下到上引擎的构造。注意它只向上查找了一级。如果想要做到更深的级别就需要跟踪当前的级别。这里会返回两种形式的数组:一些没有被设置为undefinded的元素的数组,因为它们不匹配结果。另外就是与可以正确匹配祖先元素的元素数组。
之前提到过,额外的验证祖先元素的工作会导致损失扩展性,但是它不使用保证一致性的方法就可以产生不重复的结果,这就是它的优点。
15.4 总结 {#15-4}
基于JavaScript的CSS选择器引擎难以置信的强大。它们可以让我使用平凡的选择器语法轻松地定位页面上的DOM元素。实现一个完整的选择器引擎需要考虑许多细节,但是随着浏览器的提升这些条件会被修复的,我们并不缺少工具。
本章我们学习到的东西:
- 现在主流的浏览器实现了W3C规定元素选择的 API,但是它们仍有很长的路要走。
- 如果没有性能的问题,自己创建选择器引擎对我们是有好处的。
- 为了创建一个选择器引擎,我们可以
- 利用W3C APIs
- 使用XPath
- 为了更好的性能自己转换DOM
- 从上到下的方法是最流行的,但是它要求一些清理工作,例如确保元素的一致性。
- 从下到上避免了这些操作,但是它存在性能和扩展性方面的问题。
使用浏览器实现的W3C API,对选择器实现的担心很快就会成为过去。对于许多开发人员来说,这一个可能不会太快到来。