20190527のReactに関する記事は6件です。

postcss-jsxで React x CSSinJSを快適にする


自己紹介


  • @otofu-square
  • 外部登壇2回目の初心者です?
  • JUBILEE WORKS, inc でフロントエンド業
    • TimeTree というカレンダーアプリ
    • React/Redux/TypeScript/hypernova...etc
  • 最近 wasm(Rust), Web Components に興味あり

今日お話すること


今日お話すること

  • postcss-jsx とは
  • postcss-jsx で出来るようになったこと①
  • postcss-jsx で出来るようになったこと②
  • 所感

postcss-jsx とは


出来るようになったこと①


PostCSS プラグインを CSS in JS 内のスタイルに適用出来る


例えば autoprefixer

  • styled-components や emotion などの CSS in JS は内部的に vendor prefix を付与する仕組みを持っている(stylis という CSS プロセッサ)
  • しかし stylis には CSS Grid の IE11 向けサポートがない
  • autoprefixer が持つGrid Autoplacementの機能が利用可能

どうやって PostCSS プラグインを適用するか


Webpack 上で使う

webpack.config.js
...
rules: [{
  test: /\.jsx?$/,
  exclude: /node_modules/,
  use: ["babel-loader", "postcss-loader"]
}],
...

emotion の動的なスタイルに IE11 向けの Grid スタイルが適用されている

...
const StyledWrapper = styled.div`
  /* NOTE: & { ... } でスタイルを囲まないと autoprefixer がエラーを吐く */
  & {
    display: grid;
    grid-template-columns: repeat(3, 100px);
    grid-template-rows: repeat(2, 100px);
    grid-gap: 10px;
    ...
  }
`;
...

image.png


出来るようになったこと②


stylelint の --fix が使える



良いところ

  • stylis のサポート外の vendor prefix も対応可能
  • postcss-preset-env を使ってナウい CSS が書けるように
  • stylelint の --fix が使えることでようやく安心して CSS を書ける
    • 個人的には stylelint-order の autofix が嬉しい

微妙なところ

  • autoprefixer と stylis の vendor prefix が重複する
  • Grid Autoplacement 使っても IE11 の Grid サポートは辛い...
  • stylelint のエディタサポートが微妙
    • Atom は linter-stylelint に一手間加える必要
    • VSCode は format on save が出来ない ... ?

まとめ

  • CSS in JSでもビルドタイムでスタイルを色々弄れるように
  • stylelintで快適に CSS in JS を書けるように
  • とはいえまだ全体的に洗練されてない印象
  • 各方面に PR 出していきましょう ?

余談

  • ゼロランタイムCSS in JSの動向が気になる
    • linaria, astroturf ... etc
    • ビルドタイムで CSS が生成されるので PostCSS 使い放題
  • 足早な発表だったため、何か質問や疑問があればお気軽に聞いてください!

おしまい

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ViewModel / FormModelを使ったテスタブルなコンポーネント設計

AngularでもReactでも共通する話題ですが、フロントエンドでのコンポーネントをテストしやすい設計としてViewModelとFormModelを取り入れたらうまくいった話です。

コンポーネントテストの難しさ

そもそもフロントエンドでのコンポーネントの単体テストする難しさ(面倒さ)は何故なのか。それはinputの作りにくさとoutputの扱いづらさが起因してると考えられます。
・ inputとして値を与えるために、Serviceなどの外部モックを作る必要がある。(AngularであればDIの部分)
・ outputがDOMなので、DOM操作をしての検証が必要となる。
スクリーンショット 2019-05-24 11.33.49.png

責務を分割したコンポーネント設計

テストがしやすいコンポーネントにするために、コンポーネントを責務ごとに分割しよう、というのが今回の趣旨です。

まず、単純にコンポーネントを作った際にコンポーネントがもつ責務の範囲は、
・ Service層またはStoreとのデータのやりとり
・ 自身のStateの管理
・ Viewの構築
が挙げられます。

これを
・ Service層とのデータのやりとり & Stateの管理を行うコンポーネント(Container Component)
・ Viewのテンプレートとしてのコンポーネント(Presentation Component)
・ Viewの構築ロジックをもつ値オブジェクト(ViewModel/FormModel)
に分割します。

商品情報を表示するコンポーネントを例にして、Reactで実装したケースで説明します。

スクリーンショット 2019-05-24 12.19.51.png

以下は、データの取得、State管理、View構築を含んだComponentです。

product/product.component.tsx
interface Props {
  productId: number;
}
interface State {
  product: Product | null;
}

export class ProductComponent extends Component<Props, State> {
  constructor(props) {
    super(props);
    this.state = {
      product: null
    };
    this.getProduct();
  }

  async getProduct(){
    const product: Product = await new ProductApiService().getProduct(this.props.productId);
    this.setState({...this.state, product})
  }

  formatCurrency(price: number): string{
    return ${(price || 0).toLocaleString()}`;
  }

  render() {
    return (
      <div>        
        {this.state.product ? (
          <div>
            <h1>{this.state.product.name}</h1>
            <div>
              {this.formatCurrency(this.state.product.price)}
            </div>
          </div>
        ) : null}
      </div>
    );
  }
}

1. Stateを持たないコンポーネントへ分離

Stateを持つContainerComponentと、Stateを持たないPresentationComponentへ分離します。Redux関連でも割とよく使われるコンポーネントパターンです。
https://redux.js.org/basics/usage-with-react#presentational-and-container-components
スクリーンショット 2019-05-24 11.33.57.png

ContainerComponent

ContainerComponentはView部分を子コンポーネントに移し、View部分を薄くします。

product/productContainer.component.tsx
interface ContainerProps {
  productId: number;
}
interface ContainerState {
  product: Product | null;
}

export class ProductContainerComponent extends Component<ContainerProps, ContainerState> {
  constructor(props) {
    super(props);
    this.state = {
      product: null
    };
    this.getProduct();
  }

  async getProduct(){
    const product: Product = await new ProductApiService().getProduct(this.props.productId);
    this.setState({...this.state, product})
  }

  render() {
    return (
      <div>        
        {this.state.product ? (
          <ProductComponent product={this.state.product}></ProductComponent>
        ) : null}
      </div>
    );
  }
}

PresentaionComponent

PresentationComponentはStateを持たないので、渡された値を元にViewを構築します。
ReactであればFunctionalComponentとしてViewのみを返す関数にします。

product/components/product.component.tsx
interface Props {
  product: Product;
}

export const ProductComponent: React.FC<Props> = props => {
  const formatCurrency = (price: number): string => {
    return ${(price || 0).toLocaleString()}`;
  }

  return (
    <div>
      <h1>{props.product.name}</h1>
      <div>
        {formatCurrency(props.product.price)}
      </div>
    </div>
  );
}

2. ViewModelへのロジックの分離

本題です。
PresentationComponentでのModelからViewを構築するロジックをViewModelへ移します。
ViewModelは表示に特化したモデルクラスで、モデルをViewに合わせて整形します。値の変換、ifでの表示分岐ロジックなどを担います。
ViewModelはContainerComponent内で作成し、PresentationComponentにViewModelを渡すようにします。
PresentationComponentは受け取ったViewModelを表示するのみにします。

スクリーンショット 2019-05-24 11.34.07.png

ViewModel

この例では、モデルの「名前」と「金額」を整形済みの形で返します。

product/components/productComponent.viewmodel.tsx
export class ProductComponentViewModel{
  constructor(private product: Product){}

  get name(){
    return this.product.name;
  }

  get price(){
    return ${(this.product.price || 0).toLocaleString()}`;
  }
}

PresentationComponent

関数を持たず、受け取ったViewModelのプロパティを表示するのみになります。

product/components/product.component.tsx
interface Props {
  product: ProductComponentViewModel;
}

export const ProductComponent: React.FC<Props> = props => {
  return (
    <div>
      <h1>{props.product.name}</h1>
      <div>
        {props.product.price}
      </div>
    </div>
  );
}

ContainerComponent

ViewModelを作成し、子コンポーネントに渡します。

product/productContainer.component.tsx
// ... (省略)
render() {
    return (
      <div>        
        {this.state.product ? (
          <ProductComponent product={new ProductComponentViewModel(this.state.product)}></ProductComponent>
        ) : null}
      </div>
    );
  }
// ... (省略)

ViewModelの共通化

ViewModelは原則として1つのコンポーネントに対して作り、他のコンポーネントへの流用は行いません。
今回のようなProductの値を整形したい場合に、同じ整形ロジックを別のViewModelでも使いたいシチュエーションがあります。 その場合はmodelに対応した共通ViewModelをつくり、各ViewModel側で集約(aggregate)する形をとるとよいです。

viewmodel/product.viewmodel.ts
export class ProductViewModel{
  constructor(private product: Product){}

  get name(){
    return this.product.name;
  }

  get price(){
    return ${(this.product.price || 0).toLocaleString()}`;
  }
}
product/components/productComponent.viewmodel.tsx
export class ProductComponentViewModel{
  product: ProductViewModel;

  constructor(private _product: Product){
    this.product = new ProductViewModel(_product);
  }

  get name(){
    return this.product.name;
  }

  get price(){
    return this.product.price;
  }
}

FormModel

FormModelとは、Formに特化したViewModelです。Formに表示する値の整形や、Formのバリデーションを行います。

下の例は、フィールドとサブミットボタンだけの簡単なフォームで、フィールドに値がない場合はボタンを非活性にするバリデーションを行っています。
スクリーンショット 2019-05-24 20.23.18.png

ContainerComponent

FormModelを作成して子コンポーネントに渡し、子コンポーネントからの変更のコールバックもFormModelで受け取ります。

form/formContainer.component.tsx
export class FormContainerComponent extends Component<ContainerProps, ContainerState> {
  constructor(props) {
    super(props);
    this.state = {
      name: ''
    };
  }
  handleChange(form: FormModel){
    const name = form.name;
    this.setState({...this.state, name: name});
  }

  render() {
    const form = new FormModel({
          name: this.state.name, 
          onChange: this.handleChange.bind(this)
          })
    return (
      <FormComponent form={form}/>
    );
  }
}

FormModel

フォームのバリデーションロジックを持ちます。また、フォームに変更があった場合に新しいFormModelを作成しコールバックを呼びます。

form/components/form.formmodel.ts
interface FormModelProps{
  name: string;
  onChange: (form: FormModel) => void;
}

class FormModel implements FormModelProps{
  readonly name: string;
  readonly onChange: (form: FormModel) => void;
  readonly isValid: boolean;

  constructor(private props: FormModelProps){
    this.name = props.name;
    this.onChange = props.onChange;
    this.isValid = this.validate();
  }

  validate(){
    return this.name !== '';
  }

  handleNameChange(name: string){
    this.onChange(new FormModel({...this.props, name}));
  }
}

PresentationComponent

ViewModelと同様に、FormModelの値を表示するのみにします。
onChangeのイベントハンドリングは、値をとる部分がViewに依存するのでfunctionを持ちますが、取得した値をFormModelに渡して後の処理は移譲します。

form/components/form.component.ts
interface Props{
  form: FormModel
}

export const FormComponent: React.FC<Props> = props => {
  const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const name = event.currentTarget.value;
    props.form.handleNameChange(name);
  }

  return (
    <fieldset>
        <input value={props.form.name} onChange={handleNameChange.bind(props)}/>
        <button disabled={!props.form.isValid}>submit</button>
      </fieldset>
  )
}

まとめ

・ Viewの構築ロジックをViewModelとしてピュアなJSクラスになるのでテストがしやすくなる
・ Formも同様に、FormModelにロジックを移すことでテストがしやすくなる
・ PresentationComponentはViewModelの値を表示をするのり、Snaoshotテストを行えば良い状態になる
(SnapshotテストはJestの機能なので、Jasmineを使用している場合はJestへの切り替えをおすすめします)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ViewModel / FormModelを用いたテスタブルなコンポーネント設計

AngularでもReactでも共通する話題ですが、フロントエンドでのコンポーネントをテストしやすい設計としてViewModelとFormModelを取り入れたらうまくいった話です。

コンポーネントテストの難しさ

そもそもフロントエンドでのコンポーネントの単体テストする難しさ(面倒さ)は何故なのか。それはinputの作りにくさとoutputの扱いづらさが起因してると考えられます。
・ inputとして値を与えるために、Serviceなどの外部モックを作る必要がある。(AngularであればDIの部分)
・ outputがDOMなので、DOM操作をしての検証が必要となる。
スクリーンショット 2019-05-24 11.33.49.png

責務を分割したコンポーネント設計

テストがしやすいコンポーネントにするために、コンポーネントを責務ごとに分割しよう、というのが今回の趣旨です。

まず、単純にコンポーネントを作った際にコンポーネントがもつ責務の範囲は、
・ Service層またはStoreとのデータのやりとり
・ 自身のStateの管理
・ Viewの構築
が挙げられます。

これを
・ Service層とのデータのやりとり & Stateの管理を行うコンポーネント(Container Component)
・ Viewのテンプレートとしてのコンポーネント(Presentation Component)
・ Viewの構築ロジックをもつ値オブジェクト(ViewModel/FormModel)
に分割します。

商品情報を表示するコンポーネントを例にして、Reactで実装したケースで説明します。

スクリーンショット 2019-05-24 12.19.51.png

以下は、データの取得、State管理、View構築を含んだComponentです。

product/product.component.tsx
interface Props {
  productId: number;
}
interface State {
  product: Product | null;
}

export class ProductComponent extends Component<Props, State> {
  constructor(props) {
    super(props);
    this.state = {
      product: null
    };
    this.getProduct();
  }

  async getProduct(){
    const product: Product = await new ProductApiService().getProduct(this.props.productId);
    this.setState({...this.state, product})
  }

  formatCurrency(price: number): string{
    return ${(price || 0).toLocaleString()}`;
  }

  render() {
    return (
      <div>        
        {this.state.product ? (
          <div>
            <h1>{this.state.product.name}</h1>
            <div>
              {this.formatCurrency(this.state.product.price)}
            </div>
          </div>
        ) : null}
      </div>
    );
  }
}

1. Stateを持たないコンポーネントへ分離

Stateを持つContainerComponentと、Stateを持たないPresentationComponentへ分離します。Redux関連でも割とよく使われるコンポーネントパターンです。
https://redux.js.org/basics/usage-with-react#presentational-and-container-components
スクリーンショット 2019-05-24 11.33.57.png

ContainerComponent

ContainerComponentはView部分を子コンポーネントに移し、View部分を薄くします。

product/productContainer.component.tsx
interface ContainerProps {
  productId: number;
}
interface ContainerState {
  product: Product | null;
}

export class ProductContainerComponent extends Component<ContainerProps, ContainerState> {
  constructor(props) {
    super(props);
    this.state = {
      product: null
    };
    this.getProduct();
  }

  async getProduct(){
    const product: Product = await new ProductApiService().getProduct(this.props.productId);
    this.setState({...this.state, product})
  }

  render() {
    return (
      <div>        
        {this.state.product ? (
          <ProductComponent product={this.state.product}></ProductComponent>
        ) : null}
      </div>
    );
  }
}

PresentaionComponent

PresentationComponentはStateを持たないので、渡された値を元にViewを構築します。
ReactであればFunctionalComponentとしてViewのみを返す関数にします。

product/components/product.component.tsx
interface Props {
  product: Product;
}

export const ProductComponent: React.FC<Props> = props => {
  const formatCurrency = (price: number): string => {
    return ${(price || 0).toLocaleString()}`;
  }

  return (
    <div>
      <h1>{props.product.name}</h1>
      <div>
        {formatCurrency(props.product.price)}
      </div>
    </div>
  );
}

2. ViewModelへのロジックの分離

本題です。
PresentationComponentでのModelからViewを構築するロジックをViewModelへ移します。
ViewModelは表示に特化したモデルクラスで、モデルをViewに合わせて整形します。値の変換、ifでの表示分岐ロジックなどを担います。
ViewModelはContainerComponent内で作成し、PresentationComponentにViewModelを渡すようにします。
PresentationComponentは受け取ったViewModelを表示するのみにします。

スクリーンショット 2019-05-24 11.34.07.png

ViewModel

この例では、モデルの「名前」と「金額」を整形済みの形で返します。

product/components/productComponent.viewmodel.tsx
export class ProductComponentViewModel{
  constructor(private product: Product){}

  get name(){
    return this.product.name;
  }

  get price(){
    return ${(this.product.price || 0).toLocaleString()}`;
  }
}

PresentationComponent

関数を持たず、受け取ったViewModelのプロパティを表示するのみになります。

product/components/product.component.tsx
interface Props {
  product: ProductComponentViewModel;
}

export const ProductComponent: React.FC<Props> = props => {
  return (
    <div>
      <h1>{props.product.name}</h1>
      <div>
        {props.product.price}
      </div>
    </div>
  );
}

ContainerComponent

ViewModelを作成し、子コンポーネントに渡します。

product/productContainer.component.tsx
// ... (省略)
render() {
    return (
      <div>        
        {this.state.product ? (
          <ProductComponent product={new ProductComponentViewModel(this.state.product)}></ProductComponent>
        ) : null}
      </div>
    );
  }
// ... (省略)

ViewModelの共通化

ViewModelは原則として1つのコンポーネントに対して作り、他のコンポーネントへの流用は行いません。
今回のようなProductの値を整形したい場合に、同じ整形ロジックを別のViewModelでも使いたいシチュエーションがあります。 その場合はmodelに対応した共通ViewModelをつくり、各ViewModel側で集約(aggregate)する形をとるとよいです。

viewmodel/product.viewmodel.ts
export class ProductViewModel{
  constructor(private product: Product){}

  get name(){
    return this.product.name;
  }

  get price(){
    return ${(this.product.price || 0).toLocaleString()}`;
  }
}
product/components/productComponent.viewmodel.tsx
export class ProductComponentViewModel{
  product: ProductViewModel;

  constructor(private _product: Product){
    this.product = new ProductViewModel(_product);
  }

  get name(){
    return this.product.name;
  }

  get price(){
    return this.product.price;
  }
}

FormModel

FormModelとは、Formに特化したViewModelです。Formに表示する値の整形や、Formのバリデーションを行います。

下の例は、フィールドとサブミットボタンだけの簡単なフォームで、フィールドに値がない場合はボタンを非活性にするバリデーションを行っています。
スクリーンショット 2019-05-24 20.23.18.png

ContainerComponent

FormModelを作成して子コンポーネントに渡し、子コンポーネントからの変更のコールバックもFormModelで受け取ります。

form/formContainer.component.tsx
export class FormContainerComponent extends Component<ContainerProps, ContainerState> {
  constructor(props) {
    super(props);
    this.state = {
      name: ''
    };
  }
  handleChange(form: FormModel){
    const name = form.name;
    this.setState({...this.state, name: name});
  }

  render() {
    const form = new FormModel({
          name: this.state.name, 
          onChange: this.handleChange.bind(this)
          })
    return (
      <FormComponent form={form}/>
    );
  }
}

FormModel

フォームのバリデーションロジックを持ちます。また、フォームに変更があった場合に新しいFormModelを作成しコールバックを呼びます。

form/components/form.formmodel.ts
interface FormModelProps{
  name: string;
  onChange: (form: FormModel) => void;
}

class FormModel implements FormModelProps{
  readonly name: string;
  readonly onChange: (form: FormModel) => void;
  readonly isValid: boolean;

  constructor(private props: FormModelProps){
    this.name = props.name;
    this.onChange = props.onChange;
    this.isValid = this.validate();
  }

  validate(){
    return this.name !== '';
  }

  handleNameChange(name: string){
    this.onChange(new FormModel({...this.props, name}));
  }
}

PresentationComponent

ViewModelと同様に、FormModelの値を表示するのみにします。
onChangeのイベントハンドリングは、値をとる部分がViewに依存するのでfunctionを持ちますが、取得した値をFormModelに渡して後の処理は移譲します。

form/components/form.component.ts
interface Props{
  form: FormModel
}

export const FormComponent: React.FC<Props> = props => {
  const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const name = event.currentTarget.value;
    props.form.handleNameChange(name);
  }

  return (
    <fieldset>
        <input value={props.form.name} onChange={handleNameChange.bind(props)}/>
        <button disabled={!props.form.isValid}>submit</button>
      </fieldset>
  )
}

まとめ

・ Viewの構築ロジックをViewModelとしてピュアなJSクラスになるのでテストがしやすくなる
・ Formも同様に、FormModelにロジックを移すことでテストがしやすくなる
・ PresentationComponentはViewModelの値を表示をするのり、Snaoshotテストを行えば良い状態になる
(SnapshotテストはJestの機能なので、Jasmineを使用している場合はJestへの切り替えをおすすめします)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Error]Absolute imports should come before relative imports in React + Redux

このエラーは
React momentをimportしようとした際に出たエラーです。
日本語での文献が見当たらなかったのでまとめます。

error Absolute imports should come before relative imports

直訳すると絶対的なimportは相対的なimportの前にこなきゃだめだよってことです。
ESLintのコーディング規約だと思われます。

これを

import React from 'react';
import { Link } from 'react-router-dom';
import { Map } from 'immutable';
import * as Actions from '../../actions
import  moment from 'moment';

こうすると解消されます。

import React from 'react';
import  moment from 'moment';
import { Link } from 'react-router-dom';
import { Map } from 'immutable';
import * as Actions from '../../actions
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Eslint Error]Absolute imports should come before relative imports in React + Redux

このエラーは
React momentをimportしようとした際に出たエラーです。
日本語での文献が見当たらなかったのでまとめます。

error Absolute imports should come before relative imports

直訳すると絶対的なimportは相対的なimportの前にこなきゃだめだよってことです。
ESLintのコーディング規約だと思われます。

これを

import React from 'react';
import { Link } from 'react-router-dom';
import { Map } from 'immutable';
import * as Actions from '../../actions
import  moment from 'react-moment';

こうすると解消されます。

import React from 'react';
import  moment from 'react-moment';
import { Link } from 'react-router-dom';
import { Map } from 'immutable';
import * as Actions from '../../actions
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Fleur という Flux フレームワークが良い感じ

Pixiv のエンジニアの方が作った Fleur という Flux フレームワークを触ってみて、とても良いなという感触を得たので紹介したいと思います。

ra-gg/fleur: A new fully-typed Flux Framework inspired by Fluxible

Fleur とは

【SSRも】Fleur − 新しいFluxフレームワーク【イケる】

Fleur がどんなフレームワークか、というのは作者の方がブログで詳しく書いているので、この記事では実際に書いてみたコードと使い心地に焦点を当てます。

かいつまんで紹介すると、

  • Fluxible を参考にしている
  • SSR に対応している
  • React Hooks に対応している
  • 非同期処理に対応している
  • Store は immer.js を組み込んでいる
  • SSR に対応したルーターを持っている

こんな感じで、モダンな作りと幅広い対応範囲が特徴的です。
詳しい数値はブログの方に書いてありますが、パフォーマンス面も Redux + react-redux の性能に迫るようで、 Pixiv 内のプロダクトで実際に使われているだけあってしっかりと作り込まれています。
作者の方からコメントをいただき、 Pixiv 内のプロダクトで使われているわけではなく、個人開発のプロダクト内の誤りでした。ただ実際に動いているプロダクト内でがっつり使われていることは間違いないです:bow:(19/05/27 18:03追記)

image.png

ブログ内で紹介されている画像を使わせていただきました。
各ドメインごとに Actions, Operations, Store を定義して Store をコンポーネントに繋げる仕組みになっています。
4つの要素を作るということでがっつり構造を分けることになるのですが、定義に必要な記述は少なく、 Redux のように多くの記述を必要とせずに作ることができます。

OXゲームを作ってみた

React の公式チュートリアルでは、OXゲームを作るという課題を与えられるのですが、今回はそれを Fleur を使ってやってみました。
中大規模アプリ向けと言及されていたので、正直このレベルのゲームを作る程度だと恩恵を受けるのが難しいところではあるのですが、書き心地を確認するという意味ではまあいいかなと思います。

Fleur の特徴である SSR や非同期処理やルーターなどの機能は使用していないのですが、まともなアプリケーションを作った際にまたそれらの機能の感想などは書ければなと。

実際に作ったゲームのソースコードはこちらに置いています。
nabeliwo/fleur-tic-tac-toe

作っているゲームの機能などはチュートリアルの方を見ていただければ。
最近 React の公式が日本語化されたので一度見てみることをオススメします!

Actions

まずは Action を定義します。
マス目をクリックした際に発行される、 setMark と履歴一覧のうちの好きなところに戻る jumpTo という2種類の Action を定義しています。

gameActions.ts
import { actions, action } from "@fleur/fleur";

import { Mark } from "./gameStore";

export const GameActions = actions("GameAction", {
  setMark: action<{ order: number; mark: Mark }>(),
  jumpTo: action<{ step: number }>()
});

Operations

次に Operation を定義します。
ここに非同期処理を書くことができるので async を使用していますが、今回は非同期処理を必要としないためシンプルに Action を dispatch しているだけです。

gameOperations.ts
import { operations } from "@fleur/fleur";

import { GameActions } from "./gameActions";
import { Mark } from "./gameStore";

export const GameOps = operations({
  async setMark({ dispatch }, order: number, mark: Mark) {
    dispatch(GameActions.setMark, { order, mark });
  },

  async jumpTo({ dispatch }, step: number) {
    dispatch(GameActions.jumpTo, { step });
  }
});

Store

次に Store を作ります。
ここで state を管理して dispatch した Action を受けて state を更新します。
updateWith で state を更新することができるのですが、 Redux 思考に慣れてしまった自分は新しい state を返すのではなく state を直接書き換えていることに最初「うっ…」となったのですが、実際にはこちらの方が記述が見やすくて良いですね。
特に配列の中身変更するような場面だと Redux の reducer だと配列回して該当部分だけ変更するみたいな記述になるのでちょっと見辛くなりがちでした。

ここにロジックを押し込んだので Store が少し大きくなっています。

gameStore.ts
import { listen, Store } from "@fleur/fleur";

import { GameActions } from "./gameActions";

export type Mark = "O" | "X";
export type Matrix = (Mark | null)[][];
type State = {
  history: Array<{ squares: Array<Mark | null> }>;
  xIsNext: boolean;
  step: number;
};

export class GameStore extends Store {
  static storeName = "GameStore";

  public state: State = {
    xIsNext: true,
    history: [{ squares: Array(9).fill(null) }],
    step: 0
  };

  public get allHistory() {
    return this.state.history;
  }

  public get currentHistory() {
    const { history, step } = this.state;
    return history.slice(0, step + 1);
  }

  public get currenMatrix() {
    const squares = this.getCurrentSquares();
    return [squares.slice(0, 3), squares.slice(3, 6), squares.slice(6, 9)];
  }

  public get nextMark() {
    const nextMark: Mark = this.state.xIsNext ? "X" : "O";
    return nextMark;
  }

  public get winner() {
    const lines = [
      [0, 1, 2],
      [3, 4, 5],
      [6, 7, 8],
      [0, 3, 6],
      [1, 4, 7],
      [2, 5, 8],
      [0, 4, 8],
      [2, 4, 6]
    ];
    const squares = this.getCurrentSquares();

    for (let i = 0; i < lines.length; i++) {
      const [a, b, c] = lines[i];

      if (
        squares[a] &&
        squares[a] === squares[b] &&
        squares[a] === squares[c]
      ) {
        return squares[a];
      }
    }

    return null;
  }

  private getCurrentSquares = () => {
    const currentHistory = this.currentHistory;
    const { squares } = currentHistory[currentHistory.length - 1];
    return squares;
  };

  private handleSetMark = listen(GameActions.setMark, ({ order, mark }) => {
    if (this.winner) return;

    this.updateWith((draft: State) => {
      const currentHistory = this.currentHistory;
      const squares = this.getCurrentSquares().slice();
      squares[order] = mark;

      draft.history = currentHistory.concat([{ squares }]);
      draft.xIsNext = !draft.xIsNext;
      draft.step = currentHistory.length + 1;
    });
  });

  private handleJumpTo = listen(GameActions.jumpTo, ({ step }) => {
    this.updateWith((draft: State) => {
      draft.xIsNext = step % 2 === 0;
      draft.step = step;
    });
  });
}

まだ Fleur のソースコードを読んでいないので handleSetMarkhandleJumpTo がどのように実行されるのかがわかっていないのですが、定義することで Action を listen します。

このクラスの中で storeName を定義しないとエラーになってしまったので現状とりあえず書いているのですが、もうちょっと調べてみて解決したらこの記事を編集したいと思います。
作者の方からコメントをいただき、現状 Fleur 内部で storeName を使って管理をしているため storeName の定義は必須ということでした(19/05/27 18:07追記)

View

次に Store をコンポーネントに繋ぎます。
Store から state を呼び出したり、 Operations を呼び出したりします。この繋ぎ込みを行うのを Redux で言う Container に当たる部分として考えて Component と切り分けると綺麗に分割できそう。

Game.tsx
import React, { useCallback } from "react";
import { useFleurContext, useStore } from "@fleur/fleur-react";

import { GameOps } from "../../domains/game/gameOperations";
import { GameStore } from "../../domains/game/gameStore";
import { Board } from "../Board";
import { Step } from "../Step";
import "./style.css";

export const Game = () => {
  const { allHistory, matrix, nextMark, winner } = useStore(
    [GameStore],
    getStore => {
      const store = getStore(GameStore);
      return {
        allHistory: store.allHistory,
        matrix: store.currenMatrix,
        nextMark: store.nextMark,
        winner: store.winner
      };
    }
  );
  const { executeOperation } = useFleurContext();
  const handleClickSquare = useCallback(
    (order: number) => executeOperation(GameOps.setMark, order, nextMark),
    [executeOperation, nextMark]
  );
  const handleClickStep = useCallback(
    (step: number) => executeOperation(GameOps.jumpTo, step),
    [executeOperation]
  );

  return (
    <div className="game">
      <div className="game-board">
        <Board matrix={matrix} onClick={handleClickSquare} />
      </div>

      <div className="game-info">
        <div>{winner ? `Winner: ${winner}` : `Next player: ${nextMark}`}</div>
        <ol>
          {allHistory.map((step, move) => {
            return (
              <li key={move}>
                <Step move={move} onClick={handleClickStep} />
              </li>
            );
          })}
        </ol>
      </div>
    </div>
  );
};

useFleurContext と useStore が @fleur/fleru-react の機能です。
定義した GameStore を useStore に渡すことで state を抽出することができます。

useFleurContext から executeOperation を取り出して Operation を実行することができます。

最後にアプリケーションのルートで Fleur を繋ぎ込みます。

index.tsx
import React from "react";
import ReactDOM from "react-dom";
import Fleur from "@fleur/fleur";
import { FleurContext } from "@fleur/fleur-react";

import { GameStore } from "./domains/game/gameStore";
import { Game } from "./components/Game";
import "./index.css";

const app = new Fleur({
  stores: [GameStore]
});
const context = app.createContext();

window.addEventListener("DOMContentLoaded", () => {
  const root = document.querySelector("#root");

  ReactDOM.render(
    <FleurContext value={context}>
      <Game />
    </FleurContext>,
    root
  );
});

new Fleur に定義した Store を渡すことで子孫コンポーネントで該当 Store を取り出すことができるようになっています。
Context API をうまく使っていて良い感じ。

その他のコンポーネントは特に言うことはないので GitHub のリポジトリを見て確認していただければと思います。

まとめ

簡単なゲームを作ってみての感想として、とにかく書きやすいです。
最初ブログを見たときにフレームワークの特徴として「コードの書き心地が良い」というのがあって、「主観では・・・ :thinking: 」となったのですが、書いてみると確かにこれまでやってきた Flux フレームワークと比べると個人的にとても書きやすく、少なくとも僕の書き心地の感覚とは一致しているようでした。

ブログの方でオススメのディレクトリ構成などについても詳しく記述されていて、今回のOXゲームを作るにあたって、一切手が止まることなくスラスラと書けました。
ここ最近の流れとして大事な部分である SSR やルーター部分の感想が言えないのが紹介記事としては不十分ではあると思うのですが、そちらはまた個人開発のアプリケーションで使ってみて何かあれば追記しようと思います。

React 公式の方で SSR の対応が進んでいたり、 Google のクローラーが CSR を評価できるようになってきたりなど、フレームワーク側での対応が必要じゃなくなりつつあるかもしれない昨今ではありますが、現状ではやはり toC のアプリケーションを作る上では対応しなければならない問題だと思うのでやはりフレームワーク側で担保してくれるのは非常に助かります。
リリースしたばかりでまだまだ発展途上とのことですが、十分選択肢の一つとして考えることができるフレームワークかと思いますので、 Fleur をオススメさせていただきます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む