TypeScript で React

今まで書いたサンプル程度の規模でも、 実行時に踏むまでバグに気付かないことが結構あって、 実際踏むと萎える。 React で規模の大きい SPA 作っていたらと思うとゾッとする。 コンパイル時に分かれば良いのに。

そうだ。 TypeScript で書いてみよう。 JavaScript のスーパーセットというアプローチが、 AltJS の中でも筋が良いと思っていたんだよな。

github.com

TypeScript と、型定義ファイルを管理するための tsd をインストール。

npm install -g typescript
npm install -g tsd

React と React-DOM、 その他使っているライブラリの型定義ファイルをインストールする。

cd src
tsd query react --action install --resolve
tsd query react-dom --action install --resolve
tsd query lodash --action install --resolve

TypeScript コードを JavaScriptコンパイルするには、tsc を使う。 毎回オプションを指定するのは面倒なので、 tsconfig.json を書いておく。

{
  "compilerOptions": {
    "module": "commonjs",
    "jsx": "react",
    "rootDir": "src",
    "outDir": "built"
  },
  "exclude": [
    "node_modules"
  ]
}

これで tsc を実行するだけでコンパイルできる。 ただ、tscJavaScriptコンパイルできるけど、 これをブラウザで実行できるようにしないといけない。 TypeScript になっても Browserify のお世話になるみたいだ。 package.json の scripts に書いている build コマンドを修正。

"scripts": {
  "build": "tsc && browserify built/app.js --outfile dist/bundle.js"
},

これでようやく準備が終わった。 試しに、TODO アプリのサンプルを TypeScript で書き換えてみた。

/// <reference path="typings/tsd.d.ts" />
import * as React from "react"
import * as ReactDOM from "react-dom"
import * as _ from "lodash"

// タスクのインタフェース
interface ITask {
  name?: string;
  completed?: boolean;
  editing?: boolean;
}

// タスクを保存するときに呼び出すコールバックのインタフェース
interface ITaskSaveCallback {
  (task: ITask, newValue: ITask) : void;
}

// タスクを削除するときに呼び出すコールバックのインタフェース
interface ITaskDeleteCallback {
  (task: ITask) : void;
}

// タスクを表示するコンポーネントの props のインタフェース
interface ITaskProps {
  task: ITask;
  onTaskSave: ITaskSaveCallback;
  onTaskDelete: ITaskDeleteCallback;
}

// タスクを表示するコンポーネント
class Task extends React.Component<ITaskProps, any> {
  private handleEdit = (e) => {
    this.props.onTaskSave(this.props.task, {
      editing: true
    });
  }

  private handleDelete = (e) => {
    this.props.onTaskDelete(this.props.task);
  }

  private handleChangeCheckbox = (e) => {
    this.props.onTaskSave(this.props.task, {
      completed: !this.props.task.completed
    });
  }

  render(): JSX.Element {
    return (
      <div>
        <input type="checkbox"
               checked={this.props.task.completed}
               onChange={this.handleChangeCheckbox} />
        <span>{this.props.task.name}</span>
        <a href="#" onClick={this.handleEdit}>[edit]</a>
        <a href="#" onClick={this.handleDelete}>[del]</a>
      </div>
    );
  }
}

// タスクを編集するコンポーネントの props のインタフェース
interface IEditTaskFormProps {
  task: ITask;
  onTaskSave: ITaskSaveCallback;
}

// タスクを編集するコンポーネントの state のインタフェース
interface IEditTaskFormState {
  name: string;
}

// タスクをその場編集するためのコンポーネント
class EditTaskForm extends React.Component<IEditTaskFormProps, IEditTaskFormState> {
  constructor(props: IEditTaskFormProps) {
    super(props);
    this.state = { name: "" };
  }

  private handleCancel = (e) => {
    this.props.onTaskSave(this.props.task, {
      editing: false
    });
  }

  private handleSave = (e) => {
    e.preventDefault();

    if (!this.state.name) {
      return;
    }

    this.props.onTaskSave(this.props.task, {
      name: this.state.name,
      editing: false
    });

    this.setState({ name: "" });
  }

  private handleChangeText = (e) => {
    this.setState({ name: e.target.value });
  }

  render(): JSX.Element {
    return (
      <div>
        <input type="text"
               value={this.state.name || this.props.task.name}
               onChange={this.handleChangeText}
               ref="name"/>
        <button onClick={this.handleSave}>save</button>
        <button onClick={this.handleCancel}>cancel</button>
      </div>
    );
  }
}

// タスク一覧を表示するコンポーネントの props のインタフェース
interface ITaskListProps {
  tasks: ITask[];
  onTaskSave: ITaskSaveCallback;
  onTaskDelete: ITaskDeleteCallback;
}

// タスク一覧を表示するためのコンポーネント
class TaskList extends React.Component<ITaskListProps, any> {
  render(): JSX.Element {
    var rows = this.props.tasks.map(t => {
      if (t.editing) {
        return (
          <EditTaskForm task={t}
                        onTaskSave={this.props.onTaskSave} />
        );
      } else {
        return (
          <Task task={t}
                onTaskSave={this.props.onTaskSave}
                onTaskDelete={this.props.onTaskDelete} />
        );
      }
    });

    return (
      <div>
        {rows}
      </div>
    );
  }
}

// タスクを追加するときに呼び出すコールバックのインタフェース
interface ITaskAddCallback {
  (task: ITask): void;
}

// タスク追加フォームの props のインタフェース
interface IAddTaskFormProps {
  onTaskAdd: ITaskAddCallback;
}

// タスク登録フォームを表示するためのコンポーネント
class AddTaskForm extends React.Component<IAddTaskFormProps, any> {
  private handleAdd = (e) => {
    e.preventDefault();

    var newTask = ReactDOM.findDOMNode<HTMLInputElement>(this.refs["name"]).value.trim();
    if (!newTask) {
      return;
    }

    this.props.onTaskAdd({ name: newTask });

    ReactDOM.findDOMNode<HTMLInputElement>(this.refs["name"]).value = "";
  }

  render() {
    return (
      <div>
        <input type="text" placeholder="newTask" ref="name"/>
        <button onClick={this.handleAdd}>add</button>
      </div>
    );
  }
}

// ルートコンポーネントの state のインタフェース
interface ITaskAppState {
  tasks: ITask[];
}

// ルートコンポーネント
// 状態をまとめて管理する
class TaskApp extends React.Component<any, ITaskAppState> {
  constructor(props: any) {
    super(props);

    this.state = {
      tasks: [
        { name: "foo", completed: false, editing: false },
        { name: "bar", completed: false, editing: false },
        { name: "hoge", completed: false, editing: false },
        { name: "fuga", completed: false, editing: false }
      ]
    };
  }

  private handleTaskAdd = (newTask: ITask): void => {
    var newTasks = this.state.tasks.concat(newTask);
    this.setState({ tasks: newTasks });
  }

  private handleTaskDelete = (task: ITask): void => {
    var newTasks = this.state.tasks.filter(t => {
      return t !== task;
    });
    this.setState({ tasks: newTasks });
  }

  private handleTaskSave = (task: ITask, newValues: ITask): void => {
    var newTasks = this.state.tasks.map(t => {
      if (t === task) {
        return _.extend(task, newValues);
      } else {
        return t;
      }
    });
    this.setState({ tasks: newTasks });
  }

  render(): JSX.Element {
    return (
      <div>
        <AddTaskForm onTaskAdd={this.handleTaskAdd} />
        <TaskList tasks={this.state.tasks}
                  onTaskSave={this.handleTaskSave}
                  onTaskDelete={this.handleTaskDelete} />
      </div>
    );
  }
}

ReactDOM.render(
  <TaskApp />,
  document.getElementById("content")
);

イベントハンドラにメソッドを渡すと、 this が window になってしまっていてハマった。 this を束縛するためにアローファンクション使って定義して回避。

refs は型定義ファイルのせいで、 refs.name みたいにアクセスできない。 refs["name"] じゃないといけなかった。 こればかりは仕方ない。 props や state みたいにインタフェースを指定できれば良いんだけど。

それ以外はすんなり。 コンパイルnpm run build で一発。 実行してエラー踏んで気付いてたようなミス(主に typo だけど)が、 コンパイルの時点で気付けて精神衛生上良かった。

ちなみに Visual Studio 2015 で書いたんだけど、間違ったコードは即時にエラーが表示されたり、 インテリセンスが効いたりして、 すこぶる良かった。