Sass
TypeScript
gulp
webpack
React

TypeScript, React, Sass, webpack, gulp導入記 - モダンなクライアントサイドWeb開発に挑戦した話

More than 1 year has passed since last update.

本記事の執筆に当たっては、ニコニコ生放送において、MaxMEllonさんを始めとして、皆様からのアドバイスをいただきました。ありがとうございます。

はじめに

経緯

私はこれまでJavaScriptといえば、Webサーバに配置する*.jsファイルを直接書いていました。使っているライブラリも、jQueryやBackbone.jsぐらいでした。ただ、近年のECMAScript 2015やTypeScriptという新しい言語仕様、React等の新しいライブラリの興隆を見て、時代からの遅れを感じていました。そこで、ビルドシステムの導入や新しい言語・ライブラリの利用によって、開発過程と成果物の品質を高めたいと思い、色々と挑戦してみました。

目標

以下を導入します。

  • TypeScript
    • JavaScriptへとコンパイル可能な言語の一つです。ECMAScript 2015やCoffeeScriptも検討しましたが、静的型付けによるコンパイル時エラー検出や強力なIDE補完が期待できるため、本言語を選定しました。
  • React
    • いわゆるMVCのビューのためのライブラリです。AngularJS等と比べるとビューに特化しており、設計の自由度が高いのではないかと考え、普及度の向上も鑑みて、選定しました。
  • Sass
    • CSSのプリプロセッサです。最も普及度が高いようでしたので、選定しました。ファイル形式としては、ソフトウェアエンジニア以外の方にも親しみやすいSCSSを採用しました。
  • webpack
    • JavaScriptのモジュールを結合します。Browserifyも検討しましたが、後発で普及度が高まっているこちらを選定しました。
  • gulp
    • ビルドのためのツールです。Gruntも検討しましたが、後発で普及度が高まっているこちらを選定しました。

筆者の環境

  • Windows 10 Pro 64ビット
  • IntelliJ IDEA 15.0 (無くても構いません)

各種ツールのインストール

node.jsのインストール

node.jsのオフィシャルサイトのインストーラを実行します。業務利用を想定して、LTSバージョンを利用しました。

これによって、npm(パッケージマネージャ)がインストールされ、コマンドプロンプトでnpmコマンドを実行できるようになります。

TypeScriptのインストール

>npm install typescript -g

これによって、コマンドプロンプトでtscコマンドを実行できるようになります。

もし実行できない場合は、環境変数PATHC:\Users\(ユーザ名)\AppData\Roaming\npmが含まれるようにしてください。なお、npm install-g を付けたとき、Windows環境ではC:\Users\(ユーザ名)\AppData\Roaming\npm以下にモジュールがインストールされます。

typingsのインストール

typingsは既存のJavaScriptライブラリを利用するための型定義ファイルを取得するツールです。

>npm install typings -g

これによって、コマンドプロンプトでtypingsコマンドを実行できるようになります。

TSLintのインストール

TSLintはTypeScriptのフォーマットチェックツールです。この記事ではIntelliJ IDEAから使用します。

>npm install tslint -g

これによって、コマンドプロンプトでtslintコマンドを実行できるようになります。

Sassのインストール

RubyInstaller for Windowsから、Rubyをインストールします。その後、コマンドプロンプトで以下を実行します。

>gem install sass

これによって、コマンドプロンプトでsassコマンドを実行できるようになります。

gulpのインストール

>npm install gulp-cli -g

これによって、コマンドプロンプトでgulpコマンドを実行できるようになります。

プロジェクトの作成

プロジェクトディレクトリの作成

>mkdir hello-react
>cd hello-react
>mkdir src
>mkdir src\js
>mkdir src\css
>mkdir build
>mkdir dist

作成したディレクトリ、および自動的に作成されるディレクトリの用途は以下の通りです。

  • src: ソースコードを格納します。バージョン管理下に置きます。
  • build: TypeScriptによるコンパイル結果を格納します。バージョン管理からは除外します。
  • dist: ビルドによって出力された、配備するファイルを格納します。バージョン管理からは除外します。
  • node_modules: npmの--saveまたは--save-devで取得したモジュールが格納されます。バージョン管理からは除外します。
  • typings: typingsで取得した型定義ファイルが格納されます。バージョン管理からは除外します。

package.jsonの作成

以下の作業は、hello-reactディレクトリで実行します。

>npm init

これによってhello-react内にpackage.jsonファイルが作成されます。このファイルはJavaのMavenにおけるpom.xmlのようなものかと理解しています。

ビルド用モジュールのインストール

npm install del gulp gulp-sass gulp-typescript gulp-webpack webpack --save-dev

--save-devオプションによって、hello-react/node_modulesディレクトリ内に、必要なビルド用(配備用ではない)モジュールがインストールされ、package.jsonに記録されます。

なお、package.jsonに記録されたモジュールは、以下で更新できます。

>npm update

IntellJ IDEAのプロジェクトの作成

  • Create New Projectから、Static Webを選びます。Project locationにhello-reactを選び、プロジェクトを作成します。
  • srcフォルダを右クリックし、[Mark Directory As]-[Sources Root]を選びます。
  • build, distフォルダを右クリックし、[Mark Directory As]-[Excluded]を選びます。
  • [File]-[Settings]で設定ダイアログを開きます。
    • [Language & Frameworks > TypeScript]を開きます。
      • [Enable TypeScript Compiler]をチェックします。
      • [Command line options]に--jsx react --module commonjsを入力します。これにより、JSXに対応し、また、出力されるモジュールの形式がCommonJS形式にされます。
      • [Use output path]をチェックし、build/$FileDirRelativeToSourcepath$を入力します。これにより、IntelliJ IDEAによるコンパイル結果は、gulpによるコンパイル結果と同じ場所に格納されます。
    • [Language & Frameworks > TypeScript > TSLint]を開きます。
      • [Enable]をチェックします。

最低限必要なソースファイルの作成

src/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link href="css/common.css" rel="stylesheet" type="text/css">
    <script src="js/bundle.js" type="text/javascript"></script>
</head>
<body>
<div id="content"></div>
</body>
</html>
src/js/main.tsx
// とりあえず空で良い

tslint.jsonの作成

TSLintオフィシャルのサンプルを元に作成しました。

tslint.json
{
  "rules": {
    "align": [
      true,
      "parameters",
      "arguments",
      "statements"
    ],
    "ban": false,
    "class-name": true,
    "comment-format": [
      true,
      "check-space",
      "check-lowercase"
    ],
    "curly": true,
    "eofline": true,
    "forin": true,
    "indent": [
      true,
      "tabs"
    ],
    "interface-name": true,
    "jsdoc-format": true,
    "label-position": true,
    "label-undefined": true,
    "max-line-length": [
      true,
      140
    ],
    "member-access": true,
    "member-ordering": [
      true,
      "public-before-private",
      "static-before-instance",
      "variables-before-functions"
    ],
    "no-any": false,
    "no-arg": true,
    "no-bitwise": true,
    "no-conditional-assignment": true,
    "no-consecutive-blank-lines": false,
    "no-console": [
      true,
      "debug",
      "info",
      "time",
      "timeEnd",
      "trace"
    ],
    "no-construct": true,
    "no-constructor-vars": true,
    "no-debugger": true,
    "no-duplicate-key": true,
    "no-duplicate-variable": true,
    "no-empty": false,
    "no-eval": true,
    "no-inferrable-types": false,
    "no-internal-module": true,
    "no-null-keyword": true,
    "no-require-imports": true,
    "no-shadowed-variable": true,
    "no-string-literal": true,
    "no-switch-case-fall-through": true,
    "no-trailing-whitespace": true,
    "no-unreachable": true,
    "no-unused-expression": true,
    "no-unused-variable": true,
    "no-use-before-declare": true,
    "no-var-keyword": true,
    "no-var-requires": true,
    "object-literal-sort-keys": false,
    "one-line": [
      true,
      "check-open-brace",
      "check-catch",
      "check-else",
      "check-finally",
      "check-whitespace"
    ],
    "quotemark": [
      false,
      "double",
      "avoid-escape"
    ],
    "radix": true,
    "semicolon": [true, "always"],
    "switch-default": true,
    "trailing-comma": [
      true,
      {
        "multiline": "always",
        "singleline": "never"
      }
    ],
    "triple-equals": [
      true,
      "allow-null-check"
    ],
    "typedef": [
      true,
      "parameter",
      "arrow-parameter",
      "property-declaration",
      "variable-declaration",
      "member-variable-declaration"
    ],
    "typedef-whitespace": [
      true,
      {
        "call-signature": "nospace",
        "index-signature": "nospace",
        "parameter": "nospace",
        "property-declaration": "nospace",
        "variable-declaration": "nospace"
      },
      {
        "call-signature": "space",
        "index-signature": "space",
        "parameter": "space",
        "property-declaration": "space",
        "variable-declaration": "space"
      }
    ],
    "use-strict": [
      true,
      "check-module",
      "check-function"
    ],
    "variable-name": [
      true,
      "check-format",
      "allow-leading-underscore",
      "ban-keywords"
    ],
    "whitespace": [
      true,
      "check-branch",
      "check-decl",
      "check-operator",
      "check-separator",
      "check-type"
    ]
  }
}

gulpfile.jsの作成とgulpの実行

gulpfile.js
var gulp = require('gulp');
var ts = require('gulp-typescript');
var sass = require('gulp-sass');
var del = require('del');
var webpack = require('gulp-webpack');

gulp.task('clean', function() {
    del(['dist']);
    del(['build']);
});

gulp.task('copy', function() {
    gulp.src(
        [ 'src/**/*', '!src/js/*', '!src/css/*' ],
        { base: 'src' }
    ).pipe(gulp.dest('dist'));
});

gulp.task('ts', function() {
    return gulp.src(['src/js/*.{ts,tsx}'])
        .pipe(ts({
            target: "ES5",
            jsx: "react",
            module: "commonjs"
        }))
        .js
        .pipe(gulp.dest('build/js'));
});

gulp.task('bundle', ['ts'], function() {
    gulp.src('./build/js/*.js')
        .pipe(webpack({
            entry: ['./build/js/main.js'],
            output: {
                filename: 'bundle.js',
                library: 'app'
            },
            devtool: 'source-map',
            resolve: {
                extensions: ['', '.js']
            },
        }))
        .pipe(gulp.dest('dist/js'));
});

gulp.task('scss', function() {
    gulp.src(['src/css/*.scss'])
        .pipe(sass())
        .pipe(gulp.dest('dist/css'));
});

gulp.task('watch', function() {
    gulp.watch(['src/**/*', '!src/js/*', '!src/css/*'], ['copy']);
    gulp.watch('src/js/*.{ts,tsx}', ['bundle']);
    gulp.watch('src/css/*.scss', ['scss']);
});

gulp.task('default', ['copy', 'bundle', 'scss', 'watch']);

コマンドプロンプトからgulpを実行します。

>gulp

gulpfile.jsとはMakefileのようなものと理解しています。gulpを実行することにより、'default'で指定されたタスクが実行されます。特に、'watch'タスクは、ファイルの更新を監視し、各種タスクを自動実行します。gulpを終了するには、Ctrl+Cを押してください。

実装

アプリケーション概要

ダミーのRESTful APIであるJSONPlaceholder/postsに接続し、CRUD操作を行います。

実装するクラスの構成は以下の通りです。

hello-react.png

ライブラリの取得

>npm install jquery history react react-dom react-router --save

--saveオプションにより、配備するNodeモジュールがインストールされ、package.jsonに記録されます。

型定義ファイルの取得

>typings init
>typings install jquery --ambient --save
>typings install history --ambient --save
>typings install react --ambient --save
>typings install react-dom --ambient --save
>typings install react-router --ambient --save

--ambientオプションによりDefinitelyTypedから、型定義ファイルが検索されます。

--saveオプションにより、取得した型定義ファイルが、typings.jsonに記載されます。

なお、typings.jsonに記録された型定義ファイルを改めて取得したいときは、以下で取得できます。

>typings install

サブモジュールの実装

src/js/typebone.ts
/// <reference path="../../typings/browser.d.ts" />

import * as $ from 'jquery';

export interface ICollectionListener<T> {
    onUpdateCollection: (sender: Collection<T>) => void;
}

export class Collection<T> {
    private models: T[];
    private url: string;
    private idAttribute: string;
    private listeners: ICollectionListener<T>[];
    constructor(url: string) {
        this.url = url;
        this.idAttribute = 'id';
        this.listeners = [];
    }
    public addListener(listener: ICollectionListener<T>): void {
        this.listeners.push(listener);
    }
    public removeListener(listener: ICollectionListener<T>): void {
        let index: number = this.listeners.indexOf(listener);
        if (index >= 0) {
            this.listeners.splice(index, 1);
        }
    }
    public get(): T[] {
        return this.models;
    }
    public add(item: T): JQueryPromise<any> {
        let d: JQueryDeferred<any> = $.Deferred();
        $.ajax({
            url: this.url,
            dataType: 'json',
            type: 'post',
            data: item,
            success: (data: any): void => {
                if (!(this.idAttribute in data)) {
                    console.log('response body must contain id attribute.');
                }
                this.models.push(data);
                d.resolve();
                for (let i: number = 0; i < this.listeners.length; ++i) {
                    this.listeners[i].onUpdateCollection(this);
                }
            },
            error: (xhr: JQueryXHR, status: string, err: string): void => {
                d.reject(xhr, status, err);
            },
        });
        return d.promise();
    }
    public fetch(): JQueryPromise<any> {
        let d: JQueryDeferred<any> = $.Deferred();
        $.ajax({
            url: this.url,
            dataType: 'json',
            cache: false,
            success: function(data: any): void {
                this.models = data;
                d.resolve();
                for (let i: number = 0; i < this.listeners.length; ++i) {
                    this.listeners[i].onUpdateCollection(this);
                }
            }.bind(this),
            error: function(xhr: JQueryXHR, status: string, err: string): void {
                d.reject(xhr, status, err);
            }.bind(this),
        });
        return d.promise();
    }
    public set(item: T): JQueryPromise<any> {
        let d: JQueryDeferred<any> = $.Deferred();
        let id: string = item[this.idAttribute];
        $.ajax({
            url: this.url + '/' + id,
            dataType: 'json',
            type: 'put',
            data: item,
            success: (data: any): void => {
                for (let i: number = 0; i < this.models.length; ++i) {
                    if (this.models[i][this.idAttribute].toString() === id) {
                        this.models[i] = item;
                        break;
                    }
                }
                d.resolve();
                for (let i: number = 0; i < this.listeners.length; ++i) {
                    this.listeners[i].onUpdateCollection(this);
                }
            },
            error: (xhr: JQueryXHR, status: string, err: string): void => {
                d.reject(xhr, status, err);
            },
        });
        return d.promise();
    }
    public remove(id: string): JQueryPromise<any> {
        let d: JQueryDeferred<any> = $.Deferred();
        $.ajax({
            url: this.url + '/' + id,
            dataType: 'json',
            type: 'delete',
            success: (data: any): void => {
                for (let i: number = 0; i < this.models.length; ++i) {
                    if (this.models[i][this.idAttribute].toString() === id) {
                        this.models.splice(i, 1);
                        break;
                    }
                }
                d.resolve();
                for (let i: number = 0; i < this.listeners.length; ++i) {
                    this.listeners[i].onUpdateCollection(this);
                }
            },
            error: (xhr: JQueryXHR, status: string, err: string): void => {
                d.reject(xhr, status, err);
            },
        });
        return d.promise();
    }
}

メインモジュールの実装

src/js/main.tsx
/// <reference path="../../typings/browser.d.ts" />
/// <reference path="typebone.ts" />

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import * as ReactRouter from 'react-router';
import * as $ from 'jquery';
import * as Typebone from './typebone';

class AppModel {
    public collection: Typebone.Collection<IPostItem> = new Typebone.Collection<IPostItem>('http://jsonplaceholder.typicode.com/posts');
}

let appModel: AppModel = new AppModel();

interface IPostItem {
    userId: number;
    id: number;
    title: string;
    body: string;
}

interface IPostProps extends React.Props<any> {
    key: string;
    item: IPostItem;
}

interface IPostState {
    title?: string;
    body?: string;
}

class Post extends React.Component<IPostProps, IPostState> {
    constructor(props: IPostProps, context: any) {
        super(props, context);
        this.state = this.props.item;
    }
    public render(): JSX.Element {
        return (
            <div className="post">
                <input type="text" value={this.state.title} onChange={this.handleTitleChange.bind(this)}/>
                <input type="text" value={this.state.body} onChange={this.handleBodyChange.bind(this)}/>
                <input type="button" value="Update" onClick={this.handleUpdateClick.bind(this)}/>
                <input type="button" value="Delete" onClick={this.handleDeleteClick.bind(this)}/>
            </div>
        );
    }
    private handleTitleChange(e: React.FormEvent): void {
        this.setState({title: (e.target as HTMLInputElement).value});
    }
    private handleBodyChange(e: React.FormEvent): void {
        this.setState({body: (e.target as HTMLInputElement).value});
    }
    private handleUpdateClick(e: React.FormEvent): void {
        let item: IPostItem = this.props.item;
        item.title = this.state.title;
        item.body = this.state.body;
        appModel.collection.set(item);
    }
    private handleDeleteClick(e: React.FormEvent): void {
        appModel.collection.remove(this.props.item.id.toString());
    }
}

interface IPostListProps extends React.Props<any> {
    data: IPostItem[];
}

class PostList extends React.Component<IPostListProps, any> {
    public render(): JSX.Element {
        let nodes: JSX.Element[] = this.props.data.map(function(item: IPostItem): JSX.Element {
            return (<Post key={item.id.toString()} item={item} />);
        });
        return (
            <div className="postList">
                {nodes}
            </div>
        );
    }
}

interface IPostFormProps extends React.Props<any> {
    onPostSubmit: (item: IPostFormState) => void;
}

interface IPostFormState {
    title?: string;
    body?: string;
}

class PostForm extends React.Component<IPostFormProps, IPostFormState> {
    constructor(props: IPostFormProps, context: any) {
        super(props, context);
        this.state = {
            title: '',
            body: '',
        };
    }
    public render(): JSX.Element {
        return (
            <form className="postForm" onSubmit={this.handleSubmit.bind(this)}>
                <input type="text" placeholder="title" value={this.state.title} onChange={this.handleTitleChange.bind(this)}/>
                <input type="text" placeholder="body" value={this.state.body} onChange={this.handleBodyChange.bind(this)}/>
                <input type="submit" value="Post"/>
            </form>
        );
    }
    private handleTitleChange(e: React.FormEvent): void {
        this.setState({title: (e.target as HTMLInputElement).value});
    }
    private handleBodyChange(e: React.FormEvent): void {
        this.setState({body: (e.target as HTMLInputElement).value});
    }
    private handleSubmit(e: React.FormEvent): void {
        e.preventDefault();
        let title: string = this.state.title;
        let body: string = this.state.body;
        if (title === '' || body === '') {
            return;
        }
        this.props.onPostSubmit(this.state);
        this.setState({
            title: '',
            body: '',
        });
    }
}

interface IPostBoxProps extends React.Props<any> {
}

interface IPostBoxState {
    data: IPostItem[];
}

class PostBox extends React.Component<IPostBoxProps, IPostBoxState> implements Typebone.ICollectionListener<IPostItem> {
    constructor(props: IPostBoxProps, context: any) {
        super(props, context);
        this.state = {data: []};
    }
    public componentDidMount(): void {
        appModel.collection.fetch().fail(
            (xhr: JQueryXHR, status: string, err: string) => {
                console.error(xhr.statusText, status, err);
            }
        );
        appModel.collection.addListener(this);
    }
    public componentWillUnmount(): void {
        appModel.collection.removeListener(this);
    }
    public render(): JSX.Element {
        return (
            <div className="postBox">
                <h1>Posts</h1>
                <PostList data={this.state.data}/>
                <PostForm onPostSubmit={this.handlePostSubmit.bind(this)}/>
            </div>
        );
    }
    public onUpdateCollection(sender: Typebone.Collection<IPostItem>): void {
        this.setState({data: appModel.collection.get()});
    }
    private handlePostSubmit(post: IPostItem): void {
        appModel.collection.add(post).fail(
            (xhr: JQueryXHR, status: string, err: string) => {
                console.error(xhr.statusText, status, err);
            }
        );
    }
}

const routes: JSX.Element = (
    <ReactRouter.Router history={ReactRouter.hashHistory}>
        <ReactRouter.Route path="/" component={PostBox}>
        </ReactRouter.Route>
    </ReactRouter.Router>
);

$(function(): void {
    ReactDOM.render(routes, document.getElementById('content'));
});

SCSSの実装

css/common.scss
$primary-color: #111188;
$background-color: #eeeeee;

body {
    color: $primary-color;
    background-color: $background-color;
}

改めてビルド

>gulp clean
>gulp

以上で、dist/index.htmlを閲覧できるようになりました。なお、ダミーのRESTful APIを用いているため、期待通りに動作しない機能もあります。

まとめ

ビルドシステムの構築に関しては、情報収集に苦労しましたが、一度構築してしまえばあまり手を入れることは無く、使い回しもできると思います。

TypeScriptに関しては、ライブラリを利用する際、JavaScriptを対象に書かれているドキュメントをTypeScriptに読み替えなければならない、必要に応じて型定義ファイルを参照しなければならないことなどから、ライブラリ利用のハードルが上がっています。しかし、利用が軌道に乗れば、コンパイルによるエラー発見やIDEによる強力な補完によって、開発効率の向上を実感できました。TypeScriptの定義通り、Java等の静的型付け言語の感覚で実装できるので、とりあえずコードの一部を書き換えて、コンパイルエラーの出ている箇所を修正していくというようなコーディング手法も実践できました。まだTypeScriptを体系的に学習したわけではありませんが、IntelliJ IDEA上で表示されるTSLintの警告の助けを借りて、TypeScriptならではの書き方を学習できました。

Reactに関しては、まだチュートリアルを行っただけのレベルですが、ビューをコンポーネントの組み合わせで構築でき、モデルについては自分の好きなように設計できるので、アプリケーションに合ったわかりやすいアーキテクチャを作る手助けになりそうです。なお、モデル部分には当初Backbone.jsを用いる予定でしたが、Model.get()で型付きの戻り値が得られないなど、TypeScriptの恩恵に預かれない部分が多そうなので、類似のモジュールをtypebone.tsとして自作しました。

Sassに関しては、ほとんど触っていませんが、SCSSはCSSと同じ感覚で書けるので、CSSからの移行はスムーズにできそうです。

本記事と同様の内容で、Reactの代わりにVue.jsを用いた記事も書きましたので、ご参照ください。