この記事はfreee Engineers Advent Calendar 2016の5日目です。
こんにちは、freeeのエンジニアの @joe-re です。
僕からはflowtypeによる、Fluxアーキテクチャへの型システムの導入についてお話しさせていただきます。
背景
Reactによるコンポーネント指向設計、Fluxによる単方向フローによって、僕たちは階層化されすぎているViewにおけるイベントの発行と購読、煩雑なDOM操作と状態管理から解放されました。
Fluxアーキテクチャにおいては、Component、ActionCreator、Storeがそれぞれの層で完全に分離されています。StoreはComponentの存在を知らないし、ComponentはStoreを購読するだけで中のロジックは一切知らないし、ActionCreatorはただの関数群です。
チーム開発の中でこの関係性を守らせつつ、追加の実装におけるエンバグを防ぐためには、それぞれのIFの定義をちゃんと理解して、壊さないことが必要です。
例えばActionCreatorの作成するActionのパラメータにNullを許さない(つもりで実装している)ものがあったとして、機能追加時にNullが混入するようになると、いずれかのStoreのロジックやComponentの表示が壊れる可能性があります。
1人で開発している上ではあまり気にならないかもしれませんが、大人数のチーム開発においては大きな問題です。
そこでflowtypeを導入し、実装時に想定したIFの明示、壊された時に気づける仕組みの構築をしました。
環境
登場するサンプルコードはすべて以下のバージョンのflowtypeで検証しています。
$ flow version
Flow, a static type checker for JavaScript, version 0.36.0
また、Fluxはflux-utilsを使って実装しています。
(他のライブラリでも、精通している方であれば読み替えられると思います。)
flowtypeの基本
flowtypeはFacebookの開発している型チェックのツールで、JavaScriptにおける型アノテーションの拡張を提供します。
// @flow
function bar(x): string {
return x.length;
}
bar('Hello, world!');
上記のコードは、bar関数の返り値としてstringを明示していますが、実際にはnumber型を返しているのでエラーとなります。
$ flow
tryFlow.js:3
3: return x.length;
^^^^^^^^ number. This type is incompatible with the expected return type of
2: function bar(x): string {
^^^^^^ string
: string
の部分がflowtypeの提供している型アノテーションです。
また、flowtype大きな特徴の1つに強力な型推論があります。
// @flow
function foo(x) {
return x * 10;
}
foo('Hello, world!');
foo関数はxを10倍にして返却します。この時 * 10しているので、xの型はnumber型だと推論されます。
しかし呼び出し部分では文字列(string型)を渡しています。
これをflowにかけると以下のようなエラーになります。
$ flow
foo.js:5
5: foo('Hello, world!');
^^^^^^^^^^^^^^^^^^^^ function call
3: return x * 10;
^ string. This type is incompatible with
3: return x * 10;
^^^^^^ number
xはnumberなのに、stringを渡しているのはおかしい、という推論がされました。非常に強力な型推論です。
flowtypeはデフォルトでこの挙動を示すので、既存のコードに導入する場合には、推論できずにエラーになっている部分に型アノテーションを記述していく方法を取るのが簡単です。
導入方法
flowtypeの型アノテーションはbabelプラグインで除去することができます。
すでにbabelでのtranspile環境が整っている場合には、pluginをinstallし、babelrcに追記するだけです。
install
npm install --save-dev babel-plugin-transform-flow-strip-types
.babelrc
{
"plugins": ["transform-flow-strip-types"]
}
このあたりは、現環境においてTypeScriptを選ぶかflowtypeを選ぶかの1つの要素でしょう。すでにbabelでtranspile環境を整えている場合や、babelを使ってESNextに追従していきたいと思っている人は、導入への障壁は低いです。
Type-Safe Component
Reactでの記述については、公式にドキュメントがあります。
ここではよく使われているであろうjsxにおいて、propsとstateの型付けの例を挙げます。
Reactの描画のために必要な情報はstateとpropsのみになるので、ここの型付けをするのが壊れない実装をするためには一番効果的です。
ボタンを押下すると、表示している数字がインクリメントされる簡単なComponentで例を示します。
// @flow
import React from 'react';
import ReactDOM from 'react-dom';
class IncrementalButton extends React.Component {
props: { onClick: (e: SyntheticEvent) => void };
render() {
return (
<button onClick={this.props.onClick}>
increment
</button>
);
}
}
class Counter extends React.Component {
state: { count: number };
constructor(props: any) {
super(props);
this.state = { count: 0 };
}
handleClick(_e: SyntheticEvent) {
this.setState({ count: ++this.state.count });
}
render() {
return (
<div>
<h1>Counter</h1>
{this.state.count}
<IncrementalButton
onClick={this.handleClick.bind(this)}
/>
</div>
);
}
}
ReactのStateとPropsは以下のように型付けしています。
// Counterのstate
state: { count: number };
// IncrementalButtonのprops
props: { onClick: (e: SyntheticEvent) => void };
class直下にstateとpropsをそれぞれ定義することで、型付けすることができます。
IncrementalButtonの実装を以下のように変えてみます。
class IncrementalButton extends React.Component {
props: {
onClick: (e: SyntheticEvent) => void,
label: string
};
render() {
return (
<button onClick={this.props.onClick}>
{this.props.label}
</button>
);
}
}
propertyとしてlabelを受け取り、それを表示するように変えました。
flowを走らせると以下のようなエラーになります。
$ flow
counter.jsx:7
7: props: {
^ property `label`. Property not found in
38: <IncrementalButton
^ props of React element `IncrementalButton`
label propertyが渡されていない、というエラーになりました。
以下のようにCounter Component内でIncrementalButtonを呼び出している箇所で、label propertyを与えることで解消できます。
class Counter extends React.Component {
// ..省略
render() {
return (
<div>
<h1>Counter</h1>
{this.state.count}
<IncrementalButton
onClick={this.handleClick.bind(this)}
label='インクリメント'
/>
</div>
);
}
}
時にはpropertyをoptionalにしたい時もあると思います。その時はOptional Propertyとして定義しましょう。
以下のように、{ propertyName?: type }
にすることでOptionalPropertyになります。
IncrementalButtonのpropsを以下のように変えてみます。
props: {
onClick: (e: SyntheticEvent) => void,
label?: string
};
この定義にしておけばlabel propertyを渡していなくてもエラーになりません。
このままでは渡さない時のbuttonの表示がundefinedになってしまうので、以下のようにしてdefaultPropsの定義をしておくと良いです。
class IncrementalButton extends React.Component {
props: {
onClick: (e: SyntheticEvent) => void,
label?: string
};
static defaultProps: { label: string };
render() {
return (
<button onClick={this.props.onClick}>
{this.props.label}
</button>
);
}
}
IncrementalButton.defaultProps = { label: 'increment' };
また、labelは必ず渡されるけどnullの時もある、という場合ならMaybe Typesで定義します。
props: { label: ?string }
Labelは渡されないこともあるし、渡されたとしてもnullの時もある、という場合は両方とも定義することができます。
props: { label?: ?string }
Type-Safe Flux
Fluxは単方向フローを実現するアーキテクチャです。
上の有名な図に従うと、各層の通信は以下で成り立ちます。(ここではComponent、Action、Storeを実装の対象にします。)
- ActionCreator関数をComponent(React Views)から呼び出す。
- ActionCreator関数を呼び出した結果生成されたActionをDispatcher経由でDispatchする。
- StoreはDispatcher経由でDispatchされたActionに従い、状態を変更する。
- ComponentはStoreの状態変更時に発生するEventをlistenし、renderを開始する。
各層をまたいで型安全性を保つためには、それぞれの通信において型情報が失われないようにする必要があります。
コメントの投稿、削除ができる簡単なアプリケーションの実装で例を示していきます。
今回作成したコードは以下におきましたので、ご参照ください。
ActionCreatorの定義
まずは以下のように発生するアクションの定義をします。
今回はコメントの登録と削除のみなので、CreateとDeleteのみです。
type CREATE = { type: 'create', comment: string };
type DELETE = { type: 'delete', id: number };
これらをUnion Typesでまとめた定義を作成します。
export type ActionTypes = CREARE | DELETE;
詳しくは後述しますが、この定義は後でStoreから参照するため、Exportしています。
ここで定義したActoinのみが発行されることを保証するため、以下のようにdispatchをラップし、それを各ActionCreatorのActionの発行で使うようにします。
function dispatch(params: ActionTypes) {
Dispatcher.dispatch(params);
}
export default {
create(comment: string) {
dispatch({ type: 'create', comment });
},
delete(id: number) {
dispatch({ type: 'delete', id });
}
};
これでActionCreator内部でdispatchされるActionは、ActionTypes(Union)で定義されたもののいずれかを満たすことが保証されるようになりました。
試しにdeleteの実装からidを取り除いてみると、以下のようにUnionTypesを満たしていない旨のエラーが通知されます。
// ...省略
delete(id: number) {
dispatch({ type: 'delete' });
}
scripts/CommentActions.js:18
18: dispatch({ type: 'delete' });
^^^^^^^^^^^^^^^^^^^^^^^^^^^^ function call
18: dispatch({ type: 'delete' });
^^^^^^^^^^^^^^^^^^ object literal. This type is incompatible with
9: function dispatch(params: ActionTypes) {
^^^^^^^^^^^ union: CREATE | DELETE
Member 1:
7: export type ActionTypes = CREATE | DELETE;
^^^^^^ CREATE
Error:
18: dispatch({ type: 'delete' });
^^^^^^^^ string. Expected string literal `create`, got `delete` instead
5: type CREATE = { type: 'create', comment: string };
^^^^^^^^ string literal `create`
Member 2:
7: export type ActionTypes = CREATE | DELETE;
^^^^^^ DELETE
Error:
7: export type ActionTypes = CREATE | DELETE;
^^^^^^ property `id`. Property not found in
18: dispatch({ type: 'delete' });
^^^^^^^^^^^^^^^^^^ object literal
ちょっと見づらいですが、よく見るとidプロパティがないという内容のエラーもちゃんとあります。
7: export type ActionTypes = CREATE | DELETE;
^^^^^^ property `id`.
ActionCreatorはただの関数群で、Componentから直接呼び出されるのでこの時点でComponent → Actionの型付けは完了しています。
Componentからの呼び出し時に、以下のように要求するプロパティを満たしていない場合にはエラーとなります。
import CommentActions from './CommentActions';
class CommentContainer extends React.Component {
// ...省略
handleCreateComment(comment: string) {
CommentActions.create(Number(comment)); //あえてNumberに変換
}
// ...省略
}
scripts/CommentContainer.jsx:9
9: CommentActions.create(Number(comment));
^^^^^^^^^^^^^^^ function call
9: CommentActions.create(Number(comment));
^^^^^^^^^^^^^^^ number. This type is incompatible with
14: create(comment: string) {
^^^^^^ string. See: scripts/CommentActions.js:14
Storeの定義
Storeでは、dispatchされたActionに従い、状態の更新を行います。
まずはStoreの中で持つStateを定義します。
export type Comment = {id: number, comment: string };
type State = Comment[];
定義したStateを満たすように、Storeの状態変更ロジックを実装します。
以下に実装の例を挙げます。
import type { ActionTypes } from './CommentActions';
let count = 0; // 今回はサーバ通信しないため、ここでIDを降る
class CommentStore extends ReduceStore<State> {
getInitialState(): State {
return [];
}
reduce(state: State, action: ActionTypes): State {
switch (action.type) {
case 'create':
return state.concat({ id: ++count, comment: action.comment });
case 'delete':
const deleteId = action.id;
return state.filter((v) => v.id !== deleteId);
default:
return state;
}
}
}
const instance = new CommentStore(Dispatcher);
export default instance;
import type { ActionTypes } from './CommentActions';
として、Actionで定義したUnionTypesを取得し、reduce関数(Reduxで言うとreducer)の引数に型として与えています。
reduce(state: State, action: ActionTypes): State {
// ...省略
}
これにより、DispatchされたActionの型をStoreが知ることができます。
さらにflowtypeのUnionTypesはnarrow downが効くので、case文で他の型が入る可能性を排除すると、型の範囲が狭まります。
例えばreduce関数のcase文において、createアクションのみの可能性が残るように分岐した後に、deleteアクションにしか存在しないはずのidプロパティにアクセスするとエラーとなります。
reduce(state: State, action: ActionTypes): State {
switch (action.type) {
case 'create':
console.log(action.id);
return state.concat({ id: ++count, comment: action.comment });
// ...省略
}
scripts/CommentStore.js:20
20: console.log(action.id);
^^ property `id`. Property not found in
20: console.log(action.id);
^^^^^^ object type
これにより、Actionの種類による分岐後の処理における型安全が保証されます。(Action → Storeの通信)
最後に残ったのはStore → Componentですが、それはStoreの型定義時にGenricsでStateを与えることで解決します。
class CommentStore extends ReduceStore<State> {
// ...省略
}
flux-utilsのReduceStoreにはデフォルトでgetStateという関数が用意されていて、ContainerComponentはその関数でStoreの状態を取得できます。
そこでReduceStoreのmoduleを定義し、getStateメソッドで渡された総称型を返すようにします。
declare module 'flux/utils' {
declare class ReduceStore<T> {
getState(): T;
}
}
これにより、ContainerComponent内でStoreの状態を取得した時の型情報を取得できるようになります。
static calculateState(_prevState: State): State {
const comments = CommentStore.getState();
return { comments };
}
これはflux-utilsのReduceStore特有の関数ですが、他のFlux実装において同じ役割を果たすAPIにも同じ考え方で適用できると思います。
Componentの実装は先に述べたものとあまり変わらないので割愛します。
気になる方は https://github.com/joe-re/typesafe-flux-sample をご参照ください。
おわりに
今回紹介させてもらったものは、試行錯誤の末たどり着いたものですが、flowtypeやTypeScriptもどんどん進化しているし、まだまだ良い実装はありそうだしこれからも出てきそうだなー、と思っています。
知見がおありの方は是非コメントやメンションください。
またfreeeではすべてを型付けしたくてたまらないエンジニアを切実に募集しています。
ともにフロントエンドに秩序をもたらしましょう。
明日はオブジェクト指向、DDD、UMLなど僕のような若造では口に出すのも憚られる恐ろしい界隈に精通した炎の闘球児、@kompiro です。
お楽しみにー!