Electron を試す 4 – 簡易音楽プレーヤー 2

2015年10月29日 0 開発 , , , ,

前回、音楽プレーヤーを作成したのだが、もうすこし機能を盛り込みたくなった。せっかく Spectrum を取得しているのだから Analyzer 表示がほしい。ジャケット画像も表示できたら更によし。

というわけで、これらの実装と Electron 的な考察などをまとめてみる。

新しい UI

もくじ

Spectrum Analyzer

前回、というより元となった nw.js を使ってみる 5 – 簡易音楽プレーヤーのサンプル時点で既に音声の Spectrum 取得は実装されていたのだが、UI を決めかねていた。専用のスペースを作るのか、それとも Electron なので専用ウィンドウを個別に表示するのか、などなど。

結局、画面上部の中央にある再生情報を Spectrum Analyzer と共用することにした。この部分をクリックすると、再生情報と Spectrum Analyzer がトグル式で切り替わる。

Spectrum Analyzer

本機能の実装は以下の記事を参考にした。

Spectrum の取得

サンプルでは音楽の再生に Web Audio API を利用している。音声のデコードは Audio で処理し、それに対して GainNodeAnalyserNod を接続している。これらのうち後者から再生している音声の Spectrum を得られる。以下はその実装。

export default class AudioPlayer {
  constructor() {
    this._context = ( () => {
      const audioContext = ( window.AudioContext || window.webkitAudioContext );
      if( audioContext ) { return new audioContext(); }

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

    this._audio = null;

    this._sourceNode = null;

    this._gainNode = this._context.createGain();
    this._gainNode.gain.value = 1.0;
    this._gainNode.connect( this._context.destination );

    this._analyserNode = this._context.createAnalyser();
    this._analyserNode.fftSize = 64;
    this._analyserNode.connect( this._gainNode );

    this._isPlaying = false;
  }

  get spectrums() {
    if( !( this._sourceNode && this._isPlaying ) ) { return null; }

    const spectrums = new Uint8Array( this._analyserNode.frequencyBinCount );
    this._analyserNode.getByteFrequencyData( spectrums );

    return spectrums;
  }
}

事前に AnalyserNode.fftSize を設定しておくと、その 1/2 の要素数を持つ値コレクションを取得できる。fftSize を 64 としているため、32 の要素を持つ。コレクションは Uint8Array なので値の範囲は 0 〜 255 となる。

spectrums プロパティを参照した場合、その時点で最新の値コレクションを算出して返す。

Canvas による描画

サンプルの View 部分は React で管理している。そのため Spectrum の描画を React コンポーネントと Canvas のどちらにするか迷った。

React コンポーネントにした場合、spectrums を props に指定するような設計になるだろうけど、この値の算出は参照された時に限定される。そのため初回だけ更新されて、あとは上位コンポーネントからの更新を待つことになる。

これを防ぎ、なるべくリアルタイムに描画するためには window.requestAnimationFrame のコールバックで forceUpdate するような実装が必要。…それなら Canvas でよくないか?ということで Canvas を採用することにした。とはいえ、Canvas むき出しなのも扱いにくいため、これを包含する React コンポーネントを実装。

const MAX_SPECTRUM = 255;

export default class SpectrumAnalyzer extends React.Component {
  constructor( props ) {
    super( props );

    this._canvas = null;

    this._canvasContext = null;

    this._animationId = null;

    this._playbackStateBefore = props.audioPlayerStore.playbackState;
  }

  componentDidMount() {
    this._canvas        = this.refs.canvas;
    this._canvasContext = this._canvas.getContext( '2d' );

    this._canvasContext.scale( window.devicePixelRatio, window.devicePixelRatio );
  }

  componentWillUnmount() {
    this._stopSpectrumAnayzer();
  }

  render() {
    // Update spectrum analyzer
    const playbackStateNow = this.props.audioPlayerStore.playbackState;
    if( this._playbackStateBefore !== playbackStateNow ) {
      if( playbackStateNow === PlaybackState.Stopped ) {
        this._stopSpectrumAnalyzer();
      } else {
        this._startSpectrumAnalyzer();
      }

      this._playbackStateBefore = playbackStateNow;
    }

    const style = { display: this.props.useSpectrumAnalyzer ? 'block' : 'none' };
    return (
      <div
        className="audio-player__container__info__container__spectrum-analyzer"
        style={ style }
        onClick={ this.props.onClickInfoDisplay }>
        <canvas ref="canvas">
        </canvas>
      </div>
    );
  }

  _drawBackground( spectrums, baseX, width ) {
    this._canvasContext.fillStyle = '#ecf0f1';
    this._canvasContext.fillRect( 0, 0, width, this._canvas.height );

    for( let i = 1, max = spectrums.length; i < max; ++i ) {
      this._canvasContext.fillRect( i * baseX, 0, width, this._canvas.height );
    }
  }

  _drawGraph( spectrums, baseX, width ) {
    let percent = spectrums[ 0 ] / MAX_SPECTRUM;
    let height  = this._canvas.height * percent;
    let y       = this._canvas.height - height;

    this._canvasContext.fillStyle = '#bdc3c7';
    this._canvasContext.fillRect( 0, y, width, height );

    for( let i = 1, max = spectrums.length; i < max; ++i ) {
      percent = spectrums[ i ] / MAX_SPECTRUM;
      height  = this._canvas.height * percent;
      y       = this._canvas.height - height;

      this._canvasContext.fillRect( i * baseX, y, width, height );
    }
  }

  _startSpectrumAnalyzer() {
    const spectrums = this.props.audioPlayerStore.spectrums;
    if( !( spectrums ) ) { return; }

    this._adjustCanvasSize();
    this._canvasContext.clearRect( 0, 0, this._canvas.width, this._canvas.height );

    const max   = spectrums.length;
    const width = ( this._canvas.width / max ) / 2;
    const baseX = width * 2;

    this._drawBackground( spectrums, baseX, width );
    this._drawGraph( spectrums, baseX, width );

    this._animationId = requestAnimationFrame( this._startSpectrumAnalyzer.bind( this ) );
  }

  _stopSpectrumAnalyzer() {
    this._canvasContext.clearRect( 0, 0, this._canvas.width, this._canvas.height );
    cancelAnimationFrame( this._animationId );
  }

  _adjustCanvasSize() {
    const width  = this._canvas.offsetWidth  * window.devicePixelRatio;
    const height = this._canvas.offsetHeight * window.devicePixelRatio;
    if( this._canvas.width  !== width  ) { this._canvas.width  = width;  }
    if( this._canvas.height !== height ) { this._canvas.height = height; }
  }
}

再生状態の判定なども含めているため Flux Store 部分と疎にしきれていないのだけど大意は把握できると思う。renderer メソッド以降が描画部分。

はじめに Canvas のサイズ変更へ対応する必要がある。また Retina などの高解像度ディスプレイも想定しなければならない。このあたりの管理は以下の記事を参考に実装した。

Canvas の領域サイズは CSS 側で親にあわせて width/height ともに 100% とする。こうしておくと DOM のリサイズ機能を利用できる。これらは offsetWidth/offsetHeight に反映される。

これを前提として、描画のタイミングで Canvas の描画サイズとなる width/height に offsetWidth/offsetHeight を同期する。無用なリサイズを避けるため、同期は値の更新を判定して実行する。

高解像度への対応は Window.devicePixelRatio を利用する。この値はディスプレイの描画ピクセル比率を格納している。例えば 2015/10 時点における Mac の Retina ディスプレイなら 2 が設定される。

この値を CanvasRenderingContext2D.scale() の x と y に指定することで、描画の座標系がディスプレイと一致する。更にこの値を Canvas の width/heigth にも掛けることで、描画領域も適切なサイズになる。

Spectrum 部分は背景とグラフの 2 種類を描画している。ただし交互に色などを変えながら描画する場合、ワンセットにするより分割したほうが効率的とのことなので、そのようにした。この知識は以下の記事から得た。

この工夫、32 ( Spectrums ) x 2 ( 背景とグラフ ) なら誤差の範疇だけどこういうのを知ってしまうと反映したくなる。エンジニアの性というか、そんな感じ。早すぎる最適化ともいう。

ジャケット画像の取得と表示

音楽情報の取得で採用している musicmetadata は、音楽ファイルに埋め込まれたジャケット画像を取得できる。いままでこのデータは捨てていたのだけど、今回はこれを活用してみる。

画像の取得と保存

音楽ファイルに画像が埋め込まれている場合、musicmetadata から取得した Object の picture に Buffer として格納される。

現在の musicmetadata 処理は Main プロセスで実行しているため、Node 由来の Buffer をそのまま Renderer に渡すのは抵抗がある。もし Buffer としてそのまま処理できるにしろ、それを IndexedDB に保存したり View 部分の img.src に指定するのは厄介そう。

特に後者は Base64 変換が面倒。しかも同じアルバムに属する曲だと画像も一緒なことが多いだろうから、冗長性を回避するために一意性の管理を含めた画像キャッシュ機能もほしくなる。そのうえ img.src に指定するときは data:image/jpeg や png などの MIME 指定まで必要だ。やっていれらない。

というわけで Buffer を画像ファイルとして出力し、そのパスだけを管理する方針で実装する。

画像ファイルの保存先は userData 直下の images ディレクトリとした。userData はアプリ固有の場所で、IndexedDB や Web Storage もここに保存される。

画像ファイルとして保存する場合でも冗長性の問題はついてまわる。これは画像の一意性を担保・判定することで対応。処理は以下のようになる。

  1. 画像となる Buffer の SHA-1 Hash を得る
  2. Hash をファイル名とする
  3. images ディレクトリ内に同名ファイルが存在しない場合だけ保存
  4. 保存された画像ファイルのフルパスを音楽情報に設定 ( IndexedDB や表示に使用 )

この方法だと SHA-1 Hash が衝突しない限り、画像の一意性を保証できる。ジャケット画像として指定されるぐらいのデータ サイズ ( 大きくても 32bit * w256 * h256 ぐらい? ) なら衝突を心配する必要もないだろう。

ファイル名の拡張子については musicmetadata の返す picture.format で決定している。ここには jpg や png が設定される。これは id3v2.3.0 の Attached_picture を踏襲しているのだろうか。

表示

画像の表示は非常に簡単である。img.src へ画像ファイルのフルパスを指定するだけでよい。Windows はダメかも?と思っていたが、OS X と Windows のどちらもフルパス指定で画像を表示できた。

サンプルの表示箇所は再生情報と次項で解説する Artist & Album View になる。

同一パスのファイルなら Chromium 側で同じ画像として扱われ、キャッシュも含めて効率的に処理してくれるだろう。画像ビューアーでもないわけだし、こうした機能は Chromium 任せで十分と考える。

Artist & Album View

iTunes のアーティスト表示を参考にアーティストとアルバムを表示する UI を実装してみた。全アーティストの一覧が左、その選択に対応するアルバムと所属する曲の一覧を右に表示する。

曲情報の階層化

従来の実装では全曲をひとつのテーブルで表示していた。そのため曲情報 Array をそのまま React コンポーネントに反映するだけの単純処理で済む。しかし今回はアーティスト、アルバムという親子関係がある。よって曲情報の階層化が必要。

階層化にあたり、IndexedDB の保存形式から見直すべきか迷った。従来式だと曲情報の Array が保存されている。これを Artists, Albums, Musics のようにするべきか否か。

変更した場合、IndexedDB 管理まわりを相当に変更することとなる。そのうえで Store の対応も必要。ならば IndexedDB はそのままに、読み込んだ Array を動的に階層化したほうがよいだろうと判断した。

アーティストのソートにおける定冠詞

iTunes などの音楽プレーヤーを見ると、アーティスト名のソートにおいて定冠詞が考慮されている。つまり、

  1. The BEATLES
  2. The Who

というアーティストならば、定冠詞 the を除外した名前で判定され、

  1. BEATLES
  2. Who

と扱われ比較されるようだ。定冠詞の大文字・小文字は無視。サンプルでもこれを参考に比較関数を実装してみた。

export default class Artist {
  static compare( a, b ) {
    const nameA = a.name.toLowerCase().replace( 'the ', '' );
    const nameB = b.name.toLowerCase().replace( 'the ', '' );

    return ( nameA === nameB ? 0 : ( nameA < nameB ? -1 : 1 ) );
  }
}

ディスク番号によるアルバム内の分類

アルバムの元がレコードや CD 媒体として流通していた場合、複数枚で構成されている可能性がある。これはどのように扱うべきだろうか。

レコード時代には A 面と B 面でコンセプトを変えるものもあった。有名なものだと Queen の 2nd における White/Black side とか。CD の時代になっても The Smashing Pumpkins の Mellon Collie and the Infinite Sadness みたいな複数枚のコンセプト アルバムが作られている。

こうした意図を汲むならば、ディスク単位でアルバム表示することになる。曲の所属するディスクごとにジャケット画像を変えている可能性もあり、それを判定できる。

しかし今回は iTunes と同様にアルバム内でディスク番号を分類することにした。アルバムとして分けてしまうと曲の追加と削除処理が面倒というのが、その理由。この処理は以下のように実装した。

export default class AlbumList extends React.Component {
  _renderMusics( musics ) {
    const currentMusic = this.props.context.musicListStore.currentMusic;
    const currentPlay  = this._getCurrentPlay();

    // Group by disc number
    const discs = {};
    musics.forEach( ( music ) => {
      if( discs[ music.disc ] === undefined ) {
        discs[ music.disc ] = [];
      }

      discs[ music.disc ].push( music );
    } );

    // Multi disc
    const keys = Object.keys( discs );
    if( 1 < keys.length ) {
      const results = [];
      keys.forEach( ( key ) => {
        results.push( (
          <div key={ key } className="album-list__item__body__disc">
            Disc { key }
          </div>
        ) );

        discs[ key ].forEach( ( music ) => {
          results.push( this._renderMusic( music, currentMusic, currentPlay ) );
        } );
      } );

      return results;
    }

    // Single disc
    return musics.map( ( music ) => {
      return this._renderMusic( music, currentMusic, currentPlay );
    } );
  }
}

曲情報 Array をディスク番号でグループ化する。ディスク番号が Key となる連想配列を生成するような感じ。この Key 単位でループさせることでディスク番号の階層を表示している。

なお、曲情報 Array は以下の比較関数でソート済み。優先度はディスク番号、トラック番号となる。これを前提とすることで、グループ化されたディスク内でトラック番号の順に並ぶ。

export default class Music {
  static compare( a, b ) {
    if( a.disc !== b.disc ) {
      return ( a.disc < b.disc ? -1 : 1 );
    }

    return ( a.track === b.track ? 0 : ( a.track < b.track ? -1 : 1 ) );
  }
}

userData のディレクトリ名

Electron の userData となるディレクトリ名はどのように決定されるのだろう?electron/app.md の app.getPath(name) 欄をみると以下のように書いてある。

The directory for storing your app’s configuration files, which by default it is the appData directory appended with your app’s name.

your app’s name というのは package.json の name フィールドに指定された値になるようだ。この package.json はビルドで組み込んだものか、electron-prebuilt に指定されたパス直下にあるものになる。

今回のサンプルでは開発とアプリ用の package.json を分けており、後者となる src/package.json が該当する。このファイルの name フィールドを書き換えてアプリを再起動したところ、その名前のディレクトリ内にデータが保存された。

では、name に設定した値が既存アプリと衝突した場合はどうなるのか。実行してみたところ、既存アプリのディレクトリと結合された。設定した名前を本サンプルの元になった NW.js の音楽プレーヤーと同じにしてみたら、その IndexedDB の内容がそのまま読み込まれてしまった。

つまり、アプリのデータを相互に破壊する危険性がある。てっきり UUID や GUID なりの一意な識別子で衝突回避しているのだと想像していたが、そんなことはなかった。衝突したことの検知や対策は用意されておらず自衛するしかないようだ。

もし自衛するとしたら、name フィールドには Java パッケージ名のように me.akabeko.audioplayer とでも指定しておくのがよいだろう。自身の Web サイトなどで取得しておきたドメインを逆順にしてアプリ名と結合する。

これでも不安な場合は UUDI や GUID を指定しておく。

サンプル プロジェクト

今回実装したサンプルを以下に公開する。新機能が実装されたのは v1.0.1 以降となる。

material-flux を試す

2015年6月24日 0 開発 , , ,

React による Web アプリケーション開発で Flux に facebook/flux を採用していたけど、シングルトンが扱いにくいので代替を検討した。

flummox

Flux ライブラリを採用するにあたり、単純であることを重視した。規模が小さく簡潔で、その気になれば自分で書き直せるようなものがよい。機能としては Store/Action だけ管理できれば十分である。

次に ES6 ( これからは ES2015 と呼ぶべきだろうか? ) に対応していること。ライブラリの提供する Store/Action を利用するにあたり Object.assign() などの Mix-In 機構ではなく ES6 class の継承で書きたい。

すこし前に以下の記事を読み、これらの条件を満たすものとして acdlite/flummox を採用するつもりだった。

しかし flummox は Store/Action と React コンポーネントを関連づけるため FluxComponent という React コンポーネントを使う点がイマイチ。View 部分を React 以外 ( dekujs/deku とか ) に変えたくなったとき困りそうだ。

Flux はデータフロー管理に徹してほしいので View ライブラリの依存は避けたい。というわけで却下。

material-flux

そもそも単純さを重視するなら自分で実装したほうがよくないか?というわけで設計を検討してみた。

しかしシングルトンを避けて Store/Action をシンプルに管理しようとすると babelify で ES6 を試すでも触れた material-flux みたいになった。ならばいっそ、これを採用したほうがよいだろう。というわけで material-flux を試す。

material-flux は小規模で設計も簡潔である。付属のサンプルと作者の解説記事を読むだけで、すぐに利用できるだろう。

問題があるとすれば Store/Action がシングルトンであることに依存していた箇所の修正である。また、EventEmmiter を直に使わず React コンポーネントのように setState で更新通知する点も注意が必要。

以下のプロジェクトを facebook/flux から material-flux へ乗り換えたときの覚書をまとめる。

提供されるクラス

material-flux の提供するクラスは以下の 3 種類。

  1. Action
  2. Store
  3. Context

Action と Store は他の Flux 実装でもお馴染みだが、Context は特徴的だ。Context は dispatcher をインスタンス化する。Action と Store も Context 継承クラス内で管理される。

Action

Action の実装は以下のようになる。

import { Action } from 'material-flux';

/**
 * 音声プレーヤーの操作種別を定義します。
 * @type {Object}
 */
export const Keys = {
  play:     Symbol( 'AudioPlayerAction.play' ),
  pause:    Symbol( 'AudioPlayerAction.pause' ),
  stop:     Symbol( 'AudioPlayerAction.stop' ),
  seek:     Symbol( 'AudioPlayerAction.seek' ),
  volume:   Symbol( 'AudioPlayerAction.volume' ),
  unselect: Symbol( 'AudioPlayerAction.unselect' )
};

/**
 * 音声プレーヤーを操作します。
 */
export default class AudioPlayerAction extends Action {
  /**
   * 音声を再生します。
   *
   * @param {Music} music 再生対象とする音楽情報。
   */
  play( music ) {
    this.dispatch( Keys.play, music );
  }

  // ...以下、略
}

Action はコンストラクタに Context が渡されて内部に参照が保存される。その dispatcher への Action 登録は dispatch メソッド経由でおこなう。Action 識別子の定義はサンプルどおり Symbol にした。この方法だと keyMirror に比べて単純な名前でも衝突を避けられて便利だ。

dispatch メソッドは第一引数に Action 識別子、第二引数に可変長のパラメーターを指定する。これは Store 側で同じ識別子に関連付けたメソッドの引数として渡される。プロパティ名を決める必要もなく、パラメータの順番だけ気をつければよい。管理しやすい設計である。

facebook/flux から乗り換える場合、Action 識別子を定義していた 〜Constans 系ファイルが不要になる。というか、facebook/flux を利用する場合でも識別子は Action 系ファイル内に定義して export するほうが分かりやすいと思う。

Store

Store の実装は以下のようになる。

コメントにシングルトンとあるが、これは消し忘れ。実際には Context 内でインスタンス化される。サンプル プロジェクトの実装を見て混乱するかもしれないので、このコメントがミスであることをここに明示しておく。

import { Store }   from 'material-flux';
import { Keys }    from '../actions/AudioPlayerAction.js';
import AudioPlayer from '../model/AudioPlayer.js';

/**
 * 音声プレーヤーを操作します。
 * このクラスはシングルトンとして実装されます。
 *
 * @type {AudioPlayerStore}
 */
export default class AudioPlayerStore extends Store {
  /**
   * インスタンスを初期化します。
   *
   * @param {Context}        context        コンテキスト。
   * @param {MusicListStore} musicListStore 音楽リスト。
   */
  constructor( context, musicListStore ) {
    super( context );

    this.register( Keys.play,     this._actionPlay     );
    this.register( Keys.pause,    this._actionPause    );
    this.register( Keys.stop,     this._actionStop     );
    this.register( Keys.seek,     this._actionSeek     );
    this.register( Keys.volume,   this._actionVolume   );
    this.register( Keys.unselect, this._actionUnselect );

    /**
     * 変更監視される値。
     * @type {Object}
     */
    this.state = {
      /**
       * 再生対象となる音楽情報。
       * @type {Music}
       */
      current: null,

      /**
       * 再生状態。
       * @type {PlayState}
       */
      playState: PlayState.Stopped
    };

    // ... 以下、略
  }

  // ... 以下、略
}

Store もコンストラクタに Context が渡される。Action との関連付けは register メソッド経由でおこなう。第一引数に Action 識別子、第二引数に Action と関連付ける関数やメソッドを指定する。

複数 Store 間の依存は facebook/flux の dispatcher 的に waitFor メソッドで管理可能だが、処理の待機が不要なら、この例のようにコンストラクタで依存 Store の参照を受け取って保持してもよい。手抜きともいえるが。

もうひとつの特徴として state がある。これは React コンポーネントと同様に監視対象とするデータを定義し、setState メソッドで更新すると変更通知が発生する。例えば playState というプロパティが監視対象だとして、

this.setState( { playState: PlayState.Playing } );

のようにすると EventEmitter による通知が発生する。プロパティを更新せずに通知だけ発生させたい場合は、引数なしで実行すればよい。

this.setState();

material-flux のサンプルにはない用法だが、Store クラスの実装をみると引数なしでもエラーにせず、最後に this.emit( ‘change’ ) していることが確認できる。

通知の購読は onChange、解除は removeChangeListener または removeAllChangeListeners でおこなう。React.Component 継承クラスでは暗黙の bind( this ) が廃止されたため、onChange と removeChangeListener に同じインスタンスを渡すためには

// Store の館対象となる bind 済み Listener を一意にするためのフィールド
this.__onChangeAudioPlayer = this._onChangeAudioPlayer.bind( this );

のように bind( this ) したものを保存して渡す必要がある。こうした管理が面倒で、かつ Store に複数のコールバックを登録することがないなら removeAllChangeListeners を利用するほうが楽である。このメソッドはコールバックをすべて解除するため、onChange に登録したインスタンスとの整合を考えなくて済む。

facebook/flux から乗り換える場合、state/setState 関連の修正が大量に発生するため注意が必要。かわりに EventEmitter 関連の冗長な実装が不要となる。

データ更新と通知を React コンポーネントの state 風にしたのはよい設計だと思う。これらの処理はセットで実行されることが多いため記述が簡潔になる。また、引数なしの呼び出しで単純な通知になるのも、この用途で別メソッドを用意するよりスマートだ。

Context

Context は Store/Action を結びつける。実装は以下のようになる。

import { Context }       from 'material-flux';
import MusicListAction   from './actions/MusicListAction.js';
import MusicListStore    from './stores/MusicListStore.js';
import AudioPlayerAction from './actions/AudioPlayerAction.js';
import AudioPlayerStore  from './stores/AudioPlayerStore.js';

/**
 * アプリケーションを表します。
 */
export default class AppContext extends Context {
  /**
   * インスタンスを初期化します。
   */
  constructor() {
    super();

    this.musicListAction = new MusicListAction( this );
    this.musicListStore  = new MusicListStore( this );

    this.audioPlayerAction = new AudioPlayerAction( this );
    this.audioPlayerStore  = new AudioPlayerStore( this, this.musicListStore );
  }
}

facebook/flux でシングルトンだったものが Context 内のインスタンスとして管理される。AudioPlayerStore が MusicListStore に依存することを考慮した順番でインスタンス化している。

Context の利用

いままで Action/Store を直に import していた箇所を Context に置き換える。まず Context のインスタンスだが、これはどこで生成してもよい。Context に含まれる Action/Store を必要とする箇所となるだろう。

管理が面倒ならアプリケーションのエントリー ポイントでインスタンス化して、それを React コンポーネントの props 経由で渡す。例えば以下のような感じで。

import AppContext    from './AppContext.js';
import MainViewModel from './vm/MainViewModel.js';

let context = null;

window.onload = () => {
  context = new AppContext();

  React.render(
    <MainViewModel context={ context } />,
    document.querySelector( 'body' )
  );
}

子コンポーネントに伝搬してゆくときは props で渡してゆけばよい。

こうすると React コンポーネントの受け取る外部データを props に統一しやすくなる。props にのみ依存するようにすれば、モックやスタブを渡しやすくなるため render のテストなどに役立つだろう。

まとめ

facebook/flux からの移行は作業量が多くて苦労したが、それに見合う結果を得られたと感じる。シングルトンがなくなり処理の順序が明確になったこと、Store の state 管理が特に気に入った。

また、実装が小規模なため不明な点をコードで確認しやすい点もよい。大きなフレームワークに乗るより、こうした小さなライブラリを組み合わせて実装するほうが好きだ。

Firefox のデバッガで npm から入れた React モジュールが大量表示される問題、解決

2015年4月30日 0 開発 , ,

タイトルは長いけど小ネタ。

以前 watchify を試すという記事で npm から入れた React を require すると Firefox のデバッガに大量の React モジュールが表示されて辛いという問題に触れた。

例えば以下のような感じで require や import した場合、

// ES5
var React = require( 'react' );

// ES6
import React from 'react';

Chrome Developer Tools の Sources だとファイルがツリー表示され npm 経由のものは node_modules というフォルダに集約してくれる。しかし Firefox 付属のデバッガではファイル表示が階層化されないため npm 経由で入れたものは間接参照しているものも含めすべて並列に表示される。

そのため、自前のソースを探すのが難しくなる。node_modules の react/package.json をみると main が react.js になっており、このファイルからは更に ./lib/React を export している。ここから更に require が続いてゆくため、それらがすべて Browserify 対象となり Source Map などに含まれる。

一方、react/dist/react.js は Browserify で結合された単一ファイルのようである。ならばこれを読めばよいのでは?ということで参照を以下のように変更したら予想通り Firefox 標準デバッガには単体の react.js だけ表示された。

// ES5
var React = require( 'react/dist/react' );

// ES6
import React from 'react/dist/react';

React Add-on を利用したいなら参照パスを react-with-addons に変更すればよい。デメリットがあるとすれば、参照パスが長いのと React 部分をデバッガで追うのが面倒になることぐらいか。

Node や npm を利用している開発者では常識なのかもしれないが、私は npm で入れたものを require する場合、モジュール名だけ指定して配下は参照しないものという先入観があり、恥ずかしながら気づかなかった。

この問題を回避するためにこれまで React は Bower 管理 ( こちらは dist のものだけ含まれ require するとそれが参照される ) していたけれど、これからはパッケージ管理を npm に一本化できそう。嬉しい。