TypeScriptでもReduxのHMR対応しておきましょう

TypeScriptの6つめ。

HMRには3段階あって、非Reactアプリ、Reactアプリと続いて最後にReact+Reduxアプリに到達します。

// /store/Store.ts
import { createStore, combineReducers, applyMiddleware } from 'redux';
import { routerReducer as router, routerMiddleware } from 'react-router-redux';
import { History } from 'history';
import { composeWithDevTools } from 'redux-devtools-extension';

import { app } from './AppReducer';

export default (history: History) => {
    const store = createStore(
        combineReducers({ router, app }),
        composeWithDevTools(
            applyMiddleware(
                routerMiddleware(history),
            ),
        ),
    );
    if (module.hot) {
        module.hot.accept('./AppReducer.ts', () => {
            const nextReducer = { router, app: require<{app: typeof app}>('./AppReducer').app };
            store.replaceReducer(combineReducers(nextReducer));
        });
    }
    return store;
};

もう派生論点なんでフルにコードは示さずとも、ポイントだけ。requireを使って更新Reducerを別名で読み込んで、store.replaceReducerを呼ぶことです。

上記コードではChromeプラグインのRedux DevToolsに対応させるため、react-router-reduxを入れました。

TypeScriptでもHMRを実現する

TypeScriptの5つめ。まだまだ続きます。

$ yarn add react-hot-loader@next
$ yarn add @types/webpack-env -D

react-hot-loaderは3.0.0-beta.7が入りました。そして@types/webpack-envを!これが自分的には一番ヤバい。見つけるのに苦労した。

// webpack.config.development.ts
import * as path from 'path';
import * as webpack from 'webpack';

export default {
    entry: [
        'react-hot-loader/patch',
        './ts/index.tsx',
    ],
    output: {
        filename: 'bundle.js',
        publicPath: '/js/',
    },
    devtool: 'inline-source-map',
    module: {
        rules: [{
            test: /\.(ts|tsx)$/,
            use: [
                'react-hot-loader/webpack',
                'awesome-typescript-loader',
                'tslint-loader',
            ],
        }],
    },
    resolve: {
        extensions: ['.ts', '.tsx', '.js'],
    },
    plugins: [
        new webpack.HotModuleReplacementPlugin(),
        new webpack.NamedModulesPlugin(),
        new webpack.NoEmitOnErrorsPlugin(),
    ],
    devServer: {
        host: 'localhost',
        port: 9000,
        hot: true,
        historyApiFallback: true,
        contentBase: path.resolve('static'),
    },
};

いろいろ出し入れしてみて、一番シンプルな作りがこれかなあ。historyApiFallbackは今は必要ないですが、React RouterすなわちHistoryを用いてかつProxyでワークアラウンドしないアプリを作るときに必須です。

  • entryにreact-hot-loader/patchを入れる
  • ローダーチェーンの先頭にreact-hot-loader/webpackを入れる
  • HotModuleReplacementPluginを登録する
  • devServer.hotをtrueにする
// tsconfig.json
{
    "compilerOptions": {
        "module": "commonjs",
        "target": "es5",
        "jsx": "react",
        "lib": ["es6", "dom"],
        "types": ["webpack-env", "jest"]
    }
}

ここで、webpack-envが出てくる!これが無いとHMRの定石である、module.hotの名前解決ができない。いやーグローバル名前空間でいろいろやれるのはダメだと思いますが、JavaScript黒歴史としてこんなのが山ほどこれからも出てくることでしょう。TypeScriptはJavaScriptとそのまま地続きだからグローバルで自由なユーザー定義を禁止するような言語仕様にできなかったか。。。

// /components/App.tsx
import * as React from 'react';

import { Hello } from './Hello';
import { Counter } from './Counter';

export const App = () => (
    <div>
        <Hello compiler="TypeScript" framework="React" />
        <Counter />
    </div>
);



// /index.tsx
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { AppContainer } from 'react-hot-loader';

import { App } from './components/App';

const render = () => ReactDOM.render(
    <AppContainer>
        <App />
    </AppContainer>,
    document.getElementById('application'),
);

render();

if (module.hot) {
    module.hot.accept('./components/App', () => {
        const NextApp = require<{App: typeof App}>('./components/App').App;
        ReactDOM.render(
            <AppContainer><NextApp /></AppContainer>,
            document.getElementById('application'),
        );
    });
}

Hotにする対象として、App.tsxに受け直しておきます。これをmodule.hot.acceptを用いて監視するのですが、ES6でやったときのようにWebpack2以降のモジュール読み込みシステムなるものは使えない。tsconfig.jsonのほうでモジュールシステムをcommonjsにしているのでこうなったかなと推測してます。かといってcommonjs以外の選択肢はTypeScriptのコンパイラオプションのドキュメント読んでいろいろ研究して見たけどどうもぴったりの解がほかになかったです。

requireを使ってNextAppと別名で読み込み直すのはコードもトリッキーな印象のものになります。型を解決するために、require<{App: typeof App}>なんて書き方してますからね。もしAppをexport defaultしていると、require<default: typeof App}>(‘./components/App’).default; なんてことになってエディタによっては「defaultは予約語だ!」というようにLintで引っかかったりします。汚いが、しょうがない。いまのところ他にやり方が思いつかない。

これで、TypeScriptでReactのHMRもできました!

TypeScriptでもJest+Enzyme

TypeScriptの4つめ。

Lintができたらテストもちゃんとやっとく。最近覚えたJestそしてEnzymeでやります。

$ yarn add jest enzyme react-test-renderer ts-jest @types/jest -D

jestとenzymeを加える他に、@types/jestが必須です。必須ではないですが@types/enzymeも加えておいたほうが今後にはより良いですがとりあえず加えなくても動きます。react-test-rendererはenzymeのpeerDependenciesなため。

// package.jsonの一部
    "scripts": {
        "test": "jest"
    },
    "jest": {
        "moduleFileExtensions": ["ts", "tsx", "js"],
        "transform": {
            "^.+\\.(ts|tsx)$": "./node_modules/ts-jest/preprocessor.js"
        },
        "testMatch": [
            "**/__tests__/*Test.(ts|tsx)"
        ]
    },

package.jsonにscripts.testを追加するのとjestエントリを書きます。jest.moduleFileExtensionsはtsとtsxに加えて、たとえJSでテストを書かずともjsを加えなければなりません。これも裏で処理パイプの中で.jsが出てきてそれらを解決できなくなるのを避けるためです。webpackのresolveと同様な感じ。

transformerでts-jestを設定しています。TypeScriptファイルのトランスパイルを行うのを下記URLのように自分でコード書くのもOKなようですが、ts-jsonはより重厚なことをしています。

https://github.com/facebook/jest/blob/master/examples/typescript/preprocessor.js

// tsconfig.json
{
    "compilerOptions": {
        "module": "commonjs",
        "target": "es5",
        "jsx": "react",
        "lib": ["es6", "dom"],
        "types": ["jest"]
    }
}

@types/jestを入れ、tsconfig.jsonのcompilerOptions.typesにその型情報を読み込む設定をしないと、Jestテスト中で書くdescribeとtestの両グローバル関数が名前解決できません。またcompilerOptions.libにes6とdomを追加します。これはTypeScriptのコンパイラオプションのページ、

https://www.typescriptlang.org/docs/handbook/compiler-options.html

ここの、–libスイッチの説明にある、

  • Note: If –lib is not specified a default library is injected. The default library injected is:
    • For –target ES5: DOM,ES5,ScriptHost
    • For –target ES6: DOM,ES6,DOM.Iterable,ScriptHost

これがぶつかります。ブラウザで動かすからES5をターゲットとしてるため、Nodeで動かすJestテストやLint実行では問題が起きるということ。前にBabel環境でHMRをやるときにも似たようなことがありましたね(似て非なるのですが)。Babelではモジュール機能をON/OFFにするために面倒なことがありましたが、こちらはトランスパイル時にリンク(?)する標準モジュールの選択でということ。

// /components/__tests__/HelloTest.tsx
import * as React from 'react';
import { shallow } from 'enzyme';
import { Hello } from '../Hello';

describe('HelloComponent', () => {
    test('<Hello TypeScript>', () => {
        const hello = shallow(<Hello compiler="TypeScript" framework="React" />);
        expect(hello.find('h1').text()).toBe('Hello from TypeScript and React!');
    });

    test('<Hello JavaScript>', () => {
        const hello = shallow(<Hello framework="React" />);
        expect(hello.find('h1').text()).toBe('Hello from JavaScript and React!');
    });
});

テストはEnzymeのshallowでシンプルに書いてみました。$yarn testと、これもまたyarn run testのショートハンドが用意されています。

f:id:masataka_k:20170906080833p:plain

AtomはテストのGUIを入れてないから地味。WebStormには素敵なUIがあります。

TypeScriptでもLintを張る

TypeScriptの3つめ。

やはり大人の嗜みとしてLintを設定したいと思います。ES6ではEslintでAirbnbをやってました。TypeScriptでもAirbnbを張り付けときます。

$ yarn add tslint tslint-config-airbnb tslint-react tslint-loader -D

名前もそのまま、Tslintというのがあります。設定もJSのAirbnbがTslint版で移植されてましたが、こちらはReact関係が無いので、補いでtslint-reactを。webpackに差し込み用にtslint-loaderを入れます。

{
    "extends": ["tslint-config-airbnb", "tslint-react"],
    "rules": {
        "variable-name": false,
        "ter-indent": [true, 4],
        "max-line-length": [true, 120]
    }
}

Tslintの設定は、プロジェクトルートにtslint.jsonを置きます。私の好みとしてインデント4空白、一行最大120字というのに変えたほか、ReactでStateless Componentで先頭大文字の名前の関数を書くとひっかかかるのを緩和するために、variable-name=falseにしました。それだけ。

// package.jsonの一部
    "scripts": {
        "lint": "tslint --project ./ --type-check './ts/**/*.{ts,tsx}'"
    },

package.jsonのscripts.lintにコマンドを書きますが、型チェック(=文法チェック)も同時にがっつりやってくれるように、–projectと–type-checkスイッチをつけます。–projectはスイッチの引数にルートフォルダを指定。これはtsconfig.jsonのある場所を設定するとのヘルプ説明でした。

$ yarn lint

yarn run lintもショートハンドでyarn lintがあります。本家Eslint版のAirbnbと比べて、このTslint版はなんか緩くあまり怒られない。

// webpack.config.development.tsの一部
    module: {
        rules: [{
            test: /\.(ts|tsx)$/,
            use: [
                'awesome-typescript-loader',
                'tslint-loader',
            ],
        }],
    },

tslint-loaderをローダーチェーンの一番下に入れます。これで一番最初にLintフィルターしてくれる。

設定のベスト

Tslintの設定で、まだまだ何がベストが見えてないです。JSX構文について緩い感じがするので、Eslintのほうを見つつTslintを試行錯誤するってのがおいおいやってきます。

そのココロは、TypeScriptと生きてく決心がついてきた。まだ二日の"にわか"ですけど。

Webpackの設定をTypeScriptで書く

TypeScriptの2つめ。前記事の続き。

Webpack DevServerを動かしてTypeScriptで書いたReactアプリを動かしましたが、このWebpackの設定をTypeScriptで書くことも可能。Babel使ってES6で書くこともできましたので驚くことではない。

$ yarn add ts-node -D

yarnでts-nodeをいれます。あとはwebpack.config.development.tsと拡張子を.jsから変更。

// webpack.config.development.ts
import * as path from 'path';

export default {
    entry: './ts/index.tsx',
    output: {
        filename: 'bundle.js',
        publicPath: '/js/',
    },
    devtool: 'inline-source-map',
    module: {
        rules: [{
            test: /\.(ts|tsx)$/,
            use: 'awesome-typescript-loader',
        }],
    },
    resolve: {
        extensions: ['.ts', '.tsx', '.js'],
    },
    devServer: {
        host: 'localhost',
        port: 9000,
        contentBase: path.resolve('static'),
    },
};

ほとんど何も変わらない。。。importがFlow風な、exportがES6風な。これでpackage.jsonのscripts.startを「webpack-dev-server –config webpack.config.development.ts」とあわせる。

TypeScriptのReact-Webpack DevServer実行

TypeScriptのReactアプリを作るに際し、一番シンプルなのはWebpack DevServerで実行だけするというものかなと思います。NodeとYarnはすでに入ってること前提で、あとはテキストエディタ&WEBブラウザだけでOK。

$ mkdir TsReact
$ cd TsReact
$ yarn init
$ yarn add react react-dom
$ yarn add @types/react @types/react-dom -D
$ yarn add typescript webpack webpack-dev-server path awesome-typescript-loader -D

まずターミナルからプロジェクトの基盤を作ります。フォルダ作ってYarnでやるべきことをやる。reactとwebpack関係はES6のプロジェクトと一緒ですが、Babelの一群がtypescriptひとつで代替されるので必要なものが格段に少なくなります。

// tsconfig.json
{
    "compilerOptions": {
        "module": "commonjs",
        "target": "es5",
        "jsx": "react"
    }
}

TypeScriptのコンパイラ(=トランスパイラ)の設定をプロジェクトルートのtsconfig.jsonに書きます。まずは最低限で上記のとおり。ブラウザで実行するのでes5がターゲット。スキーマを見るとjsxはreact-domかreact-nativeか処理しないか(preserve)。例のFacebook OSSライセンスが揉めれば、いずれはpreserveで非Reactなものに対応させるようなことあるかもしれませんが、今はReact以外はありません。

このtsconfig.jsonの内容をpackage.jsonに格納する方法を探したけど見つからなく、どうもできないみたい。BabelやEslintみたいに格納させて欲しいなあ。

http://json.schemastore.org/tsconfig

// webpack.config.development.js
const path = require('path');

module.exports = {
    entry: './ts/index.tsx',
    output: {
        filename: 'bundle.js',
        publicPath: '/js/',
    },
    devtool: 'inline-source-map',
    module: {
        rules: [{
            test: /\.(ts|tsx)$/,
            use: 'awesome-typescript-loader',
        }],
    },
    resolve: {
        extensions: ['.ts', '.tsx', '.js'],
    },
    devServer: {
        host: 'localhost',
        port: 9000,
        contentBase: path.resolve('static'),
    },
};

Webpack DevServerの設定ですが、こちらはプロジェクトルートのwebpack.config.development.jsへ。あとでproduction環境も作りますから、はじめからdevelopmentをいれたファイル名にしておきました。/tsフォルダにソースコードを置き、/js/bundle.jsという名前で配信するようにしています。/static/index.htmlに静的なエントリHTMLをおいて、これはdevServer.contentBaseで静的ファイルサービスの設定をしました。bundle.jsの読み込みとアプリケーションホルダーのdivを持つだけのHTMLです。

注意としては、resolveに、TypeScriptのソース拡張子である.tsおよび.tsxに加えて、.jsも並べること。Webpack DevServerが背後でjsをステルスに提供しているのでそれも解決できるようにしないとトランスパイルエラーが出ます。TypeScriptは、awesome-typescript-loaderが処理します。同じ役割でts-loaderというのも世にありますがドキュメントを読んでみた結果、後に行うHMR環境を作るのはこちらが良いとのことなのでawesomeで。

// /components/Hello.tsx
import * as React from 'react';

export type Props = {
    compiler?: string;
    framework: string;
};

export const Hello = (props: Props) => (
    <h1>Hello from {props.compiler || 'JavaScript'} and {props.framework}!</h1>
);



// /components/Counter.tsx
import * as React from 'react';

export class Counter extends React.Component<{}, { counter: number }> {
    constructor(props) {
        super(props);
        this.state = { counter: 0 };
    }

    private interval: number;

    componentDidMount() {
        this.interval = window.setInterval(
            () => this.setState({ counter: this.state.counter + 1 }),
            1000,
        );
    }

    componentWillUnmount() {
        window.clearInterval(this.interval);
    }

    render() {
        return <h2>{this.state.counter}</h2>;
    }
}



// /index.tsx
import * as React from 'react';
import * as ReactDOM from 'react-dom';

import { Hello } from './components/Hello';
import { Counter } from './components/Counter';

ReactDOM.render(
    <div>
        <Hello compiler="TypeScript" framework="React" />
        <Counter />
    </div>,
    document.getElementById('application'),
);

簡単なReactアプリとして、二つのコンポーネント(Hello.tsxとCounter.tsx)をindex.tsxでまとめるものです。HelloがStateless Componentでプロパティを設定するもの、CounterがClass Componentでステートを持つものにしました。Flowを書いてみた後でTypeScriptを触ると、なんて一緒なのだろうと。探せば細かく違いはありながらも概ね違和感なく、TypeScript初手のはずなのに特に疑問なくすぐ慣れます。まさにES6+FlowでTypeScriptだわ。

// package.json
{
    "name": "TsReact",
    "version": "1.0.0",
    "private": true,
    "scripts": {
        "start": "webpack-dev-server --config webpack.config.development.js"
    },
    "dependencies": {
        "react": "^15.6.1",
        "react-dom": "^15.6.1"
    },
    "devDependencies": {
        "@types/react": "^16.0.5",
        "@types/react-dom": "^15.5.4",
        "awesome-typescript-loader": "^3.2.3",
        "path": "^0.12.7",
        "typescript": "^2.5.2",
        "webpack": "^3.5.5",
        "webpack-dev-server": "^2.7.1"
    }
}

package.jsonにscripts.startを足します。おまけでprivateをtrueにして、ライセンス等の情報は削りました。private=trueにしておくと削っても警告されません。そうでなければ折に触れてnpmやyarnより警告表示されます。

$ yarn start

yarn startでWebpack DevServerが起動します。yarn run startのショートハンドでyarn startが有効。

f:id:masataka_k:20170906061030p:plain

動かすとこんな感じ。今回、同時にAtomを入れてみました。AtomでTypeScript環境つくるのも簡単。結構成熟している。

TypeScriptのReact開発環境を作った

Labor Dayの連休を利用して、TypeScriptでReact開発環境を作ることをやってみました。

  • (TypeScriptで書いたReactアプリを、以下同じ) Webpack DevServerで実行する
  • 構文および書き癖チェックする
  • Jest+Enzymeでテストする
  • Webpack DevServerでHMR実行する
  • Webpackでproductionビルドする

結果として、あまりはまらずに全部できた。できてみると現金ですがTypeScript良いように思います。これまでAlt-JSの走りとしてCoffeeScriptをスルーし、DartもTypeScriptも喰わず嫌いで来てましたが、JS界隈がBabelでトランスパイルし、WEBフロントはWebpackでバンドルすること前提でECMA Script 6が共通言語となりつつある雰囲気の中、さらに新しいもの好きにFlowを入れて型を付けてみようとしたら、それは只のTypeScriptだったというオチです。言語的にいくつか確認すべきことはありながら、これまで書いて来たソースコードのほとんどが修正すくなく移行できました。

要点たくさんあるので、ぽつぽつ書いていきます。今日はお呼ばれBBQ。