HTML5ゲーム開発環境構築支援ツールを作った件の続き。通信回数削減という名目のもとで、HTMLにJSファイルを静的に埋め込む方法について調査する。試した環境はMac OSX Lion(10.7.2)のNode v0.4.12。
ちなみにこの「埋め込み」がNodeに適した処理かどうかは今回考慮していない。JavaScriptでやりたい個人的な理由があったというだけ。動的に埋め込むミドルウェアとしてはconnect-assetmanagerが面白そう。
期待する結果
<script src="hello.js"></script>
が、以下のように変換される。CDATAセクションの書き方が妥当であるかどうかは要調査。
<script> //<![CDATA[ console.log("hello, world!"); //]]> </script>
xml-streamを使う(失敗)
パッと見インタフェースがよさそうなxml-stream。ただCDATAを扱えるという記述がない&文字列を無条件でエスケープしてしまうので、JSの埋め込みには使用できなかった。また、Node 0.6.3だと依存モジュールのインストールに失敗した。
コード
var XmlStream = require('xml-stream'), fs = require('fs'), path = require('path'); var stream = fs.createReadStream(path.join(__dirname, 'index.html')), xml = new XmlStream(stream); xml.on('updateElement: script', function (item) { if (item.$.src) { delete item.$.src; item.$text = fs.readFileSync(item.$.src).toString(); } }); xml.on('data', function (data) { process.stdout.write(data); });
結果
<script>console.log('hello, world');</script>
Apricotを使う(失敗)
rubyのライブラリにインスパイアされたというApricotを使う。DOM要素を追加してもHTMLが変更されないという現象に遭遇し、これが未解決。inner()がバグっているらしく、付属のサンプルも動かない。printデバッグしてみたが原因がよくわからなかった。Nodeを0.6系に上げても解決しない。
コード
var fs = require('fs'), path = require('path'), Apricot = require('apricot').Apricot; Apricot.open('index.html', function (err, doc) { doc.find('script[src]').each(function (item) { var src = item.src, text = fs.readFileSync(src).toString(), cdata = doc.document.createCDATASection(text); item.removeAttribute('src'); // item.appendChild(cdata); <- 何も起こらない // doc.inner(cdata); <- 何も起こらない doc.inner('\n//<![CDATA[\n' + cdata.nodeValue + '\n//]]>\n'); // afterなどに変えると正常動作 }); process.stdout.write(doc.toHTML); });
結果
<script> </script>
NodeHtmlParserを使う?
NodeHtmlParserを使おうとしたが、DOM要素の変更・追加に関するドキュメントが見つからなかったのでパス。
再・Apricotを使う
xml-streamのバグ(Node v0.6.3でインストールできない)よりもApricotのバグ(innerだけ動作しない)の方が軽微なので、コード側でApricotのバグを回避することを検討。恐ろしく残念なコードになる上にバグが入りやすくなるが、当面は気にしない。Apricotが動かないのが自分の環境だけという可能性も高いし。
コード
var fs = require('fs'), path = require('path'), Apricot = require('apricot').Apricot, _ = require('underscore'), scripts = []; function scriptMark(fileName) { return '&&' + fileName + '&&'; } Apricot.open('index.html', function (err, doc) { var html = doc.find('script[src]').each(function (item) { scripts.push(item.src); doc.after(scriptMark(item.src)); item.removeAttribute('src'); }).toHTML; _.each(scripts, function (src) { var script = fs.readFileSync(src).toString(); html = html.replace( '</script>' + scriptMark(src), '//<![CDATA[\n' + script + '\n//]]>\n</script>'); }); process.stdout.write(html); });
結果
<script> //<![CDATA[ console.log('hello, world'); //]]> </script>
追記
上記スクリプトはscriptタグが複数存在していた場合に対応できていない。doc.after()の代わりに以下を使うこと。
item.parentNode.insertBefore(doc.document.createTextNode(scriptMark(item.src)), item.nextSibling);
browserifyによるrequireの連結
結合対象ファイルにrequireが含まれていた場合、目的である通信回数削減は達成できない。browserifyによってJSを連結し、これに対処する。AMDはrequireと違い、意図的に非同期を使用しているところなので、特に対応すべきではないだろう。
コード
var fs = require('fs'), path = require('path'), Apricot = require('apricot').Apricot, _ = require('underscore'), browserify = require('browserify'), scripts = []; _.str = require('underscore.string'); function scriptMark(fileName) { return '&&' + fileName + '&&'; } Apricot.open('index.html', function (err, doc) { var html = doc.find('script[src]').each(function (item) { scripts.push(item.src); doc.after(scriptMark(item.src)); item.removeAttribute('src'); }).toHTML; _.each(scripts, function (src) { var b = browserify(), script = fs.readFileSync(src).toString(); b.append(script); html = html.replace( '</script>' + scriptMark(src), '//<![CDATA[\n' + b.bundle() + '\n//]]>\n</script>'); }); process.stdout.write(html); });
結果
<script> // <![CDATA[ var require = function (file, cwd) { var resolved = require.resolve(file, cwd || '/'); var mod = require.modules[resolved]; // (中略) console.log('hello, world'); //]]> </script>
browserifyが生成するコードがわりと長い。package.jsonのdependenciesが無いあるいは空っぽの時は、この処理を省略すべきだろう。
追記
処理対象のJavaScriptがrequireを使っているなら、既にbrowserifyやrequire.jsといったライブラリを使っているはずで、ここで余計に挿入されるコードは不要のはず。あとクライアントサイドで使われることを考慮すると、require.jsに切り替えたほうがAMD対応などが見えてきて嬉しいかも。
ついでに圧縮する
通信回数だけでなく通信量も削減する。これにはuglify-jsを使うと簡単。YUI Compressorよりも効率がいいらしいが、よく調べていない。
コード
var fs = require('fs'), path = require('path'), Apricot = require('apricot').Apricot, _ = require('underscore'), browserify = require('browserify'), scripts = []; _.str = require('underscore.string'); function scriptMark(fileName) { return '&&' + fileName + '&&'; } function compressJavaScripts(js) { var jsp = require('uglify-js').parser, pro = require('uglify-js').uglify, ast = jsp.parse(js); ast = pro.ast_mangle(ast); ast = pro.ast_squeeze(ast); return pro.gen_code(ast); } Apricot.open('index.html', function (err, doc) { var html = doc.find('script[src]').each(function (item) { scripts.push(item.src); doc.after(scriptMark(item.src)); item.removeAttribute('src'); }).toHTML; _.each(scripts, function (src) { var b = browserify(), script = fs.readFileSync(src).toString(); b.append(script); html = html.replace( '</script>' + scriptMark(src), '\n//<![CDATA[\n' + compressJavaScripts(b.bundle()) + '\n//]]>\n</script>'); }); process.stdout.write(html); });