LoginSignup
68
55

More than 5 years have passed since last update.

関数型ReactでビジュアルFizzBuzz

Last updated at Posted at 2016-12-19

この記事は、
Aizu Advent Calendar 2016 - Qiita 20日目の記事です。
前日は @nktafuseさん、次は@nbawofpさんです。

長い間、Scalaを離れて、ReactとTypeScriptを使い続けています。しかし関数型の考えや感性は無くならないもので相変わらず関数型にチャレンジし続けているので、その知見共有です。

さて、皆さんご存知FizzBuzz問題です。しかし、いつものように文字列で結果を出すだけでは面白くないので、数列を可視化しつつ範囲やFizzとBuzzの値(倍数)をリアルタイムに変更出来たら面白いんじゃないかと思い作って見ました。まずデモをご覧ください。

demo.gif

あとで解説をしますが、すぐ試したい方は、このリポジトリからどうぞ。

今回の記事は、このFizzBuzzを通して、

  • DOM操作による実装と欠点
  • Reactによる実装の利点と欠点
  • 関数型(FRP)Reactによる実装の利点

というような順序で説明していきたいと思います。Webフロントエンド開発に明るくない方でも、分かりやすいような解説を心掛けたいと思っています。

DOM操作による実装

申し訳ありません。あらかじめ申し上げると、書き始める当初からDOM操作(素JavaScript)による実装は、泥沼になると想定されたので、実装を行なっていません。実装の概要と欠点について書きたいと思います。

DOM操作による実装は、JavaScript標準で用意されている、

  • document.getElementById
  • element.getElementsByClassName
  • document.createElement
  • document.createTextNode
  • element.appendChild
  • element.innerHTML

等のDOM操作APIを利用して実装する手法を指します。jQueryを利用してもDOMを操作するという点において、本質的に変わりはありません。この辺りのAPIは、皆さん良くご存知でしょうしドキュメントも豊富なので、特筆すべき事はありません。

欠点

DOM操作を利用した実装スタイルですが、実に多くの欠点が存在します。

  • 操作APIによる非直感的なDOM生成
  • DOMツリーの状態管理
  • パフォーマンス

まず1つ目は、「操作APIによる非直感的なDOM生成」です。まずは、次のコードを見てみましょう。どのようなDOMが生成されるか想像出来るでしょうか。

function createParagraph(text) {
    const paragraph = document.createElement("p");
    const textNode = document.createTextNode(text);
    paragraph.appendChild(textNode);
    return paragraph;
}

const messageTexts = ["Hello.", "How are you?", "I'm Fine!"];
const messages = document.getElementById("messages");

messageTexts.map(createParagraph).forEach(function (message) {
    messages.appendChild(message);
});

なかなかß瞬時には、以下のようなHTMLが動的に生成される事が想像できなかったと思います。

<div id="messages">
   <p>Hello.</p>
   <p>How are you?</p>
   <p>I'm FIne!</p>
</div>

この問題は、慣れなどで簡単に解決出来るような簡単な問題ではありません。JavaScript上では、上で記述したようなDOMを生成するようなコードの他に、生成したDOMに対してイベントハンドラを定義したり、Ajaxで外部のAPIを叩いたり、数字や文字列や日付に関する計算を行ったり、大規模になるにつれ、DOMを生成するコードはソースコードの海に埋もれてしまいます。このような状況で、生成されるHTMLの変更は容易には行えないことがわかると思います。

2つ目の点は、「DOMツリーの状態管理」です。これは1番目の問題と似ています。手続き的に生成されたDOMは、HTMLとして描画された瞬間に、静的な構造から一変して人間が状態を意識し続けなければなりません。「あそこのliの子要素には、aタグがあって、その中にimgタグがあって、その画像のURLは・・・」このような場合、正しく要素が挿入され、望んだ挙動かどうかを確認するのは、非常に困難です。自動化ツールを導入したとしても、状態と向き合わなければいけないことは確実です。状態(副作用)を含んだコードは、テストが非常に困難です。

3つ目の点は、「パフォーマンス」です。DOM操作APIの処理は遅くはないのですが、DOM操作による再描画がパフォーマンスの原因になるようです。適切なDOM操作を心掛けて最小限の再描画にするのは、GCがない言語でメモリ操作に気を使う作業と似ているのかもしれません。

以上が、DOM操作における実装の大きな欠点となります。

Reactによる実装と利点

前の節では、DOM操作APIによる実装とその欠点についてまとめました。その問題を解決するためにDOM(HTML)と、それに関連するデータを分離し、アプリケーションを構築するMV*フレームワークが数多く登場しました(Backbone.js, knockoutjs, Angular, Vue)。この節では、(広義で)MV*フレームワークの一種であるReactについて述べていきたいと思います。

せっかくなので、ビジュアルFizzBuzzの例を見ながら、Reactの特徴やその利点について見ていきましょう。記事の冒頭でデモを流しましたが、FizzBuzzは、以下のような画面になっています。

screenshot.png

上の入力フォームは、数列の数(上限1000)、Fizzの倍数、Buzzの倍数を入力できるようになっています。それに応じて、下の表が姿を変えます。ところで、このような場合は大きく分けて、上の入力部分と下の表部分で、個別に管理したくなりますよね。このような形です。

react-component.png

アプリケーション全体をApp、入力部分をInputs、表部分をContainerとしました。それではそれに対応するビジュアルFizzBuzzのコードを見ていきましょう。

/**** index.tsx *****/
ReactDOM.render(<App max={100} fizz={3} buzz={5} />, document.getElementById("content"));

/**** App.tsx *****/
interface IAppProps {
    max: number;
    fizz: number;
    buzz: number;
}

interface IAppState {
    list: List<number>;
    fizz: number;
    buzz: number;
}

export default class App extends React.Component<IAppProps, IAppState> {
    constructor(props) {
        super(props);

        const list = Range(1, this.props.max + 1).toList();
        const {fizz, buzz, max} = this.props;

        this.state = { list, fizz, buzz };
    }

    // 中略

    render() {
        const {max, fizz, buzz} = this.props;

        return <div>
            <div className="fizzbuzzInputs">
                <MaxInput
                    max={max}
                    handleMaxChange={(max) => this.handleMaxChange(max)} />
                <FizzBuzzInput
                    fizz={fizz}
                    buzz={buzz}
                    handleFizzChange={(fizz) => this.handlFizzChange(fizz)}
                    handleBuzzChange={(buzz) => this.handlBuzzChange(buzz)}
                    />
            </div>
            <FizzBuzzContainer {...this.state} />
        </div>;
    }
}

まず、index.tsxを見ていきましょう。ReactDOM.renderというメソッドには、というHTMLタグのような記法が第一引数に渡されています。この記法はJSX記法と呼ばれるもので、js/tsソースコード上で記述できるHTMLを拡張したようなものです。このAppコンポーネント(タグ)は、どのように定義されているのでしょうか。App.tsxのコードを見てみましょう。Appはクラスとして定義されており、React.Componentというクラスを継承しています。Appクラスの中で一番大事なメソッドは、renderメソッドです。renderメソッドは、JSX記法で書かれたオブジェクトを戻り値としています。そのJSXオブジェクトも、また別なInputコンポーネント、Containerコンポーネントを含んでいます。

JSX記法はHTMLによる記述とほぼ変わらず、タグの内容や属性を変更したければ直感的に、非エンジニアの方でも変更できると思います。これはDOM操作APIの非直感的であるが為に引き起こされる問題を解決します。

欠点

Reactは、DOM操作APIによる全ての問題を解決したのでしょうか。今回のポイントはズバリ「状態」にあります。まずは、コンポーネントの関係を図に表したので見てみましょう。

react-fizzbuzz.png

先程説明したとおりコンポーネントは、親から受け取る引数「Props」とコンポーネント自身が持っている「State」から成り立ちます。Stateは、自身で閉じている場合は問題が無いのですが、他のコンポーネントのStateに依存する場合に問題が生じます。ビジュアルFizzBuzzの場合は、FizzBuzzの文字列のList(["1", "2", "Fizz", ...])を生成するために、全体を管理するAppコンポーネントが数列の上限値(max)や倍数(fizz, buzz)をStateに保持して上げなければなりません。Containerには、それらの保持しているStateをContainerのProps経由で渡してあげるのですが、それらの値を変更するのはどこでしょう。図を確認するとInputコンポーネント系統(紙面の都合上MaxInputが代表)であるとわかります。MaxInputコンポーネントの保持する値(max)を変更する部分のコードを見てみましょう。

/**** MaxInput.tsx *****/
<input
  type="number"
  value={this.state.max}
  onChange={(e) => {
    const max = Number(e.target.value);
    const validatedMax = Math.abs(max) > 1000 ? 1000 : Math.abs(max);
    handleMaxChange(validatedMax);
    this.setState({ max: validatedMax });
} } />

/**** App.tsx *****/
class App extends React.Component {
  // 中略
  private handleMaxChange(max: number) {
    // Object.assign(this.state, {list: Range(...)}); と同じ。
    this.setState({ ...this.state, ...{ list: Range(1, max + 1).toList() } });
  }

  render{
      // 中略
     return ...
       <MaxInput
         max={max}
         handleMaxChange={(max) => this.handleMaxChange(max)} />
  }
}

MaxInputのonChange属性では、イベント関数が定義されています。はじめにイベントオブジェクトから値を取得し、負の値や1000を超えた場合の処理をして正常な値にします。その後、その値をhandleMaxChange関数と自身のstateに渡しています。自身のstateに渡す理由は、コンポーネントそのものは静的な値しか持てない為、入力フォームの変更されていく値を保持し続けるには、stateを利用するしかありません。ちなみに、index.tsxから最初受け取ったmaxの初期値は、props経由で受け取る為、また意味が違うmaxです。とても複雑ですね・・・。

interface IMaxInputProps {
  max: number;
  handleMaxChange: (max: number) => void;
}

interface IMaxInputState {
  max: number;
}

気を取り直して、handleMaxChangeを見てみましょう。この関数では、maxを利用して数列List(1, 2, 3, ..., max)を新しいstateとして設定しています。コールバック関数は、他(特に親)コンポーネントの状態を変更する為に子コンポーネントに渡していたのです。

まとめると、コンポーネント自体がStateを保持するのは問題無いのですが、Propsと意味合いが異なる場合、コールバック関数などを通じて他コンポーネントにも影響が及ぶ場合にアプリケーション全体として複雑になってしまう。これがReactを使う場合の問題点となります。

関数型に至るまでの遷移

ここまでに、ReactのStateにまつわる複雑な問題を見てまいりました。あらかじめ言ってしまうと、この問題を解決するには、Fluxアーキテクチャと呼ばれるアーキテクチャを導入する(導入したフレームワークを使う)のですが、この記事では一旦忘れてもらって結構です。ここでのポイントは、状態をコンポーネントから分離して扱う手段が必要だという事です。状態を扱うには、畳み込み演算を使います(この記事で必要な関数型知識は畳み込み演算のみです。安心してください)。一番メジャーな畳み込み演算は、配列の合計値を求めるコードです。

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].reduce((sum, x) => sum + x);
// => 55

reduceは、配列から呼び出せるメソッドで引数には、関数を受け取ります。受け取る関数の第一引数は、それまでの合計を表す変数(sum)、第二引数は、現在見ている要素の値(x)を表します。例えば今見ている要素が5であるならばsumは、0+1+2+3+4=10, xは5になります。そうして配列が空っぽ(末尾)になるまで見ていって最終的な値(sum+x)を算出します。この畳込み演算ですが、今回のケースでは、こういった見方が出来ないでしょうか。

[event, event, event, event].reduce((currentState, e) => 
    // currentStateとeを使用した新しいstate
);

配列には、ユーザが起こしたイベント(maxの値を変更した、fizz, buzzの値を変更した)reduceの中は、今までの状態と現在見ているイベントの値から新しい状態を生成出来るはずです。しかし問題は、eventは同期的ではない(かつ、イベントはアプリが終了するまで無限に続く)という点と、そのイベントが起きた時の値が逐一欲しいという点です。

FRPライブラリBacon.js

非同期の畳込み演算を実現するには、Bacon.jsというFunctional Reactive Programming(FRP)ライブラリを使います。Bacon.jsには、非同期な処理を行うための仕組みが多く存在しますが、今回はBusと呼ばれる自分でイベントを起こせる仕組みのみを使います。ごくごく簡単な例を見てみましょう。文字列を流すようなイベントがあり逐一文字列の長さを得たいとします。同期的なコードとしては以下のようになるでしょう。

["hello", "FRP", "world!"].reduce((_, message: string) => 
  console.log(message.length)
)

しかし、やはり同期的なので結果は、実行された段階で一気に出力されてしまいますし、配列の決められた長さ分しか文字列の長さを得ることが出来ません。Bacon.jsを使ったバージョンのコードを見てみましょう。

// カスタムイベントBusの生成。
const bus: Bacon.Bus<any, any> = new Bacon.Bus();

// 畳み込み演算に利用するための関数。
// 現在の文字列の長さを得るのに前の値は要らない。
const func = (_, message: string): number => message.length;

  // 生成したBusと関数funcの紐付け。長さの初期値を-1としておく。
  // 型パラメータは、update<func, init> => <message, length, init>
  const property = Bacon.update<string, number, number>(-1,
   [bus], func
);

// 畳み込み演算の開始
property.onValue((length) => console.log(length));

// Busにpushでイベントを発火
console.log("----");
bus.push("hello");
console.log("^^^^");
bus.push("FRP");
console.log("~~~~");
bus.push("world!");

// -1
// ----
// 5
// ^^^^
// 3
// ~~~~
// 6
};

見事、非同期的に畳み込み演算を行うことが出来ました! 順序としては、

  • 非同期的なイベントを管理するBusの生成
  • 演算の内容を示す畳み込み関数の準備
  • Busと畳込み関数の紐付け
  • 演算の開始(onValue)
  • イベントの発火

となります。察しの良い人は気づいたかもしれませんがFRPとは、Observerパターンの関数型による再実装です。Bacon.jsでは、イベント時の関数を用意するだけなのでとてもお手軽ですね。

次は、それまでの状態を考慮した例を見てみましょう。

// 複数のイベントに対応するためにBusを増やす。
const addItemBus: Bacon.Bus<any, any> = new Bacon.Bus();
const removeItemBus: Bacon.Bus<any, any> = new Bacon.Bus();

// 2つのイベント
const addItem = (list: List<string>, message: string): List<string> => 
  list.push(message);
const removeItem = (list: List<string>, message:   string): List<string> =>  
  list.remove(list.indexOf(message));

// イベントとBusの紐付け。型パラメータが少し複雑・・・。
// update<addItem, removeItem, 畳み込んだ後の型, init>
// => update<string, string, List<string>, List<string>>
const property = Bacon.update<string, string, List<string>, List<string>>(List<string>(),
  [addItemBus], addItem,
  [removeItemBus], removeItem
);

// 変わらず、畳み込み演算(イベントの監視)開始。
property.onValue((list) => console.log(list));

console.log("----");
addItemBus.push("hello");
addItemBus.push("baconjs");
addItemBus.push("world");
console.log("~~~~");
removeItemBus.push("baconjs");
// List []
// -------
// List [ "hello" ]
// List [ "hello", "baconjs" ]
// List [ "hello", "baconjs", "world" ]
// ~~~~
// List [ "hello", "world" ]

もうお分かりですね? イベントを起こしたかったら、その数だけBusを生成する。イベントの数だけ関数を用意する。非常にシンプル!これだけで、自由に非同期な畳み込みが行うことができます。ちなみにこの例では、配列ではなくImmutableライブラリ(これもReactと同じFacebook製でReact使用時に推奨されています!)のListを使っています。配列に比べメソッドが充実しており、メソッドは破壊的メソッドではなくListのコピーを逐一生成してくれるので、参照を気にする必要がありません(とっても便利ですね)。

オブジェクト指向による隠蔽、そして複数イベントの同時畳み込み

非同期畳み込みが行えるようになったら次に考えることは、複数の非同期畳み込みを同時に行うということです。安心してくださいBacon.jsでは、とても簡単に非同期畳み込みが出来ます。加えて、オブジェクト指向を利用してユーザに優しく必要な部分のみをメソッドとして抽出しました。

class MessageAction {
  private bus: Bacon.Bus<any, any>;

  constructor() {
      this.bus = new Bacon.Bus();
  }

  // イベントを発火するメソッドを用意
  pushMessage(message: string) {
      this.bus.push(message);
  }

  createProperty(): Bacon.Property<string, number> {
    return Bacon.update<string, number, number>(-1,
          [this.bus], this._pushMessage.bind(this)
      );
  }

  // 畳み込みに使われる関数内容は、private
  private _pushMessage(_, message: string): number {
    return message.length;
  }
}

class ListAction {
  private addItemBus: Bacon.Bus<any, any>;
  private removeItemBus: Bacon.Bus<any, any>;

  constructor() {
      this.addItemBus = new Bacon.Bus();
      this.removeItemBus = new Bacon.Bus();
  }

  addItem(message: string) {
      this.addItemBus.push(message);
  }

  removeItem(message: string) {
      this.removeItemBus.push(message);
  }

  createProperty(): Bacon.Property<string, List<string>> {
    return Bacon.update<string, string, List<string>, List<string>>(List<string>(),
      [this.addItemBus], this._addItem.bind(this),
       [this.removeItemBus], this._removeItem.bind(this)
    );
  }

  private _addItem(list: List<string>, message: string): List<string> {
    return list.push(message);
  };

  private _removeItem(list: List<string>, message: string): List<string> {
    return list.remove(list.indexOf(message));
  };
}

// Busの生成は、コンストラクタに任せる。
const messageAction = new MessageAction();
const listAction = new ListAction();

const messageProperty = messageAction.createProperty();
const listProperty = listAction.createProperty();

// Bacon.onValuesメソッドで、複数の畳込み演算をマージできる。
// ユーザは、マージされた結果のみに集中して処理を書ける。
Bacon.onValues(messageProperty, listProperty, 
  (length: number, list: List<string>) => {
    console.log("-------");
    console.log(`length = ${length}`);
    console.log(`list = ${list}`);
    console.log("-------");
});

// イベントの発火は、Action経由で行う。
messageAction.pushMessage("hello");
listAction.addItem("hello");

messageAction.pushMessage("baconjs");
listAction.addItem("baconjs");

messageAction.pushMessage("world");
listAction.addItem("world");

listAction.removeItem("baconjs");

// -------
// length = -1
// list = List []
// -------
// -------
// length = 5
// list = List []
// -------
// -------
// length = 5
// list = List [ "hello" ]
// -------
// -------
// length = 7
// list = List [ "hello" ]
// -------
// -------
// length = 7
// list = List [ "hello", "baconjs" ]
// -------
// -------
// length = 5
// list = List [ "hello", "baconjs" ]
// -------
// -------
// length = 5
// list = List [ "hello", "baconjs", "world" ]
// -------
// -------
// length = 5
// list = List [ "hello", "world" ]
// -------

実に素晴らしいです。オブジェクト指向と関数型の融合で、実に合理的に非同期イベントを複数同時に扱うことに成功しました。TypeScriptを利用することで、引数のチェックも行えて安全安心ですね。ここでのポイントは、Bacon.onValuesで複数のPropertyをまとめて、畳込み関数では、その分だけ引数が増えるというだけですね。また、Busの発火アクションとPropertyのみを公開し他の部分を上手く隠蔽した、このオブジェクトのことをActionと呼ぶことにします。

関数型(FRP)Reactによる実装

それでは、満を持して関数型Reactによる実装を見ていきましょう。まずはじめに、Action毎で管理していたBusを管理するオブジェクトを用意します。

export default class Dispatcher {
  private handlers: Map<string, Bacon.Bus<any, any>>;

  constructor() {
    this.handlers = Map<string, Bacon.Bus<any, any>>();
  }
  // busを文字列で指定する
  public stream(name: string): Bacon.Bus<any, any> {
    return this.bus(name);
  }
  // 文字列で指定したbusにデータを流す
  public push(name: string, value: any): void {
    this.bus(name).push(value);
  }
  // busをplugする
  public plug(name: string, value: any): void {
    this.bus(name).plug(value);
  }
  // busを生成する
  private bus(name: string): Bacon.Bus<any, any> {
    if (this.handlers.has(name)) {
      return this.handlers.get(name);
    } else {
      const newBus = new Bacon.Bus();
      this.handlers = this.handlers.set(name, newBus);
      return newBus;
    }
  }
}

小難しそうなコードが並んでいますが、最悪理解しなくて構いません。単なるハッシュマップでBusを管理しているバスプールです(本当にそれ以上でもそれ以下でもありません)。Fluxアーキテクチャに当てはめて一番適切な名前がDispatcherとなります。先程も言ったとおり構える必要はありません。ただのバスプールで大丈夫です。バスプールが使われている現場を見てみましょう。

/**** maxAction.ts ****/
import * as Bacon from "baconjs";
import Dispatcher from "./dispatcher";
import { FizzBuzz } from "../models/fizzbuzz";

const CHANGE_MAX = "CHANGE_MAX";

export default class MaxAction {
  private d: Dispatcher;
  private initialValue: number;

  constructor(dispatcher: Dispatcher, initialValue: number) {
    this.d = dispatcher;
     this.initialValue = initialValue;
  }

  public changeMax(max: number) {
    this.d.push(CHANGE_MAX, max);
  }

  public createProperty(): Bacon.Property<number, number> {
    return Bacon.update<number, number, number>(this.initialValue,
      [this.d.stream(CHANGE_MAX)], this._changeMax.bind(this)
    );
   }

  private _changeMax(_, newMax: number): number {
    return FizzBuzz.validateMax(newMax);
  }
}

/**** fizzbuzzAction.ts ****/
const CHANGE_FIZZ = "CHANGE_FIZZ";
const CHANGE_BUZZ = "CHANGE_BUZZ";

export interface IFizzbuzz {
  fizz: number;
  buzz: number;
}

export class FizzbuzzAction {
  private d: Dispatcher;
  private initialFizz: number;
  private initialBuzz: number;

  constructor(dispatcher: Dispatcher, initialFizz: number, initialBuzz: number) {
      this.d = dispatcher;
      this.initialFizz = initialFizz;
      this.initialBuzz = initialBuzz;
  }

  public changeFizz(fizz: number) {
    this.d.push(CHANGE_FIZZ, fizz);
  }

  public changeBuzz(buzz: number) {
    this.d.push(CHANGE_BUZZ, buzz);
  }

  public createProperty(): Bacon.Property<IFizzbuzz, IFizzbuzz> {
    const initialValue = { fizz: this.initialFizz, buzz: this.initialBuzz };

    return Bacon.update<IFizzbuzz, number, number, IFizzbuzz>(initialValue,
      [this.d.stream(CHANGE_FIZZ)], this._changeFizz.bind(this),
      [this.d.stream(CHANGE_BUZZ)], this._changeBuzz.bind(this));
    }

  private _changeFizz(oldFizzbuzz: IFizzbuzz, newFizz: number): IFizzbuzz {
    return { ...oldFizzbuzz, ...{ fizz: newFizz } };
  }
  private _changeBuzz(oldFizzbuzz: IFizzbuzz, newBuzz: number): IFizzbuzz {
    return { ...oldFizzbuzz, ...{ buzz: newBuzz } };
  }
}

/**** fizzbuzz.ts ****/
import { Range, List } from "immutable";

export namespace FizzBuzz {
  export function createFizzbuzzList(max: number, fizz: number, buzz: number): List<string> {
    const fizzbuzz = fizz * buzz;

    return Range(1, max + 1).map((n) =>
      n % fizzbuzz === 0 ? "fizzbuzz" :
      n % fizz === 0 ? "fizz" :
      n % buzz === 0 ? "buzz" :
      n.toString()).toList();
  }

  export function validateMax(max: number): number {
      return Math.abs(max) > 1000 ? 1000 : Math.abs(max);
    }
}

Bacon.jsで非同期畳み込みをした例と比較してみましょう。フィールドに置かれていたBusの代わりにDisptcherを介して固定文字列で指定しているだけです。注意点は、他のActionと名前が被らないようにするだけです。また、privateメソッドに書いてあった畳み込み関数自体は、fizzbuzz.tsに単純な関数の集まりとしてまとめています。これは、アプリケーション全体で使うロジックをテストしやすくするためです。あとは、設計方針に合わせてこのロジックの扱いを変えてください。テストも一応覗いてみましょう。

import { List, is } from "immutable";
import { FizzBuzz } from "../fizzbuzz";

describe("fizzbuzz", () => {
  it("should return fizzbuzz list", () => {
     const expected = List.of("1", "2", "fizz", "4", "buzz",
       "fizz", "7", "8", "fizz", "buzz", "11", "fizz", "13", "14", "fizzbuzz");
     const actual = FizzBuzz.createFizzbuzzList(15, 3, 5);

     expect(is(expected, actual)).toBeTruthy();
  });

  it("should return normal number", () => {
    const expected = 50;
    const actual = FizzBuzz.validateMax(50);

    expect(expected).toBe(actual);
  });

  it("should return abs of negative number", () => {
    const expected = 50;
    const actual = FizzBuzz.validateMax(-50);

    expect(expected).toBe(actual);
  });

  it("should return limited number", () => {
    const expected = 1000;
    const actual = FizzBuzz.validateMax(99999);

    expect(expected).toBe(actual);
  });
});

これまたシンプルです。ここまで来るとReactや非同期処理は関係ありません。純粋な値の計算のテストに過ぎません。ImmutableのListは、等価比較も容易に行うことが出来ます。

index.tsxを見てみましょう。

const initialMax = 100;
const initialFizz = 3;
const initialBuzz = 5;

const dispatcher = new Dispatcher();
const maxAction = new MaxAction(dispatcher, initialMax);
const maxProperty = maxAction.createProperty();
const fizzbuzzAction = new FizzbuzzAction(dispatcher, initialFizz, initialBuzz);
const fizzbuzzProperty = fizzbuzzAction.createProperty();

Bacon.onValues(maxProperty, fizzbuzzProperty, (max: number, fizzBuzz: IFizzbuzz) => {
  const { fizz, buzz } = fizzBuzz;
  const fizzbuzzList = FizzBuzz.createFizzbuzzList(max, fizz, buzz);
  const props = { fizz, buzz, max, fizzbuzzList, maxAction, fizzbuzzAction };

  ReactDOM.render(<App {...props} />, document.getElementById("content"));
});

少しごちゃごちゃしますが、何も今までと変わりはありません。Dispacherを生成し、Actionを生成し、Propertyを生成、そしてonValuesメソッドで非同期畳み込みの開始です。非同期畳み込みでは、スタートからアプリケーションに必要な情報が全て手に入ります。すなわち、max, fizz, buzzの3つです。その3つがあるということは、FizzBuzzの文字列をこの時点で求めることが出来ます。あとは、求めた値、描画に必要な値をProps経由で子コンポーネントに伝えていくだけです。

利点

Bacon.jsを導入することで、アプリケーションはどのように変わったのでしょうか。再び図に表してみましょう。

frp-fizzbuzz.png

大きく変わった点は、コンポーネントから一切状態(State)が消えたという点です。Stateが消えた場合どのようなメリットが生まれるでしょうか。Stateが無くなったということは、コンポーネントはPropsのみに依存することになります。人間が状態について考える必要が無くなるということでもありますし、テストがしやすくなるということも上げられます。究極まで言ってしまうと、Reactコンポーネントが属性によりイベントが正常に動作することを保証しているので、イベントに関する状態の遷移をテストする必要が無くなります(PropsがAの場合、状態遷移後のPropsがBの場合について、それぞれテストすれば良い)。これは、関数から副作用を取り除いた場合のテストのしやすさの獲得とよく似ています。

次に気になる点は、状態の管理がコンポーネントからBacon Propertiesにしわ寄せされただけなのでしょうか?いいえ違います。Bacon.jsの畳み込みの基本を思い出してみてください。我々は、状態について考えてはいません。あくまで、今まで畳み込まれた値と現在来ている値を使って計算処理を書いたに過ぎません。我々は状態から解放されたのです。またFluxになぞらえてBacon Propertiesを単一のState(Store)としていますが、Bacon.onValues関数により初めて状態がマージされるため、ユーザは小さいStateについてのみ考えれば良いため巨大なStateを考えずに済みます。

最後に今回のBacon.jsを使ったアプローチはライブラリを小さく(Bus機能のみ)導入したに過ぎないので、Fluxに登場するAction、Store、Dispatcher(単なるバスプールですよ!)について改めて考える必要はありません。その為、問題が起きたときの対処がし易いですし、別のFRPライブラリに乗り換えることも簡単です。また、新たな概念を取り入れたいときでもReactに依存したコードは無いため、比較的拡張は楽に済むはずです。

まとめ

関数型ReactによるビジュアルFizzBuzz(リポジトリはこちら)は、いかがだったでしょうか。Bacon.jsを使ったReactのパターンの解説は、こちらのブログGood bye Flux, welcome Bacon/Rx?で紹介されているものです。翻訳されている記事もあります。紹介記事ではFluxとの比較を行っていますが、ReactとBacon.jsの知識に精通していないと理解をするまでに時間が掛かるのと、module.exportsのみを利用したカジュアルなスタイルだったため、この記事ではTypeScriptとclassベースのオブジェクト指向を使っ
モダンなスタイルに書き換えました。また、Webフロントエンド解説に明るくない方でもステップバイステップで一気にモダンな開発に移行出来るように配慮して記事を書いたつもりです(ところどころ解説が雑かもしれません・・・)。皆さんも関数型の力を取り入れてフロントエンド開発を便利に解りやすくしていきましょう!

68
55
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
68
55