React.js を試す - ツリービュー
前に書いた node-webkit を使ってみる 2 の最後で「簡単なファイラーでも作ってみようと思う」などと宣言したのだけど UI をなにで実装するか迷っていた。
ファイラーの UI は典型的なツリー ビューを採用するつもりだった。しかし長らく愛用してきた jQuery だと内部的なデータ構造と DOM を対応づけるような管理が面倒なのでファイラーみたいなものには向かなそう。
例えばツリーのどこかがクリックされて紐づく内部データを処理したいとるする。jQuery だと DOM 構築で要素の id
や class
にインデックスを割り当てておきイベント ハンドラで取得判定...という感じになるだろう。しかしこうした連携の仕組みを自前で実装するのはとても辛い。
jQuery、静的 HTML に対して簡単に装飾するならよいのだけど動的な部分が増えると難易度が跳ね上がる。いま仕事で取り組んでいる Web アプリでもこの問題に悩んでいて代替を検討していた。そんなとき以下の Advent Calendar が目に止まった。
ここで紹介されている React.js や Ractive.js はこの悩みを解決するのによさそうだ。特に React.js は見聞きする機会が増えており興味もあった。ならば学習とファイラーの布石をかねてツリー ビューでも実装するか、というわけで簡単なサンプルを作成。以降にその過程などを記録しておく。
開発環境
開発環境としてエディターに Sublime Text 3、プラグインは ReactJS と SublimeLinter-jsxhint を採用。React.js などは Bower 経由でインストールしてビルドは iTerm2 から gulp で実行する。
IDE でいいものがあればよいのだけど、とりあえず今回はこれで。もしかしてエディターでも Atom だともっと便利なプラグインとかあって楽なのだろうか。
ビルド環境
アプリのビルド環境は以下のような感じ。
- ビルド作業は gulp.js で管理
- JavaScript の require と結合は Browserify
- Bower で入れた JavaScript ライブラリの require は debowerify
- React.js の JSX コンパイルは reactify
- プロジェクト直下の src フォルダで開発
- gulp release でプロジェクト直下の dist フォルダにリリース用イメージを出力
gulpfile.js は以下のようになる。
var gulp = require( 'gulp' );
var $ = require( 'gulp-load-plugins' )();
/**
* リリース用イメージを削除します。
*
* @param {Function} cb コールバック関数。
*/
gulp.task( 'clean', function( cb ) {
var del = require( 'del' );
del( [ './dist' ] );
cb();
} );
/**
* プロジェクトをビルドして開発用イメージを scr フォルダに生成します。
*
* @return {Object} gulp ストリーム。
*/
gulp.task( 'build', function() {
var browserify = require( 'browserify' );
var source = require( 'vinyl-source-stream' );
var buffer = require( 'vinyl-buffer' );
return browserify(
'./src/js/main.js',
{
debug: true,
transform: [ 'reactify', 'debowerify' ]
}
)
.bundle()
.pipe( source( 'app.js' ) )
.pipe( buffer() )
.pipe( $.sourcemaps.init( { loadMaps: true } ) )
.pipe( $.uglify() )
.pipe( $.sourcemaps.write( './' ) )
.pipe( gulp.dest( './src/js' ) );
} );
/**
* ブロジェクトのリリース用イメージを dist フォルダに生成します。
*/
gulp.task( 'release', [ 'clean', 'build' ], function() {
gulp.src( './src/fonts/**' ).pipe( gulp.dest( './dist/fonts' ) );
gulp.src( './src/js/app.js' ).pipe( gulp.dest( './dist/js' ) );
var assets = $.useref.assets();
gulp.src( './src/*.html' )
.pipe( assets )
.pipe( $.if( '*.css', $.minifyCss() ) )
.pipe( assets.restore() )
.pipe( $.useref() )
.pipe( gulp.dest( './dist' ) );
} );
/**
* 開発用リソースの変更を監視して、必要ならビルドを実行します。
*/
gulp.task( 'watch', [ 'build' ], function () {
gulp.watch( [ './src/js/*.js', '!./src/js/app.js' ], [ 'build' ]);
} );
/**
* プロジェクト フォルダをルートにして HTTP サーバーを起動します。
*/
gulp.task( 'server', [ 'watch' ], function () {
var connect = require( 'connect' );
var serveStatic = require( 'serve-static' );
var app = connect();
app.use( serveStatic( __dirname ) );
app.listen( 8080 );
} );
/**
* gulp の既定タスクです。
*/
gulp.task( 'default', [ 'build' ] );
この間まで Browserify の transform 設定は package.json へ定義していたがビルドに関するものは gulpfile.js にまとまっていたほうが分かりやすそうなので、そのようにした。
trabsform に reactify を指定すると Broserify による結合時に JSX をコンパイルして JS 化してくれる。debowerify と混在させても大丈夫。ただし全ファイルを単一化するため JSX の数やサイズが増えてくるとビルド時間が心配になる。中間ファイルを利用した差分コンパイルとか必要になりそう。
gulp-react だと JSX のコンパイル結果を JS ファイルとして出力できるから、そちらのほうがよいかも。とはいえ差分コンパイルを実現しないことには管理するファイルが増えるだけ (require
対象も JS になるので React.js 由来か区別しにくい) なので今回は reactify でゆく。
そういえば gulpfile スタイルガイド - v0.5.0 という記事をみつけたので、これを読んだら影響をうけてスタイルを変更するかもしれない。
React.js によるツリービュー
まず以下の HTML を用意する。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Test: React.js - Explorer</title>
<!-- build:css css/app.css -->
<link rel="stylesheet" href="../bower_components/normalize.css/normalize.css">
<link rel="stylesheet" href="css/icomoon.css">
<link rel="stylesheet" href="css/style.css">
<!-- endbuild -->
</head>
<body>
<article class="l-content">
<h1 class="title">Test: React.js - Explorer</h1>
<div class="explorer"></div>
</article>
<script src="js/app.js" charset="UTF-8"></script>
</body>
</html>
.l-content .explorer
が React.js によるツリービューの構築対象。app.js は gulp によってビルドされた JavaScript。元となるファイルは以下となる。
var React = require( 'react' );
/**
* アイテム種別「フォルダ」を示す値。
* @type {String}
*/
var ITEM_TYPE_FOLDER = 'folder';
/**
* アイテム種別「ファイル」を示す値。
* @type {String}
*/
var ITEM_TYPE_FILE = 'file';
/**
* 指定された範囲のランダムな整数を取得します。
*
* @param {Number} min 下限。
* @param {Number} max 上限。
*
* @return {Number} 整数。
*/
function getRandomInt( min, max ) {
return Math.floor( Math.random() * ( max - min + 1 ) ) + min;
}
/**
* フォルダ内のアイテムを取得します。
* このアプリはサンプルなので、得られる結果はランダムな 3 パターンから選択されます。
*
* @return {Array} アイテムのコレクション。
*/
function getSubItems() {
switch( getRandomInt( 0, 2 ) ) {
case 1:
return [
{ name: 'dir-1', type: ITEM_TYPE_FOLDER },
{ name: 'dir-2', type: ITEM_TYPE_FOLDER }
];
case 2:
return [
{ name: 'dir', type: ITEM_TYPE_FOLDER },
{ name: 'music.aac', type: ITEM_TYPE_FILE },
{ name: 'sample.jpg', type: ITEM_TYPE_FILE }
];
default:
return [
{ name: 'dir', type: ITEM_TYPE_FOLDER },
{ name: 'test.txt', type: ITEM_TYPE_FILE }
];
}
}
/**
* フォルダ用の描画オブジェクトを取得します。
*
* @param {Object} component コンポーネント。
*
* @return {Object} 描画オブジェクト。
*/
function renderFolder( component ) {
var children = null;
if( component.state.children ) {
children = component.state.children.map( function( item, index ) {
return ( <li key={index}><Explorer item={item} /></li> );
} );
}
var style = component.state.expanded ? {} : { display: 'none' };
var mark = component.state.expanded ? 'icon-arrow-down' : 'icon-arrow-right';
var icon = 'icon-folder';
return (
<div>
<div onClick={component.onClick}>
<i className={mark}></i>
<i className={icon}></i>
<span>{component.props.item.name}</span>
</div>
<ul style={style}>
{children}
</ul>
</div>
);
}
/**
* ファイル用の描画オブジェクトを取得します。
*
* @param {Object} component コンポーネント。
*
* @return {Object} 描画オブジェクト。
*/
function renderFile( component ) {
var icon = 'icon-file';
return (
<div onClick={component.onClick}>
<i className={icon}></i>
<span>{component.props.item.name}</span>
</div>
);
}
var Explorer = React.createClass( {
/**
* コンポーネントの状態を初期化します。
*
* @return {Object} 初期化された状態オブジェクト。
*/
getInitialState: function() {
return {
expanded: false,
enumerated: false
};
},
/**
* コンポーネントの描画オブジェクトを取得します。
*
* @return {Object} 描画オブジェクト。
*/
render: function() {
return ( this.props.item.type === ITEM_TYPE_FOLDER ? renderFolder( this ) : renderFile( this ) );
},
/**
* アイテムがクリックされた時に発生します。
*/
onClick: function() {
if( this.props.item.type === ITEM_TYPE_FOLDER ) {
if( !this.state.enumerated ) {
this.setState( { enumerated: true } );
this.setState( { children: getSubItems() } );
}
this.setState( { expanded: !this.state.expanded } );
}
}
} );
module.exports = function( target ) {
var root = {
name: 'root',
type: 'folder'
};
React.render(
<Explorer item={root} />,
document.querySelector( target )
);
};
explorer.jsx ではツリービューとなる Explorer コンポーネントを定義している。
フォルダとファイルで DOM を分岐。フォルダのほうは初回クリック時に子要素を取得して以降はトグル形式でそれらの表示を切り替える。子要素はファイル システムからのフォルダ内列挙を模倣してランダムにしてみた (それっぽく見せるだけ。パターン少なすぎでイマイチだけど)。
コンポーネントのデータ定義は props
と state
に大別されるそうだ。props
は基本的に Read Only で動的取得や生成するものは state
にするものらしい。
React.createClass
に指定するオブジェクトにおいて render
など既定の関数やイベント ハンドラとして呼び出される関数の this
には操作対象となるコンポーネントが紐付けられている。よってサンプルの onClick
関数では DOM ではなくコンポーネントに関連付けられたデータそのものを取得できる。
また setState
関数で state
を更新すると、その内容に基いて DOM が自動更新される。ただし Virtual DOM という機構によってデータの差分がチェックされ、なるべく更新が最小となるように工夫されている。このあたりの話は冒頭に紹介した Virtual DOM のほうの Advent Calendar に詳しい。
ファイルの末尾にある module.exports
は require
を想定したものである。React.render
では第二引数にコンポーネントの操作対象を指定するのだが、これを外部から変更できるように関数としてくくりだした。
使用する側は以下のようになる。
var explorer = require( './explorer.jsx' );
explorer( '.l-content .explorer' );
アプリをビルドして実行するとこんな感じになる。
アイコン類は IcoMoon で生成した Web フォントを採用。このサービスはプリセットだけでも実用的だが自作 SVG も利用できるので気に入っている。
はじめは JSX に抵抗があったものの開発しているうちに慣れた。テンプレートと処理の距離が近いことに批判があるかもしれないけど DOM を直接操作せずデータと紐づけているだけなので破綻しにくい設計だと感じた。
昔に触れた WPF の MVVM で例えると React.js の JSX は V に寄せた VM という印象。今回は JSX 内でデータ処理も定義したけど規模が大きくなったらこうした M 部分は分割して JSX が仲介するように設計したほうがよいかもしれない。
サンプル
今回作成したサンプルを以下に公開する。
clone して README.md の Installation に書かれた手順を実行するとアプリが生成される。