prototype.jsを読み解く

第7回Prototypeライブラリ(2052~2276行目)

Prototype 1.6.0 RC1

次期バージョン1.6.0のためのリリース候補1.6.0 RC1が出ています。

前回のRC0から着実にバグを修正してきているようです。Event まわりなど、ごっそりと書き換えられている部分もあり、この連載のコードがそのまま残っていない所も出てきてしまっていますが、連載を一通り読んでいる方なら、新しいコードを自ら読み解くこともできるようになっているかと思います。:-)

今回は巨大な Selector クラスです。これも二分割でお送りします。

Selector クラス

500行を超える大物クラスです。

CSSのセレクタに基づく要素のマッチ機能を提供します。CSS3の仕様に含まれるものも実装されていますので、そちらも参考にしてください。

2052: /* Portions of the Selector class are derived from Jack Slocum's DomQuery,
2053:  * part of YUI-Ext version 0.40, distributed under the terms of an MIT-style
2054:  * license.  Please see http://www.yui-ext.com/ for more information. */
2055: 
2056: var Selector = Class.create();
2057: 
2058: Selector.prototype = {
2059:   initialize: function(expression) {
2060:     this.expression = expression.strip();
2061:     this.compileMatcher();
2062:   },
2063: 

コンストラクタです。使い方としては、var s = new Selector("div.header");という形でCSSセレクタ文字列を指定し、var elems = s.match(element)でマッチする要素の配列を返します。

コンストラクタでは、渡されたCSSセレクタ文字列をインスタンス内に保存し、マッチさせるコードを事前にコンパイル(というほどのものでもないのですが)するためにcompileMatcher()を呼び出しています。

2064:   compileMatcher: function() {
2065:     // Selectors with namespaced attributes can't use the XPath version
2066:     if (Prototype.BrowserFeatures.XPath && !(/\[[\w-]*?:/).test(this.expression))
2067:       return this.compileXPathMatcher();
2068: 
2069:     var e = this.expression, ps = Selector.patterns, h = Selector.handlers,
2070:         c = Selector.criteria, le, p, m;
2071: 
2072:     if (Selector._cache[e]) {
2073:       this.matcher = Selector._cache[e]; return;
2074:     }
2075:     this.matcher = ["this.matcher = function(root) {",
2076:                     "var r = root, h = Selector.handlers, c = false, n;"];
2077: 
2078:     while (e && le != e && (/\S/).test(e)) {
2079:       le = e;
2080:       for (var i in ps) {
2081:         p = ps[i];
2082:         if (m = e.match(p)) {
2083:           this.matcher.push(typeof c[i] == 'function' ? c[i](m) :
2084:               new Template(c[i]).evaluate(m));
2085:           e = e.replace(m[0], '');
2086:           break;
2087:         }
2088:       }
2089:     }
2090: 
2091:     this.matcher.push("return h.unique(n);\n}");
2092:     eval(this.matcher.join('\n'));
2093:     Selector._cache[this.expression] = this.matcher;
2094:   },
2095: 

コンストラクタから呼び出されるcompileMatcher()です。Selectorクラス内では、自前のJavaScriptによるCSSクラスによる検索コードと、ブラウザのXPathを使ったものとの二種類が用意されています。XPathを使う方が速いので、可能ならそちらを使いますが、条件によってはJavaScriptによるものも使われます。

まず、ネームスペース属性が使われていると、ブラウザ内蔵のXPathエンジンではうまく扱えないようなので、その場合はJavaScriptコードを使います。またそもそもXPathが実装されていないブラウザの場合もJavaScript版を使います(最近のブラウザでは、IEすべてとSafariの2以前が使えません⁠⁠。

XPathが使える場合はcompileXPathMatcher()を使って事前準備を行いそのまま帰ります。この関数の2069行目以降はJavaScript版の実装部分です。

変数名が長い物が多いので、短い名前に代入しています。eはCSSフィルタ文字列、psはSelector.patternsに入っているCSSフィルタにマッチさせるための正規表現が入ったオブジェクト、hは様々なサポート関数が入ったオブジェクト(ですが、結局使われていないようです⁠⁠、cがSelector.patternsで見つかった構成要素に対して、マッチ関数がどういうアクションを取るべきか、を記述したオブジェクト、となっています。

この関数の最後で、CSSフィルタ文字列に対してのマッチ関数、という対応で_cacheという変数にキャッシュとして代入しています。もし以前と同じフィルタ文字列が来た場合には、このキャッシュからマッチ関数を再利用できます。

2075行目から、マッチ関数の組立てです。最初this.matcherには文字列の配列が入ります。これから徐々にそこにコード文字列をpush()していって、最後にjoin("\n")してeval()した結果、最終的にはthis.matcherには関数オブジェクトが入ります。

2078行目からのwhileループは、CSSフィルタ文字列eの先頭から、パースできる部分を処理しては削っていく、削りようがなくなったら抜ける、という形になっています。

ループ内では、何も変化していない(先に進んでいない)ことをチェックする為にle = eしています(leはおそらくlast expressionですね⁠⁠。その後、ps内の全てのプロパティでさらにループしています。

psであるSelector.patternsは2260行目から定義されていて、tagName, id, classNameなど、フィルタの構成要素をプロパティ名にもち、それにマッチするための正規表現オブジェクトがプロパティ値に入っています。

といことで、2081行目の段階で、iにはフィルタの構成要素名、pには正規表現オブジェクトが入っています。

そして、現在処理中のCSSフィルタ文字列eに対して match(p)を行い、マッチするようならcに入っている処理内容をmatcherに追加して、eから今回マッチした部分を取り除き、for文をbreakしてwhileループに戻ります。

ここで、2083行目の処理からわかるように、c (Selector.criteria)の中身は関数オブジェクトか、Templateクラスで処理される文字列、となっています。

whileループが終わると、matcherの最後に返り値の為の部分を追加し、それをjoin("\n")して単一文字列にした上でeval()します。matcherの先頭には this.matcher = function(root) { という部分がありました。これでthis.matcherが関数オブジェクトとして上書きされます。

最後に、Selector._cacheにキャッシュさせて終了です。

2096:   compileXPathMatcher: function() {
2097:     var e = this.expression, ps = Selector.patterns,
2098:         x = Selector.xpath, le,  m;
2099: 
2100:     if (Selector._cache[e]) {
2101:       this.xpath = Selector._cache[e]; return;
2102:     }
2103: 
2104:     this.matcher = ['.//*'];
2105:     while (e && le != e && (/\S/).test(e)) {
2106:       le = e;
2107:       for (var i in ps) {
2108:         if (m = e.match(ps[i])) {
2109:           this.matcher.push(typeof x[i] == 'function' ? x[i](m) :
2110:             new Template(x[i]).evaluate(m));
2111:           e = e.replace(m[0], '');
2112:           break;
2113:         }
2114:       }
2115:     }
2116: 
2117:     this.xpath = this.matcher.join('');
2118:     Selector._cache[this.expression] = this.xpath;
2119:   },
2120: 

2096行目からはXPath版のマッチ関数を作るcompileXPathMatcher()です。

こちらではSelector.criteria, Selector.handlersの代わりにSelector.xpathに入っているルールを用います。

compileMatcher()と違う所は、Selector.criteriaではなくSelector.xpathを使うことと、最後にthis.matcherに関数を入れるのではなくthis.xpathにXPath式を入れる、というくらいです。

2121:   findElements: function(root) {
2122:     root = root || document;
2123:     if (this.xpath) return document._getElementsByXPath(this.xpath, root);
2124:     return this.matcher(root);
2125:   },
2126: 

ライブラリ内でも使われているfindElements()メソッドです。

コンストラクタでthis.xpathが定義されていれば、document._getElementsByXPath()を使って見つかった要素の配列を返します。

そうでなければthis.matcherに定義した関数を呼び出します。

2127:   match: function(element) {
2128:     return this.findElements(document).include(element);
2129:   },
2130: 

こちらもよく使われるmatch()メソッドです。先ほどのfindElements()を呼び出し、返ってきた配列の中に、elementが含まれているかどうかをbooleanで返します。

2131:   toString: function() {
2132:     return this.expression;
2133:   },
2134: 

toString()メソッドでは、コンストラクタに渡されたCSSフィルタ文字列を返します。

2135:   inspect: function() {
2136:     return "#<Selector:" + this.expression.inspect() + ">";
2137:   }
2138: };
2139: 

inspect()メソッドでは、Selectorクラスだとわかる形で、CSSフィルタ文字列を返します。

2140: Object.extend(Selector, {
2141:   _cache: {},
2142: 

2140行目からは Selectorオブジェクトに対して関数を追加しています。これらはprototype以下ではなくSelector直下なので、インスタンスのメソッドとしては現れず、参照する場合はSelector._cacheなどと記述する形になります。

2141行目の_cacheは、CSSフィルタ文字列からマッチ関数(やXPath式)を取り出す際にキャッシュしておくための場所です。

2143:   xpath: {
2144:     descendant:   "//*",
2145:     child:        "/*",
2146:     adjacent:     "/following-sibling::*[1]",
2147:     laterSibling: '/following-sibling::*',
2148:     tagName:      function(m) {
2149:       if (m[1] == '*') return '';
2150:       return "[local-name()='" + m[1].toLowerCase() +
2151:              "' or local-name()='" + m[1].toUpperCase() + "']";
2152:     },
2153:     className:    "[contains(concat(' ', @class, ' '), ' #{1} ')]",
2154:     id:           "[@id='#{1}']",
2155:     attrPresence: "[@#{1}]",
2156:     attr: function(m) {
2157:       m[3] = m[5] || m[6];
2158:       return new Template(Selector.xpath.operators[m[2]]).evaluate(m);
2159:     },
2160:     pseudo: function(m) {
2161:       var h = Selector.xpath.pseudos[m[1]];
2162:       if (!h) return '';
2163:       if (typeof h === 'function') return h(m);
2164:       return new Template(Selector.xpath.pseudos[m[1]]).evaluate(m);
2165:     },
2166:     operators: {
2167:       '=':  "[@#{1}='#{3}']",
2168:       '!=': "[@#{1}!='#{3}']",
2169:       '^=': "[starts-with(@#{1}, '#{3}')]",
2170:       '$=': "[substring(@#{1}, (string-length(@#{1}) - string-length('#{3}') + 1))='#{3}']",
2171:       '*=': "[contains(@#{1}, '#{3}')]",
2172:       '~=': "[contains(concat(' ', @#{1}, ' '), ' #{3} ')]",
2173:       '|=': "[contains(concat('-', @#{1}, '-'), '-#{3}-')]"
2174:     },
2175:     pseudos: {
2176:       'first-child': '[not(preceding-sibling::*)]',
2177:       'last-child':  '[not(following-sibling::*)]',
2178:       'only-child':  '[not(preceding-sibling::* or following-sibling::*)]',
2179:       'empty':       "[count(*) = 0 and (count(text()) = 0 or translate(text(), ' \t\r\n', '') = '')]",
2180:       'checked':     "[@checked]",
2181:       'disabled':    "[@disabled]",
2182:       'enabled':     "[not(@disabled)]",
2183:       'not': function(m) {
2184:         var e = m[6], p = Selector.patterns,
2185:             x = Selector.xpath, le, m, v;
2186: 
2187:         var exclusion = [];
2188:         while (e && le != e && (/\S/).test(e)) {
2189:           le = e;
2190:           for (var i in p) {
2191:             if (m = e.match(p[i])) {
2192:               v = typeof x[i] == 'function' ? x[i](m) : new Template(x[i]).evaluate(m);
2193:               exclusion.push("(" + v.substring(1, v.length - 1) + ")");
2194:               e = e.replace(m[0], '');
2195:               break;
2196:             }
2197:           }
2198:         }
2199:         return "[not(" + exclusion.join(" and ") + ")]";
2200:       },
2201:       'nth-child':      function(m) {
2202:         return Selector.xpath.pseudos.nth("(count(./preceding-sibling::*) + 1) ", m);
2203:       },
2204:       'nth-last-child': function(m) {
2205:         return Selector.xpath.pseudos.nth("(count(./following-sibling::*) + 1) ", m);
2206:       },
2207:       'nth-of-type':    function(m) {
2208:         return Selector.xpath.pseudos.nth("position() ", m);
2209:       },
2210:       'nth-last-of-type': function(m) {
2211:         return Selector.xpath.pseudos.nth("(last() + 1 - position()) ", m);
2212:       },
2213:       'first-of-type':  function(m) {
2214:         m[6] = "1"; return Selector.xpath.pseudos['nth-of-type'](m);
2215:       },
2216:       'last-of-type':   function(m) {
2217:         m[6] = "1"; return Selector.xpath.pseudos['nth-last-of-type'](m);
2218:       },
2219:       'only-of-type':   function(m) {
2220:         var p = Selector.xpath.pseudos; return p['first-of-type'](m) + p['last-of-type'](m);
2221:       },
2222:       nth: function(fragment, m) {
2223:         var mm, formula = m[6], predicate;
2224:         if (formula == 'even') formula = '2n+0';
2225:         if (formula == 'odd')  formula = '2n+1';
2226:         if (mm = formula.match(/^(\d+)$/)) // digit only
2227:           return '[' + fragment + "= " + mm[1] + ']';
2228:         if (mm = formula.match(/^(-?\d*)?n(([+-])(\d+))?/)) { // an+b
2229:           if (mm[1] == "-") mm[1] = -1;
2230:           var a = mm[1] ? Number(mm[1]) : 1;
2231:           var b = mm[2] ? Number(mm[2]) : 0;
2232:           predicate = "[((#{fragment} - #{b}) mod #{a} = 0) and " +
2233:           "((#{fragment} - #{b}) div #{a} >= 0)]";
2234:           return new Template(predicate).evaluate({
2235:             fragment: fragment, a: a, b: b });
2236:         }
2237:       }
2238:     }
2239:   },
2240: 

2143行目からはSelector.xpathオブジェクトです。

これはSelector.prototype.compileXPathMatcher()から使われます。Selector.patternsでマッチしたものに対するアクションを定義しています。Selector.patternsから使われているプロパティはlaterSibling, child, adjacent, descendant, tagName, id, className, pseudo, attrPresence, attrなので、このxpathオブジェクトのプロパティのうちそれ以外のoperators, pesudosは直接は使われません。xpathオブジェクトの中の関数から利用されます。

descendant, child, adjacent, laterSiblingにはそれを示すXPathルールが文字列として入っています。

tagNameでは関数として定義されています。このmにはSelector.patternsのマッチ文字列を使ったマッチで返されるオブジェクトが渡されるため、タグ名を示す文字列が入っている場所はm[1]となります。そこに入っているタグ名から必要なXPathルールを作成します。

className, id, attrPresence には文字列が入っていますが、ここにはTemplateクラスで用いられる #{1} という表記の文字列が含まれています。ここがマッチ文字列のグルーピングの1番目で置き換えられます。

attrでも関数オブジェクトが指定されています。mとして渡されたマッチ結果を使って、CSSの属性セレクタの演算子部分によって適用するXPathルールを分けるために、Selector.xpath.operatorsのハッシュをテーブルとして使っています。それをTemplateクラスに渡して返ってきた文字列を返します。

pseudoでは擬似セレクタに対応するために、セレクタごとに処理を分ける目的でSelector.xpath.pseudosオブジェクトを参照しています。この参照テーブルのうち、first-child, last-child, only-child, empty, checked, disabled, enabledはほぼXPathルール文字列が入っているだけですが、'not'の部分に関しては、再帰的に処理するためにcompileXPathMatcher()と似たような処理を行っています。

例えばfirst-childには'[not(preceding-sibling::*)]'という文字列が入っています。入力されたCSSセレクタが'*:first-child'だった場合、最終的に生成されるXPath式は'.//*[not(preceding-sibling::*)]'というものになります。これは、コンテキストノードの内側の任意の要素のうち、先行する兄弟要素が存在しないものにマッチします。

他も同じようにXPath式を解釈すれば、該当するCSSセレクタと同等になります。

nth-child, nth-last-child, nth-of-type, nth-last-of-type, first-of-type, last-of-type, only-of-type は Selector.xpath.pseudo.nth() を呼んでいます。

これらの関数は、2272行目の正規表現にマッチした時に実行されます。長くてわかりにくいのですが、例えば:nth-child(5)の(5)の部分は正規表現において(\((.*?)\))?の部分にあたり、引数の値5はマッチ結果としてm[6]に入ります。

そこで、first-of-typeとlast-of-typeではm[6]に1を入れた上でそれぞれnth-of-typeとnth-last-of-typeを呼び出す、という形に変換されます。

nth-childでは、"count(./preceding-sibling::*) + 1"というXPath式をnth()関数に渡します。これは結果としてnth-child(99)の場合には'[ (count(./preceding-sibling::*) + 1) = 99]'というXPath式に変換されてそれを返します。これはコンテキストノードの内側の要素のうち、選考する兄弟要素の数+1が99になる要素、すなわち99番目の要素にマッチします。

nth()関数の中では、数値の引数だけでなく'even', 'odd'や'2n+1'という形式の式もm[6]として受け取れるようになっています。その部分をパースして最終的なXPath式にしているのが2232行目とその後のnew Template行になります。

2241:   criteria: {
2242:     tagName:      'n = h.tagName(n, r, "#{1}", c);   c = false;',
2243:     className:    'n = h.className(n, r, "#{1}", c); c = false;',
2244:     id:           'n = h.id(n, r, "#{1}", c);        c = false;',
2245:     attrPresence: 'n = h.attrPresence(n, r, "#{1}"); c = false;',
2246:     attr: function(m) {
2247:       m[3] = (m[5] || m[6]);
2248:       return new Template('n = h.attr(n, r, "#{1}", "#{3}", "#{2}"); c = false;').evaluate(m);
2249:     },
2250:     pseudo:       function(m) {
2251:       if (m[6]) m[6] = m[6].replace(/"/g, '\\"');
2252:       return new Template('n = h.pseudo(n, "#{1}", "#{6}", r, c); c = false;').evaluate(m);
2253:     },
2254:     descendant:   'c = \"descendant\";',
2255:     child:        'c = \"child\";',
2256:     adjacent:     'c = \"adjacent\";',
2257:     laterSibling: 'c = \"laterSibling\";'
2258:   },
2259: 

2241行目からはSelector.criteriaです。compileMatcher()から参照されます。

compileMatcher()では、Selector.patternsから入力されたCSSフィルタ文字列を先頭から徐々にパースしていき、マッチした部分に対して、Selector.criteriaで定義されるハッシュ表に関連付けられているJavaScriptコードに変換します。

出力されるJavaScriptはほぼSelector.handlers以下の関数の呼び出しとなっています。Selector.patternsの正規表現では、タグ名やクラス名などがマッチ結果として残るように設計されているので、それらを引数として渡して呼び出しています。

JavaScriptコード文字列で使われている部分において、nが現時点で処理対象となりうるノードの配列、hがSelector.handlers、rがマッチの際のルートノード、cが直前のコンビネータ状態(例えばselector + selectorという形なら"adjacent"が入る。なければfalse)を示します。

tagNameは2269行目の正規表現で、2372行目のh.tagName()を呼び出します。やっていることは、ノード配列nから、そのタグ名にマッチするものに絞り込む、という処理です。結果は再度nに代入されます。

classNameは2271行目の正規表現で、2415行目のh.className()を呼び出します。こちらもやっていることは対象となるノード配列から、指定されたクラス名にマッチするものだけを返しています。

idは2270行目の正規表現にマッチした場合で、2391行目のh.id()を呼び出しています。実際にはコンビネータの処理が入りうるので、それほど単純ではありませんが、こちらも対象ノード配列n以下で、指定されたidにマッチするものを返しています。

attrPresenceは2273行目の正規表現で、指定された属性が存在するかどうかだけを見るためにh.attrPresence()を呼び出しています。

attrは2274行目の正規表現で、属性の値の等価、否定、マッチングを行います。後述する正規表現の解説で書いているように、$5, $6に属性の値の部分が入ります。それらのどちらかをm[3]に入れて、h.attr()を呼び出しています。Templateクラスを使ってそれらを代入してJavaScriptコード文字列にしています。

pseudoは2272行目の正規表現にマッチした結果です。正規表現の解説は後述しますが、$6の部分は関数形式の引数があった場合に$6が存在し、そこに引数の値が入ります。$1にはnth-of-childなどの擬似クラスの名前が入ります。こちらも結果をTemplateを使って変換して返しています。

残りのdescendant, child, adjacent, laterSiblingはコンビネータに対する正規表現マッチの結果で、コンビネータ状態を表すcに状態文字列を入れる、というJavaScriptコードを返しています。

2260:   patterns: {
2261:     // combinators must be listed first
2262:     // (and descendant needs to be last combinator)
2263:     laterSibling: /^\s*~\s*/,
2264:     child:        /^\s*>\s*/,
2265:     adjacent:     /^\s*\+\s*/,
2266:     descendant:   /^\s/,
2267: 
2268:     // selectors follow
2269:     tagName:      /^\s*(\*|[\w\-]+)(\b|$)?/,
2270:     id:           /^#([\w\-\*]+)(\b|$)/,
2271:     className:    /^\.([\w\-\*]+)(\b|$)/,
2272:     pseudo:       /^:((first|last|nth|nth-last|only)(-child|-of-type)|empty|checked|(en|dis)abled|not)(\((.*?)\))?(\b|$|\s|(?=:))/,
2273:     attrPresence: /^\[([\w]+)\]/,
2274:     attr:         /\[((?:[\w-]*:)?[\w-]+)\s*(?:([!^$*~|]?=)\s*((['"])([^\]]*?)\4|([^'"][^\]]*?)))?\]/
2275:   },
2276: 

2260行目からはパーサの核となる正規表現Selector.patternsです。

ハッシュ形式のオブジェクトとなっていて、値として正規表現オブジェクト、プロパティ名としてそのパターンの名称が入っています。この名称部分は他の処理の部分でもキーとして使われます。

このpatternsは、for (var foo in patterns)としてループで上から順にマッチするかどうかをチェックし、マッチしたら該当する処理関数(XPathか自前JavaScriptかで変わる)部分に渡します。コメント部分を読むと、正規表現の優先順位の都合上、上記for文が、定義した順でプロパティ名を列挙してくれることに依存しているようです。基本的に最長マッチにしたいのに、descendantの短い正規表現が先に来るとそれにマッチしてしまうから、ということだと思われますが、実際にその後に行われる処理を見てみると、特に順番がこの通りでなくてもうまくパースできるように見えます。

ECMAの仕様によると、"The mechanics of enumerating the properties is implementation dependent. The order of enumeration is defined by the object."となっており、for-inでのプロパティ列挙順はなんら保障はされていないようですが、簡単にFirefox, IE7で試してみたところ、for-inはここで定義された順にプロパティ名を返しているようです。

また、()によるグルーピングは、マッチ後のテンプレート文字列で#{1}などの形で参照されます。

以下、難しそうな正規表現だけ説明します。

2272行目はpseudo正規表現です。これは分解すると以下のようになります。

部分正規表現 グルーピング マッチ例
^:( $1
(first|last|nth|nth-last|only)(-child|-of-type) $2, $3 first-child, nth-of-type
|empty|checked|(en|dis)abled|not $4 checked, disabled
)
( $5
\((.*?)\) $6 (99)
)?
( $7
\b|$|\s|(?=:) $8
)

2274行目はattr正規表現です。これは分解すると以下のようになります。

部分正規表現 グルーピング マッチ例
^\[ [
((?:[\w-]*:)?[\w-]+) $1 href, src
\s*
(?:
([!^$*~|]?=) $2 =, ~=, |=, ^=, $=, *=
\s*
( $3
(['"])([^\]]*?)\4 $4, $5 "value", 'value'
|
([^'"][^\]]*?) $6 all, 99
)
)?
\] ]
  • ECMA-262 第三版 - 12.6.4 The for-in Statement

おすすめ記事

記事・ニュース一覧