Sencha ExtJS/Touch で Browserify を活用する

Browserifyについて

JavaScript には10年以上の歴史がありますが、標準的なモジュールの仕組みが現れたのはごく最近です。CommonJSと呼ばれるこの方式は、サーバサイドJavaScriptの代表格である Node.js に採用された*1ことで徐々に浸透してきましたが、最もJavaScriptを利用する機会の多いWebブラウザ環境においては、JavaScriptをローディングする仕組みの制限もあり*2、長らくこの仕組を使うことはできませんでした。

Browserifyは、こうしたCommonJS形式のモジュール参照を識別して解決し、1つのJSファイルにまとめてくれるプログラムです。配布/テスト前にソースコードにBrowserifyを適用しておくことで、Webブラウザ環境でも必要なモジュールをロード済みの状態で実行できるようになります。

特に、Browserifyは Node.jsの標準モジュールおよびパッケージマネージャ(npm)で配布されるライブラリなどについても組み込むことができるため、Node.jsのエコシステムで育まれた資産をWebブラウザ環境にも流用することができる点が大きなメリットになります。

なお、WebブラウザにおけるJavaScriptなどのコンポーネント管理のための仕組みとしては、他にBowerなどがありますが、利用しているコンポーネントの管理やスクリプトのロードの指定についてはHTML中に記述する必要が出てくるため、JavaScriptのモジュール管理という点だけで考えると管理が煩雑になるきらいがあります。

Senchaとの組み合わせ

話は変わって、SenchaというコンポーネントベースのHTML5フレームワークがあります。Sencha ExtJSはPCブラウザを想定したもので、Sencha Touchはタッチベースのスマートデバイスを前提としています。どちらもあらかじめUIコンポーネントが多数用意されており、JavaScriptコード内にコンポーネントJSONで指定するだけで簡単にUIの構築ができるところが魅力の1つです。

しかしながら、Sencha ExtJS/Touchには独自のモジュールローディングの仕組みがあり、あまりこれら標準的なモジュールシステムとの組み合わせの相性がよくありません。特に、Browserifyでは静的にモジュールの参照解決をビルド時に行いファイル結合するのに対し、Senchaでは(少なくともDevフェーズにおいては)クラスごとの依存関係を調べて該当するモジュールパスにあるJSファイルを動的にロードするようになっているのも、問題を難しくしています。

たとえば、以下のソースコードではJSファイル(app/view/Main.js)内に記述されたJSON設定の中のrequiresプロパティからモジュールを識別し、パスをたどってJSファイルを読み込むようになっています。

app/view/Main.js

Ext.define('MyApp.view.Main', {
    extend: 'Ext.panel.Panel',
    requires:[
        // コード内でapp/view/MyPanel.jsを参照していることを明示
        'MyApp.view.MyPanel'
    ],
    constructor: function() {
        var myPanel = Ext.create('MyApp.view.MyPanel', {});
        // ...
    },
    ....
});

app/view/MyPanel.js

// app/view/Main.jsから参照されているため、このファイルは自動的に読み込まれる
Ext.define('MyApp.view.MyPanel', {
    extend: 'Ext.form.Panel',
    constructor: function() {
        // ...
    },
    ....
});

問題点

ここで別プロジェクトで作成していたCommonJS形式のモジュール、あるいはnpmで配布されているライブラリを上記コンポーネント内で利用したいと思ったとします。 Senchaでは開発フェーズにおいては各クラスモジュールのソースコードファイルを別々にロードしますので、Browserifyで単一のファイルに纏めることはできません。また、先にBrowserifyを適用してしまうと、ソースコードファイルの配置されているパス関係も崩れてしまうので、Senchaのローディングシステムが上手く動かないことが容易に想像されます。

app/view/MyPanel.js

Ext.define('MyApp.view.MyPanel', {
    extend: 'Ext.panel.Panel',
    constructor: function() {
        this.callParent(arguments);
        // npm で配布されている underscore.js モジュールを参照
        var _ = require('underscore');
        var tmpl = _.template('<p>Hello, <b><%= name %></b></p>')
        var html = tmpl({ name: 'John' });
        // ...
    },
    ....
});

ストレートに考えると、以下の方法が思いつくかもしれません。

  1. 依存しているモジュールをそれぞれBrowserify化しておき、Senchaからは外部ファイルとしてロードする
  2. 常にSenchaコマンドを使ってファイルをビルドし、出力ファイルに対してさらにBrowserifyを適用する

1 の場合は、今までどおりSenchaで外部JSファイルを利用する場合とほぼ同じため、多くを望まなければこれで特に支障はないかもしれません。ただし以下の様な問題点があります。

  • 外部Senchaコード内でCommonJS形式のモジュール参照の記述が使えない
  • Senchaから利用するライブラリが増えるたびにそれぞれ別個にビルドし参照に加えなければならない
  • Senchaでライブラリを参照するために、それぞれ常にグローバルに名前空間を作ってライブラリを展開する必要がある(具体的にはBrowserifyのstandaloneオプションを使ってそれぞれビルド出力する必要がある)

2 の場合は、既にSencha内で参照しているモジュールは全て組み込まれた状態であり、そのファイルに対してBrowserifyを適用するので、お互いに干渉することはなさそうですが、以下の様な問題があります。

  • 毎回Senchaコマンド(Sencha専用のビルドツール)を利用して1つのファイルにまとめるのは、開発フェーズではコストが高い作業である
  • Senchaコマンドでの結合ではSourceMapが適用されないため、開発フェーズで全て1ファイルにまとめてしまうとデバッグ時に苦労する

これらの問題によりSenchaとCommonJS(Browserify)の共存が難しくなっています。Node.jsによる資産をSenchaで流用するのが難しくなるだけではなく、Senchaとは本質的に関係のないモジュールまでSenchaのローディングシステムを使って開発してしまい、他プロジェクトでの再利用を妨げてしまうなどの弊害もあります。なんとか解決できないものでしょうか。

extract-required による解決

ここで、上記とは違ったアプローチを考えてみます。基本的には1.のアプローチに近い形になるのですが、要するに、Sencha形式で書かれたソースコードの中から、CommonJS形式で呼ばれているモジュールを自動的に抽出できればよいのではないでしょうか。そうすれば上記の1の問題点は解決となりそうです。

そのために、新しいプログラムを用意しました。ここで紹介するextract-requiredは、JavaScriptソースコード中からCommonJS形式のモジュール参照を検出し、モジュール名をリストで出力するモジュールです。内部でEsprimaを利用しJavaScriptコードの静的解析を行って、require() 関数の呼び出しを検出しています。

ここではこのextract-requiredをGruntタスク化したgrunt-extract-requiredを利用し、ソースコード中に含まれるモジュール参照をリストしたソースコードファイルを生成するようにします。

Gruntfile.coffee

module.exports = (grunt) ->

    require("load-grunt-tasks")(grunt)

    grunt.initConfig
        pkg: require "./package"

        watch:
            sencha:
                files: [ "app/**/*.js" ]
                tasks: [ "build" ]

        extract_required:
            sencha:
                files: [
                    src: [ "app/**/*.js" ]
                    dest: "build/common/required.js"
                ]

        browserify:
            sencha:
                files:
                    "build/common/required-bundle.js" : [
                        "build/common/required.js"
                    ]
            options:
                bundleOptions:
                    standalone: "require"

        clean:
            sencha:
                src: [ "build/common" ]

    grunt.registerTask "build", [ "extract_required", "browserify" ]
    grunt.registerTask "default", [ "build" ]

上記のGrunt設定ファイルでは、ビルド時にextract_requiredタスクを走らせてbuild/common/required.jsファイルに出力します。出力されたrequired.jsファイルは対象となるソースコード内で参照されているモジュールへのrequire()コールと、モジュールを外部から参照するための関数が含まれます。

build/common/required.js

var requireCalled;
module.exports = function (name) { // 外部からモジュールを参照するための関数
  // prevent recursive require call
  if (requireCalled) { throw new Error("Cannot find module '" + name + "'"); }
  requireCalled = true;
  try {
    return require(name);
  } finally {
    requireCalled = false;
  }
};
require("underscore");
require("datejs");
require("q");
...

このファイルに対してbrowserifyタスクを実行することで、参照しているモジュールの中身をすべて含む1つのJSファイル(build/common/required-bundle.js)ができあがります。browserifyタスクにはstandaloneオプションを"require"に設定していますので、モジュール参照のための関数はrequireという名前でグローバルに公開されます。Senchaベースのソースコードからrequire()呼び出しによってモジュールが参照される時は、この公開されたrequire()関数を呼び出すことになります。

あとは下記のようにして、HTMLファイルに対してBrowserifyによって生成されたJavaScriptファイル(build/common/required-bundle.js)を含めるようにしておけば大丈夫です。

index.html

<!DOCTYPE HTML>
<html>
<head>
    <meta charset="UTF-8">
    <title>MyApp</title>
    <!-- <x-compile> -->
        <!-- <x-bootstrap> -->
            <link rel="stylesheet" href="bootstrap.css">
            <script src="ext/ext-dev.js"></script>
            <script src="bootstrap.js"></script>
        <!-- </x-bootstrap> -->
        <script src="build/common/required-bundle.js"></script>
        <script src="app.js"></script>
    <!-- </x-compile> -->
</head>
<body></body>
</html>

サンプルコードなど

サンプルとなるSenchaのプロジェクトはこちらにあります。

まとめ

Senchaは非常に魅力的なUIコンポーネント群を提供してくれていますので、対象となるプロジェクトによっては大変有用だと思われます。しかしながら、近年のJavaScriptのコミュニティによる開発の最先端はほぼNode.jsに端を発するものがほとんどであり、ほぼ標準であると言えますので、これらを無視していくのはあまりにももったいないことです。

本当はSenchaを使うときは骨の髄までSencha流に染まるべきなのかもしれませんが、それでもNode.js界隈の資産を活用できるように工夫するのは、それなりに有意義だと考えています。

*1:正確にはCommonJSのModule1.0相当、さらにそれに独自方式も加えられている模様 http://meso.hatenablog.com/entry/20110626/1309082158

*2:スクリプトファイルの読み込みが非同期でしかできないため、非同期専用の特別な形式(AMD)でモジュール参照を記述する必要がある