最近はJSでもテンプレート・エンジンが多く使われています。
そしてGruntのモジュールでもJSTのプリコンパイラというものが多く登場しています。
テンプレート・エンジンにはコンパイル・メソッド(または同様の機能)が実装されていますが、プリコンパイラとの違いとか使い分けの話です。
テンプレート・エンジンのコンパイル・メソッド
まずは、underscore互換のlodash、jQueryで使えるJsRender、mustache互換のテンプレート・エンジンHandlebarsの、それぞれの挙動を見てみます。
lodash
まずはlodashです。
_.templateメソッドにテンプレート文字列を入れて実行します。
var compiled = _.template('<div><%= name %></div>');
これをconsole.logしてみると
// function (obj) {
// obj || (obj = {});
// var __t, __p = '', __e = _.escape;
// with (obj) {
// __p += '<div>' +
// ((__t = ( name )) == null ? '' : __t) +
// '</div>';
// }
// return __p
// }
関数が生成されています。先ほど入れた文字列の断片も見えます。これをデータの入ったオブジェクトを入れて実行します。
var html = compiled({
name : 'taro'
});
console.log(html); //<div>taro</div>
htmlの中には展開された文字列が生成されました。
JsRender
続いてJsRender。流れはlodashとほぼ同じですが、コンパイルのメソッドが$.templatesなのと、文字列の生成がrenderメソッドになっています。
var compiled = $.templates('<div>{{: name }}</div>');
console.log(compiled); // object
console.log(compiled.fn);
// object.fn
// function anonymous(data,view,j,u
// /**/) {
// // unnamed
// var j=j||jQuery.views,v,ret="";
// try{
// ret+="<div>";
// ret+=(v=data.name)!=u?v:"";
// ret+="</div>";
// return ret;
// }catch(e){return j._err(e);}
// }
var html = compiled.render({
name : 'taro'
});
console.log(html); //<div>taro</div>
$.templatesメソッドはオブジェクトを返してきて、その中にあるfnにlodashと同じように、テンプレート文字列の断片のようなものが入っています。
Handlebars
最後にHandlebarsです。HandlebarsはHandlebars.compileメソッドでコンパイルするか、Handlebars.templateメソッドを使用します。
var compiled = Handlebars.compile('<div>{{name}}</div>');
console.log(compiled);
// function (context, options) {
// if (!compiled) {
// compiled = compileInput();
// }
// return compiled.call(this, context, options);
// }
var html = compiled({
name : 'taro'
});
console.log(html); // <div>taro</div>
compiledをconsole.logすると、さきほどの二つと違って断片はありませんが、生成された関数にデータ・オブジェクトを入れて実行すると文字列が生成されるという流れは同じです。
プリコンパイラ
続いてプリコンパイラと呼ばれるものが何を生成するかを見ていきます。
ここでは、Gruntのタスクのgrunt-contrib-handlebars、gruntjs/grunt-contrib-jst、
grunt-contrib-hogan(公式のgrunt-contribではないようです)を使います。
grunt-contrib-handlebars
まずは、grunt-contrib-handlebarsです。
Gruntfileの設定と対象のテンプレートの中身は以下です。
handlebars: {
build: {
options: {
namespace: "Test.Templates",
processName: function(filename){
return (/\/([a-zA-Z1-9-]+)\.html$/.exec(filename))[1];
}
},
files: {
"public/js/templates/hb.js": ["src/templates/hb.html"]
}
}
},
<div>{{name}}</div>
とりいそぎこちらを実行してみます。
this["Test"] = this["Test"] || {};
this["Test"]["Templates"] = this["Test"]["Templates"] || {};
this["Test"]["Templates"]["hb"] = Handlebars.template(function (Handlebars,depth0,helpers,partials,data) {
this.compilerInfo = [4,'>= 1.0.0'];
helpers = this.merge(helpers, Handlebars.helpers); data = data || {};
var buffer = "", stack1, functionType="function", escapeExpression=this.escapeExpression;
buffer += "<div>";
if (stack1 = helpers.name) { stack1 = stack1.call(depth0, {hash:{},data:data}); }
else { stack1 = (depth0 && depth0.name); stack1 = typeof stack1 === functionType ? stack1.call(depth0, {hash:{},data:data}) : stack1; }
buffer += escapeExpression(stack1)
+ "</div>";
return buffer;
});
すると、グローバルオブジェクト(this)にGruntfileのnamespaceオプションで設定したTest、Templatesという名前空間(オブジェクト)が生成され、そこにHandlebars.templateの実行結果が格納されました。とりいそぎ、次にいきます。
grunt-contrib-jst
続いてgrunt-contrib-jstです。
こちらも同様に実行してみます。このタスクはlodashテンプレートが対象になります。
jst: {
build: {
options: {
namespace: "Test.Templates",
processName: function(filename){
return (/\/([a-zA-Z1-9-]+)\.html$/.exec(filename))[1];
}
},
files: {
"public/js/templates/lo.js": ["src/templates/lo.html"]
}
}
}
<div>{{name}}</div>
実行結果は以下です。
this["Test"] = this["Test"] || {};
this["Test"]["Templates"] = this["Test"]["Templates"] || {};
this["Test"]["Templates"]["lo"] = function(obj) {
obj || (obj = {});
var __t, __p = '', __e = _.escape;
with (obj) {
__p += '<div>{{name}}</div>';
}
return __p
};
さきほどと同様、設定で指定した名前空間がつくられ、loプロパティにメソッドが追加されました。このメソッド、最初に行ったlodashの_.templateメソッドの結果とほぼ同じです。
grunt-contrib-hogan
続いてgrunt-contrib-hoganです。
hogan: {
build: {
options: {
namespace : 'Test.Templates',
prettify: true,
defaultName: function(filename){
return (/\/([a-zA-Z1-9-]+)\.html$/.exec(filename))[1];
}
},
files:{
"public/js/templates/ho.js": ["src/templates/ho.html"]
}
}
}
<div>{{name}}</div>
こちらも同様に実行してみます。
this["Test"] = this["Test"] || {};
this["Test"]["Templates"] = this["Test"]["Templates"] || {};
this["Test"]["Templates"]["ho"] = new Hogan.Template(function(c,p,i){var _=this;_.b(i=i||"");_.b("<div>");_.b(_.v(_.f("name",c,p,0)));_.b("</div>");return _.fl();;});
似たような形で、名前空間を作ったのちに、hoプロパティにHogan.Templateコンストラクタの返り値が入ったものが生成されました。
つまるところ
this["Test"]["Templates"]["hb"]、this["Test"]["Templates"]["ho"]、this["Test"]["Templates"]["lo"]のそれぞれですが、typeofで型を調べてみるとすべてfunctionです。
これは最初にテンプレート・エンジンのコンパイル・メソッドで生成されたメソッド(JsRenderでいう、renderメソッド)が生成され、グローバルの名前空間にエクスポートされたことになります。
つまるところ、プリコンパイラのプリコンパイルというのは、事前にテンプレートをコンパイルして、最適化されたJavaScriptのテンプレート(JST)に変換する(この場合、グローバル空間にエクスポートもしている)というもののようです。
実際使ってみる
プリコンパイルしたものを実際に使ってみます。
Handlebarsの場合
まずはHandlebarsと、プリコンパイルしたjsを読み込みます。
script(src="/js/lib/handlebars.js")
script(src="/js/templates/hb.js")
グローバルにエクスポートされたメソッドにデータ・オブジェクトを入れて実行すると、文字列が生成されます。
var html = this["Test"]["Templates"]["hb"]({
name : 'taro'
});
console.log(html); //<div>taro</div>
Hoganの場合
同様にHogan。
script(src="/js/lib/hogan-2.0.0.js")
script(src="/js/templates/ho.js")
こちらはrenderメソッドを使います。
var html = this["Test"]["Templates"]["ho"].render({
name : 'taro'
});
console.log(html); //<div>taro</div>
lodashの場合
同様にlodash
script(src="/js/lib/lodash.js")
script(src="/js/templates/lo.js")
おなじく、データを入れて実行します。
var html = this["Test"]["Templates"]["lo"]({
name : 'taro'
});
console.log(html); //<div>taro</div>
結局のところ
それぞれのテンプレート・エンジンに機能の差があるので、使い方には差異がありますが、基本原理は同じような仕組みなのかと思われます。
AMD
さきほどのgruntタスクにはそれぞれamdオプション(hoganは昔のオプション名と同じamdWrapper)があります。これはその名前のとおり、AMDの仕様で出力してくれるオプションです。
具体的にはdefineに包んだ状態で書き出されます。
それぞれ同じような形なので、ここでは、Handlebarsの例をのせます。
handlebars: {
build: {
options: {
amd: true,
namespace: "Test.Templates",
processName: function(filename){
return (/\/([a-zA-Z1-9-]+)\.html$/.exec(filename))[1];
}
},
files: {
"public/js/templates/hb-amd.js": ["src/templates/hb.html"]
}
}
},
実行すると以下のように出力されます。
define(['handlebars'], function(Handlebars) {
this["Test"] = this["Test"] || {};
this["Test"]["Templates"] = this["Test"]["Templates"] || {};
this["Test"]["Templates"]["hb"] = Handlebars.template(function (Handlebars,depth0,helpers,partials,data) {
this.compilerInfo = [4,'>= 1.0.0'];
helpers = this.merge(helpers, Handlebars.helpers); data = data || {};
var buffer = "", stack1, functionType="function", escapeExpression=this.escapeExpression;
buffer += "<div>";
if (stack1 = helpers.name) { stack1 = stack1.call(depth0, {hash:{},data:data}); }
else { stack1 = (depth0 && depth0.name); stack1 = typeof stack1 === functionType ? stack1.call(depth0, {hash:{},data:data}) : stack1; }
buffer += escapeExpression(stack1)
+ "</div>";
return buffer;
});
return this["Test"]["Templates"];
});
見事にdefineで包まれました。依存ファイルとしてHandlebarsが指定されています。
ちなみに
require.jsではtextプラグインを使う事によって、通常のテキスト(テンプレート)を読み込むことができます。今回でいうところのプリコンパイルする前のテンプレートです。
ただし、これらはドメインが違う場合は動作しません。
これは、require.jsの仕様で、同じドメイン内ではXHRで、外部ドメインのときはscriptタグを使って読み込みを行おうとするからです。
scriptタグで外部からのリソースを読みこむと、type属性にtext/javascript以外を指定しても、JavaScriptとしてパースされます。よかれと思ってテキストやテンプレートを読み込んでも、JavaScriptの構文に反していれば、そこでエラーになってしまいます。
もはや定番となったJSONPという凄いハック技がありますが、これも同様にscriptタグで読み込みますが、読み込む内容がJavaScriptとしてパースできるために実行されます。
AMDのdefineも似たような形で実行できます。
そのような理由から、もしテンプレートやテキストファイルなどを別ドメインにホスティングして、require.jsで読み込む必要がある場合は、AMD仕様で出力することで解決できるかと思います。
まとめ
とくに大量のテンプレートを必要とする場合などは、AMD仕様のJSTを非同期的に呼ぶことで、効率よいリソース読み込みができるかと思います。
たかがプリコンパイル、されどプリコンパイル。