*こちらは 俺得フロントエンド (2) LT 会 の発表資料になります
自己紹介
株式会社オプト シニアエンジニア @sisisin(しめにゃん)
- GitHub
- フロントエンドの人だけどスクラムマスター・インフラ・サーバーサイドといろいろやります
- 今は AWS/Rails/React なプロダクトのテックリードやりつつ社内アジャイル相談窓口とか社内フロント講師(?)やってます
Redux を typeless へリプレイスするためのハウツー
目次
- はじめに
- typeless の紹介と、typeless のプロジェクト構成を説明し何をやるべきかを整理
- 出てきた移行タスクについて、それぞれの計画と実現について説明
- まとめ
はじめに
今回はFluxライブラリ移行をホンキで考えたらこういうアプローチが考えられるな、という知見を共有してみようと思いこのような題目にしました
Redux→typeless にフォーカスしましたが、任意の Flux ライブラリから Redux、あるいはその逆を検討する際にも転用できる発想も盛り込まれていると思います
Flux層のような基盤ライブラリの置き換えを本当にやる人おるんかという気持ちがあるので俺得フロントエンド会で供養しようと思いました
それではよろしくお願いします
typeless とは
TypeScript フレンドリーで、「type annotation less」にアプリケーションが書けることを標榜している Flux ライブラリ
React hooks によって様々な機能を実現・提供しているので、React でのみ利用できます
特徴として、
- TypeScript フレンドリーに作られており、型アノテーションなしに型安全を実現できる
- ActionCreator,Reducer,Epic(SideEffect 操作)を 1 ライブラリですべて取り扱える
- ActionCreator+Reducer+Epic を 1feature と言う単位で取り扱い、この feature は React hooks を使って ReactComponent として表現される
typeless に入門する という記事も書いたので、この辺も参照していただければと
公式 Doc のカウンターアプリのコードを元に紹介してみます
(公式 Doc より抜粋)
typeless では Module という単位で 1Epic(SideEffect)+Reducer を取り扱い、この Module を定義するために構成するファイル群を Feature と呼びます
1Feature は以下の 4 ファイル(フォルダ)で構成します
- symbol.ts
- Module に対応する Symbol を定義するファイル。HMR の関係で別ファイルにする必要がある
- interface.ts
- ActionCreator 定義と State の interface 定義をし、Module を生成するファイル
- module.tsx
- Epic,Reducer,その他ビジネスロジック,エントリポイント Component を定義するファイル
- components/
- この Module に関連する Component を定義するフォルダ
export const CounterSymbol = Symbol('counter');
import { createModule } from 'typeless';
import { CounterSymbol } from './symbol';
export const [useModule, CounterActions, getCounterState] = createModule(CounterSymbol)
// Actions Creators生成
.withActions({
startCount: null, // nullはpayloadなしAction
countDone: (count: number) => ({ payload: { count } }),
})
// State定義
.withState<CounterState>();
export interface CounterState {
isLoading: boolean;
count: number;
}
import React from 'react';
import * as Rx from 'typeless/rx';
import { CounterActions, CounterState } from './interface';
import { Counter } from './components/Counter';
// 副作用処理のためのEpic処理定義
useModule
.epic()
// `count` Actionがdispatchされたら、その500ms後に `countDone` Actionを発火する
.on(CounterActions.startCount, () => Rx.of(CounterActions.countDone(1)).pipe(Rx.delay(500)));
const initialState: CounterState = {
isLoading: false,
count: 0,
};
// reducer定義。immerを利用しているので、mutation処理としてstate更新を記述する
useModule
.reducer(initialState)
.on(CounterActions.startCount, state => {
state.isLoading = true;
})
.on(CounterActions.countDone, (state, { count }) => {
state.isLoading = false;
state.count += count;
});
// このModuleにおけるエントリポイントComponent定義
export default function CounterModule() {
// load epic and reducer
useModule();
return <Counter />;
}
import React from 'react';
import { useActions } from 'typeless';
import { CounterActions } from '../interface';
export function Counter() {
// ActionCreatorを `dispatch` 関数でラップ
const { startCount } = useActions(CounterActions);
// CounterModule の Storeから状態を取得
const { isLoading, count } = getCounterState.useState();
return (
<div>
<button disabled={isLoading} onClick={startCount}>
{isLoading ? 'loading...' : 'increase'}
</button>
<div>count: {count}</div>
</div>
);
}
大雑把にまとめると、
- ActionCreator は interface.ts に定義され、
withAction
関数の引数にオブジェクトとして渡すことで定義する - Reducer は module.tsx に定義され、
useModule.reducer(init).on(...)
で定義する - SideEffect は module.tsx に定義され、
useModule.epic().on(...)
で定義する。RxJS の Observable や Promise が使える - Component から State を参照したり Action を dispatch するためには hooksAPI を使う
という感じになりますね
さて、typeless に移行するということは、最終的に全てのコードをこの形に書き換えるということになります
typeless がどんなプロジェクト構成なのか確認したところで、そこからやるべきタスクを洗い出してみます
移行タスクたち
事前準備
- React を使う
- TypeScript を使う
- ContainerComponent を FunctionalComponent 化する
- ディレクトリ構造・ファイル命名を typeless way に揃える
移行作業
- ActionCreator の移植
- Reducer の移植
- SideEffect の移植
- useDispatch,useSelector の移植
React を使う
使いましょう
Vue で typeless を使ってみるという試みもあるので、もしかしたら Vue でもいいかもしれません(?
TypeScript を使う
使いましょう
昨今の複雑なフロントエンドで型を使わないとリファクタもままならないし辛いことも多いですからね
ContainerComponent を FunctionalComponent 化する
先述の通り、hooks API を利用するので ContainerComponent は FunctionalComponent に書き換えていく必要があります
都合のいいことに、最近の react-redux
には typeless の hooks と似た API として useDispatch
, useSelector
という hooks が提供されています
これに置き換えていきましょう
redux-tutorial の VisibleTodoList の例
// ...
const mapStateToProps = state => ({
todos: getVisibleTodos(state.todos, state.visibilityFilter),
});
const mapDispatchToProps = dispatch => ({
toggleTodo: id => dispatch(toggleTodo(id)),
});
export default connect(
mapStateToProps,
mapDispatchToProps,
)(TodoList);
// ...
export default () => {
const stateProps = useSelector<AppState>(state => ({
todos: getVisibleTodos(state.todos, state.visibilityFilter),
}));
const dispatch = useDispatch();
const dispatchProps = {
toggleTodo: id => dispatch(toggleTodo(id)),
};
const props = { ...stateProps, ...dispatchProps };
return <TodoList {...props} />;
};
*補足:
- useDispatch で取れる dispatch 関数はメモ化されていないので、useMemo でメモ化するヘルパを用意すると良いです
- typescript-fsa,typesafe-actions,redux-aggregate のような Action 生成を型安全にやるヘルパライブラリを利用している場合、ActionCreator の推論をここでも流用できます(redux-aggregate でしか検証していないですが)
- useSelector も state の型を毎回書くのも辛いのでヘルパ関数作っとくと便利です
- こちらは単に型アノテーションを付けた変数に代入しておくだけで良い
上記 を取り込んだヘルパ hook 関数の例: https://gist.github.com/sisisin/38135f66185e32432f8092efc50536b2
ディレクトリ構造・ファイル命名を typeless way に揃える
地味に重たいやつです
typeless の紹介した際にお話したとおり、typeless way を踏まえると以下のような構成にするのが良さそうです
.
+-- features/
| +-- todo/
| +-- interface.ts # interface,actionCreator実装
| +-- module.tsx # reducer,midlleware実装
| +-- componets
| +-- TodoView.tsx # ContainerComponent
必ずしも揃えなくてもいいかもしれないでsが、とある事情(後述します)により、これが実施されていると移行がやりやすくなるので頑張ってやります
さて、単にファイル移動するだけではありますが、 import
のパスを変えたり、 interface.ts
や module.tsx
にそれぞれ ActionCreator や Reducer を寄せようと思ったらちまちまやるのは大変だし、コンフリも恐ろしいです
そんなときは、そう、みんな大好き TypeScript CompilerAPI の出番ですね
ts-morph という TypeScript CompilerAPI のラッパーライブラリを利用すると AST 操作がとても手軽に書けます
ts-morph を使って TypeScript プロジェクトのリファクタ・AST 操作をお手軽にやる という記事も書いたので、こちらもどうぞ
今回のディレクトリ構造・ファイル命名を typeless way に揃えるのはこのスクリプトを使って一括で実施しました
ほぼスクリプト一発なのでコンフリもあまり怖くないし、レビューもコンパイルが通ってればまず問題ない(なかった)と良いことずくめですね
ここまでで事前準備タスクが終わりました
さて、今一度移行タスクを見てみましょう
移行タスクたち
事前準備
React を使うTypeScript を使うContainerComponent を FunctionalComponent 化するディレクトリ構造・ファイル命名を typeless way に揃える
移行作業
- ActionCreator の移植
- Reducer の移植
- SideEffect の移植
- useDispatch,useSelector の移植
よーしあとは移植していくだけだ!
🤔🤔🤔
立ちはだかる壁
- 実際にやるとしても単に書き換えるのは非常に大変
- そもそもどうやって Redux と typeless の共存・段階的移行をやるのか
- 移行期間の混乱もなるべく抑えながら進行出来るようにしたい
-> なんとか移行コストを減らしたい・・・
そんなときは・・・
そんなときは、そう、みんな大好き TypeScript CompilerAPI の出番ですね(本日二度目)
移行時のコーディングを楽にするためになんとかして既存実装の移植を自動化出来ないか?という観点で TypeScript AST を操作し、typeless 実装の生成を考えてみます
さて、Flux アーキテクチャにおいて ActionCreator や Reducer はほぼ共通の構造をしています
ということは、Redux 向けの ActionCreator と Reducer 実装は typeless 向けにもほぼそのまま利用ができるのではないか?という閃きがあるわけですね
やってみました
typeless 実装を生成する
今回自分は ActionCreator,Reducer,Reducer の UnitTest について自動生成しました
自動生成のアプローチはどれも大体一緒なので、代表して ActionCreator 実装の生成について説明します
(この資料書いてて思ったけど ContainerComponent の移行も AST 操作で行けるかも・・・)
typeless 実装を生成する
plop というジェネレータ用のライブラリを利用して、簡単な部分のコード生成と、CompilerAPI を使うときの前処理などをしておきます
- interface.ts を以下のような plop タスクで修正
[
// ...
{
type: 'modify',
path: interfacePath,
pattern: /^(.*)$/m,
template: `import { createModule } from 'typeless';
import { {{pascalCase name}}Symbol } from './symbol';
// --- Actions ---
export const [handle, {{pascalCase name}}Actions, get{{pascalCase name}}State] = createModule({{pascalCase name}}Symbol)
.withActions({
---ActionMapHere---
})
.withState<{{pascalCase name}}State>();
$1`,
},
];
CompilerAPI から生成したい部分以外はほぼ固定の値になるので、plop を使った正規表現と文字列置換で十分対応ができます
キモは ---ActionMapHere---
という文字列で、ここに CompilerAPI を利用して作った ActionCreator 関数群を入れ込みます
typeless 実装を生成する
ts-morph を利用して、既存の ActionCreator 記述から typeless 用の ActionCreator 生成をします
元ファイルのイメージがこんな感じ
import { createAggregate } from 'redux-aggregate';
import { UsersState } from './interface';
export const usersMT = {
fetchUsers(state: UsersState): UsersState {
return state;
},
fetchUsersFulfilled(state: UsersState, users: User[]): UsersState {
return { ...state, users };
},
};
export const usersAggregate = createAggregate(usersMT, 'users/');
export const usersReducer = usersAggregate.reducerFactory<UsersState>({ users: User[] });
// epics...
ts-morph を利用して、既存の ActionCreator 記述から typeless 用の ActionCreator 生成をする
redux-aggregate というライブラリでは、 usersMT
という変数定義(= VariableDeclaration
Node) に、Reducer 実装・ActionCreator 実装を置く形になります
Reducer 実装の第二引数を元に ActionCreator 実装を生成するのが redux-aggregate の仕組みなので、今回の場合はこの第二引数を持ってくれば ActionCreator が生成できそうだな、とわかります
他のプロジェクトでも、同様に法則・構造から自動生成のための取っ掛かりを掴めば同じ要領でやれると思います
実際に書いたスクリプト(抜粋)
async function writeActionMap(moduleFullPath: string, interfaceFullPath: string) {
const p = createProject();
const file = p.getSourceFile(moduleFullPath);
// const **MT = {}; という変数定義Nodeを取得する
const mtDec = file.getVariableDeclarations().find(dec => {
return (
dec
.getSymbol()
.getName()
.indexOf('MT') > -1
);
})!;
// メソッド定義とメソッド名を取得
let mtMethods: MtMethod[] = [];
mtDec.forEachDescendant((node, traversal) => {
if (TypeGuards.isMethodDeclaration(node)) {
// 各種MTを実装している関数Nodeを取得
mtMethods.push({
name: node.getSymbol().getName(),
methodDec: node,
});
}
});
// 取得してきた情報から、typeless ActionCretor文字列を生成する
const indent = ' ';
const actions = mtMethods
.map(({ name, methodDec }) => {
const args = methodDec.getParameters();
if (args.length === 1) {
return `${indent}${name}: null,`;
} else {
const p = args[1];
const key = `${indent}${name}`;
const body = `(${p.getText()}) => ({ payload: { ${p.getText().replace(/:.*/, '')} } }),`;
return `${key}: ${body}`;
}
})
.join('\n');
const interfaceFile = p.getSourceFile(interfaceFullPath);
interfaceFile.replaceWithText(interfaceFile.getText().replace('---ActionMapHere---', actions));
await p.save();
interfaceFile.fixMissingImports();
await p.save();
}
生成されたコード(抜粋)
import { createModule } from 'typeless';
import { UsersSymbol } from './symbol';
// --- Actions ---
export const [handle, UsersActions, getUsersState] = createModule(UsersSymbol)
.withActions({
fetchUsers: null,
fetchUsersFulfilled: (usres: User[]) => ({ payload: { users } }),
})
.withState<UsersState>();
移行タスクの再確認をしてみます
移行タスクたち
事前準備
React を使うTypeScript を使うContainerComponent を FunctionalComponent 化するディレクトリ構造・ファイル命名を typeless way に揃えるジェネレータタスクを作り込む
移行作業
ActionCreator の移植Reducer の移植- ActionCreator,Reducer の自動生成
- Reducer の置き換え
- SideEffect の移植
- useDispatch,useSelector の移植
さて、自動生成によって置き換え時の負担がだいぶ軽減したことでしょう
ですが、 SideEffect が Reducer 更新に関わる上に SideEffect の部分が恐らく最も複雑で移行が困難なことが見込まれます
これについて、段階的移行プランを考察してみます
redux+typeless アーキテクチャを設計する
結論から述べると、 view -> redux effect/reducer -> view
というフローを以下のように変更することで Redux+typeless の同居状態を実現します
view -> redux effect/(reducer) -> typeless effect/reducer -> view
これについて、理由を説明していきます
さて、ジェネレータで ActionCreator,Reducer 実装を完全に移植しているという状況があります
つまり、Redux effect に何らかの Action が来たときに、その payload を元に、対応する typeless Action を発火させれば State を Redux,typeless 側で完全に同期出来る、ということになります
例えば、以下のような「ユーザーを取得開始したらローディング状態にして、取得完了したら state を得られたユーザーで更新する」という Reducer があるとします
function userReducer(state, action) {
switch (action.type) {
case 'FETCH_USERS':
return { ...state, isLoading: true };
case 'FEATCH_USERS_FULFILLED':
return { users: action.users, isLoading: false };
default:
return state;
}
}
この時起きるのは、
- どこかから
dispatch(fetchUsers())
が実行される - SideEffect により users 取得
- SideEffect 層によって
dispatch(fetchUsersFullfilled(users))
が発火される
になります
これを、
- どこかから
dispatch(fetchUsers())
が実行される- typless でも
dispatch(fetchUsers())
を実行する
- typless でも
- SideEffect により users 取得
- SideEffect 層によって
dispatch(fetchUsersFullfilled(users))
が発火される- typless でも
dispatch(fetchUsersFullfilled(users)))
を実行する
- typless でも
というように出来れば、Reducer の持つ State は完全に同期出来ると考えられます
つまり、 view -> redux effect/reducer -> view
という Redux のフローが、
view -> redux effect/(reducer) -> typeless effect/reducer -> view
という Redux+typeless という形で実現ができるということになるわけです
もちろんこの dispatch 処理を一つ一つ移植するのは簡単ではないのでこれもヘルパを書きましょう
自分はこんな使い方が出来るヘルパクラスを用意しました
const bridgeEpic = new TypelessBridgeBuilder(usersAggregate, UsersActions)
.add('fetchUsers')
.add('fetchUsersFulfilled')
.build();
要は既存の ActionCreator と typeless ActionCreator を渡して、対応する Action(例えば fetchUsers など)が来たら typeless でも内部的に dispatch するという処理をするクラスです
この .add
メソッドはもちろん既存の ActionCreator が推論されて候補として出てくるし、関係ない文字列を渡すとコンパイルエラーになります
これによって型安全に Redux Action を typeless へ受け流す処理ができました
余談ですが、別途 Redux middleware を自作してそちらで受け流し処理を書く、というのでも良いかもしれません
自分は今回、redux-observable を利用していて、このライブラリの性質上「任意の Action を受けて何らかの処理をする」ということが SideEffect/Reducer の単位でやりやすかったのでそのように実装しました
さて、このヘルパ関数を用いれば、Redux reducer はすぐにでも完全撤廃が可能になります
なぜなら State の更新処理はほぼ自動生成&型安全に移行が出来るので、かなりコストを抑えて移行作業が出来るためですね
ということで、ここまでで移行のための準備・段取り・設計が済みました
実際に移行作業の進め方を考察していきましょう
実際の移行作業について
ActionCreator の移植Reducer の移植- ActionCreator,Reducer の自動生成
- Reducer の置き換え
- SideEffect の移植
- useDispatch,useSelector の移植
Reducer,SideEffect の置き換え
, useDispatch,useSelector の置き換え
これらについては機械的にマイグレーションするのは難しそうなので、マッスルを発揮していくことになります
・・・しかし単にマッスルじゃ現実難しい場合もあると思います
そのため、影響範囲等から実際の移行プロセスについて考えてみましょう。概ね次の 3 択になるかと思います
1 つの SideEffect,Reducer の組み合わせについて、
- 一気に typeless 化
- Reducer のみを一気に typeless 化し、SideEffect を段階的に移行
- Redux/typeless の Reducer 共存し、Component からの参照や SideEffect を段階的に移行
それぞれメリデメ見ていきます
一気に typeless 化
基本的に依存の末端にある Reducer/SideEffect についてはこれでよい
- コードベースの変更: 小〜中
- ランニングコスト: 小
- 他の Reducer/SideEffect からの依存が少ない部分でないと辛いことになる
- 基盤系の redux 実装が移植されていないと、redux store に依存することになる
- 例えば Toast の reducer が未移植なら
store.dispatch(showToastMessage())
といった処理を API 呼び出しのあとに実行するコードを書く、みたいなのが起きる - とはいえこれはそんなに大きなデメリットではない
- 例えば Toast の reducer が未移植なら
例えばユーザー登録が終わったあとに、トーストメッセージを出す、という epic を以下のように書くイメージ
useUsersModule.epic().on(UsersActions.registerFulfilled, () => {
// Redux storeのdispatchメソッドを直接実行してあげる
store.dispatch(showToastMessage('ユーザの登録が完了しました'));
// typeless側ではこのあとやることがないので何もしない
return null;
});
なお、これは最終的に toastReducer が typeless 移植されると以下のようになります
useUsersModule.epic().on(UsersActions.registerFulfilled, () => {
- store.dispatch(showToastMessage('ユーザの登録が完了しました'));
- // typeless側ではこのあとやることがないので何もしない
- return null;
+ // ToastMessageを発行する
+ return ToastActions.showMessage('ユーザの登録が完了しました');
});
Reducer のみを一気に typeless 化し、SideEffect を段階的に移行
SideEffect が複雑な場合に、一度にやりきらず段階的にやる場合にはこちら
- コードベースの変更: 小
- ランニングコスト: 中
- SideEffect 層 は残したまま Redux Reducer だけ完全移植してしまう
- Component などからの参照はすべて常に typeless state を参照
- Redux の
createStore
関数からも取っ払うことで、全く参照されないことをちゃんと保証しておく
先述の view -> redux effect/(reducer) -> typeless effect/reducer -> view
というアーキテクチャを実施する形
SideEffect 層を残すために、Action を typeless へ受け流すヘルパを利用し、なるべく影響を抑えながら実施する
Redux Action から typeless Action への受け流しは双方のインターフェースが揃っていることを人間が保証しないといけない場合が多いと思うので、改修コストが上がることが見込まれる
なるべく早く SideEffect 層も移植してしまいたいところ
Redux/typeless の Reducer 共存し、Component からの参照や SideEffect を段階的に移行
基盤系の依存が多い Reducer/SideEffect で止む無く選択する
State の参照が多すぎる場合、一気に書き換えるのは大変なので一旦 State の共存をさせて段階的に移行していくというプラン
- コードベースの変更: 小
- ランニングコスト: 大
- Reducer 実装の二重管理になるので極力避けたい
- シグネチャを揃えておければ Redux Reducer 内では typeless Reducer を呼ぶだけという手もあるかもしれない(未検証)
- SideEffect は 2 つ目の案と同じアプローチ
- それに加え、Component からの参照(=
useSelector
からの参照)も段階的に移行していく
以上の 3 つの選択肢を適材適所で使い分けて移行作業を実施していくと良さそうです
SideEffect は頑張りがいるものの、段階的な移行を無理なく行える設計になっているはずです
また、useDispatch,useSelector の置き換えについては reg-suit のようなビジュアルリグレッションテストを導入してあると安心して移行できる、などの手段もあります
最終的なマッスル部分についても、設計・準備次第でなるべく堅実に移行が出来るというわけです
まとめ
事前準備
- React を使う
- TypeScript を使う
- ContainerComponent を FunctionalComponent 化する
- useSelector,useDispatch をヘルパでラップしつつ使いましょう
- ディレクトリ構造・ファイル命名を typeless way に揃える
- やっていきましょう。地味に大変なので TypeScript CompilerAPI を利用して一気にやると便利です
- ジェネレータタスクを作り込む
- typeless 実装の自動生成をすれば勝利への大きな一歩となるでしょう
移行作業
- ActionCreator,Reducer の自動生成
- Reducer の置き換え
- SideEffect の移植
- 3 つの段階的移行の選択肢を使い分けながらマッスルを発揮していきましょう
- useDispatch,useSelector の移植
- こちらもマッスル案件ですが、ビジュアルリグレッションテストを導入して堅実な進行を!
以上の手順によって、順次依存の少ない SideEffect/Reducer から潰していけばきっとあなたも typeless 化ができる!
やったぜ!
まとめ
というわけで typeless 移行するお話でした
完全に俺得です、本当にありがとうございました