はじめに
ES6 や React、Webpack といった技術の勉強のために簡単なシンプルページアプリケーションを作って、その過程をここに記録として書いていきます。時系列に何の整理もせず書いていくので分かりづらいかもしれないですが、ソースコードも公開するので、何かの参考になれば幸いです。
作るもの
JSON を見やすく可視化して、かつ編集も可能な JSON Visual Editor を作ります。これの元となるのはずいぶん前に作った「JavaScript のオブジェクトを可視化するやーつ」で、もっと使いやすくしたかったのと、React の勉強にちょうど良さそうなので、作ってみることにしました。
- 動作する実物:JSON Visual Editor
- ソースコード:ogaoga/json-visual-editor
環境構築
今回は、React を ES6 (Babel) で、スタイルは Stylus で書いて、Webpack でビルドします。Gulp などは使用せず、npm-scripts でタスクを実行します。
これを1から構築するのではなく、これに似た構成のプロジェクトをベースに構築しました。
これをフォークしたのがこちらです。
この JSON Visual Editor は、このリポジトリでオープンソースとして公開します。
ビルド
README に記載の通りにビルドします。
Bootstrap の削除
$ npm run dev
でビルドしたら、Bootstrap に関係するエラーが出たので、Bootstrap を削除しました。
src ディレクトリ追加
src/
ディレクトリを作ってそこにソースを入れるようにします。試しに Page コンポーネントを作って、src/index.jsx
から読み込んでみます。
import React from 'react';
import ReactDOM from 'react-dom';
import Page from './Page';
export class App extends React.Component {
render() {
return (
<Page />
);
}
}
ReactDOM.render(<App/>, document.querySelector("#myApp"));
import React from 'react';
import ReactDOM from 'react-dom';
export default class Page extends React.Component {
render() {
return (
<div>Page</div>
);
}
}
Page
クラスに default
キーワードをつけず、エラーとなってしまいハマりました。
また、webpack の設定も変更しました。
Stylus の設定
スタイルシートの言語は Stylus が好きなので、その設定を行います。stylus-loader という Webpack のプラグインがあるので、READMEに従ってインストール。
現在の構成では、プラグインの指定を webpack.loaders.js ファイルで行います。
...
{
test : /\.styl$/,
loader: 'style!css!stylus'
},
...
もともと css の設定が記述されていましたが、削除しました。
Stylus のファイルは src/styles/
以下に置きます。ルートとなる main.styl
を src/index.jsx で import します。
import './styles/main.styl';
今回の修正は、こちらで確認できます。
追記:構成をちょっと変えました。
CSS フレームワーク「Material Design Lite」のインストール
多少は綺麗なスタイルにしたいので、Material Design Lite を導入。Material-UI という選択肢もあったけど、React との結びつきが強そうだったので、スタイルだけを利用できる MDL に。
$ npm install --save material-design-lite
でインストールして、Stylus で組み込む。
@import "../../node_modules/material-design-lite/material.min.css"
@import url("https://fonts.googleapis.com/icon?family=Material+Icons")
JavaScript も。
import '../node_modules/material-design-lite/material.min.js';
ESLint のインストール(2016/06/01 追記)
まずは、ESLint のインストール。
$ npm install -g eslint
$ eslint --init
対話形式で設定ファイル(.eslintrc)を生成することができます。
Webpack から利用するので、eslint-loader もインストール。
$ npm install --save-dev eslint-loader
webpack.config.js で、eslint-loader を読み込む設定を追加(preLoaders
の部分)。
module: {
loaders: loaders,
preLoaders: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
loader: 'eslint-loader'
}
]
},
React での UI の構築
ひとまず環境は整ったので、次に UI を構成してみます。出来上がったレイアウトはこちら。
左側がテキストエリアで、ここに JSON を記述すると、右側のエリア(このスクリーンキャプチャーだとテキストが書いてあるエリア)に、その構造をあらわすテーブルが表示されます。テキストエリアには、JSON をコピーしたりクリアするボタン、JSON の文字数を示す文字列が存在します。
React のコンポーネント構成は下記の通り。
-
App
-
Page
- TextArea
-
ControlsArea
- Copy Button
- Clear Button
- Text Count
- VisualizedData
-
Page
まだ、UI の動作の実装、データのバインディングはできていません。この時点のソースコードはこちら。
UI の起点となる Page.jsx では、全体のレイアウトを定義しています。この中で、JSON を記入する TextArea、操作用のボタン等を配置する ControlsArea、記入された JSON をビジュアル化する VisualizedData を組み込んでいます。
この構造がベストかどうかは現時点ではわかりません。今後、実装していく中で調整が入ると思われます。
データのハンドリング
テキストエリアに記入した JSON を App コンポーネントで管理して、更新されるとそれに従って UI が更新されるようにします。
データの定義
ルートコンポーネントである App でデータを保持するようにします。
export class App extends React.Component {
// コンストラクタでデータを保持する変数を定義。
constructor(props) {
super(props);
this.state = {
data: null
};
// this をバインド。
this.updateData = this.updateData.bind(this);
}
// データを更新するためのメソッド。
updateData(newData) {
this.setState({data: newData});
}
// data でデータを渡して、updateData でデータ更新用メソッドを渡す。
render() {
return (
<Page data={this.state.data} updateData={this.updateData} />
);
}
}
ReactDOM.render(<App/>, document.querySelector("#myApp"));
コンストラクタで定義した data
を下記のように子コンポーネントに渡します。
<Page data={this.state.data} updateData={this.updateData} />
このように、Page 以下のコンポーネントに、data
を渡します。同時に、TextArea で JSON が変更された時に data
を更新するためのメソッド updateData()
を渡して、データを更新できるようにします。
また、これは React とは関係ないですが、
// this をバインド。
this.updateData = this.updateData.bind(this);
独自のメソッド内の this
は React.component
ではないので、.bind()
で this
が使えるようにします。(参考:Why this.setState is undefined in React ES6 class? · Issue #283 · goatslacker/alt)
データの更新
Page に渡されたデータは、this.props.data
でアクセスできます。これを、TextArea と VisualizedData に渡します(現時点では、TextArea に渡す必要はないですが)。また、TextArea には、データ更新用のメソッドも同様に渡します。
...
<TextArea data={this.props.data} updateData={this.props.updateData} />
...
<VisualizedData data={this.props.data} />
...
TextArea では、3秒ごと(暫定)に記入した JSON を VisualizedData で表示するように、渡された更新用メソッドを呼び出します。
export default class TextArea extends React.Component {
constructor(props) {
super(props);
let text = (props.data === null) ? "" : JSON.stringify(props.data)
this.state = {
text: text
};
}
componentDidMount() {
setInterval((() => {
let text = this.refs.jsonText.value;
try {
let data = JSON.parse(text);
this.props.updateData(data);
} catch(e) {
console.log('Not JSON: '+text);
}
}).bind(this), 3000);
}
render() {
return (
<textarea placeholder="Write JSON code here."
defalutValue={this.state.text}
ref="jsonText"></textarea>
);
}
}
コンポーネントが DOM に追加された時に呼び出される componentDidMount()
で周期的に this.props.updateData()
を呼び出すようにしてデータを更新しています。<textarea>
内のテキストは this.refs.jsonText.value
で呼び出していますが、これは ref="jsonText"
と指定することで参照できるようになっています。
更新されたデータの表示
TextArea で this.props.updateData()
を呼び出すと、App コンポーネントの updateData()
が呼び出され、データが更新されます。
updateData(newData) {
this.setState({data: newData});
}
これにより、子コンポーネントに変更が伝わり、表示が更新されます。
export default class VisualizedData extends React.Component {
render() {
return (
<div>{JSON.stringify(this.props.data)}</div>
);
}
}
VisualizedData では、渡されたデータを JSON の文字列に変換して表示しています。特に、更新を監視するような処理はありません。
今後、文字列ではなくデータ構造をテーブルで可視化する処理を追加します。
データのビジュアライズ
VisualizedData にデータが渡ってきたので、これをテーブル形式で表示します。Object
データをテーブルに変換するコンポーネント ObjectType
はこんな感じです。
export default class ObjectType extends React.Component {
render() {
let data = this.props.data;
let result = null;
if (data === null) {
// null
result = (<span className="null">null</span>);
}
else if (typeof(data) === typeof({}) && data !== null) {
// Object or Array
let rows = Object.keys(data).map((name) => {
return (
<tr>
<th>{name}</th>
<td><ObjectType data={data[name]} /></td>
</tr>
);
});
result = (
<table>
<tbody>
{rows}
</tbody>
</table>
);
}
else if (typeof(data) === typeof(1)) {
// Number
result = (<span className="number">{data}</span>);
}
else if (typeof(data) === typeof("a")) {
// String
result= (<span className="string">"{data}"</span>);
}
else if (typeof(data) === typeof(true)) {
// Boolean
result = (<span className="boolean">{(data)?'true':'false'}</span>);
}
else {
// something else
result = (<span className="undefined">{data}</span>);
}
return result;
}
}
渡されたデータは this.props.data
に入っていて、型を判断して Object 型の場合には、テーブル(<table>
)で描画します。オブジェクトの各要素ごとにキー(配列の場合はインデックス番号)と値をテーブルの行として描画するのがこの部分。
// Object or Array
let rows = Object.keys(data).map((name) => {
return (
<tr>
<th>{name}</th>
<td><ObjectType data={data[name]} /></td>
</tr>
);
});
ここで、値を描画する部分で再帰的に <ObjectType />
を呼び出しています。すべての行を rows
配列に保持したら、<table>
で括って返します。
// result は render() の戻り値
result = (
<table>
<tbody>
{rows}
</tbody>
</table>
);
これにスタイルをあてると、データがテーブル形式で表示されます。
ここまでで、記入した JSON がビジュアライズされるところまでできました。他にも細かい点が実装できていないですが、ひとまず GitHub Pages にデプロイしてみます。
GitHub Pages へのデプロイ
Webpack は、ビルドするとアセットを bundle.js というファイルにパッキングします。現在の設定では、
$ npm run build
を実行すると、public/
ディレクトリに(.map ファイルとともに)保存されます。この中にある index.html を開くと bundle.js が読み込まれて、アプリケーションとして動作します。
これを GitHub Pages に簡単にデプロイするために、gh-pages を使います。
$ npm install gh-pages --save-dev
$ npm run build
$ gh-pages -d public
これだけで、https://ogaoga.github.io/json-visual-editor/ にデプロイされました!
この方法だと手動でデプロイする必要があるため、master ブランチに push すると自動的に Travis CI でテストして、デプロイするようにしたいと思います。 Travis CI からデプロイするのは意外と大変そうなので後回しにしました。
UI の状態コントロール
JSON の入力に応じて、データの表示だけではなく周辺の UI も状態を制御する必要があります。
<textarea> を state で管理する。
最初の実装では、<textarea>
に記入された文字列を value
属性で管理していなかったので、React の公式ドキュメントに沿って変更します。
<textarea id="json-text"
placeholder="Write JSON code here."
value={this.state.text}
onChange={this.onChange}
ref="jsonText"></textarea>
これだけだと入力時に内容が更新されないので、onChange
イベントで onChange
メソッドを呼び出して、value
を入力された文字列で更新する処理を追加します。
onChange(event) {
this.setState({text: event.target.value});
}
これで、<textarea>
の変更が伝播するようになります。
コンポーネント構成を組み替える。
Copy ボタンや文字数カウンタなどを管理している ControlArea コンポーネントを、TextArea コンポーネントの子コンポーネントとなるように変更します。これにより、文字列を簡単に渡せるようになり、文字数の表示やボタンの disabled の制御が簡単になります。
<ControlsArea text={this.state.text}
clearText={this.clearText} />
text
属性で入力されたテキストを渡すのと同時に、clearText というメソッドを追加して、Clear ボタンを押した時に文字列を空にできるようにします。
clearText() {
this.setState({text: ''});
}
文字数カウントの実装
ControlArea に入力された文字列が渡されるようになったので、これをもとに文字数カウントを表示させます。
<div className="float-right control-count">
<span className="text-count">{this.props.text.length}</span>
</div>
ボタンの disabled の制御
Copy ボタンと Clear ボタンは、文字列が入力されている時だけ有効なので、disabled 属性にこの条件を与えてボタンを制御します。
<button id="copy-to-clipboard"
data-clipboard-target="#json-text"
disabled={this.props.text.length==0}
className="mdl-button mdl-js-button mdl-button--raised mdl-js-ripple-effect">Copy</button>
Clear ボタンの実装
Clear ボタンを押すと文字列が空になるようにするために、TextArea コンポーネントでそのメソッドを定義して、そのインタフェースを ControlsArea に渡します。これを、<button>
の onClick
で呼び出すことで、文字列を空にできます。
<button className="mdl-button mdl-js-button mdl-button--raised mdl-js-ripple-effect"
disabled={this.props.text.length==0}
onClick={this.props.clearText}>Clear</button>
このように、あくまで渡された文字列データをもとに各種の制御を行い、<textarea>
との依存関係をつくらないことがポイントです。また、親コンポーネントのデータを更新したい場合は、そのインタフェースを渡して、直接操作させないことも重要です。
これらの変更はこちらをご確認ください。
Redux の導入
UI コンポーネントが増えてきたり構造が複雑になってくると、ルートのコンポーネントで管理しているデータを操作するメソッドを props で渡したり、コンポーネント間で state のデータをやり取りするのが面倒になってきます。また、全然依存がないコンポーネントがデータやメソッドをバケツリレーしなければならないのも気持ち悪いです。
これを解決するために、Redux というフレームワーク導入します。これにより、各コンポーネントの state で管理していたデータを一元管理して、そのデータを操作するメソッドをどのコンポーネントからでも呼び出せるようにして、依存関係を減らします。
Redux の導入はこれだけでも長くなるので、別の記事に分けました。こちらも併せてご覧ください。
React + ES6 + Webpack で JSON Visual Editor を作ってみる(Redux 導入編) - Qiita
現在はここまで。開発の進捗に沿って追記していきます。