この記事はフラーAdvent Calendar 2019の15日目の記事です。
はじめに
airpc という Redux Toolkit のようなReduxのロジック周りのコード量を減らすためのライブラリを作ってみたので、それについての記事を書いてみました。
素のRedux
素のReduxだとActionCreatorでActionを作って、ActionをReducerにくぐらせて状態を更新しています。
ActionCreatorとReducerの簡単な例を書いてみます
type State = {
some: string;
};
const initialState: State = { some: "" };
export const actionCreator = ( data: string ) => ({
type: "action" as const,
payload: data
});
type Actions = ReturnType<typeof actionCreator>;
export default function reducer(
state = initialState,
action: Actions
): State {
switch (action.type) {
case "action": {
return {
...state,
some: action.payload
};
}
default:
return state;
}
}
ただ、stateを操作したいだけなのに actionCreator
とreducer
のswitch文中のロジックの2通りを書かないといけません。面倒ですね。
enjoyableなRedux (by airpc)
そこでReduxのActionCreatorとReducerを一体化させて、もっと楽に書けるようにairpc
を使ってReduxのロジックを書いてみましょう
import { withRedux } from "airpc";
type State = {
pinned: string;
};
const initialState: State = { some: "" };
const [actions, reducer] = withRedux(
class Hogehoge {
constructor(public state: State) {}
someMethod = (pinned: string) => ({ ...this.state, pinned });
},
initialState
);
export default reducer;
export { actions };
ActionCreatorとReducerが消えた代わりに、HogehogeというクラスがwithReduxという関数の引数内で宣言されている奇妙なコードが出てきました。
state操作のロジックはHogehogeクラス内のメソッドとして書かれており、先程の素のReduxに比べるとコード全体がスッキリしたような印象があるんじゃないかと思います。
また、withRedux関数は、actionsとreducerの2つの値を返しています。
actionsはActionCreatorの集合体のようなものです。
reducerは普通のreducerなので、combineReducerやcreateStoreなんかにそのまま食わせられます。
それではwithRedux関数についてもう少し詳しく見ていきましょう。
withRedux
withReduxの第1引数にはstateを操作するクラスを渡します。このクラスのことを便宜上ロジッククラスとでも呼びましょう。ロジッククラスは次のルールに従います。
ロジッククラス
項目 | ルール |
---|---|
クラス名 | 自由 |
コンストラクタ | 引数に public state:State のみを必ず取る |
メソッド | 任意の長さの引数を取れる。必ず、State型のオブジェクトを返す |
プロパティ | プロパティを持つことは禁止されている |
withReduxの第2引数にははstateの初期状態を渡します。
withRedux関数内部ではロジッククラスを元にロジッククラスと同じメソッドを持ったクラスのインスタンスを生成し、それをactionsとして返しています。ですので、このactionsをactionCreatorとして使う際には以下のようにdispatch関数内でactionsのメソッドを実行すればOKです
import {actions} from '../modules/hogehoge'
dispatch(actions.someMethod("hi"));
簡単ですね!
どうやって実現しているのか?
airpcのソースコードを覗いてみましょう
import { ActionCreator, ValidState } from "./typings/action";
class WrapRedux {
constructor(target: any) {
const subscribe = (type: string) => {
const actionType = target.name + "_" + type;
(this as any)[type] = (...args: any[]) => ({ type: actionType, args });
};
Object.keys(new target()).forEach(type => {
if (type === "state") return;
subscribe(type);
});
Object.getOwnPropertyNames(target.prototype).forEach(type => {
if (type === "constructor") return;
subscribe(type);
});
}
}
function exposeRedux<T extends any>(instance: T) {
const update = (state: any, v: { type: string; args: any }): T["state"] => {
const { type, args } = v;
const [name, method] = type.split("_");
if (instance.constructor.name !== name) return state;
if (instance[method]) {
instance.state = state;
return { ...state, ...instance[method](...args) };
} else {
return state;
}
};
return update;
}
export function withRedux<A extends any, B>(
target: { new (state: B): A },
initialState: B
): [
Omit<ActionCreator<A, Required<B>>, "state">,
(
state: B | undefined,
action: Omit<A[keyof A], "state">
) => ValidState<B, A["state"]>
] {
const instance = new target(initialState);
const methods = new WrapRedux(target) as any;
const update = exposeRedux(instance);
const reducer = (state = initialState, action: any) => update(state, action);
return [methods, reducer];
}
airpcのwithRedux関数はWrapReduxとexposeReduxの2つのクラスと関数から成り立っています。
まずWrapReduxの方から見ていきましょう
WrapRedux
class WrapRedux {
constructor(target: any) {
const subscribe = (type: string) => {
const actionType = target.name + "_" + type;
(this as any)[type] = (...args: any[]) => ({ type: actionType, args });
};
Object.keys(new target()).forEach(type => {
if (type === "state") return;
subscribe(type);
});
Object.getOwnPropertyNames(target.prototype).forEach(type => {
if (type === "constructor") return;
subscribe(type);
});
}
}
WrapReduxでは要するに、ActionCreatorをクラスの情報から自動生成しています。
コンストラクタで引数にwithReduxの第1引数のクラスを受け取り、
引数のクラスの持つメソッドに対応するActionCreatorをWrapReduxクラスのメソッドとして生成しています。
次にexposeReduxを見ていきましょう
exposeRedux
function exposeRedux<T extends any>(instance: T) {
const update = (state: any, v: { type: string; args: any }): T["state"] => {
const { type, args } = v;
const [name, method] = type.split("_");
if (instance.constructor.name !== name) return state;
if (instance[method]) {
instance.state = state;
return { ...state, ...instance[method](...args) };
} else {
return state;
}
};
return update;
}
exposeReduxは要するに、dispatchされたActionに対応するクラスのメソッドを実行し、新しいstateの作成を行っています。
exposeReduxの引数はwithReduxの第1引数のクラスのインスタンスとなります。
exposeRedux内のupdate関数はreducerとなります。update関数では、dispatchされたactionの値に該当するクラスインスタンスのメソッドにactionの値を渡して実行し、戻り値を新しいstateとしています。
あとはwithRedux
関数でWrapReduxとexposeReduxのセットアップをして、actionsとreducerを返しています。
ユーザはreducerを普通にcombineReducerなんかに食わせてactionsをdispatch関数内で実行すればOKです。
あとがき
今回作ったairpcは一応テストは書いているものの、誰のレビューも受けていない信頼性の低いライブラリなので、プロダクションでReduxのactionCreatorとReducerをどうにかしたい場合は普通にRedux Toolkit を使ったほうが良いと思います。
自分でこういったライブラリを作ってみるのもそれはそれで勉強になります。特にライブラリの型安全性を実現するためにConditionalTypesを駆使するあたりは結構勉強になりました!(本記事では触れてないけど...)