prototype.jsを読み解く

第2回 Prototypeライブラリ(198~639行目)

この記事を読むのに必要な時間:およそ 19.5 分

0338:   capitalize: function() {
0339:     return this.charAt(0).toUpperCase() + this.substring(1).toLowerCase();
0340:   },
0341: 
0342:   underscore: function() {
0343:     return this.gsub(/::/, '/').gsub(/([A-Z]+)([A-Z][a-z])/,'#{1}_#{2}').gsub(/([a-z\d])([A-Z])/,'#{1}_#{2}').gsub(/-/,'_').toLowerCase();
0344:   },
0345: 
0346:   dasherize: function() {
0347:     return this.gsub(/_/,'-');
0348:   },
0349: 

338行目からはcapitalize()メソッドです。インスタンス文字列の一文字目をtoUpperCase()を使って大文字にし,substring(1)を使って二文字目以降を取り出してtoLowerCase()で小文字にして,それらを結合したものを返します。

342行目からはunderscore()メソッドです。公式APIによると,キャメルケースの文字列を,単語ごとに'_'(下線)で区切られた文字列に変換する,とあります。

実装としては,インスタンス文字列に対して連続してgsub()関数を適用して,最後にtoLowerCase()で小文字にする,という処理になっています。

まず最初にgsub(/::/, '/')で'::'を'/'に変換します。これはPerlなどで名前空間を分割するのに用いられる'::'を,ファイル階層にマップするための置換,のように見えますが,underscore()でこのような処理が必要になる理由は不明です。

次にgsub(/([A-Z]+)([A-Z][a-z])/,'#{1}_#{2}')で,一個以上の大文字が連続した後に一文字の大文字,さらに一文字の小文字が続いたものを,後ろ二文字とその前で分けて下線で区切ります。

ここまでで "camelCAse" という文字列が "camelC_Ase" となります。ここの処理は,大文字一文字の単語を分割するために必要となります。

最後にgsub(/([a-z\d])([A-Z])/,'#{1}_#{2}')で大文字から始まっている単語とそれ以前を下線で分割し,その結果をtoLowerCase()して返しています。ここまでで"camelCAse"という文字列は"camel_c_ase"となり,目的が達成されます。

346行目からのdasherize()メソッドは,単純に文字列中の下線"_"をダッシュ"-"にすべて置換するだけです。

0350:   inspect: function(useDoubleQuotes) {
0351:     var escapedString = this.gsub(/[\x00-\x1f\\]/, function(match) {
0352:       var character = String.specialChar[match[0]];
0353:       return character ? character : '\\u00' + match[0].charCodeAt().toPaddedString(2, 16);
0354:     });
0355:     if (useDoubleQuotes) return '"' + escapedString.replace(/"/g, '\\"') + '"';
0356:     return "'" + escapedString.replace(/'/g, '\\\'') + "'";
0357:   },
0358: 

350行目からはinspect()メソッドです。これは他の型にも定義されているように,インスタンスの内容をわかりやすく出力するためのものです。

String型では,0x00から0x1Fまでの文字を,'\u001f'のように変換して返します。この際,202行目で定義されているString.specialCharオブジェクトに該当する特殊文字だった場合には,より一般的な表示文字列に変換します。

メソッドのuseDoubleQuotes引数に応じて,二重引用符か一重引用符で返し,その際中身の文字列中の引用符を適切にエスケープするようにしています。

0359:   toJSON: function() {
0360:     return this.inspect(true);
0361:   },
0362: 
0363:   unfilterJSON: function(filter) {
0364:     return this.sub(filter || Prototype.JSONFilter, '#{1}');
0365:   },
0366: 
0367:   isJSON: function() {
0368:     var str = this.replace(/\\./g, '@').replace(/"[^"\\\n\r]*"/g, '');
0369:     return (/^[,:{}\[\]0-9.\-+Eaeflnr-u \n\r\t]*$/).test(str);
0370:   },
0371: 
0372:   evalJSON: function(sanitize) {
0373:     var json = this.unfilterJSON();
0374:     try {
0375:       if (!sanitize || json.isJSON()) return eval('(' + json + ')');
0376:     } catch (e) { }
0377:     throw new SyntaxError('Badly formed JSON string: ' + this.inspect());
0378:   },
0379: 

359行目からの toJSON()メソッドは,他の型と同様に JSON としての文字列を返しますが,ここでの実装は単に inspect(true) を呼ぶだけです。この場合は二重引用符を使った文字列が返されます。

363行目からは unfilterJSON() メソッドです。引数として渡されたフィルタ関数か,デフォルトして使われる Prototype.JSONFilter 関数を使ってインスタンス文字列を置換します。

Prototype.JSONFilterを使う場合,'/*-secure-\n{"name": "Violet"}\n*/'という文字列が,'{"name": "Violet"}'という文字列になります。

このようなコメント形式で括られた文字列は,サーバ側から返される際に「簡単にJSON文字列に変換することができるが,<script>タグなどで直接読み込まれたとしても使うことができないようにする」という用途で使われることがあります。

367行目からはisJSON()メソッドです。正規表現を使って簡易チェックを行っているだけなので,厳密にJSONとして正しいかどうかをチェックしているわけではありません。

公式APIには"something".isJSON()がfalseを返す,となっており(文字列が引用符で括られていないため⁠⁠,数値表現に含まれる"a"などは許されているために"aaa".isJSON()はtrueを返してしまったりします。

この関数は元々Safariのバグに対処するためのものですが(Rails Trac ticket #7834⁠,'.'を'@'で置換している部分は理由がわかりません。

368行目で二重引用符で括られている文字列は問題ない,ということで消去し,のこった文字列に対して,JSON構文で使われる文字列しか含まれていないかどうか,という正規表現チェックが行われます。

372行目からはevalJSON()メソッドです。まず,unfilterJSON()をデフォルトのフィルタで呼び出して,保護用のコメント文字列を必要に応じて取り除きます。

375行目では,sanitize 引数が指定されていればisJSON()メソッドでチェックを行い,そうでなければそのままでeval()を呼び出します。

eval()時には,"("と")"で対象となる文字列を括っています。これは,例えばハッシュ形式のオブジェクトとして評価しようと"{'key':1}"という文字列をそのままeval()しようとすると,通常のコンテキストでは文を括るための中括弧,⁠gotoなどで使う)ラベル'key',数値の1という形に解釈されます。この場合引用符付きの'key'という文字列はラベルとして不正なのでエラーとなってしまいます。

それを避けるために,変数として評価されるコンテキストを強制するために"(", ")"で括る,という手法がよく使われます。

注:

以前eval()が置かれる状況によって,中身が式のコンテキストで評価されるか文のコンテキストで評価されるかが変化してしまう,という状況に陥ったような記憶があるのですが,今回検証した限りでは文のコンテキストで評価されているようです。
ECMA-262 第三版の仕様を見ても,eval()内の文字列がどのコンテキストで評価されるべきであるか,という記述は見つけることができませんでした。

条件が偽となるか,eval()が例外を発生させると377行目に行きますが,そうでなければeval()の返り値をreturnで返します。

もし例外が発生したり,isJSON()が偽を返したりする場合には,SyntaxError型の例外をnewしてそれをthrowしています。

0380:   include: function(pattern) {
0381:     return this.indexOf(pattern) > -1;
0382:   },
0383: 
0384:   startsWith: function(pattern) {
0385:     return this.indexOf(pattern) === 0;
0386:   },
0387: 
0388:   endsWith: function(pattern) {
0389:     var d = this.length - pattern.length;
0390:     return d >= 0 && this.lastIndexOf(pattern) === d;
0391:   },
0392: 

380行目からのinclude()メソッドは,引数として渡された文字列がインスタンス文字列中に含まれているかどうかをbooleanで返します。実装としてはindexOf()に引数を渡して-1より大きいかどうかをチェックしているだけです(indexOf()は見つかると0以上の整数を返します⁠⁠。

384行目からのstartsWith()メソッドも定型句のショートカット関数で,インスタンス文字列が引数の文字列から始まっているかどうかをbooleanで返します。

わざわざ==ではなく===を使っているかは不明ですが,厳密な比較をしても特に害があるわけでもないので問題はないでしょう。

388行目からのendsWith()メソッドも同様ですが,引数として渡された文字列の方がインスタンス文字列よりも長い場合には,比べるだけ無駄なので先に偽を返します。そうでなければ,lastIndexOf()を使ってインスタンス文字列が指定された文字列で終わっているかどうかを確認します。正規表現で書いたほうがすっきりしますが,効率のためか,lastIndexOf() の返り値が(後ろから探して見つかった時のインデックス⁠⁠,⁠インスタンス文字列の長さ - 引数文字列の長さ)の値と等しいかどうか,で判断しています。

0393:   empty: function() {
0394:     return this == '';
0395:   },
0396: 
0397:   blank: function() {
0398:     return /^\s*$/.test(this);
0399:   }
0400: });
0401: 

393行目からのemtpy()メソッドは,インスタンス文字列が空文字列かどうかをbooleanで返すだけです。

397行目からのblank()メソッドは,インスタンス文字列にホワイトスペース文字のみが含まれているかどうかをbooleanで返します。

ちなみにJavaScriptにおけるホワイトスペースは,"15.10.2.12 CharacterClassEscape"において"the set of characters containing the characters that are on the right-hand side of the White Space (7.2) or Line Terminator (7.3) productions" として定義されています。

ここでWhiteSpaceには\u0009 (Tab),\u000B (Vertical Tab),\u000C (Form Feed),\u0020 (Space),\u00A0 (No-break space),Unicode "space separator"が含まれます。最後の文字群は,日本語の範囲ではいわゆる全角スペースが含まれています。

また,Line Terminatorには\u000A (Line Feed),\u000D (Carriage Return),\u2028 (Line separator),\u2029 (Paragraph separator)が含まれます。

  • ECMA-262 第三版 - 15.10.2.12 CharacterClassEscape
  • ECMA-262 第三版 - 7.2 White Space
  • ECMA-262 第三版 - 7.3 Line Terminators
0402: if (Prototype.Browser.WebKit || Prototype.Browser.IE) Object.extend(String.prototype, {
0403:   escapeHTML: function() {
0404:     return this.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
0405:   },
0406:   unescapeHTML: function() {
0407:     return this.replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>');
0408:   }
0409: });
0410: 

String型の,escapeHTML(),unescapeHTML()メソッドをブラウザを判別した上で上書きしています。

元々の関数は,ブラウザの挙動を使った興味深い実装でしたが,Prototype.Browser.WebKit,Prototype.Browser.IEに合致するブラウザの場合には,replace()を使ったシンプルな関数に置き換えられています。

これは,これらのブラウザでは元々の関数のやり方では,スペースが保存されず,変換結果が期待通りにならないため,のようです。

0411: String.prototype.gsub.prepareReplacement = function(replacement) {
0412:   if (typeof replacement == 'function') return replacement;
0413:   var template = new Template(replacement);
0414:   return function(match) { return template.evaluate(match) };
0415: }
0416: 
0417: String.prototype.parseQuery = String.prototype.toQueryParams;
0418: 

411行目からは,すでに定義されているString.prototype.gsub関数オブジェクトのプロパティとして,prepareReplacementという関数オブジェクトを定義しています。どう使われているかはString.prototype.gsub()メソッドの項を参照してください。

417行目では,toQueryParams()メソッドの別名としてparseQuery()を定義しています。

0419: Object.extend(String.prototype.escapeHTML, {
0420:   div:  document.createElement('div'),
0421:   text: document.createTextNode('')
0422: });
0423: 
0424: with (String.prototype.escapeHTML) div.appendChild(text);
0425: 

419行目からは,String.prototype.escapeHTML()メソッドのプロパティとして,div,textを定義しています。これらの使われ方はescapeHTML()の項を参照してください。

424行目もescapeHTML()で使われるdiv,textの事前準備です。ここでwith句を使っているのは,String.prototype.escapeHTML.div.appendChild (String.prototype.escapeHTML.text)を短く記述するためです。

著者プロフィール

栗山淳(くりやまじゅん)

S2ファクトリー株式会社株式会社イメージソース所属。
本業はWeb制作会社の裏方。得意分野はFreeBSDやPerlのはずだが,必要に迫られるとHTML/CSSやJavaScriptも書く。