N0ラボ(仮)


JavaScript編

HTMLからDOM要素を生成する

クロスブラウザコードを書こうとするとトラップが多すぎるJavaScriptだから、 こういうのをモジュール化しておくと役に立つと思うんだ。 基本的なAJAXライブラリではDOM生成系のメソッドは入ってないのが多いので、 そういうのと組み合わせてもいいかと。 最近自分もjQueryを使ったり使わなかったりしているが、DocumentFragmentをjQueryが扱えないのでやっぱりこれを使っていたりする。

innerHTMLとか言う得体の知れないもの(謎)は断固使いたくない人向け。
目にも悪いし。 (foo.innerHTML='<div id="'+hoge.id+'" class="'+hoge.cls+'">'+hoge.content+'</div>';とか混乱しません?)

以前公開していたものは単純にブラウザ間の違いを吸収したdocument.createElement()+element.setAttributeNode()、 という方針で作っていてそこそこ便利だったが、 実際使ってみると若干重くて、繰り返し使うとかなりパフォーマンス上問題があった。 そこで、D Bookmarkerでは、繰り返し使われた時の軽量化に、テンプレートを作っておいてcloneNode(true)して必要なところだけ差し替える、 という対処を自前で実装していたが、そのアイデアを取り込んだ。 あとは、以前から(X)HTMLを渡したら要素を生成するというアイデアはあったが、この改造と非常に親和性が良かったので採用。 ついでにコードの可読性も上がっていい感じ。

例えばこんなHTMLをDOMで作りたい場合。

<p>リスト</p>
<ul id="checks">
<li><input type="checkbox" value="c1" />内容1</li>
<li><input type="checkbox" value="c2" />内容2</li>
<li><input type="checkbox" value="c3" />内容3</li>
</ul>
<p><input type="button" value="ボタン" onclick="clicked();" /></p>

ベタに作ろうとすると概ねこうなる。これを解読するのはしんどい。

var df = document.createDocumentFragment();
var p1 = document.createElement('p');
p1.appendChild(document.createTextNode('リスト'));
df.appendChild(p1);
var ul = document.createElement('ul');
var ulid = document.createAttribute('id');
ulid.value = "checks";
ul.setAttributeNode(ulid);
for (var i = 0; i < 3; i++){
  var li = document.createElement('li');
  var check = document.createElement('input');
  check.type = 'checkbox';
  check.value = 'c' + i;
  li.appendChild(check);
  li.appendChild(document.createTextNode('内容' + i));
  ul.appendChild(li);
}
df.appendChild(ul);
var p2 = document.createElement('p');
var button = document.createElement('input');
button.type = "button";
button.value = "ボタン";
button.onclick = clicked;
p2.appendChild(button);
df.appendChild(p2);

作り直すとこうなる。多少ましかと。

var nodeTemplate = new NodeTemplate();
var df = document.createDocumentFragment();
df.appendChild(nodeTemplate.pattern('<p>リスト</p>').create());
var ulpattern = nodeTemplate.pattern('<ul id="checks">${0}</ul>');
var lipattern = nodeTemplate.pattern('<li><input type="checkbox" value="${0}" />${1}</li>');
var dful = document.createDocumentFragment();
for (var i = 0; i < 3; i++){
  dful.appendChild(lipattern.create('c'+i, '内容'+i));
}
df.appendChild(ulpattern.create(dful));
var p2 = elementGenerator.pattern('<p><input type="button" value="ボタン" /></p>').create();
p2.firstChild.onclick = clicked;
df.appendChild(p2);

動作条件は、ブラウザがDOMParserまたはXMLHttpRequestまたはActiveXObjectに対応している必要あり。あとDOM操作が一通りできる必要あり。 IE6/IE7/Firefox3/Opera9/Safari3/Chrome1で動作確認している。IE5.5以前はDOM操作があまりにできないので不可。

改造しすぎてほとんど以前のバージョンの痕跡が見あたらない。 あと面倒そうだったので前バージョンでは対応していたイベント属性対応はやめてしまった。

create()の速度を測ってみたら、どのブラウザでも多少前後はするけど、 現バージョン置換パラメータなし>>>手でcreateElement≒現バージョン置換パラメータ1個>現バージョン置換パラメータ2個>以前のバージョン≒現バージョン置換パラメータ3個、 といったところ。まあまあかな。置換パラメータがないと本当にcloneNode(true)しかしないので爆速。

なんだかテンプレートエンジンに近くなってきたが、HTMLが崩れるテンプレートエンジンって個人的には嫌いなんだよな。 <table>と<tr>の間に余計な文字が入ったりするのが耐えられない。と言いつつ仕事だと使ったりするけど。 正しいHTMLでテンプレートエンジン的な実装を思いついたらまた改造するかも。

ずいぶん前置きが長くなったが、コードは以下に。

2010/9/19追記:以前apply()としていた関数名をcreate()に変えました。 あと、子要素生成時の置換する引数による挙動の違いがなくなってわかりやすくなりました。

2010/12/26追記:オブジェクト名をElementGeneratorからNodeTemplateに変えました。より適切な名前にはなったと思う…。なんかまだしっくりこないけど。

ダウンロード用JSファイル


/**
 * 要素生成オブジェクト。
 * 使用例
 * var nodeTemplate = new NodeTemplate();
 * var pattern_a = nodeTemplate.pattern('<a href="${0}">${1}</a>');
 * var anode1 = pattern_a.create('http://foo.jp', 'Foo');
 * var boldcontent = nodeTemplate.pattern('<b>Foo2</b>').create();
 * var anode2 = pattern_a.create('http://foo.jp', boldcontent);
 * var pattern_p = nodeTemplate.pattern('<p><input type="checkbox" id="${0}" />${1}</p>');
 * var pnode = pattern_p.create('check1', 'CHECK1');
 *
 * style属性、イベント属性(onclick等)は生成に対応していません。
 * テーブルを生成する場合、tbodyの省略はできません。
 * 
 * 置換パラメータ(${n})の部分はcreate()のn番目の引数で置換します。
 * パターン内では置換パラメータの番号はすべて異なっている必要があります。
 * createの引数としては、ECMAScriptのString, Number, Boolean型と、
 * DOMのElement, Text, Comment, DocumentFragment型に対応しています。
 * 置換可能な部分は、要素の内容または属性値です。
 * 要素の内容の場合は、createの引数は上記の7種類のどれでも可ですが、
 * 属性値の場合はString, Number, Boolean型のみが指定できます。
 * 
 * なお、IEで意図した通りに動作させるために、置換パラメータを指定しない方が良い箇所があります。
 * 各要素のname属性、select要素のsize属性に指定するとIEが予想外の挙動をします。
 * (name属性では、ラジオボタンの排他チェックが変な挙動をしたり、スクリプトからの要素指定に、
 *  DOM以前の呼び出し方(document.form1.text1とかdocument.images["img1"]とか)ができなくなることがあります。
 *  select要素のsize属性に指定すると、リストボックスにすることができないようです。)
 * button要素のtype属性、子要素になっているinput要素のtype属性に指定すると、実行時エラーになります。
 * 
 * @author again@T(http://nijzero.dw.land.to/)
 */
function NodeTemplate() {
  var ie = ['Msxml2.DOMDocument.6.0', 'Msxml2.DOMDocument.5.0', 'Msxml2.DOMDocument.4.0', 'MSXML2.DOMDocument', 'Microsoft.XMLDOM'];
  var usedtype = (function() {
    var tmp;
    try {tmp = new DOMParser(); return -1;} catch (e1) {}
    for (var i = 0; i < ie.length; i++) {
      try {tmp = new ActiveXObject(ie[i]); return i;} catch (e2) {}
    }
    try {tmp = new XMLHttpRequest(); return -2;} catch (e3) {}
    throw new Error('対応しないブラウザです');
  })();
  
  function parse(xmlExpression) {
    var xmldoc;
    if (usedtype == -1) {
      var parser = new DOMParser();
      xmldoc = parser.parseFromString(xmlExpression, 'text/xml');
    } else if (usedtype == -2) {
      var req = new XMLHttpRequest();
      req.open('GET', 'data:text/xml;charset=utf-8,' + encodeURIComponent(xmlExpression), false);
      req.send(null);
      xmldoc = req.responseXML;
    } else {
      xmldoc = new ActiveXObject(ie[usedtype]);
      xmldoc.async = false;
      xmldoc.loadXML(xmlExpression);
    }
    return xmldoc;
  }

  function XHTMLPattern(xhtmlExpression) {
    var argTest = /\$\{([0-9]+)\}/;
    var replaceInfos = [];
    this.replaceInfos = replaceInfos;

    var isIESpecialAttribute = this.isIESpecialAttribute;
    var isOperaSpecialChild = this.isOperaSpecialChild;

    function elementMaker(xmlElement, index) {
      var elm = null, attrname, at;
      var tagname = xmlElement.nodeName.toLowerCase();
      var createArgStrs = ['<', tagname];
      var needsSpecial = false;
      for (at = 0; at < xmlElement.attributes.length; at++) {
        attrname = xmlElement.attributes[at].name.toLowerCase();
        if (isIESpecialAttribute(tagname, attrname)) {
          var attrvalue = xmlElement.attributes[at].value;
          var testresult = argTest.test(attrvalue);
          if (!testresult) {
            needsSpecial = true;
            createArgStrs.push(' ', attrname, '="', attrvalue, '"');
          }
        }
      }
      createArgStrs.push('>');
      if (needsSpecial) {try {elm = document.createElement(createArgStrs.join(''));}catch (e) {}}
      if (!elm) {
        elm = document.createElement(xmlElement.nodeName);
      }
      for (at = 0; at < xmlElement.attributes.length; at++) {
        attrname = xmlElement.attributes[at].name.toLowerCase();
        if (argTest.test(xmlElement.attributes[at].value)) {
          var nextindex = index.slice(0);
          nextindex.push(attrname);
          replaceInfos.push({index: nextindex, expression: xmlElement.attributes[at].value});
        } else {
          if (isIESpecialAttribute(tagname, attrname)) {
            try {elm[attrname] = xmlElement.attributes[at].value;}catch (e2) {}
          } else {
            var newattr = document.createAttribute(attrname);
            newattr.value = xmlElement.attributes[at].value;
            elm.setAttributeNode(newattr);
          }
        }
      }
      return elm;
    }

    function nodeTreeMaker(xmlElement, index) {
      if (!index) {index = [];}
      var element = elementMaker(xmlElement, index.slice(0));
      var nextindex;
      for (var i = 0; i < xmlElement.childNodes.length; i++) {
        var childNode = xmlElement.childNodes[i];
        if (childNode.nodeType == 1) {
          nextindex = index.slice(0);
          nextindex.push(i);
          var childElement = arguments.callee(childNode, nextindex);
          element.appendChild(childElement);
        } else {
          if (argTest.test(childNode.nodeValue)) {
            nextindex = index.slice(0);
            nextindex.push(i);
            replaceInfos.push({index: nextindex, expression: childNode.nodeValue});
          }
          if (childNode.nodeType == 8) {
            element.appendChild(document.createComment(childNode.nodeValue));
          } else {
            if (isOperaSpecialChild(xmlElement.nodeName.toLowerCase())) {
              element.value = childNode.nodeValue;
            } else {
              element.appendChild(document.createTextNode(childNode.nodeValue));
            }
          }
        }
      }
      return element;
    }

    this.node = nodeTreeMaker(parse(xhtmlExpression).firstChild);
  }
  XHTMLPattern.prototype.isIESpecialAttribute = function(nodeName, attrName) {
    if (attrName == 'name') {return true;}
    if (nodeName == 'input' && (attrName == 'type' || attrName == 'value')) {return true;}
    if (nodeName == 'button' && attrName == 'type') {return true;}
    return false;
  };
  XHTMLPattern.prototype.isOperaSpecialChild = function(nodeName) {
    return nodeName == 'textarea';
  };
  /**
   * XHTMLパターンの置換パラメータ(${n})の部分を置換したDOM要素を返す。
   * 
   * @param 各置換パラメータ${0}, ${1}, ${2}, ...を置換するオブジェクト
   * @return DOM要素
   */
  XHTMLPattern.prototype.create = function() {
    function select(elm, idxary) {
      var thisidx = idxary.shift();
      if (typeof thisidx == 'string') {
        return [elm, thisidx];
      } else {
        if (idxary.length === 0) {
          return [elm, elm.childNodes[thisidx]];
        } else {
          return arguments.callee(elm.childNodes[thisidx], idxary);
        }
      }
      return null;
    }

    var argReplace = /\$\{([0-9]+)\}/g;
    var argFind = /\$\{([0-9]+)\}/;
    var args = arguments;

    function replaceParam(before) {
      return before.replace(argReplace, function(s, num) {return args[parseInt(num, 10)];});
    }

    function replaceTextWithText(all, numstr) {
      var num = parseInt(numstr, 10);
      if (!(args[num].nodeType)) {
        return args[num];
      } else if (args[num].nodeType == 3) {
        return args[num].nodeValue;
      }
      return all;
    }

    function replaceTextWithObj(textnode) {
      var reres;
      var indices = [];
      argReplace.lastIndex = 0;
      while ((reres = argReplace.exec(textnode.nodeValue)) !== null) {
        indices.push(reres.index);
        indices.push(argReplace.lastIndex);
      }
      if (indices[0] === 0) {indices.shift();}
      if (indices[indices.length - 1] == textnode.nodeValue.length) {indices.pop();}
      var textnodes = [];
      for (var i = indices.length - 1; i >= 0; i--) {
        textnodes.push(textnode.splitText(indices[i]));
      }
      textnodes.push(textnode);
      for (i = 0; i < textnodes.length; i++) {
        reres = argFind.exec(textnodes[i].nodeValue);
        if (reres) {
          textnodes[i].parentNode.replaceChild(args[parseInt(reres[1], 10)], textnodes[i]);
        }
      }
    }

    var node = this.node.cloneNode(true);

    var targets = [];
    for (var ri = 0; ri < this.replaceInfos.length; ri++) {
      targets.push(select(node, this.replaceInfos[ri].index.slice(0)));
    }
    for (ri = 0; ri < this.replaceInfos.length; ri++) {
      var elm = targets[ri][0];
      var child = targets[ri][1];
      var before = this.replaceInfos[ri].expression;
      if (typeof child == 'string') {
        if (this.isIESpecialAttribute(elm.nodeName.toLowerCase(), child)) {
          elm[child] = replaceParam(before);
        } else {
          var newattr = document.createAttribute(child);
          newattr.value = replaceParam(before);
          elm.setAttributeNode(newattr);
        }
      } else {
        if (this.isOperaSpecialChild(elm.nodeName.toLowerCase())) {
          elm.value = replaceParam(before);
        } else {
          child.nodeValue = before.replace(argReplace, replaceTextWithText);
          replaceTextWithObj(child);
        }
      }
    }

    return node;
  };

  var patterns = {};

  /**
   * XHTML表現に対応したXHTMLパターンオブジェクトを返す。
   * 
   * @param xhtmlExpression 妥当なXHTML要素として解析可能な文字列。
   *   トップレベル要素は1つであり、"<p>...</p><div>...</div>"のような文字列はパースエラーになるので注意。
   * @return XHTMLパターンオブジェクト。
   */
  this.pattern = function(xhtmlExpression) {
    if (patterns[xhtmlExpression]) {
      return patterns[xhtmlExpression];
    }
    var patternObj = new XHTMLPattern(xhtmlExpression);
    patterns[xhtmlExpression] = patternObj;
    return patternObj;
  };
}

非同期テキスト通信用XMLHTTPRequestラッパ

非同期通信でテキストとかJSONを取得するのに楽なように。 定型コードをまとめておいたから短い行数でリクエストを投げれるようにしたところと、 何回もリクエストを投げるときの効率を多少考えたあたりが工夫ポイントかと。

いまどきprototype.jsやjQueryも使わずにフルスクラッチで書いてる奴がどれぐらいいるのか知らないけど。 ファイルサイズでかいからできるだけ使いたくないんだよ、あれ。


/**
 * AsyncHttpClientオブジェクトのコンストラクタ
 * 使用例
 * http = new AsyncHttpClient();
 * 
 * @author again@T(http://nijzero.dw.land.to/)
 */
function AsyncHttpClient(){
  var ie = ["Msxml2.XMLHTTP.5.0", "Msxml2.XMLHTTP.4.0", "Msxml2.XMLHTTP.3.0", "Msxml2.XMLHTTP", "Microsoft.XMLHTTP"];
  var usedtype = (function(){
    try {new XMLHttpRequest(); return -1;} catch(e1) {}
    for(var i=0;i<ie.length;i++){
      try {new ActiveXObject(ie[i]); return i;} catch(e2) {}
    }
    throw new Error('対応しないブラウザです');
  })();
  
  function paramString(param){
    function checkOwnProperty(param, prop){
      if(param.hasOwnProperty){return param.hasOwnProperty(prop);}
      return param[prop] && !param.constructor.prototype[prop];
    }
    function toString(obj){
      return obj === null ? "null" : encodeURIComponent(obj.toString());
    }
    var params = [];
    for(var key in param){
      if(checkOwnProperty(param, key)){
        params.push(toString(key) + "=" + toString(param[key]));
      }
    }
    return params.join("&");
  }
  
  function getRequestObject(callback){
    var httprq;
    switch(usedtype){
    case -1:
      httprq = new XMLHttpRequest();
      break;
    default:
      httprq = new ActiveXObject(ie[usedtype]);
    }
    httprq.onreadystatechange = function(){
      if(httprq.readyState==4){callback(httprq.status, httprq.responseText);}
    };
    return httprq;
  }
  
  /**
   * POSTメソッドでリクエストする。
   * 使用例
   * try{
   *   http.post("http://example.com/search", {"key":"aaa", "index":"10"}, function(status, text){...});
   * }catch(e){
   *   alert(e.message);
   * }
   * 
   * @param url URL
   * @param param パラメータを格納したオブジェクト。
   * @param callback コールバック関数。引数は、statusがHTTPステータスの数字、textがレスポンス文字列。
   * @throws Error 何らかの都合でHTTPリクエストができない場合。
   * @return 要素
   */
  this.post = function(url, param, callback){
    var httprq = getRequestObject(callback);
    try{
      httprq.open('POST', url, true);
      httprq.setRequestHeader("Content-Type","application/x-www-form-urlencoded");
      httprq.send(paramString(param));
    }catch(e){
      throw new Error('HTTPリクエストが失敗しました。');
    }
  };
  
  /**
   * GETメソッドでリクエストする。
   * 使用例
   * try{
   *   http.get("http://example.com/search", {"key":"aaa", "index":"10"}, function(status, text){...});
   * }catch(e){
   *   alert(e.message);
   * }
   * 
   * @param url URL
   * @param param パラメータを格納したオブジェクト。
   * @param callback コールバック関数。引数は、statusがHTTPステータスの数字、textがレスポンス文字列。
   * @throws Error 何らかの都合でHTTPリクエストができない場合。
   * @return 要素
   */
  this.get = function(url, param, callback){
    var httprq = getRequestObject(callback);
    try{
      httprq.open('GET', param ? (url + "?" + paramString(param)) : url, true);
      httprq.setRequestHeader("If-Modified-Since", "Thu, 01 Jun 1970 00:00:00 GMT");
      httprq.send(null);
    }catch(e){
      throw new Error('HTTPリクエストが失敗しました。');
    }
  };
}

以上2つ合わせてコメントを消して、たった260行、9KBの俺フレームワーク。