script.aculo.usを読み解く

第4回builder.js

builder.jsは、DOMを簡単に構築できるライブラリです。

prototype.jsにも、バージョン1.6でDOM構築を簡単にするためのElementコンストラクタが導入されましたが、script.aculo.usのBuilderのほうが開発の歴史が古いので、いくつかの点で一日の長があります。

機能の紹介

DOMを構築するときは、次のような一連の操作が頻出します。

var elt = document.createElement('button');
elt.setAttribute('id','hoge');
elt.onclick=doSomething
elt.appendChild(document.createTextNode('hello button'));

builder.jsを使うと、次のように簡単に書くことができます。

Builder.node('button',{id:'hoge',onclick:'doSomething()'},'hello button');

さらに便利なのは、このBuilder.nodeはこのように入れ子にできることです。これはprototype.jsのElementコンストラクタにはない機能です。

Builder.node('div',[
  Builder.node('a',{href:'./foo.png'},
    Builder.node('img',{src:'./foo.png'})),
  Builder.node('a',{href:'./bar.png'},
    Builder.node('img',{src:'./bar.png'}))]);

入れ子にした結果はこのようになります。

<div>
  <a href="./foo.png">
    <img src="./foo.png"/>
  </a>
  <a href="./bar.png">
    <img src="./bar.png"/>
  </a>
</div>

便利な機能 Builder.dump()

私がBuilder.jsで愛してやまないのは、次のようにも書けることです。

DIV([
  A({href:'./foo.png'},
   IMG({src:'./foo.png'})),
  A({href:'./bar.png'},
   IMG({src:'./bar.png'}))])

このショートカットモードともいうべき機能は、Builder.dump()を呼ぶと、そこでDIV関数やA関数が定義されて、使えるようになります。DIV関数やA関数の実体はBuilder.node関数です。まるで閉じタグのないHTMLのように見えるのがお気に入りです。

Builder.node関数の使いかた

Builder.node関数は、引数に何が与えられるかで動作が変わります。引数の与えかたは以下のようになっています。

  • Builder.node( 要素名 )
  • Builder.node( 要素名, 属性 )
  • Builder.node( 要素名, 入れ子 )
  • Builder.node( 要素名, 属性, 入れ子 )

要素名は、文字列で、要素のタグ名を与えます。

属性は、オブジェクトを与えます。プロパティ名に例えばid、className、style、onclickなどを使います。

入れ子は、要素に追加される、単一の文字列か数字かノード、あるいはそれらの配列を与えます。

この入れ子の中の配列は次のように扱われます。

Builder.node('p','foobar1']) ==
Builder.node('p',['foo','bar',1]) ==
Builder.node('p',['foo',['bar',1]]) ==

<p>foobar1</p>

このライブラリは、Array.mapといった配列操作と組み合わせて使うことで、大きな威力を発揮します。例えばこれは、配列からボタンをまとめて作る例です。

DIV(['foo','bar','foobar'].map(function(x,i){
        return BUTTON({onclick:"alert('" + x + i + "')"},x)}));

Builder

それでは、実際のコードを見ていきましょう。

0001: // script.aculo.us builder.js v1.8.1, Thu Jan 03 22:07:12 -0500 2008
0002: 
0003: // Copyright (c) 2005-2007 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us)
0004: //
0005: // script.aculo.us is freely distributable under the terms of an MIT-style license.
0006: // For details, see the script.aculo.us web site: http://script.aculo.us/
0007:

1~7行目は著作権表示です。

0008: var Builder = {
0009:   NODEMAP: {
0010:     AREA: 'map',
0011:     CAPTION: 'table',
0012:     COL: 'table',
0013:     COLGROUP: 'table',
0014:     LEGEND: 'fieldset',
0015:     OPTGROUP: 'select',
0016:     OPTION: 'select',
0017:     PARAM: 'object',
0018:     TBODY: 'table',
0019:     TD: 'table',
0020:     TFOOT: 'table',
0021:     TH: 'table',
0022:     THEAD: 'table',
0023:     TR: 'table'
0024:   },

9~24行目のNODEMAPは、後述するように、親タグがDIVでは、innerHTML法を使うのに不都合があるタグを列挙したものです。COLタグの親タグは'table'、OPTIONタグの親タグは'select'となっているのがわかります。

0025:   // note: For Firefox < 1.5, OPTION and OPTGROUP tags are currently broken,
0026:   //       due to a Firefox bug
0027:   node: function(elementName) {
0028:     elementName = elementName.toUpperCase();
0029:     
0030:     // try innerHTML approach
0031:     var parentTag = this.NODEMAP[elementName] || 'div';
0032:     var parentElement = document.createElement(parentTag);
0033:     try { // prevent IE "feature": http://dev.rubyonrails.org/ticket/2707
0034:       parentElement.innerHTML = "<" + elementName + "></" + elementName + ">";
0035:     } catch(e) {}
0036:     var element = parentElement.firstChild || null;
0037:       
0038:     // see if browser added wrapping tags
0039:     if(element && (element.tagName.toUpperCase() != elementName))
0040:       element = element.getElementsByTagName(elementName)[0];
0041:     
0042:     // fallback to createElement approach
0043:     if(!element) element = document.createElement(elementName);
0044:     
0045:     // abort if nothing could be created
0046:     if(!element) return;
0047:
0048:     // attributes (or text)
0049:     if(arguments[1])
0050:       if(this._isStringOrNumber(arguments[1]) ||
0051:         (arguments[1] instanceof Array) ||
0052:          arguments[1].tagName) {
0053:           this._children(element, arguments[1]);
0054:         } else {
0055:           var attrs = this._attributes(arguments[1]);
0056:           if(attrs.length) {
0057:             try { // prevent IE "feature": http://dev.rubyonrails.org/ticket/2707
0058:               parentElement.innerHTML = "<" +elementName + " " +
0059:                 attrs + "></" + elementName + ">";
0060:             } catch(e) {}
0061:             element = parentElement.firstChild || null;
0062:             // workaround firefox 1.0.X bug
0063:             if(!element) {
0064:               element = document.createElement(elementName);
0065:               for(attr in arguments[1]) 
0066:                 element[attr == 'class' ? 'className' : attr] = arguments[1][attr];
0067:             }
0068:             if(element.tagName.toUpperCase() != elementName)
0069:               element = parentElement.getElementsByTagName(elementName)[0];
0070:           }
0071:         } 
0072: 
0073:     // text, or array of children
0074:     if(arguments[2])
0075:       this._children(element, arguments[2]);
0076: 
0077:      return element;
0078:   },

25~78行目のnodeは、このBuilderのなかで最も大切な部分で、DOMを構築して返す関数です。

タグを作るには、まずはinnerHTML法を試します。この方法は、作りたいタグの親タグをcreateElementで作り、親タグのinnerHTMLにタグを書き込み、親タグのfirstChildから作りたかったタグを取り出すやりかたです。

それで上手くいかなければ、 createElement法を使うようになっています。この方法は、単純に作りたいタグをcreateElementで作る方法です。

初期のライブラリでは単純なcreateElement法だけが使われていましたが、IE6に対応するために、リビジョン2090でinnerHTML法がとられるようになりました。それは、createElementを使うと、ブラウザのバグや仕様にでくわすことがあるからで、特に'table'や'option'が鬼門のようです。

30行目で、innerHTML法を試します。

31行目で、親タグは、たいていは'div'でよいのですが、NODEMAPにあげられているものは別です。

32行目で、親タグをcreateElementします。

33行目で、IEの仕様を回避しつつinnerHTMLに書き込みます。ここで、try..catch文を使っているのは、リビジョン2707にあるとおり、IE6の仕様で、いくつかのHTML要素のinnerHTMLプロパティを読み取り専用としていて、書き込もうとするとエラーがでて処理が止まってしまうからです。

36行目で、親タグから所望のタグを取り出します。

39行目で、本当に所望のタグかどうか、タグ名を確かめます。ブラウザが勝手にタグを追加することがあって、単純な親タグのfirstChildによる取りだしが失敗することがあるからです。

40行目で、タグ名が違っていたら、改めて親タグにgetElementsByTagNameを使って取りだしなおします。

43行目で、ここまでの処理が失敗していたら、createElement法を試します。

46行目で、どちらの方法も失敗したら、処理を中止します。

50行目で、2番めの引数が、入れ子か属性かによって処理がかわります。

53行目で、入れ子であれば、後述する_children関数に渡して終わりです。

55~71行目で、属性であれば、これまでのタグ作りをはじめからやりなおします。これまでの処理が無駄になるのと、先ほどとそっくりなコードがまた書かれているのには、あまり感心しません。

先ほどと違うのは、innerHTML法のとき次のようにすることと、

0057:             try { // prevent IE "feature": http://dev.rubyonrails.org/ticket/2707
0058:               parentElement.innerHTML = "<" +elementName + " " +
0059:                 attrs + "></" + elementName + ">";

createElement法ならば次のようにすることです。

0064:               element = document.createElement(elementName);
0065:               for(attr in arguments[1]) 
0066:                 element[attr == 'class' ? 'className' : attr] = arguments[1][attr];

66行目で、class属性の指定はclassName属性にいれかえます。

75行目で、3番めの引数は入れ子として扱うので、後述の_children関数に渡します。

77行目で、結果のelementを返します。

0079:   _text: function(text) {
0080:      return document.createTextNode(text);
0081:   },

79~81行目の_textは、引数の値の文字列ノードを作って返す関数です。

0083:   ATTR_MAP: {
0084:     'className': 'class',
0085:     'htmlFor': 'for'
0086:   },
0087:

83~87行目のATTR_MAP は、後述するとおり、'className'、'htmlFor'と属性名を指定されたときに、それぞれHTMLで対応する属性名の'class'、'for'に指定をおきかえるのに使う対応表です。このように間接的に指定するのは、classとforがJavaScriptの予約語だからです。

0088:   _attributes: function(attributes) {
0089:     var attrs = [];
0090:     for(attribute in attributes)
0091:       attrs.push((attribute in this.ATTR_MAP ? this.ATTR_MAP[attribute] : attribute) +
0092:           '="' + attributes[attribute].toString().escapeHTML().gsub(/"/,'&quot;') + '"');
0093:     return attrs.join(" ");
0094:   },

88~94行目の_attributesは、属性を表現したオブジェクトを受けとって、HTMLの表現に整形した文字列を返す関数です。

91行目で、属性名を、ATTR_MAPを使って、'className','htmlFor'の指定をHTMLの表現の'class'、'for'におきかえています。

92行目で、属性の値の内部にあるダブルクォートを、HTMLの記法の&quot;に、正規表現で置き換えます。これで次のような指定も正しく扱うことができます。

{onclick:'alert("' + 'hello' + '")'}

93行目で、全ての結果を結合して、"属性名1=値1 属性名2=値2 ..."という形の文字列を返します

0095:   _children: function(element, children) {
0096:     if(children.tagName) {
0097:       element.appendChild(children);
0098:       return;
0099:     }
0100:     if(typeof children=='object') { // array can hold nodes and text
0101:       children.flatten().each( function(e) {
0102:         if(typeof e=='object')
0103:           element.appendChild(e)
0104:         else
0105:           if(Builder._isStringOrNumber(e))
0106:             element.appendChild(Builder._text(e));
0107:       });
0108:     } else
0109:       if(Builder._isStringOrNumber(children))
0110:         element.appendChild(Builder._text(children));
0111:   },

95~111行目の_children は、入れ子を処理するための関数です。1番めの引数に入れ子の親のDOM要素を、2番めの引数に子をとります。

96行目で、子がtagName属性を持ったDOM要素ならば、単に親にappendChildします。

100行目で、子がtypeof演算子に'object'を返すならば、配列であるとみなします。

101行目で、子の配列をflattenメソッドでつぶしてから、eachメソッドで、配列の各要素について、次のような処理をします。

102行目で、各要素のうち、typeof演算子に'object'と返すものは、DOM要素とみなして、親にappendChildします。

105行目で、各要素のうち、typeof演算子に'string'か'number'を返すものは、上述の_text関数を使って適当なDOM要素にしてから、親にappendChildします。

109行目で、子がtypeof演算子に'object'ではなく、'string'か'number'を返すならば、上述の_text関数を使って適当なDOM要素にしてから、親にappendChildします。

0112:   _isStringOrNumber: function(param) {
0113:     return(typeof param=='string' || typeof param=='number');
0114:   },

112~114行目の_isStringOrNumberは、引数の型が文字列か数字であるかを返す関数です。typeof演算子を使って判断します。

0115:   build: function(html) {
0116:     var element = this.node('div');
0117:     $(element).update(html.strip());
0118:     return element.down();
0119:   },

115~119行目のbuildは、引数にHTMLが書かれた文字列をとって、そのDOMを返す関数です。DIV要素を作ってHTMLを書き込み、中身をElement.downメソッドで取り出して返します。

0120:   dump: function(scope) { 
0121:     if(typeof scope != 'object' && typeof scope != 'function') scope = window; //global scope 
0122:   
0123:     var tags = ("A ABBR ACRONYM ADDRESS APPLET AREA B BASE BASEFONT BDO BIG BLOCKQUOTE BODY " +
0124:       "BR BUTTON CAPTION CENTER CITE CODE COL COLGROUP DD DEL DFN DIR DIV DL DT EM FIELDSET " +
0125:       "FONT FORM FRAME FRAMESET H1 H2 H3 H4 H5 H6 HEAD HR HTML I IFRAME IMG INPUT INS ISINDEX "+
0126:       "KBD LABEL LEGEND LI LINK MAP MENU META NOFRAMES NOSCRIPT OBJECT OL OPTGROUP OPTION P "+
0127:       "PARAM PRE Q S SAMP SCRIPT SELECT SMALL SPAN STRIKE STRONG STYLE SUB SUP TABLE TBODY TD "+
0128:       "TEXTAREA TFOOT TH THEAD TITLE TR TT U UL VAR").split(/\s+/);
0129:   
0130:     tags.each( function(tag){ 
0131:       scope[tag] = function() { 
0132:         return Builder.node.apply(Builder, [tag].concat($A(arguments)));  
0133:       } 
0134:     });
0135:   }
0136: }

120~136行目のdumpは、DIV(P('hello world'))などと書けるように、DIV関数やP関数などをまとめて定義する関数です。デフォルトでは、これらはwindow以下に定義されてグローバル関数になりますが、引数にオブジェクト(いわゆるインスタンス)か関数(いわゆるクラス定義)を与えて、これらのスコープを制限することもできます。

132行目からわかるように、DIV(...)の実体はBuilder.node ('DIV',...)です。JavaScriptのapplyの定義はこちらを参照してください。

おすすめ記事

記事・ニュース一覧