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もできました!