资讯专栏INFORMATION COLUMN

jQuery中的选择器引擎Sizzle

elisa.yang / 1956人阅读

摘要:生成终极匹配器主要是返回一个匿名函数,在这个函数中,利用方法生成的匹配器,去验证种子集合,筛选出符合条件的集合。在这个终极匹配器中,会将获取到的种子元素集合与匹配器进行比对,筛选出符合条件的元素。

读Sizzle的源码,分析的Sizzle版本号是2.3.3

Sizzle的Github主页

浏览器原生支持的元素查询方法:

方法名 方法描述 兼容性描述
getElementById 根据元素ID查询元素 IE6+, Firefox 2+, Chrome 4+, Safari 3.1+
getElementsByTagName 根据元素名称查询元素 IE6+, Firefox 2+, Chrome 4+, Safari 3.1+
getElementsByClassName 根据元素的class查询元素 IE9+, Firefox 3+, Chrome 4+, Safari 3.1+
getElementsByName 根据元素name属性查询元素 IE10+(IE10以下不支持或不完善), FireFox23+, Chrome 29+, Safari 6+
querySelector 根据选择器查询元素 IE9+(IE8部分支持), Firefox 3.5+, Chrome 4+, Safari 3.1+
querySelectorAll 根据选择器查询元素 IE9+(IE8部分支持), Firefox 3.5+, Chrome 4+, Safari 3.1+

在Sizzle中,出于性能考虑,优先考虑使用JS的原生方法进行查询。上面列出的方法中,除了querySelector方法没有被用到,其它都在Sizzle中有使用。

对于不可以使用原生方法直接获取结果的case,Sizzle就需要进行词法分析,分解这个复杂的CSS选择器,然后再逐项查询过滤,获取最终符合查询条件的元素。

有以下几个点是为了提高这种低级别查询的速度:

从右至左: 传统的选择器是从左至右,比如对于选择器#box .cls a,它的查询过程是先找到id=box的元素,然后在这个元素后代节点里查找class中包含cls元素;找到后,再查找这个元素下的所有a元素。查找完成后再回到上一层,继续查找下一个.cls元素,如此往复,直至完成。这样的做法有一个问题,就是有很多不符合条件元素,在查找也会被遍历到。而对于从右向左的顺序,它是先找到所有a的元素,然后在根据剩下的选择器#box .cls,筛选出符合这个条件的a元素。这样一来,等于是限定了查询范围,相对而言速度当然会更快。但是需要明确的一点是,并不是所有的选择器都适合这种从右至左的方式查询。也并不是所有的从右至左查询都比从左至右快,只是它覆盖了绝大多数的查询情况。

限定种子集合: 如果只有一组选择器,也就是不存在逗号分隔查询条件的情况;则先查找最末级的节点,在最末级的节点集合中筛选;

限定查询范围: 如果父级节点只是一个ID且不包含其它限制条件,则将查询范围缩小到父级节点;#box a

缓存特定数据 : 主要分三类,tokenCache, compileCache, classCache

我们对Sizzle的查询分为两类:

简易流程(没有位置伪类)

带位置伪类的查询

简易流程

简易流程在进行查询时,遵循 从右至左的流程。

梳理一下简易流程

Sizzle流程图(简易版)

简易流程忽略的东西主要是和位置伪类相关的处理逻辑,比如:nth-child之类的

词法分析

词法分析,将字符串的选择器,解析成一系列的TOKEN。

首先明确一下TOKEN的概念,TOKEN可以看做最小的原子,不可再拆分。在CSS选择器中,TOKEN的表现形式一般是TAG、ID、CLASS、ATTR等。一个复杂的CSS选择器,经过词法分析后,会生成一系列的TOKEN,然后根据这些Token进行最终的查询和筛选。

下面举个例子说明一下词法分析的过程。对于字符串#box .cls a的解析:

/**
 * 下面是Sizzle中词法解析方法 tokennize 的核心代码 1670 ~ 1681 行
 * soFar = "#box .cls a"
 * Expr.filter 是Sizzle进行元素过滤的方法集合
 * Object.getOwnPropertyNames(Expr.filter) //  ["TAG", "CLASS", "ATTR", "CHILD", "PSEUDO", "ID"]
*/
for ( type in Expr.filter ) {
    // 拿当前的选择字符串soFar 取匹配filter的类型,如果能匹配到,则将当前的匹配对象取出,并当做一个Token存储起来
    // matchExpr中存储一些列正则,这些正则用于验证当前选择字符串是否满足某一token语法
    if ( (match = matchExpr[ type ].exec( soFar )) && (!preFilters[ type ] ||
        (match = preFilters[ type ]( match ))) ) {
        matched = match.shift();
        tokens.push({
            value: matched,
            type: type,
            matches: match
        });

        // 截取掉匹配到选择字符串,继续匹配剩余的字符串(继续匹配是通过这段代码外围的while(soFar)循环实现的)
        // matchExpr中存储的正则都是元字符“^”开头,验证字符串是否以‘xxx’开头;这也就是说, 词法分析的过程是从字符串开始位置,从左至右,一下一下地剥离出token
        soFar = soFar.slice( matched.length );
    }
}

经过上述的解析过程后,#box .cls a会被解析成如下形式的数组:
Sizzle: tokens

编译函数

编译函数的流程很简单,首先根据selector去匹配器的缓存中查找对应的匹配器。

如果之前进行过相同selector的查询并且缓存还在(因为Sizzle换粗数量有限,如果超过数量限制,最早的缓存会被删掉),则直接返回当前缓存的匹配器。

如果缓存中找不到,则通过matcherFromTokens()matcherFromGroupMatchers() 方法生成终极匹配器,并将终极匹配器缓存。

根据tokens生成匹配器(matcherFromTokens)

这一步是根据词法分析产出的tokens,生成matchers(匹配器)。
在Sizzle中,对应的方法是matcherFromTokens

打个预防针,这个方法读起来,很费神呐。

在Sizzle源码(sizzle.js文件)中第 1705 ~ 1765 行,只有60行,却揉进了好多工厂方法(就仅仅指那种return值是Function类型的方法)。
我们简化一下这个方法的流程(去掉了伪类选择器的处理)

function matcherFromTokens( tokens ) {
    var checkContext, matcher, j,
        len = tokens.length,
        leadingRelative = Expr.relative[ tokens[0].type ],
        implicitRelative = leadingRelative || Expr.relative[" "],
        i = leadingRelative ? 1 : 0,

        // The foundational matcher ensures that elements are reachable from top-level context(s)
        matchContext = addCombinator( function( elem ) {
            return elem === checkContext;
        }, implicitRelative, true ),
        matchAnyContext = addCombinator( function( elem ) {
            return indexOf( checkContext, elem ) > -1;
        }, implicitRelative, true ),
        matchers = [ function( elem, context, xml ) {
            var ret = ( !leadingRelative && ( xml || context !== outermostContext ) ) || (
                (checkContext = context).nodeType ?
                    matchContext( elem, context, xml ) :
                    matchAnyContext( elem, context, xml ) );
            // Avoid hanging onto element (issue #299)
            checkContext = null;
            return ret;
        } ];
        
    // 上面的都是变量声明

    // 这个for循环就是根据tokens 生成matchers 的过程
    for ( ; i < len; i++ ) {

        // 如果碰到 祖先/兄弟 关系(">", " ", "+", "~"),则需要合并之前的matchers;
        if ( (matcher = Expr.relative[ tokens[i].type ]) ) {
            matchers = [ addCombinator(elementMatcher( matchers ), matcher) ];
        } else {
            matcher = Expr.filter[ tokens[i].type ].apply( null, tokens[i].matches );
            matchers.push( matcher );
        }
    }

    // 将所有的matchers 拼合到一起 返回一个匹配器,
    // 所有的matcher返回值都是布尔值,只要有一个条件不满足,则当前元素不符合,排除掉
    return elementMatcher( matchers );
}

Question:为什么如果碰到 祖先/兄弟 关系(">", " ", "+", "~"),则需要合并之前的matchers?

Answer:目的并不一定要合并,而是为了找到当前节点关联节点(满足 祖先/兄弟 关系[">", " ", "+", "~"]),然后利用之前的匹配器验证这个关联节点是否满足匹配器。而在“验证”这个环节并不一定非要合并之前的matchers,只是合并起来结构会更清晰。举个例子:

我们需要买汽车,现在有两个汽车品牌A、B。A下面有四种车型:a1,a2,a3,a4;B下面有两种车型:b1,b2。那么我们可以的买到所有车就是
[a1,a2,a3,a4,b1,b2]。但是我们也可以这么写{A:[a1,a2,a3,a4],B:[b1,b2]}。这两种写法都可以表示我们可以买到车型。只是第二种相对前者,更清晰列出了车型所属品牌关系。

同理,在合并后,我们就知道这个合并后的matcher就是为了验证当前的节点的关联节点。

生成终极匹配器(matcherFromGroupMatchers)

主要是返回一个匿名函数,在这个函数中,利用matchersFromToken方法生成的匹配器,去验证种子集合seed,筛选出符合条件的集合。
先确定种子集合,然后在拿这些种子跟匹配器逐个匹配。在匹配的过程中,从右向左逐个token匹配,只要有一个环节不满条件,则跳出当前匹配流程,继续进行下一个种子节点的匹配过程。

通过这样的一个过程,从而筛选出满足条件的DOM节点,返回给select方法。

查询过程demo

用一个典型的查询,来说明Sizzle的查询过程。

div.cls input[type="text"] 为例:

解析出的tokens:

[
    [
        { "value": "div", "type": "TAG", "matches": ["div"] }, 
        { "value": ".cls", "type": "CLASS", "matches": ["cls"] }, 
        { "value": " ", "type": " " }, 
        { "value": "input", "type": "TAG", "matches": ["input"] }, 
        { "value": "[type="text"]", "type": "ATTR", "matches": ["type", "=", "text"]}
    ]
]

首先这个选择器 会筛选出所有的作为种子集合seed,然后在这个集合中寻找符合条件的节点。
在寻找种子节点的过程中,删掉了token中的第四条{ "value": "input", "type": "TAG", "matches": ["input"] }

那么会根据剩下的tokens生成匹配器

matcherByTag("div")

matcherByClass(".cls")

碰见父子关系" ",将前面的生成的两个matcher合并生成一个新的

matcher:

matcherByTag("div"),

matcherByClass(".cls")

这个matcher 是通过addCombinator()方法生成的匿名函数,这个matcher会先根据 父子关系parentNode,取得当前种子的parentNode, 然后再验证是否满足前面的两个匹配器。

碰见第四条 属性选择器,生成

matcherByAttr("[type="text"]")

至此,根据tokens已经生成所有的matchers。

终极匹配器

matcher:

matcherByTag("div")

matcherByClass(".cls")

matcherByAttr("[type="text"]")

matcherFromTokens()方法中的最后一行,还有一步操作,将所有的matchers通过elementMatcher()合并成一个matcher。
elementMatcher这个方法就是将所有的匹配方法,通过while循环都执行一遍,如果碰到不满足条件的,就直接挑出while循环。
有一点需要说明的就是: elementMatcher方法中的while循环是倒序执行的,即从matchers最后一个matcher开始执行匹配规则。对应上面的这个例子就是,最开始执行的匹配器是matcherByAttr("[type="text"]")。 这样一来,就过滤出了所有不满足type="text"的元素。然后执行下一个匹配条件,

Question: Sizzle中使用了大量闭包函数,有什么作用?出于什么考虑的?
Answer:闭包函数的作用,是为了根据selector动态生成匹配器,并将这个匹配器缓存(cached)。因为使用闭包,匹配器得以保存在内存中,这为缓存机制提供了支持。
这么做的主要目的是提高查询性能,通过常驻内存的匹配器避免再次消耗大量资源进行词法分析和匹配器生成。以空间换时间,提高查询速度。

Question: matcherFromTokens中, 对每个tokens生成匹配器列表时,为什么会有一个初始化的方法?
Answer: 这个初始化的方法是用来验证元素是否属于当前context

Question: matcherFromGroupMatchers的作用?
Answer: 返回一个终极匹配器,并让编译函数缓存这个终极匹配器。 在这个终极匹配器中,会将获取到的种子元素集合与匹配器进行比对,筛选出符合条件的元素。

TODO: 编译机制也许是Sizzle为了做缓存以便提高性能而做出的选择??
是的,详细答案待补充~~~

TODO: outermostContext的作用
细节问题,还有待研究~~~

带位置伪类的查询流程

带位置伪类的查询是 由左至右

用选择器.mark li.limark:first.limark2 a span举例。

在根据tokens生成匹配器(matcherFromTokens)之前的过程,跟简易查询没有任何区别。
不同的地方就在matcherFromTokens()方法中。位置伪类不同于简易查询的是,它会根据位置伪类将选择器分成三个部分。对应上例就是如下

.mark li.limark : 位置伪类之前的选择器;

:first : 位置伪类本身;

.limark2: 跟位置伪类本身相关的选择器,

a span:位置伪类之后的选择器;

位置伪类的查询思路,是先进行位置伪类之前的查询.mark li.limark,这个查询过程当然也是利用之前讲过的简易流程(Sizzle(selector))。查询完成后,再根据位置伪类进行过滤,留下满足位置伪类的节点。如果存在第三个条件,则利用第三个条件,再进行一次过滤。然后再利用这些满足位置伪类节点作为context,进行位置伪类之后选择器 a span的查询。

上例选择器中只存在一个位置伪类;如果存在多个,则从左至右,会形成一个一个的层级,逐个层级进行查询。

下面是对应的是matcherFromTokens()方法中对位置伪类处理。

// 这个matcherFromTokens中这个for循环,之前讲过了,但是 有个地方我们跳过没讲
for ( ; i < len; i++ ) {
        if ( (matcher = Expr.relative[ tokens[i].type ]) ) {
            matchers = [ addCombinator(elementMatcher( matchers ), matcher) ];
        } else {
            matcher = Expr.filter[ tokens[i].type ].apply( null, tokens[i].matches );

            // Return special upon seeing a positional matcher
            // 这个就是处理位置伪类的逻辑
            if ( matcher[ expando ] ) {
                // Find the next relative operator (if any) for proper handling
                j = ++i;
                for ( ; j < len; j++ ) { // 寻找下一个关系节点位置,并用j记录下来
                    if ( Expr.relative[ tokens[j].type ] ) {
                        break;
                    }
                }
                return setMatcher(// setMatcher 是生成位置伪类查询的工厂方法
                    i > 1 && elementMatcher( matchers ), // 位置伪类之前的matcher
                    i > 1 && toSelector(
                        // If the preceding token was a descendant combinator, insert an implicit any-element `*`
                        tokens.slice( 0, i - 1 ).concat({ value: tokens[ i - 2 ].type === " " ? "*" : "" })
                    ).replace( rtrim, "$1" ), // 位置伪类之前的selector
                    matcher, // 位置伪类本身的matcher
                    i < j && matcherFromTokens( tokens.slice( i, j ) ), // 位置伪类本身的filter
                    j < len && matcherFromTokens( (tokens = tokens.slice( j )) ), // 位置伪类之后的matcher
                    j < len && toSelector( tokens ) // 位置伪类之后的selector
                );
            }
            matchers.push( matcher );
        }
    }

setMatcher()方法的源码,在这里生成最终的matcher, return给compile()方法。

//第1个参数,preFilter,前置过滤器,相当于伪类token之前`.mark li.limark`的过滤器matcher
//第2个参数,selector,伪类之前的selector (`.mark li.limark`)
//第3个参数,matcher,    当前位置伪类的过滤器matcher `:first`
//第4个参数,postFilter,伪类之后的过滤器 `.limark2`
//第5个参数,postFinder,后置搜索器,相当于在前边过滤出来的集合里边再搜索剩下的规则的一个搜索器 ` a span`的matcher
//第6个参数,postSelector,后置搜索器对应的选择器字符串,相当于` a span`
function setMatcher( preFilter, selector, matcher, postFilter, postFinder, postSelector ) {
    //TODO: setMatcher 会把这俩货在搞一次setMatcher, 还不太懂
    if ( postFilter && !postFilter[ expando ] ) {
        postFilter = setMatcher( postFilter );
    }
    if ( postFinder && !postFinder[ expando ] ) {
        postFinder = setMatcher( postFinder, postSelector );
    }
    
    return markFunction(function( seed, results, context, xml ) {
        var temp, i, elem,
            preMap = [],
            postMap = [],
            preexisting = results.length,

            // Get initial elements from seed or context
            elems = seed || multipleContexts( selector || "*", context.nodeType ? [ context ] : context, [] ),

            // Prefilter to get matcher input, preserving a map for seed-results synchronization
            matcherIn = preFilter && ( seed || !selector ) ?
                condense( elems, preMap, preFilter, context, xml ) :
                elems,

            matcherOut = matcher ?
                // If we have a postFinder, or filtered seed, or non-seed postFilter or preexisting results,
                postFinder || ( seed ? preFilter : preexisting || postFilter ) ?

                    // ...intermediate processing is necessary
                    [] :

                    // ...otherwise use results directly
                    results :
                matcherIn;

        // Find primary matches
        if ( matcher ) {
            // 这个就是 匹配位置伪类的 逻辑, 将符合位置伪类的节点剔出来
            matcher( matcherIn, matcherOut, context, xml );
        }

        // Apply postFilter
        if ( postFilter ) {
            temp = condense( matcherOut, postMap );
            postFilter( temp, [], context, xml );

            // Un-match failing elements by moving them back to matcherIn
            i = temp.length;
            while ( i-- ) {
                if ( (elem = temp[i]) ) {
                    matcherOut[ postMap[i] ] = !(matcherIn[ postMap[i] ] = elem);
                }
            }
        }

        if ( seed ) {
            if ( postFinder || preFilter ) {
                if ( postFinder ) {
                    // Get the final matcherOut by condensing this intermediate into postFinder contexts
                    temp = [];
                    i = matcherOut.length;
                    while ( i-- ) {
                        if ( (elem = matcherOut[i]) ) {
                            // Restore matcherIn since elem is not yet a final match
                            temp.push( (matcherIn[i] = elem) );
                        }
                    }
                    postFinder( null, (matcherOut = []), temp, xml );
                }

                // Move matched elements from seed to results to keep them synchronized
                i = matcherOut.length;
                while ( i-- ) {
                    if ( (elem = matcherOut[i]) &&
                        (temp = postFinder ? indexOf( seed, elem ) : preMap[i]) > -1 ) {

                        seed[temp] = !(results[temp] = elem);
                    }
                }
            }

        // Add elements to results, through postFinder if defined
        } else {
            matcherOut = condense(
                matcherOut === results ?
                    matcherOut.splice( preexisting, matcherOut.length ) :
                    matcherOut
            );
            if ( postFinder ) {
                postFinder( null, results, matcherOut, xml );
            } else {
                push.apply( results, matcherOut );
            }
        }
    });
}

【参考资料】

IE浏览器的兼容性查询

JQuery - Sizzle选择器引擎原理分析

jQuery源码剖析(七)——Sizzle选择器引擎之词法分析

jQuery 2.0.3 源码分析Sizzle引擎 - 词法解析

Optimize Selectors(选择器使用的最佳方式)

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/96089.html

相关文章

  • jQuery源码分析2-JQ对象添加一些方法和属性

    摘要:节点修改对象的属性,这就相当于把对象转成了一个类数组,最后返回,可用于链式调用。如果传入的是单标签,且第二个参数是一个纯对象例如则把后面对象的属性一一添加到创建的这个节点的属性上。 我们先看看jQuery的原型中初始化了哪些属性和方法: jQuery.fn = jQuery.prototype = { jquery: core_version, //jquery版本号 ...

    TANKING 评论0 收藏0
  • jQuery 源码系列(四)Tokens 词法分析

    摘要:欢迎来我的专栏查看系列文章。我们以为例,这是一个很简单的,逗号将表达式分成两部分。这是针对于存在的情况,对于不存在的情况,其就是的操作,后面会谈到。参考源码分析引擎词法解析选择器参考手册本文在上的源码地址,欢迎来。 欢迎来我的专栏查看系列文章。 在编译原理中,词法分析是一个非常关键的环节,词法分析器读入字节流,然后根据关键字、标识符、标点、字符串等进行划分,生成单词。Sizzle 选择...

    rollback 评论0 收藏0
  • 前端培训-中级阶段(6)- jQuery元素节点操作(2019-07-18期)

    摘要:已存在节点是移动,新节点是新增。链式操作对象为。将他们的父节点移除。从中删除所有匹配的元素。一个布尔值或者指示事件处理函数是否会被复制。以上版本默认值是一个布尔值,指示是否对事件处理程序和克隆的元素的所有子元素的数据应该被复制。 前端最基础的就是 HTML+CSS+Javascript。掌握了这三门技术就算入门,但也仅仅是入门,现在前端开发的定义已经远远不止这些。前端小课堂(HTML/...

    taoszu 评论0 收藏0
  • 前端培训-中级阶段(6)- jQuery元素节点操作(2019-07-18期)

    摘要:已存在节点是移动,新节点是新增。链式操作对象为。将他们的父节点移除。从中删除所有匹配的元素。一个布尔值或者指示事件处理函数是否会被复制。以上版本默认值是一个布尔值,指示是否对事件处理程序和克隆的元素的所有子元素的数据应该被复制。 前端最基础的就是 HTML+CSS+Javascript。掌握了这三门技术就算入门,但也仅仅是入门,现在前端开发的定义已经远远不止这些。前端小课堂(HTML/...

    tigerZH 评论0 收藏0
  • jquery最佳实践笔记

    摘要:对比内部使用引擎,处理各种选择器。引擎的选择顺序是从右到左,所以这条语句是先选,然后再一个个过滤出父元素,这导致它比最快的形式大约慢。这条语句与上一条是同样的情况。 使用最新版本 因为新版本会改进性能,还有很多新功能 用对选择器 最快的选择器:id选择器和元素标签选择器原因:遇到这些选择器的时候,jQuery内部会自动调用浏览器的原生方法(比如getElementById()),所以...

    leanxi 评论0 收藏0

发表评论

0条评论

elisa.yang

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<