pixiv insideは移転しました! ≫ https://inside.pixiv.blog/

FluxのActionとStoreをちゃんと分ける話

ピクシブ株式会社 Advent Calendar 2015、19日目の記事です。

qiita.com


こんにちは、愛らしくも憎らしいJavaScriptを書いてご飯を食べている @geta6 です。業務では pixiv Sketch というサービスの開発や運営に携わっています。

pixiv Sketchでは、node.jsとReact/Fluxibleを使用してサーバーとクライアントを同じコードベースで動作させるIsomorphicな構成を採用しています。

このプロジェクトでコードレビューをしていて、チームメンバーがつまずきやすいと感じたのが『FluxにおけるActionとStoreのどちらに何を実装するべきか』という点でした。

そこで、本日は『ActionとStoreとの適切な責務の持たせ方』について話をしたいと思います。

ReactとFluxについておさらい

今年の4月にこんなスライドを作りました。

ReactとFluxについておさらいしたい方はこちらをご覧ください。

ReactとFluxのこと // Speaker Deck

ActionとStoreの責務をちゃんと分離する

ActionとStoreはどちらに何を実装するべきか迷いが生じやすく、結果として汚いコードが生まれやすい箇所になっています。

新しい機能を開発する際、多くの場合で開発者から見た実装の重点はViewに置かれます。ActionもStoreもViewの要求に応じて書かれることになるため、ActionとStoreはViewの命令に対して特定のデータを入手して返却する一連の仕組みと認識されがちです。

f:id:geta6:20151218175826p:plain

例えば「APIリクエストをどっちに書くか」といったような大きなスコープの話は問題として認識しやすいです。迷うことはあるかもしれませんが、少なくとも解決するための手法を考えることができます。

ところが「APIリクエストで受け取った値をどちらで加工するか」といったような局所的な話題においては、何が問題なのかが認識しにくくなります。ActionとStoreの境界が曖昧になり、どちらに実装されるべきかの方針が人によって異なってしまいます。

そうした状態が続くと、往々にして「気付いたらコードが汚くなっていた」という事態を招くこととなります。そこで、ActionとStoreの責務を明確に分離することにします。

※ 下記では一例としてpixiv Sketchで取っている手法を示しています。プロジェクトや使用するフレームワークによっては合致しないケースがあると思います。

それぞれの責務

ActionにはViewから受け取ったPayloadを加工(クエリを構築してAPIと通信したり)して、適切なモデル型のEntityを構築する責務を持たせます。

データ操作の権限をActionに集約することで、Viewが受け取ったEntityの過不足に関する実装の不備を追いやすくします。

f:id:geta6:20151218175751p:plain

StoreにはActionからやってきたEntityを振り分けてコンテナへ格納し、ViewへEntityを供給する責務を持たせます。

Storeは機能の増加に伴って容易に複雑化するため、ここにデータをEntityへ加工する処理などを実装してしまうと見通しが悪くなるので注意します。

f:id:geta6:20151218175940p:plain

※ PayloadはViewがActionに対して発行するオブジェクトで『あるアクションのうちどれを実行するか、idは何か』といった断片的な情報を含むものを指します。Entityは、例えばItemのEntityと言えば、完全なItem情報を持ったものを指します。

下記のようなActionの発行コードがあったとします。

このコードでは非表示状態のアイテムを表示状態へ変更しようとしています。

context.executeActionはFluxibleで提供される機能で、ViewからActionを発行するときに使うメソッドです。

// View
import React, { PropTypes } from 'react';
import get from 'lodash/object/get';

React.createClass({
  propTypes: {
    item: PropTypes.object,
  },

  contextType: {
    executeAction: PropTypes.func.isRequired,
  },

  handleShow() {
    this.context.executeAction(Action, {
      type: Action.actionTypes.showItem,
      entity: { item: { id: get(this.props.item, ['id']) } },
    });
  },
});

よくないパターン

下記の実装にはまずい点が2つあります。1点目はStoreが値の加工を行っている点で、2点目はStoreにコピペコードが存在していて非常に見通しが悪いことです。

同じプライベート値に対するsetterがいっぱいあるclassは、誰しもあまり想像したくないと思います。このままではItemEntityに変更があった場合やStoreが拡張された際の変更コストが、プロジェクト運用期間と共にうなぎ登りにかさんでいくことが予想されます。

import get from 'lodash/object/get';
import set from 'lodash/object/set';
import { fromJS } from 'immutable';
import { createStore } from 'fluxible/addons';

// Action
{ // データを右から左へ受け流してるだけ
  showItem({ context, payload }) {
    const item = get(payload, ['entity', 'item']);
    context.dispatch(Store.dispatchTypes.SHOW_ITEM, { item });
  },

  hideItem({ context, payload }) {
    const item = get(payload, ['entity', 'item']);
    context.dispatch(Store.dispatchTypes.HIDE_ITEM, { item });
  },
};

// Store
createStore({
  handlers: {
    SHOW_ITEM: 'showItem',
    HIDE_ITEM: 'hideItem',
  },

  initialize() {
    this.items = fromJS({});
  },

  showItem({ item }) {
    const newItem = set(item, ['visible'], true); // 値の加工
    const items = this.items.setIn([item.id], fromJS(newItem));
    if (!this.items.equals(items)) {
      this.items = items;
      this.emitChange();
    }
  },

  hideItem({ item }) { // showItemのコピペ
    const newItem = set(item, ['visible'], false); // 値の加工
    const items = this.items.setIn([item.id], fromJS(newItem));
    if (!this.items.equals(items)) {
      this.items = items;
      this.emitChange();
    }
  },
});

よいパターン

Actionにデータを構築させ、Storeが持つ単一のsetterに対してEntityを渡すことにします。StoreのItemEntityの受け取り口が単一になったため、データの流れが見えやすくなりました。

import get from 'lodash/object/get';
import set from 'lodash/object/set';
import { fromJS } from 'immutable';

// Action
{
  showItem({ context, payload }) {
    const item = get(payload, ['entity', 'item']);
    item.visible = true; // データの加工をActionでやる
    context.dispatch(Store.dispatchTypes.SET_ITEM, { item });
  },

  hideItem({ context, payload }) {
    const item = get(payload, ['entity', 'item']);
    item.visible = false; // データの加工をActionでやる
    context.dispatch(Store.dispatchTypes.SET_ITEM, { item });
  },
};

// Store
createStore({
  handlers: {
    SET_ITEM: 'setItem',
  },

  initialize() {
    this.items = fromJS({});
  },

  setItem({ item }) { // showItem,hideItem を setItemへ統合
    const items = this.items.setIn([item.id], fromJS(item));
    if (!this.items.equals(items)) {
      this.items = items;
      this.emitChange();
    }
  },
});

補足

Storeへdispatchする値は、基本的に何らかのEntityであることが望ましいです。

Entityをdispatchする場合、Action側では『Object Literal Shorthandを用いて特定のEntityを送る』ことを明示し、Store側では『Destructuring Assignmentを用いて特定のEntityを受け取る』ことを明示しておくと、コードの見通しがよくなります。

function someAction({ payload }) {
  const item = payload.entity.item;
  dispatch('setItem', { item }); // Object Literal Shorthand
}

function someStore() {
  this.setItem = ({ item }) => { // Destructuring Assignment
    console.log(item);
  };
}

まとめ

ActionとStoreの責務をちゃんと言語化して以降、自分を含めてチーム全体でPull Requestの質が高くなりました。ここを適当にやってしまうと後からモチベーションの低下を招いたり、リファクタが必要になったりして大変なことになります(なりました)。

みなさんも設計を始める際にはどちらにどのような責務を持たせ、何が実装されるべきかを考えておくとよいと思います。

この記事がFluxなアプリケーションを作る際の一助となれば幸いです。

明日はユデマンジュウのターンです、ご期待ください。


ピクシブ株式会社ではActionとStoreをちゃんと分けたくて仕方がないエンジニア・アルバイトの方を募集中です。こちらからどうぞ。

recruit.pixiv.net