Kengo's blog

Technical articles about original projects, JVM, Static Analysis and TypeScript.

メモ・NodeでHTMLファイルにJSファイルを埋め込む

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>
CDATAセクションの使い方について(追記)

疑問にズバリ答えるエントリがいくつか。HTMLなのかXHTMLなのかを判断して切り替える必要があるか。コメントアウトは(XHTMLがvalidなら)基本不要のはず。

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(&apos;hello, world&apos;);</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);
});
結果
<script>
//<![CDATA[
var require=function(a,b){var c=require.resolve(a,b||"/"),d=require... // 略
//]]>
</script>


以上。埋め込まれたコードがChromeで動くことは確認済み。cssに対しても同等の埋め込み・連結・圧縮が可能であると思われる。