React + TypeScript + Webpackの最小構成

このエントリは古い情報です。

今は typescript の代わりに flowtype も使えそうです。また、ブートストラップとしては create-react-app が提供されており、ここからカスタマイズしたほうが便利だと思います。

github.com


前回

ishikuro.hateblo.jp

の続き。前回のリポジトリを元にTypeScript版を作ってみたい:

GitHub - ishikuro/thinking-in-react-webpack-minimum at typescript

最終的なファイルツリー

.
├── dist
│   ├── bundle.js
│   └── index.html
├── package.json
├── README.md
├── src
│   ├── app.tsx
│   ├── tsd.json
│   └── typings
│       ├── react
│       │   └── react.d.ts
│       └── tsd.d.ts
├── tsconfig.json
└── webpack.config.js

今回もサーバーは立ち上げずにブラウザで直接ローカルのindex.htmlファイルを開いて動作を確認する。

前回と比較すると、型定義ファイル(typings/)とコンパイルオプション(tsconfig.json)が追加されている。

TypeScriptで記述

webpack中やvimプラグインで使われるので先にtypescriptのnpmを入れておく。

npm install -g typescript

.vimrc

" syntax highlightを有効にする
NeoBundle 'leafgarland/typescript-vim'
" typescriptのインデントを適切にする
NeoBundle 'jason0x43/vim-js-indent'
" コード補完や定義/参照へのジャンプをできるようにする
NeoBundle 'Quramy/tsuquyomi'
" .tsxもtypescriptとして扱う
autocmd BufNewFile,BufRead *.{ts,tsx} set filetype=typescript

型定義ファイルを追加

npm install -g tsd
cd src/
tsd query react --action install --resolve --save

コンパイル方法

TypeScript内でJSXやReactを扱うために、様々なハックが存在していたが、2015年7月ころにtypescriptがJSXをネイティブサポートしたために収束した。

  • typescript 1.6以上でコンパイルすればok, webpackで使う場合はnpm install --save-dev typescript@nextで入れる
  • 拡張子は.tsxにしておく
  • webpackでの利用方法や、Reactのコーディング方法はts-loaderのtestで提示されている。

babelの代わりにts-loaderを使う

npm install --save-dev typescript@next ts-loader

tsconfig.jsonを作成。(コンパイラオプションやリンカ的なものを指示するファイル)

{
  "compilerOptions": {
    "jsx": "react"
  },
  "files": [
    "src/typings/tsd.d.ts"
  ]
}

webpack.config.jsを更新。拡張子をtsxにして、ts-loaderを使うようにするだけ。(Option: コンパイル速度を上げるためにはexternal指定でreactをビルドに含めないようにする。html側で読み込む。参考: ReactとStylusをwebpackで使うための開発環境構築)

module.exports = {
  entry: [
    './src/app.tsx'
  ],
  output: {
    path: 'dist',
    filename: 'bundle.js'
  },
  resolve: {
    extensions: ['', '.tsx', '.ts', '.js']
  },
  externals: {
    react: 'React'
  },
  module: {
    loaders: [
      { test: /\.ts(x?)$/, loader: 'ts-loader' }
    ]
  }
};

(Option: dist/index.html reactを別ファイルで読み込む。)

<!DOCTYPE html>
<html>
  <head>
    <title>Thinking in React</title>
  </head>
  <body>
    <script src="../node_modules/react/dist/react-with-addons.min.js"></script>
    <script src="bundle.js"></script>
  </body>
</html>

src/app.tsxのサンプル。ポイントは、propsなどの受け渡しをinterfaceで行うこと。これだけでもだいぶ人に伝えやすくなると思った。

/// <reference path="typings/tsd.d.ts" />
import * as React from 'react';

interface Props {
  content: string;
}

class MyComponent extends React.Component<Props, {}> {
  render() {
    return <div>{this.props.content}</div>
  }
}

React.render(<MyComponent content="Hello World" />, document.body);

あとはコンパイルして確認

webpack
open dist/index.html

追記: ソースコードの変更点

全文はこちら: thinking-in-react-webpack-minimum/app.tsx at typescript · ishikuro/thinking-in-react-webpack-minimum · GitHub

import

/// <reference path="typings/tsd.d.ts" />
import * as React from 'react';

基本

interface PCRProps {
  category: string;
  key: string;
}

class ProductCategoryRow extends React.Component<PCRProps, {}> {
  render() {
    return (<tr><th colSpan={2}>{this.props.category}</th></tr>);
  }
}
  • propsやstateはinterfaceを作って明示する。extends React.Component<P, S>の形式でclassを作る。
  • JSXのプロパティも型があって、<th colSpan="2">もエラーになったので{2}としている。

ハマったところ

class SearchBar extends React.Component<SBProps, {}> {
  handleChange() {
    this.props.onUserInput(
        React.findDOMNode(this.refs.filterTextInput).value,
        React.findDOMNode(this.refs.inStockOnlyInput).checked
    );
  }

  render() {
    return (
      <form>
        <input
          type="text"
          placeholder="Search..."
          value={this.props.filterText}
          ref={"filterTextInput"}
          onChange={this.handleChange.bind(this)}
        />
        <p>
          <input
            type="checkbox"
            checked={this.props.inStockOnly}
            ref="inStockOnlyInput"
            onChange={this.handleChange.bind(this)}
          />
          {' '}
          Only show products in stock
        </p>
      </form>
    );
  }
}
  • React 0.13からDOMNodeの取得方法が変更になっている (React v0.13 - React Blog)
  • extends React.Componentから作る場合は、onChange={this.handleChange.bind(this)}のようにコールバックに.bindを自分で付けないとだめ

this.refsはランタイムで設定されるんだけど、TypeScriptコンパイラは分かってくれてないのでエラーを出す。

ERROR in ./src/app.tsx
(98,37): error TS2339: Property 'filterTextInput' does not exist on type '{ [key: strin
g]: Component<any, any>; }'.

ERROR in ./src/app.tsx
(98,54): error TS2339: Property 'value' does not exist on type 'Element'.

ERROR in ./src/app.tsx
(99,37): error TS2339: Property 'inStockOnlyInput' does not exist on type '{ [key: stri
ng]: Component<any, any>; }'.

ERROR in ./src/app.tsx
(99,55): error TS2339: Property 'checked' does not exist on type 'Element'.

解決方法は不明。一応、エラーを出しつつも.jsファイルは作ってくれて、意図した動作もするんだけど、とても嫌な感じ。

this.refsは使わないように、handleChange(e)から、e.target.valueみたいに取得する方針にして回避する。

Stateの初期化

interface FPTState {
  filterText: string;
  inStockOnly: boolean;
}

class FilterableProductTable extends React.Component<FPTProps, FPTState> {
  constructor(props) {
    super(props);
    this.state = {
      filterText: '',
      inStockOnly: false
    };
  }
...
  • getInitialStateはES6的な記述では使えない。代わりにconstructorを使う

JSONとかJSオブジェクトの読み込み

var PRODUCTS = [
  {category: 'Sporting Goods', price: '$49.99', stocked: true, name: 'Football'},
  {category: 'Sporting Goods', price: '$9.99', stocked: true, name: 'Baseball'},
  {category: 'Sporting Goods', price: '$29.99', stocked: false, name: 'Basketball'},
  {category: 'Electronics', price: '$99.99', stocked: true, name: 'iPod Touch'},
  {category: 'Electronics', price: '$399.99', stocked: false, name: 'iPhone 5'},
  {category: 'Electronics', price: '$199.99', stocked: true, name: 'Nexus 7'}
];

interface Product {
  category: string;
  price: string;
  stocked: boolean;
  name: string;
}

interface FPTProps {
  products: Product[];
}

React.render(<FilterableProductTable products={PRODUCTS} />, document.body);
  • そのままだとPropsに流し込めないので、一旦interfaceでラップする。