EJSとは?
javascript用のテンプレートエンジンです。
詳しくは以下を参照してください。
HTMLなどのテンプレートテキスト内にjavascriptのロジックを記述したり、変数や関数の実行を評価してテンプレートテキスト中に埋め込んだりできるものです。
本エントリにて記述する内容
テンプレートエンジンEJSの仕組みを、ソースコードを読みつつ解説したものです。
これを書いた動機として、テンプレートエンジンを作りたかったこと、テンプレートエンジンの仕組みが気になったことなどがあります。
ライセンス
解説のためにソースコードを引用しているので、ライセンスを記述しておきます。
EJSはApache License 2.0です。
また、このQiitaエントリのライセンスはQiitaの利用規約に準拠します。
ざっくりとしたEJSの原理
テンプレートとして渡された内容を解釈して、javascriptのソースコード文字列にして、それを関数として実行する(new Functionによる関数の生成)ことで、テンプレートとして成り立ちます。
<%= hoge %>などのjsとして記述されるべき部分をソースコードに直接記述し、<h1>Foo</h1>などのHTMLとして記述されるべき部分を文字列を合成する処理として、ソースコード文字列に記述してしまいます。
簡単に言えば、HTMLに埋め込まれていた「非ネイティブHTML」を「jsコード」に、HTMLコードとして記述されていた「ネイティブHTML」を「js文字列」に変換してしまいます(もちろんEJSはHTML以外にも適用可能です)。
基本的な流れ
- 文字列のパースを行い、EJSタグ文字で文字を区切り、バラバラにした状態で配列にします
- パースされた配列からEJSタグ文字に応じてjsコードにしたり、文字列にしたりして、jsのソースコードを生成します
- 生成されたjsのソースコードにさらに前後の処理を追加して、関数を構成できる文字列として成立させます
- new Functionを行うことで、文字列を関数として解釈を行い、文字列を出力する関数を生成します
- 生成された関数を用いることで、変数を埋め込めるテンプレートとして作用します
解説
それでは、ソースコードを読んで、解説していきます。
まず、ライブラリユーザがcompile
関数を呼び出したところからスタートしていきます。
1. compile関数の呼び出し
exports.compile = function compile(template, opts) {...
話を簡単にするために、オプション設定項目であるoptsはundefinedにします。
2. Templateオブジェクトの作成と返却
templ = new Template(template, opts);
return templ.compile();
Templateというオブジェクトを作り、compile関数を呼んで返却しています。
そのため、Templateクラスとそのcompile関数を読み解いていきます
Templateクラスは以下のような定義になっています。
function Template(text, opts) {...
new Template
された際の処理順から追っていきます。
3. オプションの構築
ここでもTemplateクラスのオプション引数であるoptsはundefinedです。
そのため、optsは関数内で空オブジェクト{}
になります。
また、exportsから変更できる変数項目は変更しないこととします。
そのためオプション項目はデフォルトになります。
以下はソースコードの内容からわかりやすくコードを再構築・再編集したものです。ソースコードそのままではありません。
4. Templateオブジェクトのインスタンスtemplのnew直後の状態
オプション構築が終わったあとnew Template(template)
されたオブジェクトを変数templ
に代入します。
今後の文章では、変数templ
をTemplateクラスのインスタンスのように表現し扱っていきます。
すると、現時点でオブジェクトtempl
に設定済みのkeyとvalueは以下のようになっています
以下はソースコードの内容からわかりやすくコードを再構築・再編集したものです。ソースコードそのままではありません。
templ = {
templateText: text, //テンプレートにて解釈対象となる、入力そのままのテキストが入っています
mode: null,
truncate: false,
currentLine: 1,
source: '',
dependencies: [],
opts: options, //「オプションの構築」の項目で設定したオプションオブジェクト(今回はデフォルト)
regex: new RegExp('(<%%|%%>|<%=|<%-|<%\_|<%#|<%|%>|-%>|\_%>)') //createRegex関数が呼び出されて返却された結果です、全てのEJSで使うEJSタグ文字列を変換する正規表現です
}
5. templ.compile関数の開始
まず、this.sourceが最初の時点では空白文字列なので、generateSource関数が呼び出されます。
if (!this.source) {
this.generateSource();
6. generateSource関数の処理
templateText、つまり入力のままだったテキストを、この関数は変更していきます。
主な処理としては、parseTemplateTextを呼び出した結果をmatches変数に代入します。
7. parseTemplateText関数の処理
templateTextに対して、templ.regex、つまり全てのEJSで使うEJSタグ文字列を変換する正規表現でマッチ処理させます。
このregexはgオプションがないので、最初にマッチしたマッチオブジェクトが得られます。
そのマッチオブジェクトを用いて、EJSで用いるEJSタグ文字列の前の文字列、EJSタグ文字列、EJSタグ文字列内の文字列、EJSタグ文字列、EJSタグ文字列の後の文字列(次のEJSタグ文字列の前の文字列)、EJSタグ文字列...というように、文字列をバラバラにしていきます。
例えばEJSのDocs Exampleにある以下の文字列をバラバラにすることで次に示すような配列が得られます。
入力
<% if (user) { %>
<h2><%= user.name %></h2>
<% } %>
結果
[
'<%',
' if (user) { ',
'%>',
'<h2>',
'<%=',
' user.name ',
'%>',
'</h2>',
'<%',
' } ',
'%>'
]
8. パースして得られたmatches配列の前処理
matches配列を順に読みとっていきます。
まずはEJSタグ文字列の開始と終了が合っているかどうかを検査します。
だめならErrorを投げます。
また、includeの記述があった場合、別のEJSファイルを読み込み、あらたなTemplateオブジェクトを作り、そのソースをそこに挿入する処理をしていますが、今回は割愛します。
その後、scanLine関数に、各行を投げていきます。
9. scanLine関数による行単位の処理
9-1. scanLine関数の基本構成
Templateオブジェクトのmodeを変更しながら、パースを行なっていきます。
modeは以下の通りです。
- EVAL
- ESCAPED
- RAW
- COMMENT
- LITERAL
9-2. EJSタグ文字の場合、その解釈とmodeの変更
'<%'や'<%='などの文字列の種類に応じてmodeを切り替えていきます。
代表的なタグについて記述します。
<% の時
内部で単にjsを実行するタグです。
この場合modeをEVALに変えます。
<%= の時
値や戻り値をHTMLエスケープしてHTMLに挿入します。
この場合modeをESCAPEDに変えます。
<%- の時
値や戻り値をHTMLエスケープせずにHTMLに挿入します。
modeをRAWに変えます。
%> の時
modeをnullにします。
9-3. EJSタグ文字に当てはまらなかった場合
modeに応じてsource文字列に対して出力を行います。
代表的な場合について記述します。
まず、modeがEVALの時は、source文字列にそのままlineを追加します。
case Template.modes.EVAL:
this.source += ' ; ' + line + '\n';
break;
ESCAPEDあるいはRAWの時は、その行を評価した結果をescapeFnに通す、あるいは直接__appendという関数に渡すように記述します。
case Template.modes.ESCAPED:
this.source += ' ; __append(escapeFn(' + stripSemi(line) + '))' + '\n';
break;
// Exec and output
case Template.modes.RAW:
this.source += ' ; __append(' + stripSemi(line) + ')' + '\n';
break;
そもそもmodeがnullの場合は、_addOutput関数が呼び出されます。
9-4. _addOutput関数
_addOutput関数は、様々なオプション項目などを除けば単純で、行にエスケープ文字の処理を施した上で、__append関数にその行を文字列扱いで記述するというものです。
以下がエスケープ処理の部分です。
// Preserve literal slashes
line = line.replace(/\\/g, '\\\\');
// Convert linebreaks
line = line.replace(/\n/g, '\\n');
line = line.replace(/\r/g, '\\r');
// Escape double-quotes
// - this will be the delimiter during execution
line = line.replace(/"/g, '\\"');
以下が文字列として記述する部分です。
self.source += ' ; __append("' + line + '")' + '\n';
10. sourceの主部分の完成と、compile関数の処理の完了
前項目までで、source文字列の主部分が完成しました。
さらにsource文字列に、__append関数や、出力のための__output配列、また、最後に__output配列を結合して返す処理などを記述します。
prepended += ' var __output = [], __append = __output.push.bind(__output);' + '\n';
//...
appended += ' return __output.join("");' + '\n';
そのsource文字列を用いて、new Functionをすることでその文字列を関数として生成します。
fn = new Function(opts.localsName + ', escapeFn, include, rethrow', src);
生成された関数は、いくつかのユーティリティ関数を引数にとるものです。
このユーティリティ関数とパラメータのためのデータを、生成された関数に付与してやるためのラップ関数で包んでそのラップ関数を返してやります。
var returnedFn = function (data) {
//...
return fn.apply(opts.context, [data || {}, escapeFn, include, rethrow]);
};
この関数の返却をもって、compile関数の処理が終わりました。
返却された関数は、与えられたパラメータを元に、テンプレートに沿ってjavascriptを実行して文字列を作成し返却する関数です。
この関数の実行によって、HTML文字列などが返却されるわけです。
おわりに(感想)
EJSはjavascriptなので実行時に文字列から関数を生成できますが、実行時に自身の言語を解釈・実行できないような他の言語においてはどうやればいいんでしょうね…。また調査したいと思います。
また、他のテンプレートエンジンとして、Mustacheというものも見つけました。
こちらはLogic-lessというタイプのテンプレートエンジンらしく、テンプレートテキスト中にjsやRubyなどのコードを埋め込むのではなく、データ構造を解釈してテンプレートテキストに入力していくようです。
こちらも面白いと思ったので、またソースコードを読解してみたいなと思います。
それと、この解説を元にしてテンプレートエンジンを実際に作ってみた話を、以下の勉強会で発表します!
【インタースペース&ヒトクセ共催】若手JavaScriptエンジニア&デザイナーLT交流会 - connpass
まだ席が空いていますので、ぜひご参加ください!