很多写了多年jQuery的人,只知道$('div.test > p')能选中想要的元素,却很少想过这段字符串在引擎内部经历了怎样的旅程。实际上,jQuery在早期版本中内置了一个自研的CSS选择器引擎Sizzle,即便到了现代浏览器普遍支持querySelectorAll的今天,Sizzle依然是jQuery选择器体系的核心兜底与增强层。理解它的匹配原理,尤其是它标志性的从右至左查找策略,不仅能解释许多性能问题的根源,也能让你在写选择器时有意识地优化。

一、选择器的词法分析:从字符串到Token序列
Sizzle处理选择器的第一步并不是去DOM里找节点,而是先把选择器字符串解析成一个个Token。这个过程由一个编译好的正则表达式完成,它会把div.test > p这样的字符串切分成三个Token:标签Token(div)、ID/类Token(test)、连接符Token(>)和最后的标签Token(p)。
每个Token会被标记上类型,常见的有TAG、CLASS、ID、ATTR、PSEUDO、CHILD等。同时正则还会识别出每个Token对应的位置信息,这些信息决定了后续过滤时该调用哪种匹配函数。可以说词法分析是整个引擎的地基,后面所有的匹配逻辑都建立在Token序列之上。
举例来说,div.btn:not(.disabled)会被解析为TAG型的div、CLASS型的btn,以及一个PSEUDO型的not,其内部还嵌套了另一个子选择器.not本身又会递归地走一遍词法分析。这种嵌套结构正是Sizzle能支持复杂伪类的原因。
二、为什么从右至左:性能差距的根源
假设页面有50个div,每个div下有10个p标签,现在要执行$('div.container p')的查找。如果采用从左至右的思路,引擎需要先找出所有符合条件的div,再逐个深入子树收集p元素,遍历的节点数量是两者相乘的量级。而如果反过来,先通过document.getElementsByTagName('p')拿到全页所有p标签作为候选集(种子),再对每个种子向上检查是否存在class为container的div祖先,效率会高得多。
这个策略的精髓在于:最右侧的选择器(Sizzle称之为关键选择器)通常能最快缩小候选范围。ID选择器是其中最极端的例子,因为document.getElementById是哈希级别的查找,几乎零成本。所以一个经典的经验法则是:把最具体、能命中节点最少的选择器放到最右边,比如$('div.panel .item')要优于$('.item div.panel')的写法习惯,尽管后者在语义上很少见。
可以看一个简化版的从右至左验证逻辑,帮助理解引擎内部的处理方式:
// 简化版:验证候选元素是否匹配祖先链条件
function matchUpward(elem, ancestorSelector) {
// 从候选元素出发向上查找
var parent = elem.parentNode;
while (parent && parent.nodeType === 1) {
if (matches(parent, ancestorSelector)) {
return true;
}
parent = parent.parentNode;
}
return false;
}
// 引擎流程:先取最右选择器的种子,再向左验证
var seeds = document.querySelectorAll('p'); // 种子集合
var result = [];
for (var i = 0; i < seeds.length; i++) {
if (matchUpward(seeds[i], 'div.container')) {
result.push(seeds[i]);
}
}当然,Sizzle真实的实现远比这个演示复杂,它会对Token序列做分组,遇到连接符(空格、>、+、~)就切割出一个个单元,并为每个单元编译对应的匹配函数,尽量复用中间结果。但从宏观上看,上面这段代码展示的种子筛选加向上验证的骨架,正是从右至左策略的本质。
三、连接符的分组与编译:Sizzle内部如何组织匹配
Sizzle拿到Token序列后,会从右向左扫描,以连接符为边界把选择器切分成若干组。对于div.test > p span,会被切成三段:最右的span、它的父级条件p、再往上的祖先条件div.test。最右边那组用来生成种子集合,剩下的组则编译成过滤函数链,按从右至左的顺序依次对种子的祖先或兄弟关系做校验。
不同的连接符对应不同的遍历方向:后代连接符(空格)需要一路向上检查所有祖先;子连接符(>)只检查直接父节点;相邻兄弟连接符(+)只看前一个兄弟元素;通用兄弟连接符(~)则要向前遍历所有兄弟。Sizzle为每种连接符都注册了专门的处理函数,编译阶段会根据连接符类型把这些函数串成执行链。
值得一提的是,Sizzle还会做一层编译缓存。同样的选择器字符串第二次执行时,直接复用之前编译好的函数链,跳过词法分析和分组过程。这就是为什么在高频调用的场景下,尽量保持选择器字符串的稳定和一致(而不是每次动态拼接出不同的字符串),能带来可观的性能收益。
四、与querySelectorAll的关系及实践建议
现代浏览器都支持原生的querySelectorAll,jQuery 3.x之后默认会优先走原生接口。但Sizzle并没有退役:一是它需要处理原生接口不支持的jQuery扩展选择器,比如:visible、:contains();二是当选择器中混入这些扩展伪类时,Sizzle会做拆分,把原生支持的部分交给querySelectorAll,扩展部分由自己的过滤器处理。
基于以上原理,可以总结几条实践建议。第一,关键选择器放右侧,让最具体的条件先命中。第二,尽量使用ID作为上下文起点,如$('#main .item'),配合上下文参数$('.item', document.getElementById('main'))效果更好。第三,避免过度依赖jQuery扩展伪类,尤其在大文档中使用:visible这类需要反复计算布局的伪类代价很高。第四,高频操作优先缓存$(el)结果而不是反复执行选择器。
理解Sizzle的从右至左机制,本质上是在理解浏览器DOM遍历的成本模型。节点数量、文档深度、选择器复杂度共同决定了查找开销,而把筛选条件尽早收敛,永远是选择器优化的第一原则。这套思路不仅适用于jQuery,在阅读其他选择器引擎或自己实现类似功能时,同样是通用的设计范式。