読者です 読者をやめる 読者になる 読者になる

量産型エンジニアの憂鬱

きっと僕は何物にもなれない。

Re:ゼロから始めるフロントエンド開発

JavaScript フロントエンド React

約3週間前・・・。

てことで、フロントエンドの開発に取り掛かりました。
これまでのフロントの開発経験は、

  • テンプレートエンジン(JSP、Velocity)
  • jQuery
  • CSS

StrutsTilesカスタムタグ でコピペを最小限には抑える努力はしていたのですが、ずっとHTMLの部品をコンポーネント化したいと思っていました。

今携わっている案件ではサーバサイドのフレームワークやテスト、CIやGitなどのインフラも一新しているので、フロントエンドの開発環境も整えさせてくれとおねだり。
最初はコンポーネント化を目標としてReactに目をつけ、そこから広げて行きました。

実現したいこととそれを実現するフレームワークなどは以下の通り

これまでフロントエンドは無知だったので、テスティングフレームワークとか全く知りません。
邪道な組み合わせかもしれません。


これらを組み合わせた開発環境と開発の進め方について記録しておきます。

React + ES6 + Flux でコンポーネントを作る

ReactとFluxを組み合わせた構造とデータフローの概要はこちら
f:id:duck8823:20160630204607p:plain

ざっくり自分的にまとめると

  • ActionCreators(Action)
    • Viewからのアクションを受け取る、外部APIとの接続
    • Dispatcherにアクションを渡す
  • Dispatcher
    • アクションを受け取って、コールバックを実行
  • Store
  • React Views(View)
    • データの表示、UIの動作に対するアクションの発行

f:id:duck8823:20160702103222p:plain
APIとの関係はこんな感じかな。
矢印の色は、依存元を示します。
ViewStoreActionに依存しており、子コンポーネント以外からは依存されていません。
多分ここがポイントで、依存されてないからViewの分離がしやすいんだと思います。
renderするViewを変えるだけで簡単に見た目だけ変えたり、分離したViewから同じStoreActionに依存させることで、異なるView同士の連携ができます。

今回作るコンポーネント

今回作りたいのは、ショッピングカートと注文ボタンのようなもの。
f:id:duck8823:20160630202459g:plain

商品ごとに注文ボタンがあって、注文ボタンをクリックすると対応する商品名がカートに追加され、取り消しボタンに変わります。
取り消しボタンを押したらカートから商品が消え、注文ボタンに戻ります。

ディレクトリ構造はこんな感じ。
f:id:duck8823:20160630211952p:plain

どのクラスから書き始めるのが良いかってことになるんですが、
上記の図を見るとDispatcherは他のクラスのAPIに依存していないので、そこから書き始めるのがよさそう。
Dispatcher -> StoreAction -> View の順番で作るのがファイルの行ったり来たりは少なくなりそうです。
※コールバックの順番を制御したい場合はStoreに依存することになりますが、その場合も小さいDispatcherを作ってから複数のStoreを作り、Dispatcherを大きくするのがよいと思いました。

Dispatcher

app-dispatcher.js

import {Dispatcher} from 'flux';
export default new Dispatcher;

シンプルにそのまま使ってます。コールバックを制御したい場合は拡張していきます。

ActionCreators(Action)

Dispatcher.dispath()

static add(item) {
    AppDispatcher.dispatch({
        type: Constants.ADD,
        item: item
    });
}

staticメソッドの中で、アクションをDispatcherに渡しています。
Storeの中で処理を振り分けるために、typeを渡しています。
あとは、Storeで保存などするためのデータ。ここではitemとしてViewから渡される商品オブジェクトになります。

cart-action.js

import AppDispatcher from "../../app-dispatcher";
import {Constants} from "./constants";

/**
 * カートのAction
 */
export default class CartAction {
    
    static add(item) {
        AppDispatcher.dispatch({
            type: Constants.ADD,
            item: item
        });
    }

    static remove(item) {
        AppDispatcher.dispatch({
            type: Constants.REMOVE,
            item: item
        });
    }
}

クラス全体。ここでは、2種類のActionをDispatcherに渡してます。
今回はシンプルなものですが、外部APIと接続する場合はここで実行します。

Store

Dispatcher.register()

constructor() {
    super();
    ...
    AppDispatcher.register(this._onAction.bind(this));
}

Dispatcherからアクションを受け取るために、コンストラクタでDispatcherにアクションを登録します。
ここで登録したアクションで、処理を振り分けています。


EventEmitter.emit()

 add(item) {
    ...
    this.emit(Constants.ADD);
}

Node.jsのEventEmitterを利用し、emit()でViewに変更があったことを通知します。


cart-store.js

import EventEmitter from 'events';
import AppDispatcher from '../../app-dispatcher';
import {Constants} from './constants';

const CONSTANTS = {
    CART : 'CART'
};
/**
 * カートのStore
 */
class CartStore extends EventEmitter {

    constructor() {
        super();
        var items = window.sessionStorage.getItem(CONSTANTS.CART);
        this._items = items ? JSON.parse(items) : {};
        AppDispatcher.register(this._onAction.bind(this));
    }

    /**
    * 商品を追加する
    * @param item
    */
    add(item) {
        if(this.contains(item)){
            throw new Error("already contains: " + item.id);
        }
        this._items[item.id] = item;
        this._save();
        this.emit(Constants.ADD);
    }

    /**
    * 商品を削除する
    * @param item
    */
    remove(item) {
        if(!this.contains(item)){
            throw new Error("not contains: " + item.id);
        }
        delete this._items[item.id];
        this._save();
        this.emit(Constants.REMOVE);
    }

    /**
    * idが含まれているかどうか
    * @param item
    */
    contains(item) {
        return this._items[item.id] ? true : false;
    };

    /**
    * WebStorageに保存する
    * @private
    */
    _save() {
        window.sessionStorage.setItem(CONSTANTS.CART, JSON.stringify(this._items));
    }

    /**
    * 初期化する
    * @private
    */
    _clear() {
        this._items = {};
        window.sessionStorage.removeItem(CONSTANTS.CART);
    }

    _onAction(action) {
        switch (action.type) {
            case Constants.ADD:
                this.add(action.item);
                break;
            case Constants.REMOVE:
                this.remove(action.item);
                break;
            default:
            // nothing to do.
        }
    }

    getItems() {
        var items = [];
        Object.keys(this._items).forEach((key) => items.push(this._items[key]));
        return items;
    }
}
const cartStore = new CartStore();
export default cartStore;

クラス全体です。EventEmitterを継承しています。
最後にインスタンスを生成し、そのインスタンスをexportします。

View

Store.getter()

constructor(props) {
    super(props);
    this.state = {
        items: CartStore.getItems()
    };
}

コンストラクタ内でStoreから値を取得し、Viewコンポーネントの初期値とします。


EventEmitter.on()

componentWillMount() {
    CartStore.on(Constants.ADD, () => {
        this.setState({
            items: CartStore.getItems()
        });
    });
    ...
}

componentWillMountでコンポーネントがマウントされる際(DOMツリーに追加される前)に実行する処理を登録することができます。 EventEmitterを利用してここではConstants.ADDのコールバックにsetState()を指定しています。


Action.do

render() {
   ...
   <div className="ui vertical animated button" ref="button" onClick={this._onAction.bind(this)}>
   ...
}

render()内でボタン部分にonClickを登録しています。

_onAction() {
    const {contains} = this.state;
    const {item} = this.props;
    if(contains){
        CartAction.remove(item);
    } else {
        CartAction.add(item);
    }
    this.setState({contains: contains ? false : true});
}

クリックした際に呼ばれるメソッド内で、Actionのstaticメソッドを実行します。

カートのView

cart-view.js

import React from "react";
import CartStore from "./cart-store";

import {Constants} from "./constants";

import $ from 'jquery';
import 'semantic-ui-modal/modal.css'
$.fn.modal = require('semantic-ui-modal');

import 'semantic-ui-dimmer/dimmer.css';
$.fn.dimmer = require('semantic-ui-dimmer');

import 'semantic-ui-transition/transition.css';
$.fn.transition = require('semantic-ui-transition');

/**
 * カートのView
 */
export default class CartView extends React.Component {

    constructor(props) {
        super(props);
        this.state = {
            items: CartStore.getItems()
        };
    }

    componentWillMount() {
        CartStore.on(Constants.ADD, () => {
            this.setState({
                items: CartStore.getItems()
            });
        });
        CartStore.on(Constants.REMOVE, () => {
            this.setState({
                items: CartStore.getItems()
            });
        });
    }

    _open() {
        $(".ui.modal.cart").modal('show');
    }

    render() {
        return (
            <div>
                <div className="ui labeled button modal-button" ref="modal" onClick={this._open.bind(this)}>
                    <div className="ui button">
                        <i className="shop icon"></i> Cart
                    </div>
                    <div className="ui basic left pointing label count">
                        {this.state.items.length}
                    </div>
                </div>
                <div className="ui modal cart">
                    <i className="close icon"></i>
                    <div className="header">
                        Cart
                    </div>
                    <div className="ui description">
                        <div ref="item-list" className="ui middle aligned divided list item-list">
                            {this.state.items.map((item) => {
                                return (
                                    <div key={item.id} className="item">
                                        <div className="content">
                                            {item.name}
                                        </div>
                                    </div>
                                )
                            })}
                        </div>
                    </div>
                    <div className="actions">
                        <div className="ui black deny button">
                            Cancel
                        </div>
                        <div className="ui positive right labeled icon button">
                            OK
                            <i className="checkmark icon"></i>
                        </div>
                    </div>
                </div>
            </div>
        )
    }
}
アクションを実行する注文ボタンのView

cart-button.js

import React from 'react';

import CartStore from './cart-store';
import CartAction from './cart-action';

/**
 * ボタンのView
 */
export default class CartButton extends React.Component {

    constructor(props) {
        super(props);
        this.state = {
            contains: CartStore.contains(this.props.item)
        };
        
    }

    _onAction() {
        const {contains} = this.state;
        const {item} = this.props;
        if(contains){
            CartAction.remove(item);
        } else {
            CartAction.add(item);
        }
        this.setState({contains: contains ? false : true});
    }

    render() {
        const {contains} = this.state;
        return (
            <div className="ui vertical animated button" ref="button" onClick={this._onAction.bind(this)}>
                <div className="hidden content">
                    {contains ? 'Remove' : 'Add'}
                </div>
                <div className="visible content compact">
                    <i className={contains ? "trash outline icon" : "add to cart icon"}></i>
                </div>
            </div>
        )
    }
}

ViewでjQueryCSSフレームワークを使う

カートのViewではjQuerySemantic UIを利用しています。
jQueryは嫌われているけど、やっぱり便利ですし、デザインセンスが皆無なのでCSSフレームワークに頼りたい。

ES6でjQuery$として扱いたい場合は、

import $ from 'jquery';

cssを扱いたい場合も他のjavascriptと同じようにimport文が使えます。便利。

import 'semantic-ui-modal/modal.css'

この modal のようにCSSフレームワークjQueryの機能をオーバーライドしている場合は、

$.fn.modal = require('semantic-ui-modal');

このように書くことで実現できました。

Webpackによるビルド

ES6はブラウザによって対応状況が異なるので、WebpackBabelを指定してES5形式に変換(ビルド)します。
webpack.conf.js

var webpack = require('webpack');
var path = require('path');

var ExtractTextPlugin = require('extract-text-webpack-plugin');

module.exports = {
    entry: {
        bundle: './path/to/src/entry.js'
    },
    output: {
        path: path.join(__dirname, 'path/to/dist'),
        filename: 'bundle.js'
    },
    plugins: [
        new ExtractTextPlugin('bundle.css')
    ],
    module : {
        loaders: [
            {
                test: /\.jsx?$/,
                exclude: /node_modules/,
                loader: 'babel'
            },
            {
                test: /\.css(\?.+)?$/,
                loader: ExtractTextPlugin.extract('style-loader', 'css-loader')
            },
            {
                test : /\.(svg|woff2?|eot|ttf|png|gif)$/,
                loader: 'url'
            }
        ]
    }
};

ローダー

ファイルに対してどのようにコンパイルするかを指定します。

loaders: [
    {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        loader: 'babel'
    },
    {
        test: /\.css(\?.+)?$/,
        loader: ExtractTextPlugin.extract('style-loader', 'css-loader')
    },
    {
        test : /\.(svg|woff2?|eot|ttf|png|gif)$/,
        loader: 'url'
    }
]

testで指定した正規表現にマッチするファイル対して、loaderを指定します。
js(x)ファイルはbabel(ES6をES5に変換)
cssファイルはExtractTextPlugin.extract('style-loader', 'css-loader')(実ファイルも作るプラグイン
フォントや画像などはurlbase64エンコード

babelはオプションを.babelrcで指定することができます。
queryとして記述できるのですが、テストランナーでも同じオプションを利用したいのでファイルとして置いておくと便利です。

.babelrc

{
  "presets": ["es2015", "react"]
}

CSSのファイル出力

CSSはテンプレートエンジンでも利用したいので、ファイルとして出力します。
ExtractTextPluginを利用します。

var ExtractTextPlugin = require('extract-text-webpack-plugin');

module.exports = {
    ...
    plugins: [
        new ExtractTextPlugin('bundle.css')
    ],
    module : {
        loaders: [
            ...
            {
                test: /\.css(\?.+)?$/,
                loader: ExtractTextPlugin.extract('style-loader', 'css-loader')
            },
        ]
    }
}

pluginsで指定したファイル名bundle.cssに出力されます。

出力先

output

output: {
    path: path.join(__dirname, 'path/to/dist'),
    filename: 'bundle.js'
},

ビルドの起点

entry

entry: {
    bundle: './path/to/src/entry.js'
}

起点となるスクリプトを指定します。
このスクリプトを起点として依存されていないファイルはビルドされないので注意してください。

entry.js

import 'semantic-ui-css/semantic.css'

import React from "react";
import ReactDOM from "react-dom";

import $ from "jquery";

import Cart from "./components/cart/cart-view";
import Button from "./components/cart/cart-button-view";

$(".ui.cart").each(function(){
    ReactDOM.render(
        <Cart />,
        $(this).get(0)
    );
});
$(".ui.cart-button").each(function(){
    var item = {
        id : $(this).data('id'),
        name :$(this).data('name')
    };
    ReactDOM.render(
        <Button item={item} />,
        $(this).get(0)
    );
});

テンプレートエンジンで利用するCSSはここで指定します。
また、自分はCSSフレームワークのようにセレクタコンポーネントレンダリングしたいので、

$(".ui.cart").each(function(){
    ReactDOM.render(
        <Cart />,
        $(this).get(0)
    );
});

て書いてます。これでhtmlでは

<div class="ui cart" ></div>

レンダリングしてくれます。

ビルドの実行

ビルドの実行はコマンドラインから

node_components\.bin\webpack

でビルドを実行します。
webpackはpackage.jsonで依存関係に含まれているので、npm installでインストールできます。

テスト

テストを書きましょう。テストは心を豊かにします。
ES6のテストができて、カバレッジ算出を目指しました。

スティングフレームワーク

Mocha
情報量が多かったので選びました。
describeitでテストケースを区切っていきます。
beforeEach()で各テストケースの前に実行する処理を記述できます。

beforeEach(()=>{
    ...
});

Assertionライブラリ

Chai
これも情報量が多かったので選びました。
色々用意されているみたい。

配列の要素数

expect(cart.state.items).to.have.length(1);

Errorをthrowしているかどうか。

expect(()=>CartAction.add({
        id: '1',
        name: '1'
    })).to.throw(Error);

作成したテスト

import React from "react";
import ReactTestUtils from "react-addons-test-utils";
import {expect} from "chai";

import Cart from "../src/components/cart/cart-view";
import CartButton from "../src/components/cart/cart-button-view";
import CartStore from "../src/components/cart/cart-store";
import CartAction from "../src/components/cart/cart-action";

/**
 * カートコンポーネントのテスト
 */
describe('Cart', ()=>{

    beforeEach(()=>{
        CartStore._clear();
    });

    it('カートの中身は初期値は0件である', () => {
        let component = ReactTestUtils.renderIntoDocument(<Cart />);

        expect(component.state.items).to.have.length(0);
    });

    it('カートに追加すると1件になる', () => {
        let component = ReactTestUtils.renderIntoDocument(<Cart />);
        CartAction.add({
            id  : '1',
            name: '1'
        });

        expect(component.state.items).to.have.length(1);
    });

    it('カートに3件追加し、1件削除すると2件になる', () => {
        let component = ReactTestUtils.renderIntoDocument(<Cart />);
        CartAction.add({
            id  : '1',
            name: '1'
        });
        CartAction.add({
            id  : '2',
            name: '2'
        });
        CartAction.add({
            id  : '3',
            name: '3'
        });
        CartAction.remove({
            id  : '2',
            name: '2'
        });
        expect(component.state.items).to.have.length(2);
    });

    it('既に含まれているIDを追加しようとするとエラーになって追加されない', () => {
        let component = ReactTestUtils.renderIntoDocument(<Cart />);
        CartAction.add({
            id: '1',
            name: '1'
        });
        expect(component.state.items).to.have.length(1);

        expect(()=>CartAction.add({
            id: '1',
            name: '1'
        })).to.throw(Error);

        expect(component.state.items).to.have.length(1);
    });

    it('含まれていないIDを削除しようとするとエラーになる', () => {
        let component = ReactTestUtils.renderIntoDocument(<Cart />);
        CartAction.add({
            id: '1',
            name: '1'
        });
        expect(component.state.items).to.have.length(1);

        expect(()=>CartAction.remove({
            id: '2',
            name: '2'
        })).to.throw(Error);

        expect(component.state.items).to.have.length(1);
    });

    it('リクエストボタンをクリックしたらカートに追加される', ()=>{
        let cart = ReactTestUtils.renderIntoDocument(<Cart />);
        let cartButton = ReactTestUtils.renderIntoDocument(
            <CartButton item={{id : '1', name : '1'}} />
        );
        ReactTestUtils.Simulate.click(cartButton.refs['button']);

        expect(cart.state.items).to.have.length(1);
    });

    it('既に含まれている商品のリクエストボタンをクリックしたらカートから削除される', ()=>{
        var item = {id : '1', name : '1'};
        CartAction.add(item);

        let cart = ReactTestUtils.renderIntoDocument(<Cart />);
        expect(cart.state.items).to.have.length(1);

        let cartButton = ReactTestUtils.renderIntoDocument(
            <CartButton item={item} />
        );
        ReactTestUtils.Simulate.click(cartButton.refs['button']);

        expect(cart.state.items).to.have.length(0);
    });
});


テストランナー

ES6のテストを実行するにはやはりBabelでビルドする必要があります。
ビルドしてテストを実行するためにテストランナーであるKarmaを用いました。

Karmaはkarma.conf.jsで設定します。

ビルド前のソースコードカバレッジ計算

カバレッジ計算ではistanbu-instrumenter-loaderlが使われているようです。
この場合だとwebpackでビルドしたコードに対してカバレッジを計算してしまうので、isparta-loaderを使うといいようです。

出来上がったkarma.conf.js

karma.conf.js

var path = require('path');

module.exports = function(config) {
    config.set({
        basePath: '',
        frameworks: ['phantomjs-shim','mocha'],
        files: [
            'path/to/test/**/*.spec.js'
        ],
        exclude: [
        ],
        plugins: [
            'karma-phantomjs-launcher',
            'karma-phantomjs-shim',
            'karma-mocha',
            'karma-sourcemap-loader',
            'karma-webpack',
            'karma-coverage',
            'karma-mocha-reporter'
        ],
        preprocessors: {
            'path/to/test/**/*.spec.js': ['webpack', "sourcemap"]
        },
        webpack: {
            devtools: 'inline-source-map',
            isparta: {
                embedSource: true,
                noAutoWrap: true
            },
            module: {
                loaders: [
                    {
                        test: /\.jsx?$/,
                        exclude: /node_modules/,
                        loader: 'babel'
                    },
                    {
                        test: /\.css(\?.+)?$/,
                        loader: 'style!css'
                    }
                ],
                preLoaders: [
                    {
                        test: /\.jsx?$/,
                        exclude: [
                            path.resolve('path/to/test/'),
                            path.resolve('node_modules/')
                        ],
                        loader: 'isparta'
                    }
                ]
            }
        },
        webpackMiddleware: {
            noInfo: true
        },
        port: 9876,
        browsers: ['PhantomJS'],
        reporters: ['mocha', 'coverage'],
        colors: true,
        logLevel: config.LOG_INFO,
        autoWatch: false,
        singleRun: true,
        concurrency: Infinity,
        coverageReporter: {
            dir: 'path/to/test/report/',
            reporters: [
                { type: 'text' },
                { type: 'html', subdir: '.' }
            ]
        }
    })
};

ここで使うbabelの設定は上述の.babelrcで行われるので、ちょっとスッキリしています。

テストの実行は

karma start

f:id:duck8823:20160701005751p:plain
カバレッジを計算してくれました。

レポートでhtmlを指定した場合、どの部分をカバーできていないか教えてくれます。
f:id:duck8823:20160701012639p:plain

また、package.json

"scripts": {
    "test": "karma start"
  }

と設定しているので

npm test

でも実行できます。

フロントエンド開発をちゃんとやろうと思って構築を始めたのですが、こんなにも開発環境は充実していたんですね。
jQueryで頑張っていたのが嘘のよう。

今回作成したコンポーネントGitHubに置いておきました。 github.com

フロントエンドは変化が激しいといわれてますが、それぞれ目的があって見つけてきたものなので、目的が変わらなければこのままでもいいかなと思ったりします。