この日記は私的なものであり所属会社の見解とは無関係です。 GitHub: takahashikzn

jQueryのセレクタ文法へコンビネータを追加しようとしてみた

jQueryセレクタ文法は簡単に拡張できます。みんな知ってるね。


え?知らない?ココを参照すべし。
http://james.padolsey.com/javascript/extending-jquerys-selector-capabilities/


僕は、こんな風にして

(function($) {

    $.extend($.expr.pseudos, {

        'all-parents': function(el, indx, args) {

            var cond = args[3];

            for (var $parent = $(el).parent();
                  !$parent.is('html');
                  $parent = $parent.parent()) {

                if (!$parent.is(cond)) {
                    return false;
                }
            }

            return true;
        }
    });

})(jQuery);

「全ての親要素がXXXを満たす」という擬似クラスを追加しています。


これにより、例えば

<section>
  <div class="a">
    <div class="b">
      <span class="c">NG</span>
    </div>
  </div>
  <div class="d">
    <div class="b">
      <span class="c">OK</span>
    </div>
  </div>
</section>

に対して、

.b  .c:all-parents(:not(.a))

と指定することで、OKを含むspanだけを選択できるようになるわけです。

本題

さて、親を取得するセレクタを実装できないか調べてみました。

例えばこんな感じ。

.c < .a

これは、クラスcを持つ要素の親で、クラスaを持つものを指します。


前述のものとの決定的な違いは、これはフィルタではなくコンビネータ*1だということです。
従って、上記のように擬似クラスでは実現できず、コンビネータの定義を追加する必要があります。

jQuery(というかSizzle)の実装を見てみる

言うまでもありませんが、jQueryセレクタ実装の実体はSizzleです。みんな知っ(略)
ちなみに、jQueryのバージョン2.1.0での話。


さて、早速ですがコンビネータの定義について、まず注目すべきなのは1513行目の

Expr = Sizzle.selectors = {

    // ...(略)

    relative: {
        ">": { dir: "parentNode", first: true },
        " ": { dir: "parentNode" },
        "+": { dir: "previousSibling", first: true },
        "~": { dir: "previousSibling" }
    },

    // ...(略)

}

です。ここで、コンビネータのトークンが宣言されています。たぶん。

次に、2562行目を見ると

jQuery.find = Sizzle;
jQuery.expr = Sizzle.selectors; // ←ここに注目
jQuery.expr[":"] = jQuery.expr.pseudos;
jQuery.unique = Sizzle.uniqueSort;
jQuery.text = Sizzle.getText;
jQuery.isXMLDoc = Sizzle.isXML;
jQuery.contains = Sizzle.contains;

とのことなので、relativeをいじるには

// jQuery.expr === $.expr に注意
$.extend($.expr.relative, {
    "<": {
        dir: 'parentNode'
    }
});

とすればよいはずです。


さて、ここまでは良いのですが、追加した"<"コンビネータに対して振る舞いを追加する方法がわかりません。

もう少しコードを掘ってみると、2239行目辺りの

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

// ...(略)

に辿り着きます。どうも、tokensという引数がセレクタ文字列のパース結果のようです。
ではtokensは何処で作られているのかというと、1981行目のtokenizeという関数がその責務を負っているようです。

function tokenize( selector, parseOnly ) {
    var matched, match, tokens, type,
        soFar, groups, preFilters,
        cached = tokenCache[ selector + " " ];

    if ( cached ) {
        return parseOnly ? 0 : cached.slice( 0 );
    }

    soFar = selector;
    groups = [];
    preFilters = Expr.preFilter;

    while ( soFar ) {

        // Comma and first run
        if ( !matched || (match = rcomma.exec( soFar )) ) {
            if ( match ) {
                // Don't consume trailing commas as valid
                soFar = soFar.slice( match[0].length ) || soFar;
            }
            groups.push( (tokens = []) );
        }

        matched = false;

        // Combinators
        if ( (match = rcombinators.exec( soFar )) ) {
            matched = match.shift();
            tokens.push({
                value: matched,
                // Cast descendant combinators to space
                type: match[0].replace( rtrim, " " )
            });
            soFar = soFar.slice( matched.length );
        }

        // Filters
        for ( type in Expr.filter ) {
            if ( (match = matchExpr[ type ].exec( soFar )) && (!preFilters[ type ] ||
                (match = preFilters[ type ]( match ))) ) {
                matched = match.shift();
                tokens.push({
                    value: matched,
                    type: type,
                    matches: match
                });
                soFar = soFar.slice( matched.length );
            }
        }

        if ( !matched ) {
            break;
        }
    }

    // Return the length of the invalid excess
    // if we're just parsing
    // Otherwise, throw an error or return tokens
    return parseOnly ?
        soFar.length :
        soFar ?
            Sizzle.error( selector ) :
            // Cache the tokens
            tokenCache( selector, groups ).slice( 0 );
}

さて、このtokenizeですが、よく見てみると2008行目で

// Combinators
if ( (match = rcombinators.exec( soFar )) )

とあるので、正規表現を用いてコンビネータをパースしていることがわかります。


ではこの定義は何処にあるのかというと、653行目で

rcombinators = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + "*" )

となっています。ならば、この定義を/[<>+~]/に置き換えれば…



…ん?

var Sizzle =
/*!
 * Sizzle CSS Selector Engine v1.10.16
 * http://sizzlejs.com/
 *
 * Copyright 2013 jQuery Foundation, Inc. and other contributors
 * Released under the MIT license
 * http://jquery.org/license
 *
 * Date: 2014-01-13
 */
(function( window ) {

var i,
    support,
    Expr,
    getText,
    isXML,
    compile,
    outermostContext,
    sortInput,

    rcombinators = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + "*" ),

    // ...(略)


\(^o^)/オワタ


見ての通りrcombinatorは不可視なところで宣言されているため、どうにもなりませぬ。
他のところでどれだけ頑張ろうと、無常にも

> $(".b < .a")

Error: Syntax error, unrecognized expression: .b < .a

と言われて先に進めないままです。


…というわけで、コンビネータを追加するにはjQueryのソースを書き換えるしか無いと思います。おしまい。

*1:CSSコンビネータです。YCのコンビネータではありません