javascript中でのHTML表現。

javascriptで動的なUI等を書くと、どうしてもネックになるのが、HTMLノードの表現、その整理法だ。少なくとも私はそうだ。
pagmo書いていたときなど、コードのかなりの量をHTMLノードの記述に使っている。
しかし本質的には重要なコードではないので、なるべくコンパクトに書きたい。ただ単にテキストで作って、それをinnerHTMLとかで流し込むのは、コードが汚くなるし、ソースの見た目もきたなくなるしいやだ。(短い場合は速度が速いらしいが)

common lispschemeなどでは、S式で表されるリストをつかってデータ構造とし、それをHTMLに変換することをよくするようだ。
たとえば以下のようにする。

(defun standard-page (&key title body)
  (let ((pbody (cond ((atom body) (list body))
                     ((atom (car body)) (list body))
                     (t body))))
    `(html (:xmlns "http://www.w3.org/1999/xhtml")
       (head ()
         (title () ,(str+ "Canvas wiki - " title))
         (meta (:http-equiv "content-type" :content "text/html; charset=UTF-8"))
         (meta (:name "description" :content "Canvas Common Lisp wiki"))
         (meta (:name "keywords" :content "common lisp,wiki,canvas,nagayoru,smihica,lisp"))
         (link (:rel "shortcut icon"  :href "favicon.ico"))
         (link (:rel "icon" :href "favicon.ico"))
         (link (:href "css/main.css" :rel "stylesheet" :type "text/css" :media "all")))
       (body ()
             (div (:id "main")
                  (h1 () ,title)
                  ,(get-recently-list-page)
                  (div (:id "content" :class "container")
                       ,@pbody))))))

これは、この前書いていたwikiの一部だが、何がよいかというと、データ構造がテキストでないため、リスト操作として、HTMLノードを自由にいじくれる点である。そのようにできると、テンプレートをつくり、組み合わせながら構築することができるし、関数の組み合わせでいろいろなことができる。
たとえば、ソース中の(get-recently-list-page)は、最近の更新をHTMLリストにして返す関数である。



このような方法を他言語で実現している例もあって、

http://jp.rubyist.net/magazine/?0012-qwikWeb#l8
↑のページの「HTML を配列として扱う」の項に江渡浩一郎氏のqwikWebでのHTMLタグの表現方法が書いてある。

たとえば以下のようなコードとして表現する。

require 'qwik/wabisabi-format-xml'

html = [:html,
  [:head,
    [:title, 'hello']],
  [:body,
    [:h1, 'hello, world!'],
    [:p, 'This is a ',
      [:a, {:href=>'hello.html'}, 'hello, world'],
      ' example.']]]

puts html.format_xml

で、以上のような方法をjsではどう表現できるか考えた。
たとえば、こんな風に書けるといいだろう。

js>convert_to_html_node(["div",{id:"outer",style:{width:100,height:100,color:"#FFFFFF"}},
                         ["a",{href:"http://google.com"},"google"],
                         ["span",{},["br"],["br"],"aho"]]);
 => htmlDivElement

↑の式はノードが帰ってくるものだが、

js>convert_to_html_text(["div",{id:"outer",style:{width:100,height:100,color:"#FFFFFF"}},
                         ["a",{href:"http://google.com"},"google"],
                         ["span",{},["br"],["br"],"aho"]]);
 => <div id="outer" style="width:100px;height:100px;color:#FFFFFF;"><a href="http://google.com">google</a><span><br/><br/>aho</span></div>

↑のように、文字列が帰ってくる関数もほしいだろう。

そんなこんなで以下のような関数を書いた。

まずは基本関数

Array.prototype._map = function(callback,optional){
  var this_object = this;
  if(optional &&
     optional.this_object) this_object = optional.this_object;
  for(var i=0,res=[],len=this.length;i<len;i++)
    res[i] = callback.call(this_object,this[i],i,this);
  return res;
};

var add_event = function(context,func,type){
  if (window.addEventListener)
    context.addEventListener(type.replace(/^on/,""),
                             func,
                             false);
  else if (window.attachEvent)
    context.attachEvent(type, func);
};

var appendChilds = function(elem,childArr){
  for(var i=0,l=childArr.length;i<l;i++)
    elem.appendChild(childArr[i]);
  return elem;
};

mapはmap機能を実現する関数で、あとは名前でわかるだろう。
次にノードを返すバージョン。

var number_styles = ["top","left","right","bottom","width","height","margin","padding","size"];
var styles_reg_exp = new RegExp(number_styles.join("|"),"i");

var add_style = function(elem,data){
  for(var i in data){
    if(i.match(styles_reg_exp) &&
       typeof data[i] == 'number') data[i] = data[i].toString() + 'px';
    else data[i] = data[i].toString();
    elem.style[i] = data[i];
  }
  return elem;
};

var add_attr = function(elem,data){
  for(var i in data){
    if(typeof data[i] == 'function'){
      add_event(elem,data[i],i);
    }
    else {
      if(i == 'style') add_style(elem,data[i]);
      else elem[i] = data[i].toString();
    }
  }
  return elem;
};

var convert_to_html_node = function(arr){
  if(arr instanceof Array) {
    if(arr.length == 0) return null;
    var elem = document.createElement(arr[0]);
    if(arr.length == 1) return elem;
    add_attr(elem,arr[1]);
    arr.shift(); arr.shift();
    if (arr.length == 0) return elem;
    return appendChilds( elem , arr._map(convert_to_html_node));
  } else return document.createTextNode(arr);
};

これでできた。

convert_to_html_node(["div",{id:"outer",style:{width:100,height:100,color:"#FFFFFF"}},
                      ["a",{href:"http://google.com"},"google"],
                      ["span",{},["br"],["br"],"aho"]]);
 => htmlDivElement

firebugで確認してみたが、中身までうまくできている。

ちなみに、ノードを返すバージョンではonmouseoverなどに関数をそのまま書き込める。

var xx = function(){alert("mouseover!");};
convert_to_html_node(["div",{id:"outer",
                             onmouseover:function(){xx()};
                            },"on mouseover do xx."]);

後述の文字列を返すバージョンでは、関数だった場合値は捨てられる。



文字列を返すバージョン

var empty_elements = ["area","base","br","col","hr","img","input","link","meta","param"];
var elems_reg_exp = new RegExp(empty_elements.join("|"),"i");
var tag_to_str = function(tag_name,attr){
  return function(cont){
    return ((tag_name.match(elems_reg_exp))?
            ["<",tag_name,attr_to_str(attr),"/>"]:
            ["<",tag_name,attr_to_str(attr),">",cont,"</",tag_name,">"]).join("");
  }
};

var style_to_str = function(style){
  var ret = "";
  for(var i in style)
    ret = ret + i + ":" + style[i].toString() +
          (((i.match(styles_reg_exp) &&
             typeof style[i] == 'number'))?"px;":";");
  return ret;
};

var attr_to_str = function(attr){
  var ret = "";
  for(var i in attr)
    if(typeof attr[i] != 'function')
      ret = ret + " " + i + "=\"" + ((i == 'style')?
                                     style_to_str(attr[i]):
                                     attr[i].toString()) + "\"";
  return ret;
};

var convert_to_html_text = function(arr){
  if(arr instanceof Array) {
    if(0==arr.length) return "";
    var f = tag_to_str(arr[0],((arr.length==1)?{}:arr[1]));
    arr.shift(); arr.shift();
    if(0==arr.length) return f("");
    return f(arr._map(convert_to_html_text).join(""));
  } else return arr;
};

以上でできる。

convert_to_html_text(["div",{id:"outer",style:{width:100,height:100,color:"#FFFFFF"}},
                      ["a",{href:"http://google.com"},"google"],
                      ["span",{},["br"],["br"],"aho"]]);
 => <div id="outer" style="width:100px;height:100px;color:#FFFFFF;"><a href="http://google.com">google</a><span><br/><br/>aho</span></div>

JavaScript-C 1.6 2006-11-19で動作確認した。

このようにすると、テンプレートがつくれて、動的生成のHTMLオブジェクトが作りやすくなった。また、配列データとなるので、よりコードがきれいになる。データを配列に格納して、それを操作してHTMLノード用の配列に変換し、それをさらにHTMLノードに変換し、書き込むといった操作ができるようになり、タグを直で書く必要がほとんどなくなる。