kubell Creator's Note

ビジネスチャット「Chatwork」のエンジニアのブログです。

ビジネスチャット「Chatwork」のエンジニアのブログです。

読者になる

ウェブフロントエンドの設計力を高めるためにアプリケーションの構造を捉えてみる話

こんにちはー。

フロントエンド開発部の火村(ひむら/id:eiel)です。前回までは id:cw-himura で記事を書いていましたが、個人アカウントに切り替わりました。 よろしくおねがいします。

以前はサーバーサイド開発部に所属していましたが、2019年6月ぐらいからフロントエンドチームにヘルプとして無期限レンタル移籍中です。 主な担当している業務は「難しいバグ対応」と「これからChatworkのウェブフロントエンドをどうするかを考える」です。

昨日は期待の新人であるレオくんの入社して3ヶ月の熱烈な想いでした。アツいです。 さて、今回のお題は「レガシーフロントエンド脱却への挑戦」と雑に上から投げられたのですが、未来のことを考える作業をしているので書きやすいネタがありません。 あってもオチがつきません。

ということで、設計に役立つかもしれない話をラフに書くことにしました。

アプリケーションの構造を表す型

プログラムを設計する上で、視野は広く持ちたいです。 細部に注目しすぎていては、全体をうまく設計できません。 しかし、贅沢をいえば、細部も可能な限り正しく捉えたいです。 無視はしないけど、情報を減らしたいのです。 つまり、無駄な情報は省かれているが、必要な情報は持っているモデル(模型)が必要です。 抽象的だけど、重要なことを見落とさないモデルが必要なのです。

そんなモデルが必要なのですが、私が使っているモデルに下記のような型があります。 その型は「Updater」「Selector」です。

type Effect = () => void;

type Updater<State, Action> = (state: State, action: Action) => [State, Effect];
type Selector<State, View> = (state: State) => View;

補足ですが、UpdaterやSelectorは記事を書くのに付けた独自の名前です。

Updater - 状態を遷移させて、副作用を起こす

type Updater<State, Action> = (state: State, action: Action) => [State, Effect]

Updaterを簡単に説明をしていきます。

Updaterは下記の要素を持ちます。

  • 引数のstateは「現在の状態」
  • 引数のactionは「発生した事象」を表現。状態遷移を引き起こす
  • 戻り値のStateは「次の状態」
  • EffectはUpdaterの評価によって「実行すべき副作用」

日本語で表現すると以下のような感じです。

「Updaterは状態遷移をする。遷移には現在の状態とアクションが必要で、次の状態と起こすべき副作用を返す」

Effectの戻り値はPromiseでもよいのですが、voidにすることでブラックホール感(?)を漂わせています。

Selector - 状態から必要な値を求める

type Selector<State, View> = (state: State) => View

Selectorを簡単に説明していきます。

Selectorは下記の要素を持ちます。

  • 引数のStateは「現在の状態
  • 戻り値のViewはStateから「算出可能な値

ViewはリレーショナルデータベースのビューテーブルのViewと捉えておくと誤解が少ないかもしれません。

というわけで、こちら日本語で表現すると以下のような感じです。

「Selectorは現在の状態から目的にあっている扱いやすい値を作成する」

具体例 Reducerをアプリケーションの構造でとらえよう

さて、具体的にいろんなものにモデルを適用していきます。 はじめは、簡単な例から始めましょう。 ReduxのReducerで考えてみます。

まずは、ReduxのReducerの型を思い出しましょう。

type AnyAction = { type: string }
type Reducer<State, Action extends AnyAction> = (state: State, action: Action) => State

次に、Updateと比較してみましょう。

type Updater<State, Action>                   = (state: State, action: Action) => [State, Effect]
type Reducer<State, Action extends AnyAction> = (state: State, action: Action) =>  State

違いは2点です。

  • Actionにtypeを持つ制限がある
  • 戻り値にEffectが一緒についてくる

Reducerには副作用がありません。何もしない関数と一緒にすると良さそうです。 つまり、Actionには少し制約がつきますが、ReducerからUpdaterを作ることができます。 作ってみましょう。

// no operationの略ってさっき気づいた
const noop = () => {};
export const reducerToUpdater =
  <State, Action extends AnyAction>(reducer: Reducer<State, Action>): Updater<State, Action> => {
    return (state, action) => [reducer(state, action), noop]
  };

「なるほど、Reducerは副作用がないUpdaterなのか」

具体例 クラスをアプリケーションの構造でとらえよう

Reducerの例は簡単すぎたでしょうか。

せっかくなのでいろいろ試してみましょう。 次は、オブジェクト指向のクラスで考えてみます。 世の中のフロントエンドの皆さんが大好きなCounterを題材にしてみました。

export class Counter {
    constructor(private value = 0) {
        this.value = value;
    }

    increment() {
        this.value++;
        console.log(this.humanize());
    }

    decrement() {
        this.value--;
        console.log(this.humanize());
    }

    humanize() {
        return `${this.value}回`
    }
}

感覚的な説明でお茶を濁しますが、このCounterが持っている「状態」は現在の値で間違いないでしょう。 「アクション」はincrementdecrementでしょうか。 humanizeはSelectorです。

実装してみれば示すことができます。 ということで、実装してみました。

// クラスとの対応を明確にしたいのでオブジェクトにしちゃう
type State = {
    value: number;
}
// アクションの種類
type Action = "increment" | "decrement";
type Effect = () => void;
type Updater<State, Action> = (state: State, action: Action) => [State, Effect];
type Selector<State, View> = (state: State) => View;


const noop = () => {};

// Selector
const humanize: Selector<State, string> = state => `${state.value}回`;

// Updater
export const counterUpdater: Updater<State, Action> = (state = { value: 0 }, action) => {
    switch (action) {
        case "increment":
        {
            // stateが増えたときのことも考えとこうかな
            const newState = {...state, value: state.value + 1}
            return [newState, () => console.log(humanize(newState))];
        }
        case "decrement": {
            const newState = {...state, value: state.value - 1}
            return [newState, () => console.log(humanize(newState))];
        }
        default: return [state, noop];
    }
}

このUpdaterを使う前に使いやすくします。 Stateを保持しておくStoreを用意しておくと便利です。 UpdaterからStoreを作ってみます。

export interface CounterStore {
    increment: () => void;
    decrement: () => void;
    humanize: () => string;
}

export const createCounterStore: (updater: Updater<State, Action>) => CounterStore = (updater) => {
    // stateは変わるんや
    let state: State;
    // Updaterを評価して、stateを保持してeffectを実行する
    const update = (action: Action) => {
        const updated = updater(state, action);
        [state] = updated;
        const [,effect] = updated;
        effect();
    }
    // 便利メソッドも生やしておこう
    return {
        increment: () => update('increment'),
        decrement: () => update('decrement'),
        humanize: () => humanize(state),
    }
}

完全に元のCounterと同じ使い勝手になりました。

なるほど。

  • Stateはメンバ変数である
  • メンバ変数を変更するメソッドはActionである
  • メンバ変数を参照するメソッドはSelectorである
  • インスタンス生成は現在の状態を保持することである

と、考えて良かったのかー

具体例 React Componentをアプリケーションの構造でとらえよう

React Componentについても考えてみます。

まずはStatelessなReact Componentから考えましょう。 対象とする具体的なコンポーネントを用意します。

type Props = { message: string };

const StatelessComponent =
    ({ message }: Props): React.ReactElement<Props> => {
        return (
            <div>{message}</div>
        );
    };

Selectorとよく似ているので比較してみましょう。

type StatelessComponent<Props> = (props: Props) => React.ReactElement<Props>
type Selector<State, View>     = (state: State) => View

以下が型チェックを通りそうです。

const _: Selector<Props, React.ReactElement<Props>> = StatelessComponent;

「なるほど、Componentは「PropsをState」として受け取り、「ViewとしてReact.ReactElement」を返すSelectorだったのか」

StateやEffectを持つコンポーネント

もう少し難しい例を考えましょう。

const second = 1000;

const EffectComponent: React.FC = (): React.ReactElement<Props> => {
  const [message, setMessage] = useState("");

  useEffect(() => {
    if (message == "") {
      const timer = setTimeout(() => { 
        setMessage("まだクリックしてくれないんですか?")
      }, 5 * second)

      return () => {
        clearTimeout(timer);
      }
    }
  }, message);

  const handleClick = () => {
    setMessage("こんにちはー");
  }

  return (
    <button onClick={handleClick}>クリックしてね</button>
    <div>{message}</div>
  );
};

ボタンがあるだけのコンポーネントです。

ポタンをクリックすると挨拶してくれます。

5秒たってもクリックしないと急かしてきます。

そんなコンポーネントです。

上記のコンポーネントもモデルに当てはめてみます。 UpdaterとSelectorが混在しています。 Actionはボタンがおされた場合とタイムアウトした場合がありそうです。 マウントしたときも忘れてはいけません。

さて、コードで表現してみましょう。

import React from 'react';

type LifeCycle = "unmounted" | "mounted";
type Scope = { timer: ReturnType<typeof setTimeout> | null, update: Update };
type State = [string, LifeCycle, Scope]
type Action = "mount" | "click" | "timeout" | "unmount"
type Effect = () => void;
type Updater = (state: State, action: Action) => [State, Effect];
type Selector<A> = (state: State) => A;
type Update = (action: Action) => void;


const second = 1000;

const updater = (state: State, action: Action): [State, Effect] => {
  const [message, lifeCycle, scope] = state
  switch (action) {
    case "mount": {
      if (lifeCycle === "unmounted") {
        return [[message, "mounted", scope],
          () => {
            // 通常のコンポーネントであればクロージャがキャプチャしてくれる。この例では明示的にstateの中に保持しておくことでunmount時に使えるようにする
            scope.timer = setTimeout(() => {
              scope.update("timeout");
            }, 5 * second);
          }]
      }
      return [state, () => {}];
    }
    case "unmount": {
      return [["", "unmounted", scope], () => {
        if (scope.timer) {
          clearTimeout(scope.timer);
          scope.timer = null;
        }
      }];
    }
    case "click": return [
      ["こんにちはー", lifeCycle, scope], () => {
        if (scope.timer) {
          clearTimeout(scope.timer)
          scope.timer = null;
        }
      }
    ]
    case "timeout": return [
      ["まだクリックしてくれないんですか?", lifeCycle, scope], () => {}
    ]
  }
}

const selector: Selector<React.ReactElement> =
    ([message]: State) => <div>{message}</div>;

// Updaterからupdate関数を作成する
const updateFactory = (_updater: Updater, [state, setState]: [State, (state: State) => void]) => (action: Action) => {
  const [newState, effect] = _updater(state, action);
  effect();
  setState(newState);
}

// Componentの中でupdateを使えるようにする
const useUpdate = (_updater: Updater, initial: State): [State, Update] => {
  const store = React.useState(initial);
  const update: Update = updateFactory(_updater, store)

  React.useEffect(() => {
    update("mount");
    return () => update("unmount");
    // Reactのライフサイクルと同期をとりたい
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  const [state] = store;

  return [
    state,
    update,
  ];
}

const initialState: State = ["", "unmounted", { timer: null, update: () => {} }];

const EffectComponent: React.FunctionComponent = () => {
  const [state, update] = useUpdate(updater, initialState);
  const [,,scope] = state;
  // updateをupdaterの中で使えるようにする
  scope.update = update;
  const handleClick = () => {
    update("click");
  }

  return (
      <>
        <button onClick={handleClick}>クリックしてね</button>
        {selector(state)}
      </>
  );
}

細かい説明は紙面が足りない(?)ので雑に書きますが、コードで表現できました。

重要な型を確認しておきます。

type LifeCycle = "unmounted" | "mounted";
type Scope = { timer: ReturnType<typeof setTimeout> | null, update: Update };
type State = [string, LifeCycle, Scope]
type Action = "mount" | "click" | "timeout" | "unmount"

Selectorも存在していますが、StatelessComponentのときと変わりないので、割愛します。

「なるほど。StateをもつコンポーネントはUpdaterとSelectorが悪魔合体しているんだなぁ」

紙面の都合上、丁寧に説明しません。 コンポーネントはuseStateで明示された状態があります。 そのほかに、ライフサイクルの状態を示す値とクロージャがキャプチャした変数も必要になりました。 また、アクションとしては暗黙でしたがmountunmountが表面化しました。

モデルを組みあわせて全体を考える

アプリケーションの構造の型をつかってアプリケーション内の部品を抽象化できました。 これらを道具を使ってより広い範囲を俯瞰できるようにしましょう。 部品を組み上げるのです。

ここまでの例で、さまざまな部品がUpdaterとSelectorに見なせることを確認しました。 Storeも登場しましたが、あれは決まった処理になるので、アプリケーションのロジックとしては無視してよいでしょう。 部品がすべて同じ形状なので組み合わせて使うことができます。 たとえば、複数のUpdaterをまとめられます。

const largeUpdater = composeUpdater([updater1, updater2, updater3])

型がどうなるかは実装によりますが、一例として以下のようにできます。

type LargeState = [State1, State2, State3];
type LargeAction = Action1 & Action2 & Action3;
type LargeUpdater = Updater<LargeState, LargeAction>

複数のUpdaterを組み合わせたときのStateとActionがわかります。 ここから状態遷移図や状態遷移表を作れます。 全体を俯瞰した状態遷移を確認できるでしょう。

ReactComponentのUpdaterを組み合わせる場合は、親子関係もありますし、マウントされない場合もあるでしょう。 組み合わせると以下のようになったりします。

type ParentState = [ChildState1, ChildState2] | [ChildState1, ChildState3];
type ParentAction = ChildAction1 & ChildAction2 & ChildAction3;
type ParentUpdater = Updater<ParentState, ParentAction>;

これは以下のようなコンポーネントを持つとき、全体から見たときの型です。

const Child1Component = () => {
  const [childState1] = React.useState();
  return childState1 ? <ChildComponent2 /> : <ChildComponent3 />
}

組み合わせだけではなくて、親コンポーネントの状態を子コンポーネントにPropsとして渡す場合も考えてみましょう。

type ChildState = [ChildState, ParentState];
type ChildUpdater= Updater<ChildState, Action>;

ただし、このUpdaterには注意事項があってChildUpdaterではParentStateは更新できません。 なぜならParentStateを変更して返したとしても、上から届くParentStateはそのままです。 蛇足ですが、実質以下のような型がUpdaterになるでしょう。

(state: [ParentState, ChildState], Action) => ChildState

というわけで、部品を組み合わせると全体を俯瞰できますし、いろんな組み合わせ方を考える道具にできます。 たとえば、Reduxで管理するか、ReactComponentで管理すべき状態なのか考えられます。 状態遷移をする部品をどのように組み合わせるかも、アーキテクチャの選択です。 アーキテクチャはさまざまな要素のトレードオフを選択肢して、決定する必要があります。 構造を見抜くことで、気づいていなかったトレードオフを見つけたり、選択を検討することに役立ちます。 もちろん、別の方法で考えたほうがよいことがありますので、うまく使いましょう。 このモデルでパフォーマンスの都合を見抜くのは難しい部分があります。

文章でごちゃごちゃ書いてしまいましたが、本来は図に書いてみたりするとよいでしょう。 これをチームに共有して、フィードバックを得てよりよい設計を目指します。

まとめ

設計をおこなう際には広く視野を持ちたいです。 細部の不要な情報を取り除き抽象化したモデルで考えたいです。 そのモデルとしてUpdaterとSelectorの例を紹介しました。

type Effect = () => void;
type Updater<State, Action> = (state: State, action: Action) => [State, Effect]
type Selector<State, View> = (state: State) => View

Reducerの例では、Updaterほぼそのままであることがわかりました。

クラスの例では、UpdaterとSelectorの責務をもっているのがわかりました。 分割することでより正確にクラスをとらえることができます。

コンポーネントの例では、クラスと同様UpdaterとSelectorの責務をもっているのがわかりました。 それだけでなく便利な方法に隠れている、状態やアクションをみつけることができました。

このモデルを使うと、プログラミングで登場するさまざまな抽象から状態を中心とした構造をとらえることができました。 何気なく使うプログラミングの道具の状態遷移をとらえることで、アプリケーションの状態をより正確に理解できます。 アプリケーションの状態がわかれば、不正な状態に陥りにくくなります。 仮に不正な状態が発見されても、適切な対処ができます。

こうした抽象化したモデルを利用して、アプリケーションの目的や組織構造にあうアプリケーション構造を組み立ていきましょう。

もっと詳しく…書きたいが紙面が足りないので雑談

もっと詳しく書きたいんだけど、紙が足りないんだ。

というわけで、書きたかった気がする話をざっくりと書き置きをしておきます。

Redux Store

Redux Storeに対してsubscribeしたリスナーやuseSelectorなどを含めて考えてみるとより面白いかもしれません。

アプリケーションの構造とAction

このUpdaterの元ネタは(state: State) => [State, Effect]です。 関数名がActionと同等の働きをしていましたが、それをシリアライズ可能な形に変形したものです。 UpdaterがActionを持つことで、発生したActionを順番に保持し、再生するといったこともできます。

StateでActionの代用

Actionの代わりにStateでStateを更新したらダメなのでしょうか。 Actionを通さず、Stateを使って更新しようすると特定の状態へと強制的に遷移する構造になるでしょう。 なにかの拍子で、急に元の状態にもどってしまったりするでしょう。

ぜひ深く考察してみてください。

ReactによるDOMの反映とState

ReactはReactElementの差分を検知するとDOMへ反映します。 useEffectを経由してDOMへ反映することもあります。 Reactはあるべき状態を計算し、DOMにその状態を押し付けようとしている構造です。

これも考察してみると面白いかもしれません。

アプリケーションの構造の合成

合成の話は少ししていますが、ReducerのようにUpdaterも合成できるでしょうか。 redux-loopを調べてみてください。

あとがき

「時間が足りない」というのを「紙面が足りない」と表現してみました。 コロナ禍というのもあったり、育児があったりでLTをする機会をめっきり失ってしまっているので、そのエネルギーがこの記事に向かってしまったのかもしれません。 ちょいちょい小ネタを挟んでいますが、みんなきっと許してくれるでしょう。

ちょっと真面目な話をすると、Chatworkのウェブフロントエンドはこれから開発メンバーが増えるでしょう。 増えても開発効率が落ちない構造にしたいと考えています。 そのためには、チームを分割し独立した開発体制が必要かもしれません。 また、よりリアルタイムなコミュニケーションに適したバックエンドへと移行されていくでしょう。 それに追従できる構造にする必要があります。 これまでやってきたSPAの構造から改革が求められるかもしれません。 そんな巨人に関数プログラミングの考えを使って立ち向かってみようと思っています。 私だけのチカラでは難しそうです。みんなのチカラが必要です。

つまり、私たちフロントエンドチームはこれから生まれるであろう課題へ一緒に立ち向かう人を大募集しています。

以下定型句。

[中途採用] hrmos.co

[新卒採用] hrmos.co

って、そんな定型句で終わりたくねぇ。

幕切れ

今回サンプルコードは検証もせずに記事でラフに書きながら寝かしておきました。 最終的に動かなかったらどうしようと思ったけど、動いたのでホッとしています。

サンプルコードはGitHubにおいておきましたので、興味があればどうぞ。

そんなわけで、ツッコミやマサカリに怯える生活へ帰ります。

ではでは。