アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

babelify で ES6 を試す

React v0.13.0 から ECMAScript 6 (以下 ES6) の class によるコンポーネント定義がサポートされた。また Node v0.12 でも一部の機能が有効となっている。このように ES6 もだいぶ身近になってき。先行学習もかねて趣味の開発では積極採用してゆきたい。

とはいえある程度は広範な環境で動いてほしい。そのため実装は ES6 でおこない実行用スクリプトは ES5 に変換したものを使用する。これを実現するため Browserify transform の babel/babelify を試す。

Babel

Babel は ES6 として記述されたスクリプトを ES5 に変換するためのツールである。以前は 6to5 と呼ばれていたが諸事情により名称変更したそうだ。ES6 機能を先取りするという意味で AltJS だと TypeScript が近いのかもしれない。

Babel は CLI を提供しており、npm で配布されている。単体利用するのであれば、これをインストールして、

$ npm i -g babel

以下のようにコンパイルする。CLI 版のオプションはこちらを参照のこと。

$ babel script.js --out-file script-compiled.js

Browserify と babelify

babelify は Babel の Browserify transform 版になる。Browserify は JavaScript 間の依存管理だけでなく transform により AltJS や React の JSX など様々な形式のスクリプトを標準的な JavaScript へコンパイルできる。現時点では ES6 も AltJS の一種とみなせるため babelify でコンパイルするようにしておく。

Babel と同様に Browserify も CLI ツールを提供している。これをインストールして

$ npm i -g browserify

Babel CLI と組み合わせると以下のようにコンパイルできる。

$ browserify script.js -t babelify --outfile bundle.js

gulp

私は Web フロントエンドや nw.js アプリ開発のタスク管理に gulp を利用している。

gulp タスクの内、JavaScript 依存解決とコンパイルは Browserify で管理しているため babelify もそこに組み込む。なお CLI を利用せず gulp タスクのみで参照するなら Browserify と babelify のグローバルなインストール npm i -g は不要。

はじめにタスクで利用する npm をインストール。

$ npm i --save gulp gulp-load-plugins gulp-util gulp-notify gulp-if gulp-sourcemaps gulp-uglify
$ npm i --save browserify watchify babelify vinyl-source-stream vinyl-buffer 

次にタスクを定義。

var gulp = require( 'gulp' );
var $    = require( 'gulp-load-plugins' )();

/**
 * JavaScript の依存関係を解決し、単一ファイルにコンパイルします。
 * このタスクは開発用で、JavaScript は Minify されません。
 * Minify するとデバッガで Source Maps を展開したとき、変数名を復元できず不便なので開発時はそのまま結合します。
 *
 * @return {Object} ストリーム。
 */
gulp.task( 'js', function() {
    return compile( false );
} );

/**
 * JavaScript の依存関係を解決し、単一ファイルにコンパイルします。
 * このタスクはリリース用で、JavaScript は Minify されます。
 *
 * @return {Object} ストリーム。
 */
gulp.task( 'js-release', function() {
    return compile( true );
} );

/**
 * JavaScript の変更を監視して差分コンパイルします。
 *
 * @return {Object} ストリーム。
 */
gulp.task( 'watchify', function() {
    return compile( false, true );
} );

/**
 * JavaScript の依存関係を解決し、単一ファイルにコンパイルします。
 *
 * @param {Boolean} isMinify 圧縮を有効にする場合は true。
 * @param {Boolean} isWatch  差分監視モードで実行する場合は true。
 *
 * @return {Object} ストリーム。
 */
function compile( isUglify, isWatch ) {
    var src        = './src/js/app.js';
    var dest       = './dest/js';
    var bundlejs   = 'bundle.js';
    var browserify = require( 'browserify' );
    var source     = require( 'vinyl-source-stream' );
    var buffer     = require( 'vinyl-buffer' );
    var watchify   = require( 'watchify' );
    var logger     = new bundleLogger( src, bundlejs );
    var option     = {
        basedir:   './',
        debug:     true,
        transform: [ 'babelify' ]
    };

    var bundler = null;
    if( isWatch ) {
        option.cache        = {};
        option.packageCache = {};
        option.fullPaths    = true;

        bundler = watchify( browserify( src, option ) );
        logger.watch();

    } else {
        bundler = browserify( src, option );
    }

    /**
     * Browserify による JavaScript コンパイルを実行します。
     *
     * @return {Object} ストリーム。
     */
    function bundle() {
        logger.begin();
        return bundler
            .bundle()
            .on( 'error', handleError )
            .pipe( source( bundlejs ) )
            .pipe( buffer() )
            .pipe( $.sourcemaps.init( { loadMaps: true } ) )
            .pipe( $.if( isUglify, $.uglify() ) )
            .pipe( $.sourcemaps.write( '.' ) )
            .on( 'end', logger.end )
            .pipe( gulp.dest( dest ) );
    }

    bundler.on( 'update', bundle );

    return bundle();
}

/**
 * Browserify による JavaScript コンパイルの進捗をコンソールに出力します。
 *
 * @param {String} src    コンパイルの起点となるファイル。
 * @param {String} bundle コンパイル結果となるファイル。
 */
var bundleLogger = function( src, bundle ) {
    var prettyHrtime = require( 'pretty-hrtime' );
    var beginTime;

    this.begin = function() {
        beginTime = process.hrtime();
        $.util.log( 'Bundling', $.util.colors.green( src ) + '...' );
    };

    this.watch = function() {
        $.util.log( 'Watching files required by', $.util.colors.yellow( src ) );
    };

    this.end = function() {
        var taskTime   = process.hrtime( beginTime );
        var prettyTime = prettyHrtime( taskTime );
        $.util.log( 'Bundled', $.util.colors.green( bundle ), 'in', $.util.colors.magenta( prettyTime ) );
    };
};

/**
 * Browserify のエラーが発生した時、タスク実行を維持するための関数です。
 * エラーを検知して通知します。
 */
function handleError() {
    var notify = require( 'gulp-notify' );
    var args   = Array.prototype.slice.call( arguments );

    notify
        .onError( {
            title: 'Task Error',
            message: "<%= error %>"
        } )
        .apply( this, args );

    // タスク維持
    this.emit( 'end' );
}

JavaScript のビルドは gulp js または gulp js-release、ファイル監視による差分ビルドなら gulp watchify コマンドを実行。

Bable/babelify は React JSX をコンパイルできるため reactify が不要になる。React ユーザーとしてはありがたいのだけど ES6 ではないライブラリをサポートするのはありなのだろうか?

ES6

ES6 を試すにあたり、ある程度の規模と複雑さをもつ既存コード (ES5 相当) を対象としたほうが参考になると考えた。そこでこの間の記事で作成した nw.js による音楽プレーヤーのサンプル実装を ES6 化してみた。このプロジェクトでは UI へ React、データ管理に Flux ( npm flux だけの簡素な実装) を採用している。

class

これまでクラス風に定義していたオブジェクトは

var AudioPlayer = function() {
    var _audioContext = ( () => {
        var audioContext = ( window.AudioContext || window.webkitAudioContext );
        if( audioContext ) { return new audioContext(); }

        throw new Error( 'Web Audio API is not supported.' );
    } )();

    var _gainNode = _audioContext.createGain();

    // ... 実装
};

以下のように ES6 クラス化できる。

class AudioPlayer {
    constructor() {
        this._audioContext = ( () => {
            let audioContext = ( window.AudioContext || window.webkitAudioContext );
            if( audioContext ) { return new audioContext(); }

            throw new Error( 'Web Audio API is not supported.' );
        } )();

        this._gainNode = this._audioContext.createGain();
    }

    // ... 実装
}

JavaScript でクラス風なオブジェクト定義でさまざまな流派があったけど、今後は ES6 クラスに統一されてゆくのではなかろうか。

function でクラス定義する方法だと private にしたい変数や関数は最外周の関数ローカルに定義していたが、ES6 クラスでこれを実現する方法がわからなかった。そのため this 所属させつつ接頭語にアンダースコアをつけることで private なことを明示している。

ES6 クラスではコンストラクタも導入されている。そのためメンバー変数の宣言と初期化はここで実行するのがよさそう。

export と import

CommonJS モジュールでは

var AudioPlayer = function() {
};

module.exports = AudioPlayer;

のように定義したものを

var AudioPlayer = require( './AudioPlayer.js' );

のように参照していた。ES6 ではモジュール定義と参照が export/import として標準化された。定義は以下のようになる。

export default class AudioPlayer {
}

参照は import を使用。from キーワードの後に対象へのパスを指定。

import AudioPlayer from './AudioPlayer.js';

CommonJS と同様にモジュールから複数のクラスやオブジェクトを公開できる。default を付けておくとそれが import 時の既定となる。複数公開されているものから選択、または公開オブジェクトの子を明示的に参照するときは波括弧で指定する。

import {PlayState} from '../model/constants/AudioPlayerConstants.js';

import はスクリプトのスコープ最上段へ定義しなければならない。関数ローカルなどに定義すると Syntax Error になるので注意すること。C# の using や Java の import 的にファイル先頭へ定義するとよいだろう。

nw.js において Node モジュールを window.require で参照しているのだが、これも import にすべきか悩んだ。結局 nw.js に属することを明示化するため window.require のままにしてある。外部モジュール参照という意味では import と並べてファイル先頭にすべきだけど、これらが混在すると分かりにくいので関数ローカルで定義している。

それと import した変数の命名は PascalCase にしている。通常の変数や関数は camelCase で命名しているため、こうすると名前衝突を避けられるうえ視覚的にも区別しやすい。これに慣れてから Node でプログラミングするときもモジュール スコープ単位で参照される require ではこのように名付けるようになった。例えば fsFspathPath という感じで。

let と const

いままで var で宣言していた変数を letconst に置き換えた。変更されるものは let、再代入を禁止したいものは const にしてある。

変数が ObjectArrayconst を指定するとするとそれ自体への代入は禁止されるのだが、子要素の変更は可能である。そのため let にするか迷ったけど const を採用した。インスタンス参照だけでも不変であることを明示したいので let より const のほうが好ましく感じる。

const は単体で定義できる点が気に入っている。構文が const varconst let だったら多用しなかっただろう。C# の const や Java の final は型と一緒に指定するため記述が長く面倒。そのため不変であることを強く明示したいときだけ利用していた。

とはいえ、あまりに const だらけなので他の言語と往復したとき違和感ある。いまはこうしているけど将来は考えかたが変わって let 中心にするかもしれない。CSS プリプロセッサに Stylus を導入したときも波括弧まで省略していたが、インデントだけではスコープがわかりにくいと感じて現在は省略をやめている。

React コンポーネント

React v0.13.0 Beta 1 からで React コンポーネントを ES6 クラスとして定義できるようになった。従来、React.createClass で定義していたものは

var MainViewModel = React.createClass( {
    getInitialState: function() {
        return {
            musics:  [],
            current: null,
        };
    },

    render: function() {
        return (
            <div>
                <ToolbarViewModel />
                <MusicListViewModel musics={this.state.musics} />
            </div>
        );
    }
} );

module.exports = MainViewModel;

以下のように React.Component 派生クラスとして定義できる。

export default class MainViewModel extends React.Component {
    constructor( props ) {
        super( props );
        this.state = {
            musics:  [],
            current: null,
        };
    }

    render() {
        return (
            <div>
                <ToolbarViewModel />
                <MusicListViewModel musics={this.state.musics} />
            </div>
        );
    }
}

機能面でもいろいろ変更がある。詳しくは以下の記事を参照のこと。

クラス内の関数が自動で bind されなくなるためイベント ハンドラ指定でハマるかも。

とはいえ Array.map などの function では明示的に bind(this) していたわけで this 参照が必要な関数なら常に bind 指定すべし、というルールに統一されるのはよいことだと思う。

Flux の Store とシングルトン

Flux の Store を class 化したくなった。元の実装は facebook/flux のサンプルを踏襲した簡素なもので、こんな感じ。

// Store 単位のデータ
var _audioPlayer = new AudioPlayer();

// Action に応じて呼ばれる関数
function play( music ) {
    _audioPlayer.play();
    AudioPlayerStore.emitChange();
}

// Action からのリクエスト処理
AppDispatcher.register( function( action ) {
    switch( action.type ) {
    case ActionTypes.PLAY:
        play( action.music );
        breakl
    }
} );

// Store 実装 ( npm object-assign で Mixin している )
var AudioPlayerStore = assign( {}, EventEmitter.prototype, {
    // 更新通知やデータ取得などの実装
} );

module.exports = AudioPlayerStore;

Store の利用側はシングルトンであることを期待する。これを ES6 class で実装する場合、以下に紹介されている方法がよさそうだ。

非公開の Symbol を識別子にすることでモジュール外から new されることを禁止する (例外が発生する)。インスタンスは static メソッドからのみ参照できる。static メソッドのみでインスタンス公開するのは他の言語でもよく見るパターンだ。これを参考に Store を書きなおしてみる。

const SINGLETON_PROP     = Symbol();
const SINGLETON_ENFORCER = Symbol();

class AudioPlayerStore extends EventEmitter {
    constructor( enforcer ) {
        // AudioPlayerStore.instance 以外でインスタンス生成したら例外発生
        if( enforcer != SINGLETON_ENFORCER ) {
            throw new Error( 'Cannot construct singleton' );
        }

        // 自身のメソッドで Action からのリクエスト処理
        AppDispatcher.register( this._onAction.bind( this ) );

        // 自身のプロパティとしてデータ管理
        this._audioPlayer = new AudioPlayer();
    }

    // 唯一の AudioPlayerStore インスタンスを返す
    static get instance() {
        if( !( this[ SINGLETON_PROP ] ) ) {
            this[ SINGLETON_PROP ] = new AudioPlayerStore( SINGLETON_ENFORCER );
        }

        return this[ SINGLETON_PROP ];
    }

    _onAction( action ) {
        switch( action.type ) {
        case ActionTypes.PLAY:
            this._play( action.music );
            break;
        }
    }

    _play( music ) {
        this._audioPlayer.play();
        this._emitChange();
    }

    // 更新通知やデータ取得などの実装
}

// クラスの代りにシングルトン インスタンスを公開
export default AudioPlayerStore.instance;

従来方式だとデータや関数が分散しており、同一ファイル内であっても参照関係やライフサイクルを把握しにくかった。これらをクラスに集約することで参照は this に明示され、ライフ サイクルもクラス単位となり非常に分かりやすくなった。ES6 class にはアクセス指定子がないためクラスに含めたものは外部参照できてしまうのだが集約で得られるメリットのほうが大きいので、これは受け入れることにした。

シングルトン、利用側としては楽だけど一意性の担保にオブジェクトの本質ではない実装 (しかも定形で冗長なもの) が必要なため忌避する開発者も多い。オブジェクトをシングルトンにするぐらいなら、それを所持・参照する箇所を一意にすべきという思想もある。

例えばアプリの最上層となるエントリー ポイントで管理用のインスタンスを生成して下層にはその参照を伝搬してゆくとか。エントリー ポイントはひとつなので、そこで生成されるものも一意になるという設計。iOS なら AppDelegate、Android だと Application 派生クラスをそのように実装したりする。

Store の class 化を検討しているときに見つけた azu/material-flux という Flux 実装ではシングルトンを避けて Context というクラスに Store や Action を集約している。サンプルだと Context をエントリー ポイントでインスタンス化し、それを React コンポーネントの prop に指定している。

Store や Action 間に依存があったとしても、その管理を React コンポーネントに晒すことなく Context 内に閉じられる点が好ましい。Context という命名と porp による伝搬は Android の設計を彷彿とさせる。

次に実装する機会があったらこれを採用するかもしれない。

サンプル

今回のサンプルを以下に公開する。Before が元、After が ES6 化されたプロジェクトになる。

clone して README.md の Installation & Build に書かれた手順を実行するとアプリが生成される。

Copyright © 2009 - 2023 akabeko.me All Rights Reserved.