Reduxでの有用なTypeScriptサンプル
https://github.com/reactjs/redux/tree/master/test/typescript
Reduxのリポジトリの中、test/typescriptにReduxでの有用なTypeScriptサンプルがあります。強い型付けでのMiddlewareの書き方とか参考になるもの多い。また、これらのテストは動かさずにコードをコンパイラやLintにかけることでindex.d.tsをテストするという。こういうテストもあるのだなあ。
Deep Dive
Introduction · TypeScript Deep Dive
一冊まるごとオンラインで。著者の人は何かに困ってググると、各掲示板でたくさんの人に答えてるすごい人。PDF版もあった。
型強化の呪文。noImplicitAny
上記記事をたまたま読んで目にした呪文。noImplicitAny。
Over time, they reached the point of enabling advanced compiler options, such as –noImplicitAny to prevent the compiler from inferring an any type.
advancedってすごいな。なるほど。やってみましょう。また、ドキュメントを見ると、strictNullChecksなるものも。
// tsconfig.json { "compilerOptions": { "noImplicitAny": true, "strictNullChecks": true, "module": "commonjs", "target": "es5", "jsx": "react", "lib": ["es6", "dom"], "types": ["webpack-env", "jest"] } }
ちょいちょい「暗黙のany」を怒るエラーがでてきました。たとえばこんなの。
export class Counter extends React.Component<{}, { counter: number }> { constructor(props) { super(props); this.state = { counter: 0 }; } // 省略 }
このconstructorの引数に型が無い。直すと以下。
export class Counter extends React.Component<{}, { counter: number }> { constructor(props: {}) { super(props); this.state = { counter: 0 }; } // 省略 }
結構な箇所を直したら、noImplicitAny=trueでもOKになった。明示的なanyはOK。
import { handleActions } from 'redux-actions'; import { LOCATION_CHANGE } from 'react-router-redux'; import { matchPath } from 'react-router'; import { Map } from 'immutable'; const decodeName = (pathname: string) => { const m = matchPath<{ name?: string; }>(pathname, { path: '/player/:name' }); if (m) { return { ...m.params, name: m.params.name }; } return {}; }; type AppState = Map<string, any>; export const app = handleActions<AppState, any>( { // 省略 MOVE_TO: (state: AppState, { payload: { name } }) => { return state.merge({ name }); }, [LOCATION_CHANGE]: (state: AppState, { payload: { pathname } }) => state.merge(decodeName(pathname)), }, Map({}), );
Redux ActionsのhandleActionsの型引数の2番目には、Payloadがきます。でもこれはMOVE_TOのときには、{ name } だし、LOCATION_CHANGEのときは{ pathname }だから、anyで受けたかった。で、明示しているからOKだった。
React RouterのmatchPathは面白くて、型引数にExpressスタイルのURL分解パラメータをあらかじめ型として持つ。'/player/:name'の結果を{ name?: string; }で受けたために、m.params.nameという取り方ができるようになる。
strictNullChecksは結構直すの簡単ながら、なるほどそこでnullの可能性あるかと気がつかしてくれる。とても有用。
結論
noImplicitAnyはtrueにするのが正義。strictNullChecksはさらにもっと実利がある。ほか、noImplicitThisやalwaysStrictも含めて、単にstrict=trueにすれば一番厳しい。
TypeScriptでもリリースビルドする
TypeScriptの8つめ。
Productionをやります。
// webpack.config.production.ts import * as path from 'path'; import * as webpack from 'webpack'; export default { entry: './ts/index.tsx', output: { path: path.resolve('static/js'), filename: 'bundle.js', }, module: { rules: [{ test: /\.(ts|tsx)$/, use: [ 'awesome-typescript-loader', 'tslint-loader', ], }], }, resolve: { extensions: ['.ts', '.tsx', '.js'], }, plugins: [ new webpack.optimize.UglifyJsPlugin({ sourceMap: false, comments: false }), ], };
特に論点ないですね。
$ yarn add rimraf -D
rimrafをいつも使ってます。
// package.jsonの一部 "scripts": { "build": "rimraf ./static/js/*; webpack -p --config webpack.config.production.ts" },
yarn run buildもショートハンドがあります。これはnpmと同じ。
$ yarn build
Atomを入れてTypeScriptを書く
TypeScriptの7つめ。
愛用のWebStormでTypeScriptを研究しつつ、おまけでAtomをインスコしてサンプルを作るときの清書プラットフォームとして使って見ました。
私はHomebrewでNodeもYarnも入れてますので、Atomも。MacOS向けGUIインストーラーアプリをHomebrewでいれる拡張として、このHomebrew Caskを使います。
$ brew tap caskroom/cask $ brew cask install atom
プラグインをガシガシいれます。入れたのは以下のものと、それらが依存するもの。
他にはちょこちょこ入れ出ししてみたけど、良いものが見つからず、上のでほぼいい感じ。
課題
「Go to Declaration」で型定義に飛んだりすることができなかった。調べるとバグなんだか、そうじゃないんだか。結構長い間苦労されているのかな?Atomでまだ解決できてない。
しかし、WebStormはザクザク型定義で飛んだり跳ねたりできる!すげーや商用(なのかな?)。
$ brew cask uninstall atom $ rm -rf ~/.atom
一旦、Atomはアンインストール。また時機をみて試してみます。おそらくWebStormのサブスクリプションが切れるころ。
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のショートハンドが用意されています。