20200323のReactに関する記事は13件です。

ReactにおけるDIについて

はじめに

以前、ReactでDIはどうやるのか社内で話題になったので、色々調べてまとめておきます

Inversify

inversifyではDI用のコンテナーを作成できます

公式

README通りですが、紹介します

packageをインストールします

npm install inversify reflect-metadata --save

デコレータや追加の型のために以下の項目をtsconfig.jsonに適宜追加します

tsconfig.json

{
    "compilerOptions": {
        "target": "es5",
        "lib": ["es6"],
        "types": ["reflect-metadata"],
        "module": "commonjs",
        "moduleResolution": "node",
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true
    }
}

インターフェイスを定義します

interfaces.ts

export interface Warrior {
    fight(): string;
    sneak(): string;
}

export interface Weapon {
    hit(): string;
}

export interface ThrowableWeapon {
    throw(): string;
}

これを元に本番の実装やモックの実装をします

次に識別子を作ります

TSのInterfaceは実行環境に影響を及ぼせないので、この識別子とinterfaceを後に結びつけてDIに使用します

types.ts

const TYPES = {
    Warrior: Symbol.for("Warrior"),
    Weapon: Symbol.for("Weapon"),
    ThrowableWeapon: Symbol.for("ThrowableWeapon")
};

export { TYPES };

次に実装をします、インジェクトするものは@injectableデコレータを

インジェクトするときには@injectデコレータを使います

@injectデコレータに先ほど作成した識別子を渡します

そうすることでインジェクトされます

entities.ts

import { injectable, inject } from "inversify";
import "reflect-metadata";
import { Weapon, ThrowableWeapon, Warrior } from "./interfaces";
import { TYPES } from "./types";

@injectable()
class Katana implements Weapon {
    public hit() {
        return "cut!";
    }
}

@injectable()
class Shuriken implements ThrowableWeapon {
    public throw() {
        return "hit!";
    }
}

@injectable()
class Ninja implements Warrior {

    private _katana: Weapon;
    private _shuriken: ThrowableWeapon;

    public constructor(
        @inject(TYPES.Weapon) katana: Weapon,
        @inject(TYPES.ThrowableWeapon) shuriken: ThrowableWeapon
    ) {
        this._katana = katana;
        this._shuriken = shuriken;
    }

    public fight() { return this._katana.hit(); }
    public sneak() { return this._shuriken.throw(); }

}

export { Ninja, Katana, Shuriken };

最後にコンテナーを作ります

inversify.config.ts

import { Container } from "inversify";
import { TYPES } from "./types";
import { Warrior, Weapon, ThrowableWeapon } from "./interfaces";
import { Ninja, Katana, Shuriken } from "./entities";

const myContainer = new Container();
myContainer.bind<Warrior>(TYPES.Warrior).to(Ninja);  // ←先ほど作った識別子を使って実装とインターフェイスが結びつけられる
myContainer.bind<Weapon>(TYPES.Weapon).to(Katana);
myContainer.bind<ThrowableWeapon>(TYPES.ThrowableWeapon).to(Shuriken);

export { myContainer };

以下のように利用します

index.ts

import { myContainer } from "./inversify.config";
import { TYPES } from "./types";
import { Warrior } from "./interfaces";

const ninja = myContainer.get<Warrior>(TYPES.Warrior);

expect(ninja.fight()).toBe("cut!");

これを適当にContextAPIか普通にimportでcomponentで使えるようにすればDIの完成です

テストするときはモックをbindをしなおせばモックがインジェクトされます

ContextAPi

ContextAPIはシンプルです

import React, { createContext, useContext, useMemo } from 'react';
import { AuthApi } from '../../../lib/api/AuthApi';
import { SampleApi } from '../../../lib/api/SampleApi';
import _ from 'lodash';
import { DeepPartial } from 'redux';


// インジェクトしたいもの集まりの構造を定義
export type Dependencies = {
  api: {
    auth: AuthApi;
    sample: SampleApi;
  };
};


// 構造に合わせてデフォルトの値を作る
export const defaultDependencies: Dependencies = {
  api: {
    auth: new AuthApi(),
    sample: new SampleApi(),
    productionNumber: new ProductionNumberApi()
  }
};


// Context作成
const DIContext = createContext(defaultDependencies);


// Providerを作成、propsとdefaultをマージして注入
export const DIProvider: React.FC<{
  dependencies?: DeepPartial<Dependencies>;
}> = ({ children, dependencies = {} }) => {
  const newValue = useMemo(
    () => _.merge({}, defaultDependencies, dependencies),
    [dependencies]
  );
  return <DIContext.Provider value={newValue}>{children}</DIContext.Provider>;
};

type MapDepToProps = (dep: Dependencies) => any;


// 使いやすいようにcustom hookを作成
export const useDICotext = <T extends MapDepToProps>(selector: T) => {
  const context = useContext(DIContext);
  return useMemo<ReturnType<T>>(() => {
    return selector(context);
  }, [selector, context]);
};


これだけ作って

使いたい側で以下のようにします

const authApi = useDIContext(dep => dep.api.auth)

完璧ですね

Misc

reduxのmiddleware系はContextAPIの外側でかつ、関数のみで構成されているためinversifyが使えず、上記のDIが使えません

なのでmiddlewareは引数で渡す形になります

redux-thunk

redux-thunk(以下thunk)でDIするときはextra argumentsという機能を使います

thunkではmiddlewareにwithExtraArgumentという関数が生えています

これに渡した引数が、thunkの関数のdispatch,getState,につぐ第3の引数に渡されます↓

thunkMiddleware.withExtraArgument(dependencies)



const actionCreator = ()=>(dispatch, getState, dependencies)=>{
   dependencies.api().then(()=>{
     dispatch(action)
   }
}

redux-saga

redux-saga(以下saga)では特殊な機能はなく、愚直に引数に渡し続けます

task.run(saga, dependencies)



function* saga(dependencies){
  yield fork(saga2, dependencies)
}



function* saga2(dependencies){
  yield call(dependencies.api)
  yield take(saga3, dependencies)
}

Conclude

DIも問題なくできますね

Jestのモック機能は優秀なので必要ないかと思いますが、あったらあったで便利かもしれないですね

2020/12/4追記
storybookのstoryを書くときにめちゃくちゃ便利でした

Jest

おまけです

本来Reactのテストでモックするのはjestです

DIする必要はありません

なので基本これを使いましょう

jestでmockする方法はいくつかあります

  • moduleのモック
  • objectのメソッドのモック

基本mockするためのapiはここを参照して下さい

moduleのモック

これは2つ方法があります

  • __mocks__ディレクトリを使う
  • jest.mockを使う

__mocks__ディレクトリを使う

└── models
    ├── __mocks__
    │   └── user.js
    └── user.js

このような構成でモックのコードを置きます

そしてテストコードの中で以下のコードによりmockを使うことを明記すれば、user.js__mocks__/user.jsとしてimportされます

jest.mock("path/to/user")

モックファイルの中からrequireActualを使うと本物のモジュールが、genMockFromModuleで自動モックされたモジュールを持って来れるので、虚実組み合わせて使うこともできます

あらかじめテスト用のモジュールを用意したい時に、この__mocks___ディレクトリが利用されます

jest.mockを使う

jest.mock("path/to/user",()=>{
  return {
  foo: jest.fn()
}
})

これでモックされます

テストコード側でモックコードを指定したい時に利用します

objectのメソッドのモック

jest.fnをそのままobjectのメソッドに代入します

もしモックの実装をしたい場合、実装の関数を引数として渡すか、mockImplementation関数をチェーンして、その引数に実装の関数を渡すことができます

[https://jestjs.io/docs/ja/jest-object#jestspyonobject-methodname]

spyOnの説明文で言明されてるように、 オブジェクトのメソッドの置き換えは問題ない操作だと思われます

TS的にはNGかもしれませんが、JS的やJest的には問題ないので、ガンガン代入しちゃってください

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

電卓アプリをredux&typescriptで作る

初めに

プライベートで全然コード書かない私は、せっかく前に少し勉強したreduxを完全に忘れてしまったのだった。そして今度仕事でreact-reduxを使う可能性が出てきたので慌てて復習しようと思ったところ、初心者向け記事があったので参考にしてみた。
電卓アプリで学ぶReact/Redux入門(実装編)

上記は、Reactから始まってreduxとつないでというところまで丁寧に書いてあるのでためになったのですが、私はtypescriptで書きたい人間なので、記事を参考にtypescriptで書き直してみました。
1からのステップを学びたい方は上記の記事を参考にしてみてください。私と同じく、typescriptで書きたいけど型定義などで苦戦して動かないという方はこの記事を参考にしていただければと思います。

私はreduxのプロでも何でもないため、より良い書き方などありましたらコメントいただけると幸いです。
いいからコードよこせという方は下記
https://github.com/teshimafu/redux_calclator_sample

いいから動き見せろって人は下記
https://teshimafu.github.io/redux_calculator_sample/

仕様

・数字どうしの四則演算ができる。ただし、毎回計算結果を出すため、乗除優先ではない。
・数字、演算子、数字、演算子。。。と計算可能。
 例:1+2+3-4*5=10
・=を連続して押しても再計算しない。
・=後の数字入力では、数字はクリアされた上で数字入力される。(これくらい直すかな)
・小数入力不可。
・計算結果に小数は可。
・ボタンの配置がガタついているのは仕様。何と言われても仕様。

雑なつくりで特に更新するつもりもないので、バグってても直さないかも。あくまでもredux初心者が動くとこまで使ったということで。

なんでTypescript?

時々こう言われますが、別にjavascript否定するつもりは毛頭ないので、javascriptで書けばいいじゃんと思う人はブラウザバック推奨。ただ、typescriptは誰でも見やすいという利点がある(と思っている)ので、チームでの開発とか初心者がフロントの勉強を始めようと考えている場合にはお勧めします。javascriptのほうが記述量は少ないですし型定義なども不要ではありますが、変数に何が入るのかを第三者が見つけにくかったり、実行するまで問題に気づけないことも多いという欠点があります。上級者は経験で問題を回避できるため欠点が欠点になりません。だからjavascript好きな人も一定数いるのだと思います。(MSが嫌いとか言わない)

ひな型をつくろう

公式にtypescriptがサポートされているので、言われた通りにしましょう
https://create-react-app.dev/docs/adding-typescript/

npx create-react-app my-app --template typescript

npxはnpmの最新バージョンで実行するのと同じ意味。無ければnpmコマンドで入れてね。
数分後にmy-appというフォルダが作成されるので、その中のsrc以下の構造を下記のように作り直します。ディレクトリ構造は好みなどあると思いますので、あくまで一例としてご覧ください。cssやアイコンは使わないため省略。

├── App.tsx
├── index.tsx
├── react-app-env.d.ts
├── serviceWorker.ts
├── setupTests.ts
├── store.ts
├── components
│   ├── CommonBtn.tsx
│   └── Result.tsx
├── containers
│   └── CalculatorContainer.tsx
├── modules
│   └── CalculatorContainer.ts
└── services
│   └── CalculatorService.ts

参照元の記事から変更した構成は次の通りです。
・action,reducerをmodulesにまとめた。
・四則演算はcontainersから切り離したかったのでservicesに取り出した。
・componentsを汎用化。

詳しくは次の章で見ていきます。

components

主にView担当。ボタンや表示部の構造が入っています。

CommonBtn.tsx

CommonBtn.tsx
import React from "react";

interface ButtonArgument {
  character: string | number;
  onClick: () => void;
}

const CommonBtn = ({ character, onClick }: ButtonArgument) => <button onClick={onClick}>{character}</button>;

export default CommonBtn;

電卓のボタンを構成するcomponentです。
引数には、stringまたはnumber型のcharacterと、関数型のonClickを受け取ります。
characterはボタンに表示される文字や数字になります。
onClickはMouseEvent型の関数で、ボタンを押された時の挙動を受け取ります。
CommonBtnの内部では関数の情報は持たないため、呼び出し元で挙動を決定できます。こうすることでボタンの汎用性を持たせることができます。

Result.tsx

Result.tsx
import React from "react";

interface ResultArgument {
  title: string;
  result: number;
}

const Result = ({ title, result }: ResultArgument) => (
  <div>
    {title}: <span>{result}</span>
  </div>
);

export default Result;


計算結果を表示するためのcomponentです。
何を表示しているかを伝えるためのtitleと、値を表示するためのresultを受け取って表示するだけになります。

containers

今回は1個しかないけど複数形のフォルダ。以降も同様
2019年中ごろのhooks登場で、reduxとreactのつなぎが少し楽になりました。楽になったバージョンで書きます。

CalculatorContainer.tsx

CalculatorContainer.tsx
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import Result from "../components/Result";
import { GetAllActions } from "../modules/CalculatorContainer";
import { AllState } from "src/store";
import { OPT } from "src/services/CalculatorService";
import CommonBtn from "src/components/CommonBtn";

export const CalculatorContainer = () => {
  const dispatch = useDispatch();
  const calculator = useSelector((state: AllState) => state.calculator);
  const onNumClick = (number: number) => {
    dispatch(GetAllActions.onNumClick(number));
  };
  const onOperationClick = (opt: OPT) => {
    dispatch(GetAllActions.onOperationClick(opt));
  };
  const onEqualClick = () => {
    dispatch(GetAllActions.onEqualClick());
  };
  const onResetClick = () => {
    dispatch(GetAllActions.onResetClick());
  };
  return (
    <div>
      <div>
        <CommonBtn character={1} onClick={() => onNumClick(1)} />
        <CommonBtn character={2} onClick={() => onNumClick(2)} />
        <CommonBtn character={3} onClick={() => onNumClick(3)} />
        <CommonBtn character={"/"} onClick={() => onOperationClick("DIV")} />
      </div>
      <div>
        <CommonBtn character={4} onClick={() => onNumClick(4)} />
        <CommonBtn character={5} onClick={() => onNumClick(5)} />
        <CommonBtn character={6} onClick={() => onNumClick(6)} />
        <CommonBtn character={"*"} onClick={() => onOperationClick("MUL")} />
      </div>
      <div>
        <CommonBtn character={7} onClick={() => onNumClick(7)} />
        <CommonBtn character={8} onClick={() => onNumClick(8)} />
        <CommonBtn character={9} onClick={() => onNumClick(9)} />
        <CommonBtn character={"+"} onClick={() => onOperationClick("ADD")} />
      </div>
      <div>
        <CommonBtn character={"c"} onClick={onResetClick} />
        <CommonBtn character={0} onClick={() => onNumClick(0)} />
        <CommonBtn character={"="} onClick={onEqualClick} />
        <CommonBtn character={"-"} onClick={() => onOperationClick("SUB")} />
      </div>
      <Result title={"Temporary"} result={calculator.temporaryValue} />
      <Result title={"Result"} result={calculator.showingResult ? calculator.resultValue : calculator.inputValue} />
    </div>
  );
};

上から順に

  const dispatch = useDispatch();
  const calculator = useSelector((state: AllState) => state.calculator);

以前は、mapStateToPropsとかDispatchがどうこうというおまじないをcontainerの下のほうに書いて、connectするという作業を行っていました。それの代わりとなります。詳しい使い方は公式ドキュメントをみると良いかも。https://react-redux.js.org/api/hooks
AllStateは、Redux管理しているすべてのステータスを保持する構造体で、store.tsで定義します。

calculatorには、reduxから取ってきた計算に使うstatusが入っています。

  const onNumClick = (number: number) => {
    dispatch(GetAllActions.onNumClick(number));
  };
  const onOperationClick = (opt: OPT) => {
    dispatch(GetAllActions.onOperationClick(opt));
  };
  const onEqualClick = () => {
    dispatch(GetAllActions.onEqualClick());
  };
  const onResetClick = () => {
    dispatch(GetAllActions.onResetClick());
  };

UI上のボタンクリック時の挙動をReduxのActionとつないでいます。つないだ先での挙動はmodulesで説明します。ここでは、ViewとLogicを接続しているとだけ理解すればよいです。

  return (
    <div>
      <div>
        <CommonBtn character={1} onClick={() => onNumClick(1)} />
        <CommonBtn character={2} onClick={() => onNumClick(2)} />
        <CommonBtn character={3} onClick={() => onNumClick(3)} />
        <CommonBtn character={"/"} onClick={() => onOperationClick("DIV")} />
      </div>
      <div>
        <CommonBtn character={4} onClick={() => onNumClick(4)} />
        <CommonBtn character={5} onClick={() => onNumClick(5)} />
        <CommonBtn character={6} onClick={() => onNumClick(6)} />
        <CommonBtn character={"*"} onClick={() => onOperationClick("MUL")} />
      </div>
      <div>
        <CommonBtn character={7} onClick={() => onNumClick(7)} />
        <CommonBtn character={8} onClick={() => onNumClick(8)} />
        <CommonBtn character={9} onClick={() => onNumClick(9)} />
        <CommonBtn character={"+"} onClick={() => onOperationClick("ADD")} />
      </div>
      <div>
        <CommonBtn character={"c"} onClick={onResetClick} />
        <CommonBtn character={0} onClick={() => onNumClick(0)} />
        <CommonBtn character={"="} onClick={onEqualClick} />
        <CommonBtn character={"-"} onClick={() => onOperationClick("SUB")} />
      </div>

これは、実際のUIを見たほうが早いと思うので、説明の前に見せます。
image.png

CommonBtn1個につき1つのボタンが割り当てられています。
character={0~9}が渡されているのは、数字ボタンです。同時に、onNumClickという関数が渡されています。onNumClickは数字ボタンが押された時にイベント発火する関数で先に述べたreduxのactionに押した数字を引数として渡しています。

character={"/"} character={"*"} character={"+"} character={"-"}を渡しているのは、四則演算の記号です。onOperationClickでつないだreduxのactionに紐づいています。引数の文字列については、modulesで登場します。

character={"c"} character={"="}はそれぞれつないでいるActionが異なりますが、記述方法はほぼ同じです

      <Result title={"Temporary"} result={calculator.temporaryValue} />
      <Result title={"Result"} result={calculator.showingResult ? calculator.resultValue : calculator.inputValue} />

ここは、ResultComponentにtitleと値を渡しています。temporaryとResultを表示します。
calculatorは、本節の序盤で話したように、reduxから取ってきたstateが入っているので、temporaryValueや、inputValueというstatusにアクセスできます。reduxなので、このContainerで管理するステータスは存在せず、あくまでもstateはreduxで管理して、使いたいときに取得するという書き方になります。

modules

CalculatorContainer.ts

CalculatorContainer.ts
import { OPT, Calculator } from "src/services/CalculatorService";

const INPUT_NUMBER = "INPUT_NUMBER";
const OPERATION = "OPERATION";
const EQUAL = "EQUAL";
const RESET = "RESET";

const onNumClick = (number: number) => ({
  type: INPUT_NUMBER,
  number
});

const onOperationClick = (opt: OPT) => ({
  type: OPERATION,
  opt
});

const onEqualClick = () => ({
  type: EQUAL
});

const onResetClick = () => ({
  type: RESET
});

type ClickActions =
  | ReturnType<typeof onNumClick>
  | ReturnType<typeof onOperationClick>
  | ReturnType<typeof onEqualClick>
  | ReturnType<typeof onResetClick>;

interface CalcState {
  inputValue: number;
  resultValue: number;
  temporaryValue: number;
  lastOperation?: OPT;
  showingResult: boolean;
}

export const GetAllActions = {
  onNumClick: onNumClick,
  onOperationClick: onOperationClick,
  onEqualClick: onEqualClick,
  onResetClick: onResetClick
};

const initialAppState: CalcState = {
  inputValue: 0,
  temporaryValue: 0,
  resultValue: 0,
  showingResult: false
};

const calculator = (state = initialAppState, action: ClickActions) => {
  switch (action.type) {
    case INPUT_NUMBER:
      const numAction = action as ReturnType<typeof onNumClick>;
      return {
        ...state,
        inputValue: state.inputValue * 10 + numAction.number,
        showingResult: false
      };
    case OPERATION:
      const opAction = action as ReturnType<typeof onOperationClick>;
      const temp = state.lastOperation
        ? Calculator(state.lastOperation, state.temporaryValue, state.inputValue)
        : state.showingResult
        ? state.resultValue
        : state.inputValue;
      return {
        ...state,
        inputValue: 0,
        temporaryValue: temp,
        resultValue: 0,
        lastOperation: opAction.opt
      };
    case EQUAL:
      if (state.showingResult) {
        return state;
      }
      const result = state.lastOperation
        ? Calculator(state.lastOperation, state.temporaryValue, state.inputValue)
        : state.inputValue;
      return {
        ...state,
        inputValue: 0,
        temporaryValue: 0,
        resultValue: result,
        lastOperation: undefined,
        showingResult: true
      };
    case RESET:
      return initialAppState;
    default:
      return state;
  }
};

export default calculator;

また上から順に説明します

Action

const INPUT_NUMBER = "INPUT_NUMBER";
const OPERATION = "OPERATION";
const EQUAL = "EQUAL";
const RESET = "RESET";

const onNumClick = (number: number) => ({
  type: INPUT_NUMBER,
  number
});

const onOperationClick = (opt: OPT) => ({
  type: OPERATION,
  opt
});

const onEqualClick = () => ({
  type: EQUAL
});

const onResetClick = () => ({
  type: RESET
});

action定義です。最上部で、actionの名称を定義しています。そのあとに、各関数のtypeと引数の定義を行っています。
例えば、onNumClickINPUT_NUMBERという名称のtypeのactionで、numberという引数を受け取ります。

type ClickActions =
  | ReturnType<typeof onNumClick>
  | ReturnType<typeof onOperationClick>
  | ReturnType<typeof onEqualClick>
  | ReturnType<typeof onResetClick>;

interface CalcState {
  inputValue: number;
  resultValue: number;
  temporaryValue: number;
  lastOperation?: OPT;
  showingResult: boolean;
}

export const GetAllActions = {
  onNumClick: onNumClick,
  onOperationClick: onOperationClick,
  onEqualClick: onEqualClick,
  onResetClick: onResetClick
};

ClickActionsは、定義したActionの戻り値の型定義。
CalcStateは、reduxで管理したい型定義。
GetAllActionsは、上部で定義した関数を取りまとめたもの。(名前微妙かも)

Reducer

const initialAppState: CalcState = {
  inputValue: 0,
  temporaryValue: 0,
  resultValue: 0,
  showingResult: false
};

stateの初期値です。

const calculator = (state = initialAppState, action: ClickActions) => {

stateには、reduxで現在保持しているstateが入る。初期値はinitialAppState。第二引数がClickActionsで定義されたactionでtypeによって後述されるactionが異なる。

  switch (action.type) {
    case INPUT_NUMBER:
      const numAction = action as ReturnType<typeof onNumClick>;
      return {
        ...state,
        inputValue: state.inputValue * 10 + numAction.number,
        showingResult: false
      };
    case OPERATION:
      const opAction = action as ReturnType<typeof onOperationClick>;
      const temp = state.lastOperation
        ? Calculator(state.lastOperation, state.temporaryValue, state.inputValue)
        : state.showingResult
        ? state.resultValue
        : state.inputValue;
      return {
        ...state,
        inputValue: 0,
        temporaryValue: temp,
        resultValue: 0,
        lastOperation: opAction.opt
      };
    case EQUAL:
      if (state.showingResult) {
        return state;
      }
      const result = state.lastOperation
        ? Calculator(state.lastOperation, state.temporaryValue, state.inputValue)
        : state.inputValue;
      return {
        ...state,
        inputValue: 0,
        temporaryValue: 0,
        resultValue: result,
        lastOperation: undefined,
        showingResult: true
      };
    case RESET:
      return initialAppState;
    default:
      return state;
  }
};

switch~caseでactionごとの挙動を定義。
case INPUT_NUMBERではinputValueに値を入れていきます。10*で加算することで、数字入力を表現しています。
const numAction = action as ReturnType;と書いているのは、onNumClickのactionだけが持つnumberという値を使いたいからです。ここはもう少しきれいに書けそう。ReturnType使っているので型推論だけでうまくいかなくなっている。(typescriptの話掘り下げるときりがないので気になる方はtype guardとかで調べてみてください)

case OPERATIONでは、Optを入れてstateを更新します。Optには四則演算のどれかが入るOPTという型が定義されています。Containerで登場したDIVや、ADDといった引数はここで利用されstateに保存されます。
temporaryValueに入れる値は、Calculatorという関数の計算結果または、現在入力された数字が入ります。ReturnTypeの下りはnumActionと同じ理由。
なんで条件が多少ごちゃごちゃしているかというと、
1+2=3とした後に結果を計算したいとき対応や、1+2+3-4*...のように計算を連ねるときの対応のために三項演算子を使用している。本筋ではないため深堀はしない。

case EQUALでは、計算結果をresultValueに入れます。計算自体はCalculatorで行います。今回は面倒くさかったので、=を連打されても再計算はしません。演算子直後のイコールも対応しません。イコール後に数字入れると結果がリセットされます。バグではなく仕様です(言い訳)。

case RESETは初期化です。initialStateをstateに設定します。

services

この分け方は私のやり方であり、ほかの書き方もたくさんあるのであくまでも一例です。
servicesには、react,reduxに依存しないロジックを書きます。急にreactやめてangularにするで~とか言われても困るからね。

CalculatorService.ts

CalculatorService.ts
export type OPT = "DIV" | "MUL" | "ADD" | "SUB";

export const Calculator = (opt: OPT, firstNumber: number, secondNumber: number) => {
  switch (opt) {
    case "DIV":
      return firstNumber / secondNumber;
    case "MUL":
      return firstNumber * secondNumber;
    case "ADD":
      return firstNumber + secondNumber;
    case "SUB":
      return firstNumber - secondNumber;
    default:
      return 0;
  }
};

計算はここにまとめています。今までもちらほら登場していたOPTはここで定義しています。規模が大きいなら、modelsとかでtypeの定義は別ファイルにするのが良いでしょう。今回は面倒くさくて規模が小さいため同じファイルです。OPTは四則演算4つを表すstring文字だけ入るtypeです。
Calculatorは、演算子と2つの数字を受け取って演算子ごとの計算結果を返します。

その他のファイル

store.ts

redux必須のやつ。

store.ts
import { combineReducers, createStore } from "redux";
import calculator from "./modules/CalculatorContainer";

// 全てのReducerが集約される。Reducerが増えたらここに追加する
export const rootReducer = combineReducers({
  calculator
});

// 全てのStatusが集約される。書き換え不要
export type AllState = ReturnType<typeof rootReducer>;

// Reactとreduxをつなぐためのstoreを作成。書き換え不要
const store = createStore(rootReducer);

export default store;

コメントに書いた通りです。
AllStateはredux管理の全てのstateのタイプを持ち、Containerごとに必要なStateを呼び出します(ここ言い切ってるけど合ってるよね?)

App.tsx

Container名だけ今回のものにしただけ

App.tsx
import React from "react";
import "./App.css";
import { CalculatorContainer } from "./containers/CalculatorContainer";

function App() {
  return (
    <div className="App">
      <CalculatorContainer />
    </div>
  );
}

export default App;

index.tsx

storeの紐づけをした。ググればわかると思う。

index.tsx
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import * as serviceWorker from "./serviceWorker";
import { Provider } from "react-redux";
import store from "./store";
import App from "./App";

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("root")
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

今回触れなかったファイルはデフォルトのままなので説明省略。

まとめ

電卓アプリで学ぶReact/Redux入門(実装編)の説明+本記事で、react-reduxかつtypescriptで初心者でも電卓が作れる!!!ことを願う。

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

state更新に伴うライフサイクルメソッド(更新)についてかなりザックリ解説する

stateの更新に伴うライフサイクルメソッドは4種類

stateが更新されるとReactではcomponentが再度読み込まれます。そのタイミングで以下の4つのメソッドが用意されます。

・componentWillReceiveProps
・shouldComponentMount
・componentWillUpdate
・componentDidUpdate

それぞれザックリ解説します。

componentWillReceivePropsメソッド

このメソッドは親コンポーネントの情報が更新され、渡されてくるpropsの情報も更新されている時に呼び出されます。使い方は、propsとstateがイコールの関係にあるとき、更新されたpropsをsetStateで、stateを同じ値に更新します。

shouldComponentMountメソッド

このメソッドはcomponentが更新される直前で、componentWillUpdateの直前に呼び出されます。使い道は、componentを更新するかしないかを判断する際に使います。更新前のpropsとstateの値と、更新後のそれらの値を比較することで、componentの更新の有無を決定します。返り値はtrue or falseで表現され、trueの場合componentが更新されることになります。

componentWillUpdateメソッド

componentが更新される直前で、shouldComponentUpdateがtrueだった場合のみ呼び出されます。使い道はcomponentWillMountと同じくcomponentが更新されるたびに実行したい処理を記述します。

componentDidUpdateメソッド

componentが更新された直後に呼び出されます。componentDidMountと同じく、更新されるたび行われて欲しい処理を記述します。

まとめ

長々と書きましたが、とりあえずcomponentmが更新されたら4種類の便利なメソッドが使えるようになるよくらいの認識で良さそうです。

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

リモート卒展を支える技術2020

Webサイトをリリースしました

https://racoon.vlue.blue

 2020-03-21 12.22.37.png

今年はコロナの影響で卒業式が中止になったりしましたね。
それと比べるとあまり知られてないかもしれませんが、
美大・アート系の学校の卒業展示、
いわゆる 卒展 も数多くが中止を余儀なくされました。

部活でいうと引退試合が中止になったようなものです。
悲しいですね。

そこで、卒展で展示する予定だった作品をTwitteにアップするムーブメントが起きました。
#桑沢2020 #かってに卒制展 などのハッシュタグで様々な作品が見れます。

これらの作品をアーカイブしたのが今回のWebサイトになります。

使用技術

  • React
  • GatsbyJS
  • GraphQL
  • Twitter API
  • S3
  • CloudFront
  • Lambda@Edge

特に要となっているのは GatsbyJS です。

GatsbyJS とは

詳細は色々と記事が見つかるのでそちらに任せますが、
(例:Reactの最強フレームワークGatsby.jsの良さを伝えたい!!

GatsbyJSとは、静的サイトジェネレータであり、
Reactアプリケーションを動かすフレームワークです。
npmでインストールして、create-react-app的なノリで使用できます。

ビルドしてHTMLを生成する部分はGo製の Hugo みたいのをイメージしてもらえればいいんですが、Gatsby はただの静的ジェネレータにはとどまりません。

完全なReactアプリとして動作するので、
外部のデータソースと接続すれば、動的なアプリとして振る舞います。

この辺りの仕組みを理解するには以下の記事が最高にわかりやすいです。

Reactベース静的サイトジェネレータGatsbyの真の力をお見せします
 2020-03-21 12.47.19.png

GatsbyはGraphQLを介してデータにアクセスするのですが、
ビルド時には、ローカルのGraphQLからデータを抽出して静的ファイルを出力します。
それに加えて、ブラウザでの実行時に外部のデータソースと接続してアプリにマージすることもできるのです。

例えばブログアプリを作るときに、記事はマークダウンとしてソースに含めてビルドし、
スター数やコメントはDynamoDBに保存してAppSync経由でリアルタイムに取得・表示する、
といった設計とかが可能。

データソースをどうするかで迷う

ブログやコーポレーションサイト、ポートフォリオなどの事例が多く、
それらではファイルシステムがデータソースとなっていました。
任意のディレクトリにマークダウンとしてデータを記述しておく形です。

今回使うデータは、元ツイートと、ツイートに含まれる画像の2つです。
Twitter APIの制限もあるし、コンテンツもちゃんとフィルタリングしたかったので、
静的に固めることは早々に決めたのですが、マークダウンはちょっと抵抗があります。

最初はローカルでMySQLを立てて実装しました。
データベースの接続もプラグインによって幅広くサポートされています。

 2020-03-21 12.59.44.png

ただSQLは情報が少なく、ジョインが絡む複雑なクエリを書くのが難しかったり、
サロゲートキーがGraphQLが生成するNodeのIDとバッティングしたり、
なんやかんやで面倒だったので途中からJSONに変更しました。
静的なのにソースとデータが分離されてるのもなんか気持ち悪いし。。。

ファイルシステムのプラグインを使えばJSONもこんな感じで簡単にクエリできます。

tweets.json
[
    {
        "tweet_id": "1232842391867912192",
        "created_at": "2020-02-27T01:38:25.000Z",
        "text": "#桑沢2020 \n\n私は紙で椅子をつくりました。\n\n桑沢の卒展は、自分にとってすごくすごく特別な想いがありました。\n\n小学生から憧れ続けた桑沢卒展、中止。 https://t.co/2Q1oZ0RvAm",
        "hashtags": "桑沢2020",
        "user_id": "1093916680504336384",
        "user_screen_name": "fuji_kohei",
        "embed_html": "<blockquote class=\"twitter-tweet\"><p lang=\"ja\" dir=\"ltr\"><a href=\"https://twitter.com/hashtag/%E6%A1%91%E6%B2%A22020?src=hash&amp;ref_src=twsrc%5Etfw\">#桑沢2020</a> <br><br>私は紙で椅子をつくりました。<br><br>桑沢の卒展は、自分にとってすごくすごく特別な想いがありました。<br><br>小学生から憧れ続けた桑沢卒展、中止。 <a href=\"https://t.co/2Q1oZ0RvAm\">pic.twitter.com/2Q1oZ0RvAm</a></p>&mdash; kohei_fuji (@fuji_kohei) <a href=\"https://twitter.com/fuji_kohei/status/1232842391867912192?ref_src=twsrc%5Etfw\">February 27, 2020</a></blockquote>\n",
        "images": [
            "https://pbs.twimg.com/media/ERvvnNqUYAAKtTL.jpg",
            "https://pbs.twimg.com/media/ERvvnVmVUAA71I6.jpg",
            "https://pbs.twimg.com/media/ERvvnYPU4AA5u5w.jpg",
            "https://pbs.twimg.com/media/ERvvnsOVUAA5_ht.jpg"
        ]
    },
    {...
export const query = graphql`
  query {
    allTweetsJson {
      edges {
        node {
          id
          tweet_id
          text
          user_screen_name
          images
        }
      }
    }
  }
`

ツイートの取得

Twitter APIを使ってツイートを取得してMySQLにぶっ込むNode.jsのスクリプトが手元にあったのでそれを流用しました(最初MySQLを使ってたのはこういう事情もある)。

流れとしてはこんな感じ。

  1. それっぽいハッシュタグで検索をかける。
  2. 取得したツイートでサイトに展示するものを目視で選定する。
  3. 選定したツイートの埋め込みコードをAPIで取得・保存する。

検索の search/tweets ではパラメータに include_entities: true を指定して、画像のURLが取れるようにする(ものによっては画像があるのになぜか取れない謎)。

キーワードはハッシュタグを指定するのだが、以下のようにしてリツイートを省かないとノイズが大きい。
{q: "#岡山県立大学卒展2020 exclude:retweets}
レートリミットがあるので、効率的に取得するためにcountは最大の100件を指定。
最新のツイートが取得されるので、さかのぼって取得するループを回すためには、
都度 max_id を指定してカーソルを移動させる必要がある。

埋込み用のコードは statuses/oembed で取得できる。
この埋め込みコードには、ツイート展開用の

<script async src=\"https://platform.twitter.com/widgets.js\" charset=\"utf-8\"></script>

が含まれるが、各ツイートに含める必要はないので、これは削除しておく。

必要なフィールドをいい感じにJSONに保存してデータの準備は完了。

無限スクロール

ページングはユーザー側の操作が手間なので避けたくて、
TwitterやPinterestのような無限スクロールを実装した。

racoon.gif

IntiniteScrolldataLength が21ずつ増えているのがわかると思う。

Twitterのウィジェットが重いので、カード展開前に素のテキストが一瞬見えてしまうことがあるが、もう少しみ読み込み位置を早くすれば対処は可能。
いまはコンテナの高さの85%まで来ると読み込みが走るようになっている。

ライブラリには react-infinite-scroll-component を使用した。
これをラップしたコンポーネントを作り、
親コンポーネントの useState, useEffectで更新を制御している。

let allTweets = null

export default ({data}) => {  
  const [tweets, setTweets] = useState([])
  const [hasMore, setHasMore] = useState(true)
  const [page, setPage] = useState(0)
  const PER = 21 // dividable by 3

  useEffect(() => {
    allTweets = data.allTweetsJson.edges.sort(() => { return Math.random() - .5 })
    fetchTweets() // initialize
  }, [])

  const fetchTweets = () => {
    const pageResult = allTweets.slice(page * PER, page * PER + PER)
    setTweets(tweets.concat(pageResult))
    setPage(page + 1)

    if (page * PER >= allTweets.length) {
      setHasMore(false)
    }

    if (window.twttr) {
      window.twttr.widgets.load(document.getElementById(styles.tweet_container))
    }
  }

まず useEffect 内で全てのツイートをJSONから読み込んでランダムにソートしている。
なるべくいろんな作品を見てもらえるように閲覧のたびにファーストビューの作品は入れ替えたかったので、ランダムに取得する必要があった。

静的に作った場合だとこれは不可能。
ビルド時に順番は固定されてしまい、再ビルドされるまでは変わらないことになってしまう。
これは無限スクロールの話とは別で、静的なページにはデータを持たせずに、
非同期に外部からデータを取得してくる設計にする必要がある。

そうなると別にGatsbyじゃなくてよくね...という話になるのだが、
他のページは静的にビルドしているし、動的にしたいのはここだけ、
そこはReactでいろいろ書けばいいよね!というのがまさにこのフレームワークの強みなんだと思う。

無限スクロールの話に戻るが、propsには、新しいページの結果だけでなく、
これまでの結果をマージして渡す必要があるので結合している。

const pageResult = allTweets.slice(page * PER, page * PER + PER)
setTweets(tweets.concat(pageResult))

そして新たに追加されたツイートをカードとして展開するには、再度ウィジェットの読み込みを走らせる必要がある(Twitterの公式ドキュメント)。

if (window.twttr) 
  window.twttr.widgets.load(document.getElementById(styles.tweet_container))
}

そもそもカードの展開には最初 gatsby-plugin-twitter というプラグインを使っているのだが、
こいつの仕組みがよくわからなかったのでやめた。
widgets.js が読み込まれた形跡がなく、
twttr オブジェクトもスコープに存在しないため、load() を呼び出すことができなかったからだ。

代わりに自前で <script>タグを追加する方法を取っている。

import Helmet from "react-helmet"

<Helmet>
  <script src="https://platform.twitter.com/widgets.js" type="text/javascript" async></script>
</Helmet>

Pinterest風レイアウト

ツイートには最大4枚の画像が含まれるので、それらを取り出してランダムに並べる画面を別で作っています。
無限スクロールで仕組みは前述のものと同じです。
localhost_8000_.png

GatsbyJSで画像を使う際は、gatsby-image コンポーネント使うことが推奨されています。
Working with Images in Gatsby

これは何かというと、閲覧しているデバイスに応じて複数のサイズ・解像度に画像を最適化してくれるライブラリで、GraphQLでクエリできるようになっています。
他にも以下のような特徴があります。

gatsby-image is a plugin that automatically creates React components for optimized images that:

・Loads the optimal size of image for each device size and screen resolution
・Holds the image position while loading so your page doesn’t jump around as images load
・Uses the “blur-up” effect i.e. it loads a tiny version of the image to show while the full image is loading
・Alternatively provides a “traced placeholder” SVG of the image
・Lazy loads images, which reduces bandwidth and speeds the initial load time
Uses WebP images, if browser supports the format

使い方には fluidfixed があって、
コンテナのサイズに応じて伸び縮みする fluid と、固定サイズの fixed を使い分けます。
レスポンシブ対応で、スマホサイズでは fixed にするみたいな使い分けをしています。

GraphQLはこういう風に書く

query($id: String!) {
  tweetsJson(tweet_id: { eq: $id }) {
    id
    text
    tweet_id
    embed_html
    featuredImg {
      childImageSharp {
        fluid(maxWidth: 400, quality: 100) {
          ...GatsbyImageSharpFluid
        }
        fixed(width: 200, height: 200) {
          ...GatsbyImageSharpFixed
        }
      }
    }
  }
}

と、ここまで説明したところでなんなんですが、
アニメーションやスタイルの関係上、この画面ではあえて使っていません。
flexboxと一緒に使うと表示されなかったり、なかなか使い勝手が難しいライブラリです?

(※ホバー時に元ツイートの情報を表示するようにしてたりする)
racoon.gif

スタイルの問題は他にもあって、

Pinterest風にマルチカラムにするのにCSSのcolumnsプロパティを使っているんですが、
画像の高さがそれぞれ違うので次ページ読み込み時に並び替えが起こってしまいます。
columnsはコンテンツを左から埋めていくので、
画像が追加された時に前ページの画像が全て左に寄せられてしまい、
見ていたコンテンツが消える事象が発生することなります。

これではユーザーが?となるので、これまで見ていたページの並びは固定するために、
ページごとにcolumnsを指定するコンテナでラップすることにしました。

localhost_8000___3_.png

画像の組み合わせによっては不要な余白が生じてしまうのですが。
別の仕組みでグリッドレイアウトを作れば良い話なんですが、
時間があるときでもやろうと思います。

外部画像を最適化するトリック

ソースファイルに含まれているデータは tweets.json 1ファイルのみです。

tweets.json
[
    {
        "tweet_id": "1232842391867912192",
        "created_at": "2020-02-27T01:38:25.000Z",
        "text": "#桑沢2020 \n\n私は紙で椅子をつくりました。\n\n桑沢の卒展は、自分にとってすごくすごく特別な想いがありました。\n\n小学生から憧れ続けた桑沢卒展、中止。 https://t.co/2Q1oZ0RvAm",
        "hashtags": "桑沢2020",
        "user_id": "1093916680504336384",
        "user_screen_name": "fuji_kohei",
        "embed_html": "<blockquote class=\"twitter-tweet\"><p lang=\"ja\" dir=\"ltr\"><a href=\"https://twitter.com/hashtag/%E6%A1%91%E6%B2%A22020?src=hash&amp;ref_src=twsrc%5Etfw\">#桑沢2020</a> <br><br>私は紙で椅子をつくりました。<br><br>桑沢の卒展は、自分にとってすごくすごく特別な想いがありました。<br><br>小学生から憧れ続けた桑沢卒展、中止。 <a href=\"https://t.co/2Q1oZ0RvAm\">pic.twitter.com/2Q1oZ0RvAm</a></p>&mdash; kohei_fuji (@fuji_kohei) <a href=\"https://twitter.com/fuji_kohei/status/1232842391867912192?ref_src=twsrc%5Etfw\">February 27, 2020</a></blockquote>\n",
        "images": [
            "https://pbs.twimg.com/media/ERvvnNqUYAAKtTL.jpg",
            "https://pbs.twimg.com/media/ERvvnVmVUAA71I6.jpg",
            "https://pbs.twimg.com/media/ERvvnYPU4AA5u5w.jpg",
            "https://pbs.twimg.com/media/ERvvnsOVUAA5_ht.jpg"
        ]
    },

画像のURLとしてTwitterのURLが格納されていますが、
アプリケーションから参照しているのはこのURLではありません。

これは作品の詳細ページですが、画像のsrc
/static/f6862a78b0b14267e3e9188b58d053a6/25252/ESUa1ooUYAAOn4I.jpg
となっています。
 2020-03-22 19.28.57.png

GatsbyJSの仕様では、プロジェクトルートに static という名前のフォルダを置いて、
画像などのアセットを含めるのが通例です。
そうすると、ビルド時に成果物の public の下に static が残ります。

しかし今回は static の下に画像を置くのではなく、JSONにリモートのURLを記述しているだけです。
これをビルドの成果物に含める、かつ、先述の gatsby-image の最適化を施す必要があります。

gatsby-imageに処理をさせるためには、なんらかの形でこのURLをGraphQLのNodeに変換する必要があるのですが、これを createRemoteFileNode という Gatsby Node APIs を利用して実現します。
そしてGraphQLのresolverとしてこのNodeを登録します。
(うまく説明できなくてすまぬ...)
GraphQLのリゾルバとは

コードはこんな感じ。

gatsby-node.js
exports.createResolvers = ({
  actions,
  cache,
  createNodeId,
  createResolvers,
  store,
  reporter,
}) => {
  const { createNode } = actions
  createResolvers({
    TweetsJson: {
      featuredImg: {
        type: [`File`],
        resolve(source, args, context, info) {
          return source.images.map((image) => {
            return createRemoteFileNode({
              url: image,
              store,
              cache,
              createNode,
              createNodeId,
              reporter,
            })
          })
        },
      },
    },
  })
}

まずこのファイルだが、プロジェクトルートにある gatsby-node.js いうファイルで、
ビルド時に実行されるもの。
ここにコードを書いておくと、GraphQLのNode生成などのイベントを拾ってなんらかの処理を行うことができる。

そこで createResolvers を呼んで、TweetsJson(tweets.jsonから生成されたスキーマ)に featuredImg というフィールドを生やしている。
中身は File の配列で(画像が複数枚の場合があるため)、
createRemoteFileNode の戻り値を追加している。

この前処理があってこそ、前述のこのクエリが実行できるという仕組み。

query($id: String!) {
  tweetsJson(tweet_id: { eq: $id }) {
    id
    text
    tweet_id
    embed_html
    featuredImg {
      childImageSharp {
        fluid(maxWidth: 400, quality: 100) {
          ...GatsbyImageSharpFluid
        }
        fixed(width: 200, height: 200) {
          ...GatsbyImageSharpFixed
        }
      }
    }
  }
}

Nodeにあるフィールドを追加したければ、
onCreateNodecreateNodeFieldを呼ぶのがシンプルで簡単な方法です。
実際 チュートリアル でもそうやっています。

わざわざ createResolvers なんてややこしいのを使っているのはちょっとしたハックで、
これが、全てのスキーマの処理が完了してから最後に呼ばれるAPIだからです。

TweetsJson というスキーマの元はJSONファイルであり、
このファイルをまずGraphQLのスキーマに変換する処理が発生します。
必ずこの作業が終わった後に処理をする必要があるため、createResolvers にしてあるというわけです。

これで無事に gatsby-image が処理できる形にリモートの画像URLが変換されたので、
ビルド時に様々なデバイスサイズ、解像度向けに static 配下に画像が生成されるようになります。

作品ページの生成

作品ごとのページを作ります。

racoon.gif
(サムネイルをクリックすると画像が変わる仕様。)

チュートリアルでは、マークダウンから記事ページを作成しています。
Programmatically create pages from data
ユースケースとしてはこういう使い方が多いと思いますが、今回のデータソースはJSONです。

GatsbyJSでは、あらかじめ src/pages に置いてあるファイルはビルド時に自動的にページとして出力されています。

それ以外に任意にページを作成したい場合は、先述の gatsby-node.js から createPage APIを呼び出すことになります。

gatsby-node.js
exports.createPages = async ({ graphql, actions }) => {
  const { createPage } = actions
  const result = await graphql(`
    query {
      allTweetsJson {
        edges {
          node {
            id
            tweet_id
            images
          }    
        }
      }
    }
  `)
  result.data.allTweetsJson.edges.forEach(({ node }, index) => {
    createPage({
      path: `/work/${node.tweet_id}/`,
      component: path.resolve(`./src/templates/work.js`),
      context: {
        id: node.tweet_id,
        next: index !== result.data.allTweetsJson.edges.length - 1 ? result.data.allTweetsJson.edges[index + 1].node : null,
        prev: index !== 0 ? result.data.allTweetsJson.edges[index - 1].node : null,
      },
    })
  })
}

component にテンプレートとなる Reactコンポーネントのファイルを渡しています。
context で渡した値は、テンプレート側で pageContext で受けることができます。
前後のページのリンクを作りたかったので、ここでは前後のNodeを受け渡すようにしています。

src/templates/work.js
export default ({data, pageContext}) => {

}

idは該当記事を取得するページクエリで $id で参照します。

js/src/templates/work.js
export const query = graphql`
  query($id: String!) {
    tweetsJson(tweet_id: { eq: $id }) {
      id
      text
      tweet_id
      embed_html
      hashtags
      images
      featuredImg {
        publicURL
        childImageSharp {
          fluid(maxWidth: 400, quality: 100) {
            ...GatsbyImageSharpFluid
          }
          fixed(width: 200, height: 200) {
            ...GatsbyImageSharpFixed
          }
        }
      }
    }
  }

URLは path で指定するのですが、Nodeの id は避けたほうがよいです。
コンテンツの追加/削除、再ビルドで変化する可能性があるので、
不変なユニークな値を指定しておくのが無難です。
チュートリアルにならってスラッグを指定するといいと思います。

OGP画像

デフォルトで SEO というコンポーネントがついてくる。
内部的には react-helmet を使っている。
作品のページでは、その作品の画像やテキストに切り替えたいので以下のようにして使う。

<SEO description={data.tweetsJson.text} image={data.tweetsJson.featuredImg[0].publicURL} />

createRemoteFileNode で Nodeにした外部画像ですが、
出力された static/***のパスを publicURL で取得することができます。

作品のOGPはこんな感じ。

通常のOGPと変わっていることがわかります。


(アライグマ(racoon)は Corona のアナグラムです)

ホスティング

このサイトは CloudFront で配信しています。
S3のWebホスティング機能は使用していません。

独自ドメイン(CNAME)とSSLも設定済み。

2020-03-23_0_33_52.png

Origin Access Identity を指定して、バケットの直接参照は禁止しています。
2020-03-23_0_33_46.png

以下バケットポリシー

{
    "Version": "2008-10-17",
    "Id": "PolicyForCloudFrontPrivateContent",
    "Statement": [
        {
            "Sid": "1",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ******"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::{bucket_name}/*"
        }
    ]
}

また、サブディレクトリのルートオブジェクトを設定するために、Lambda@edgeをかましてあります。
静的Webサイトホスティングでは、 /hoge/ へのアクセスを /hoge/index.html に補完してくれるのですが、
CloudFront単体ではルートでしかこれが効かないので、自前で対処する必要があります。
サブディレクトリ以下のアクセス /*/ に対して、
Lambda@Edge を関連づけた Behavior を最優先で設定します。

2020-03-23_0_33_40.png

lambda@edge
'use strict';

exports.handler = (event, context, callback) => {
    const request = event.Records[0].cf.request;
    request.uri = request.uri.replace(/\/$/i, '/index.html');
    callback(null, request);
};

Lambda@Edgeってなに?っていう方は拙著をお読みください。
Lambda@Edgeを完全に理解する?‍♀️

さらにもう一手間。
存在しないURLに対して正しく404を出すために、Custom Error Response の設定をします。

2020-03-23_0_33_33.png

404だけでなく403も設定しているのは、
S3は404エラーを403エラーで隠蔽することがあるからです。
ListBucket権限がないのが原因だったりします。

詳しくは。これも拙著です。
CloudFront×S3で403 Access Deniedが出るときに確認すべきこと

デプロイ

gatsby-plugin-s3 でやってます。
npm run deploy を流すだけです。

ドキュメントがないのですが、acl: null を指定しないと権限エラーになります。

{
  resolve: `gatsby-plugin-s3`,
  options: {
    bucketName: "******",
    acl: null
  },
},

まとめ

こんなところです。
GraphQL初めて触るし、React書くの2年ぶりとかなので割と時間がかかってしまいましたが、
GatsbyJSはかなり可能性のあるフレームワークだなーと感じました。

特有の癖はありつつも、覚えてしまえばまあ。
あまりWebに馴染みのない人が、楽にWebサイトを構築できる手段だと思って使うと痛い目を見るかもしれません。

この記事でも紹介した通り、基本静的で動かしたい、でも動的にしたいところもある、
というユースケースでは力を発揮するんじゃないですかね。

何はともあれみなさんアートを楽しんでください。

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

【WordPress】カスタムブロックの作り方を書いてみた

はじめに

WordPress 5.0からGutenbergと呼ばれるブロックエディターがデフォルトのエディターとして採用されました。
そのため、今後はこのブロックエディターによる開発が増えてくると思われます。

個人的には、今までのWysiWygエディターに不満を感じていたわけではありませんが、ブロックエディターという選択肢が増えたことで、できる事の幅が増えてくる と思います。
本記事では、このブロックエディターの新規ブロック(以降、カスタムブロック)の作り方を記載して行きます。

ブロックエディターとは?

ブロックエディターは、名前の通り、HTMLをブロックように積み上げてHTMLを作成していくエディターです。

詳細は、以下を参照ください。

https://ja.wordpress.org/gutenberg/

ブロックエディターの特徴の1つとして、上記リンク先のページにも記載がありますが、 実際のサイトと同様に表示されるエディター。 であることです。
これから記載するカスタムブロックも、実際のサイトと同様に表示されるよう作成して行きます。

また、WordPressのブロックエディターは、大きく分けて以下の2種類のカスタムブロックを作成できます。

  • 動的ブロック ・・・ 最新の投稿を表示するブロック等の動的にブロックの内容が変化するブロック
  • 静的ブロック ・・・ 動的とは反対で、画像アップロードやテキストフィールド等のブロックの内容が変化しないブロック

これから記載するカスタムブロックは静的ブロックを作成して行きます。(動的ブロックは別途記載予定)

作成するカスタムブロック

以下のようなテキストが書けて、背景色をサイドナビゲーションから選択できるカスタムブロックを作成して行きます。
サンプルブロックは、エディターと画面表示(以降、フロント)にCSSが適応されるブロックと、エディターのみCSSが適応されるブロックの2種類作成します。

sample-block-2.png

環境準備

以下がインストールされている事が前提です。

  • PHP: 7.1以上
  • Node: v10.x
  • Yarn: v1.9.4

WordPressのブロックエディターはReactで作成されています。
これから作成するカスタムブロックも、ReactのComponentを使って開発して行きます。
しかし、通常のReactを使用するのではなく、WordPress側で用意されているReactを使用します。そのため、Reactをインストールする必要はありません。

以降、ライブラリのインストールです。

[必須]package.jsonを用意する

以下のようなpackage.jsonを作成します。

{
  "name": "sample-block",
  "version": "1.0.0",
  "description": "Sample Block",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Kodak",
  "license": "GPL-2.0"
}

[任意]webpackを用意する

こちらは任意です。
JavaScriptをwebpackを通してビルドしたい方は、以下をインストールしてください。
今回は、後述する@wordpress/scriptsを使ってビルドをするので、インストールしません。

yarn add -D webpack webpack-cli

[必須]BABELを用意する

以下をインストールします。
カスタムブロックはJSXを使いたいので@babel/preset-reactもインストールします。

yarn add -D @babel/core babel-loader @babel/preset-env @babel/preset-react
  • @babel/core ・・・ BABEL本体
  • babel-loader ・・・ webpackからBABELを通すためのライブラリ
  • @babel/preset-env ・・・ 特定の環境(ブラウザ)に合わせた変換をしてくれるライブラリ
  • @babel/preset-react ・・・ JSXのコンパイル可能にするライブラリ

[任意]SASSに対応させるためのライブラリを用意する

こちらは任意です。
今回はSASSに対応させたいため、以下をインストールします。

yarn add -D node-sass css-loader sass-loader
  • node-sass ・・・ SASS(SCSS)をCSSに変換するライブラリ
  • sass-loader ・・・ webpackからSASS(SCSS)をCSSに変換するライブラリ
  • css-loader ・・・ CSSをJavaScriptにバンドルするライブラリ

[任意]CSSを拡張するためのライブラリを用意する

こちらは任意ですが、インストールしておいたほうが良いです。
ベンダープレフィックスやCSSを別ファイルとして出力してくれます。

yarn add -D postcss-loader autoprefixer mini-css-extract-plugin optimize-css-assets-webpack-plugin terser-webpack-plugin
  • postcss-loader ・・・ ベンダープレフィックスをCSSに追加するため&ネストしたCSSを解析してくれるライブラリ
  • autoprefixer ・・・ ベンダープレフィックスをCSSに追加(postcss-loaderのプラグイン)
  • mini-css-extract-plugin ・・・ CSSを別ファイルとして出力するためのライブラリ
  • optimize-css-assets-webpack-plugin ・・・ 作成するCSSを圧縮するためのライブラリ
  • terser-webpack-plugin ・・・ CSSを圧縮する際、さらにCSSを縮小してくれる(邪魔なコメントやscourceMapを削除してくれる)ライブラリ

[必須]ビルドフォルダーにゴミファイルを残さないためのライブラリを用意する

ビルドフォルダーにゴミファイルを残さないようにするため、これはインストールしておきましょう。

yarn add -D clean-webpack-plugin
  • clean-webpack-plugin ・・・ ビルドする度に、ビルドフォルダーを削除するライブラリ

[必須]カスタムブロックを作るライブラリを用意する

カスタムブロックを作成するためのライブラリです。

yarn add -D @wordpress/browserslist-config @wordpress/scripts @wordpress/blocks @wordpress/dependency-extraction-webpack-plugin
  • @wordpress/browserslist-config ・・・ WordPressのブロックエディターが動くブラウザリストのライブラリ
  • @wordpress/scripts ・・・ WordPressのブロックエディター用のwebpackが詰め込まれたライブラリ。これを使用しない場合は、webpackを別途インストールする必要がある。
  • @wordpress/blocks ・・・ WordPressでカスタムブロックを作るためのライブラリ。
  • @wordpress/dependency-extraction-webpack-plugin ・・・ ライブラリの依存関係を自動で解決するassets.phpファイルを生成してくれるライブラリ

[任意]作成するカスタムブロックに合わせてライブラリを用意する

作りたいカスタムブロックに合わせて、ライブラリをインストールします。
今回は、@wordpress/block-editor@wordpress/componentsを使用します。

yarn add -D @wordpress/block-editor @wordpress/components

各ライブラリの詳細は、公式サイトを参照してください。

https://developer.wordpress.org/block-editor/packages/

事前準備

package.json(折りたたんでいます)
{
  "name": "sample-block",
  "version": "1.0.0",
  "description": "Sample Block",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "wp-scripts build --mode=development --config webpack.config.js --watch",
    "build": "wp-scripts build --mode=production --config webpack.config.js"
  },
  "author": "Kodak",
  "license": "GPL-2.0",
  "devDependencies": {
    "@babel/core": "^7.8.7",
    "@babel/preset-env": "^7.8.7",
    "@babel/preset-react": "^7.8.3",
    "@wordpress/block-editor": "^3.7.5",
    "@wordpress/blocks": "^6.12.1",
    "@wordpress/browserslist-config": "^2.6.0",
    "@wordpress/components": "^9.2.4",
    "@wordpress/dependency-extraction-webpack-plugin": "^2.4.0",
    "@wordpress/scripts": "^7.1.3",
    "autoprefixer": "^9.7.4",
    "babel-loader": "^8.0.6",
    "clean-webpack-plugin": "^3.0.0",
    "css-loader": "^3.4.2",
    "mini-css-extract-plugin": "0.6.0",
    "node-sass": "^4.13.1",
    "optimize-css-assets-webpack-plugin": "^5.0.3",
    "postcss-loader": "^3.0.0",
    "sass-loader": "^8.0.2",
    "terser-webpack-plugin": "^2.3.5"
  },
  "browserslist": [
    "extends @wordpress/browserslist-config"
  ]
}

WordPress用のwebpack(wp-scripts)を使用して、ビルドをするため、以下のように定義します。

  "scripts": {
    "start": "wp-scripts build --mode=development --config webpack.config.js --watch",
    "build": "wp-scripts build --mode=production --config webpack.config.js"
  },

ブラウザリストはextendsを使用して読み込みます。

  "browserslist": [
    "extends @wordpress/browserslist-config"
  ]

注)mini-css-extract-pluginは、chunkFilenameを使用しているとエラーが出たのでバージョンを0.6.0にしています。

webpack.config.js(折りたたんでいます)
const autoprefixer                      = require('autoprefixer');
const MiniCSSExtractPlugin              = require('mini-css-extract-plugin');
const OptimizeCSSAssetsWebpackPlugin    = require('optimize-css-assets-webpack-plugin');
const TerserWebpackPlugin               = require('terser-webpack-plugin');
const { CleanWebpackPlugin }            = require('clean-webpack-plugin');
const DependencyExtractionWebpackPlugin = require('@wordpress/dependency-extraction-webpack-plugin');

module.exports = (env, argv) => {
                // mode('development'と'production')によって動作を切り替える関数
        function isDevelopment() {
            return argv.mode === 'development'
        }

        var config = {
        entry: {
                        editor: './src/editor.js',
                        script: './src/script.js',
        },
        output: {
            filename: "[name].js"
        },
        optimization: {
            minimizer: [
                                // sourceMapをmodeによって削除する
                new TerserWebpackPlugin({
                    sourceMap: isDevelopment()
                                }),
                                // CSSを圧縮する
                new OptimizeCSSAssetsWebpackPlugin(
                    {
                        cssProcessorOptions: {
                            map: {
                                inline: false,
                                annotation: true
                            }
                        }
                    }
                )
            ]
        },
        plugins: [
                        // assets.phpファイルを出力する
                        new DependencyExtractionWebpackPlugin(),
                        // ビルドの度にビルドフォルダーを削除する
                        new CleanWebpackPlugin(),
                        // CSSファイルを別ファイルで出力する
            new MiniCSSExtractPlugin({
                            chunkFilename: "[id].css",
                            filename: chunkData => {
                                    return chunkData.chunk.name === "script" ? 'style.css' : '[name].css';
                            }
                        })
                ],
                // source-mapを使用する
        devtool: 'source-map',
        module: {
            rules: [
                {
                                        // JavaScriptのローダーの設定
                    test: /\.js$/,
                    exclude: /node_modules/,
                    use: {
                                                // BABELを使用する
                        loader: 'babel-loader',
                        options: {
                            presets: [
                                '@babel/preset-env',
                                [
                                    '@babel/preset-react',
                                    {
                                                                                // Reactで使用する関数をBABELをトランスパイルします
                                                                                // 通常は"React.createElement.xxxx"を記述しますが、WordPressのブロックエディターはWordPress用のReactを使用するため、以下のように記述します
                                        "pragma": "wp.element.createElement",
                                        "pragmaFrag": "wp.element.Fragment",
                                        "development": isDevelopment()
                                    }
                                ]
                            ]
                        }
                    }
                },
                {
                                        // SASSのローダーの設定
                    test: /\.(sa|sc|c)ss$/,
                    use: [
                                                // CSSを別ファイル化するためのローダーを指定
                        MiniCSSExtractPlugin.loader,
                        'css-loader',
                        {
                                                        // postcss-loaderのプラグインを使って、ベンダープレフィックスをCSSに追加
                            loader: 'postcss-loader',
                            options: {
                                plugins: [
                                    autoprefixer()
                                ]
                            }
                        },
                        'sass-loader'
                    ]
                }
            ]
        },
    };
    return config;
}

Reactで使用する関数をBABELをトランスパイルします。
通常は"React.createElement.xxxx"を記述しますが、WordPressのブロックエディターはWordPress用のReactを使用するため、以下のように記述します。

'@babel/preset-react',
{
    "pragma": "wp.element.createElement",
    "pragmaFrag": "wp.element.Fragment",
}

WordPress用のwebpack(wp-scripts、dependency-extraction-webpack-plugin)を使用して、ビルドをしない方は、WordPressの依存関係を解決するために以下のような記述が必要なので注意してください。

ご参考)https://developer.wordpress.org/block-editor/packages/packages-dependency-extraction-webpack-plugin/

externals: {
    "@wordpress/blocks": ["wp", "blocks"],
    "@wordpress/editor": ["wp", "editor"],
    "@wordpress/components": ["wp", "components"],
    "@wordpress/element": ["wp", "element"],
}

プラグインとしてカスタムブロックを開発

カスタムブロックを作成する流れを図にすると以下のような感じです。

sample-block-make.png

どこから作成しても問題ありませんが、カスタムブロックJSメインコードとカスタムブロックスタイルコードを作成して、最後にカスタムブロック登録コードを作るのが良いと思います。

firstblock/editor.js(折りたたんでいます)
import './editor.scss';
import { registerBlockType } from "@wordpress/blocks";
import { RichText, InspectorControls } from "@wordpress/editor";
import { PanelBody, ColorPalette } from "@wordpress/components";

registerBlockType('sample-block/firstblock', {
    title: 'サンプルブロック(editor)',

    icon: 'wordpress-alt',

    category: 'common',

    example: {},

    attributes: {
        content: {
            type: 'string',
            source: 'html',
            selector: 'p'
        },
        colorPrefix: {
            type: 'string',
            default: ''
        }
    },

    edit( { className, attributes, setAttributes } ) {
        const { content, colorPrefix } = attributes;

        const changeBackGroundColor = (backGroundColor) => {
            let color_prefix = '';

            switch ( backGroundColor ) {
                case 'blue':
                    color_prefix = '--blue' ;
                    break;
                case 'red':
                    color_prefix = '--red';
                    break;
                case 'green':
                    color_prefix = '--green';
                    break;
                case 'yellow':
                    color_prefix = '--yellow';
                    break;
                default:
                    color_prefix = '';
                    break;
            }
            setAttributes({colorPrefix: color_prefix})
        }

        return (
            <div className={ className }>
                <InspectorControls>
                        <PanelBody title='背景カラー設定'>
                                <ColorPalette
                                    colors={[
                                        {name: 'ブルー', color: 'blue'},
                                        {name: 'レッド', color: 'red'},
                                        {name: 'グリーン', color: 'green'},
                                        {name: 'イエロー', color: 'yellow'},
                                    ]}
                                    disableCustomColors='true'
                                    onChange={ changeBackGroundColor }
                                />
                        </PanelBody>
                </InspectorControls>
                <RichText
                    className={`wp-block-sample-block-firstblock__content${colorPrefix}`}
                    tagName='p'
                    onChange={ ( content ) => setAttributes( { content: content } ) }
                    value={ content }
                />
            </div>
        );
    },

    save( { attributes } ) {
        const { content, colorPrefix } = attributes;

        return (
            <div>
                { content && (
                    <RichText.Content
                        className={`wp-block-sample-block-firstblock__content${colorPrefix}`}
                        tagName='p'
                        value={ content }
                    />
                )}
            </div>
        );
    }
});

firstblock/editor.scss(折りたたんでいます)
.wp-block-sample-block-firstblock {
  &__content {
    color: black;
    background-color:   white;
  }

  &__content--red {
    color: white;
    background-color:   red;
  }

  &__content--green {
    color: white;
    background-color:   green;
  }

  &__content--blue {
    color: white;
    background-color:   blue;
  }

  &__content--yellow {
    color: red;
    background-color:   yellow;
  }
}

edit関数がエディター表示の部分、save関数がフロントを表示する部分になります。
attributesは、主にエディターで設定した値(edit関数で設定した値)をフロントへ渡す値(save関数へ渡す値)を定義します。
上記だと、RichText書かれたValue値やColorPaletteで選択したクラス名のプレフィックスなどです。

サイドナビゲーションは、InspectorControls、PanelBodyなどを組み合わせれば簡単に実装できます。

plugin.php(折りたたんでいます)
<?php
/**
 * Plugin Name: Sample-Block
 * Plugin URI: *
 * Description: Sample用カスタムブロック.
 * Version: 1.0
 * Author: Kodak
 * Author URI: *
 * License: GPL2
 *
 * @package sample-block
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * カスタムブロックの登録.
 */
function sample_block_register() {
    // スクリプトファイルをWordPressに登録する.
    $editor_asset_file = include( plugin_dir_path( __FILE__ ) . 'dist/editor.asset.php' );
    $script_asset_file = include( plugin_dir_path( __FILE__ ) . 'dist/script.asset.php' );

    // editor-script用のJSファイル.
    wp_register_script(
        'sample-block-editor-script',
        plugins_url( 'dist/editor.js', __FILE__ ),
        $editor_asset_file['dependencies'],
        $editor_asset_file['version']
    );

    // editor-style用のCSSファイル.
    wp_register_style(
        'sample-block-editor-style',
        plugins_url( 'dist/editor.css', __FILE__ ),
        [ 'wp-edit-blocks' ],
        filemtime( plugin_dir_path( __FILE__ ) . 'dist/editor.css' )
    );

    // script用のJSファイル.
    wp_register_script(
        'sample-block-script',
        plugins_url( 'dist/script.js', __FILE__ ),
        $script_asset_file['dependencies'],
        $script_asset_file['version']
    );

    // style用のCSSファイル.
    wp_register_style(
        'sample-block-style',
        plugins_url( 'dist/style.css', __FILE__ ),
        [ 'wp-edit-blocks' ],
        filemtime( plugin_dir_path( __FILE__ ) . 'dist/style.css' )
    );

    // カスタムブロックの登録(editor).
    register_block_type(
        'sample-block/firstblock',
        [
            'editor_script' => 'sample-block-editor-script', // エディター画面の時のみスクリプトを読み込む.
            'editor_style'  => 'sample-block-editor-style', // エディター画面の時のみスタイルを読み込む.
        ]
    );

    // カスタムブロックの登録.
    register_block_type(
        'sample-block/secondblock',
        [
            'script' => 'sample-block-script', // エディター・フロントの両画面でスクリプトを読み込む.
            'style'  => 'sample-block-style', // エディター・フロントの両画面でスタイルを読み込む.
        ]
    );
}
add_action( 'enqueue_block_editor_assets', 'sample_block_register' );
add_action( 'enqueue_block_assets', 'sample_block_register', 1 );

WordPress用のwebpack(wp-scripts、dependency-extraction-webpack-plugin)を使用して、ビルドした場合、スクリプトファイル(xxxx.asset.php)が作られます。
ライブラリの依存関係やバージョン情報が書かれているので、それらを使ってJSファイルを登録します。

カスタムブロックの登録は、editor_scripteditor_stylescriptstyleの4種類があります。
4つの違いは以下の通りです。

  • editor_script ・・・ エディター画面の時のみスクリプトを読み込む
  • editor_style ・・・ エディター画面の時のみスタイルを読み込む
  • script ・・・ エディター・フロントの両画面でスクリプトを読み込む
  • style ・・・ エディター・フロントの両画面でスタイルを読み込む

例えば、本記事の最初に見せたブロックをプレビューしてフロントから見てみると以下のように表示されます。

sample-block-preview.png

上:エディターとフロントにCSSが適応されるブロック(scriptstyle)を使用した場合
下:エディターのみCSSが適応されるブロック(editor_scripteditor_style)を使用した場合

上の方は、エディターで指定した背景色(赤色)がフロントに適用されていることがわかります。
対して、下の方はエディターで指定した背景色(緑色)がフロントには反映されていないことがわかります。

最後にHookですが、editor_scripteditor_styleを使用する場合はenqueue_block_editor_assetsを使いましょう。
scriptstyleを使用する場合はenqueue_block_assetsを使いましょう。
ちゃんとブロックエディター用のHookが用意されているので、適切なHookを使用するようにしましょう。間違ってもinitは使用しないようにしましょう。

さいごに

というわけで、長々とカスタムブロックの作り方を書いてみましたが、いかがだったでしょうか。
個人的に一番苦労したのはwebpackの設定です。
私自身バックエンドのエンジニアなので、webpackの設定方法を理解するのにえらい時間が掛かりました・・・
カスタムブロックはモダンな作りになっていて面白いのですが、学習コストが高いのが辛いところですね。

今度は、動的ブロックの作成にも挑戦したいと思います。

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

Babel Pluginでdata-testid属性を自動で付加する

昨今のReactのテスト事情は、react-testing-library が主流です。
create-react-app でデフォルトで生成されるテストテンプレートもすでに @testing-library が使われるようになっています。

Add @testing-library to the default templates by kentcdodds · Pull Request #7881 · facebook/create-react-app

また、携わっていたプロジェクトでもenzymeからtesting-libraryに移行しました。

さて、testing-libraryでサポートされているクエリに data-testid があります。data-testidについて詳しくは、以下の記事を参考にしてください。

idやclassを使ってテストを書くのは、もはやアンチパターンである - Qiita

また、data-testidをいつ使うかについては、Testing Libraryのガイドプリンシパルが役に立ちます。
Guiding Principles · Testing Library

さて、testid 属性ですが以下のように自分で書くものですが、これを、ある程度自動的に付加できると便利かつソースコードをクリーンに保てます。

function App() {
  return <div data-testid="App">App</div>
}

インストール

npm install --save-dev babel-plugin-react-data-testid

api.envを用いて、test環境だけ有効にすることをオススメします。

babel.config.js
module.exports = api => {
  const isTest = api.env("test");
  return {
    presets: ["react-app"],
    plugins: isTest ? ["react-data-testid"] : []
  };
};

これで、テスト時のみコンポーネントの最初の要素に対して、コンポーネント名でdata-testidを付加します。
すでにdata-testidがある場合は処理をスキップし、ネストしてる場合は、一番上のタグに対してdata-testidを付加します。

function App() {
  return <div>App</div>
}

// ↓↓↓ テスト時のみ

function App() {
  return <div data-testid="App">App</div>
}

これで、data-testidが自動生成できるようになりました。なお、クラスコンポーネントはサポートしていません。PRは歓迎です。

akameco/babel-plugin-react-data-testid: babel plugin for react data-testid attributes

バグや要望については、気軽にIssue立ててください。
使ってみていい感じだなと思ったら :star: していただけると嬉しいです。

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

Rails2.7 Rails6 Docker React環境でシンプルCRUD実装

streampackのminsuです。
以前の記事で Docker + Rails + React の環境構築を行いindexページの表示まで行ったのでCRUD機能を追加します。
ですが期間も空いているため、折角なので以前の環境である

  • Rails 5.1.4
  • Ruby 2.4.1
  • mysql 5.7

ではなく、新しい環境で作り直します。

最新版確認
https://rubygems.org/gems/rails
https://www.ruby-lang.org/ja/downloads/

作成環境

  • Rails 6.0.2
  • Ruby 2.7
  • mysql 5.7

ファイルの用意

Gemfile Gemfile.lock Dockerfile docker-compose.yml を作成します。

Gemfile
source "https://rubygems.org"
gem "rails", "6.0.2"
Gemfile.lock
FROM ruby:2.7.0

RUN apt-get update -qq && \
apt-get install -y \
nodejs \
build-essential

RUN apt-get update && apt-get install -y curl apt-transport-https wget && \
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \
apt-get update && apt-get install -y yarn

RUN mkdir /app
WORKDIR /app

ADD Gemfile* /app/

RUN bundle install -j4 --retry 3

ADD . /app

WORKDIR /app

CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
docker-compose.yml
version: '3'
services:
  db:
    image: mysql:5.7
    command: mysqld --character-set-server=utf8 --collation-server=utf8_unicode_ci
    ports:
      - "4306:3306"
    environment:
      - MYSQL_ROOT_PASSWORD=root
    volumes:
      - mysql_vol:/var/lib/mysql
  app:
    build: . 
    command: /bin/sh -c "rm -f /app/tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/app
    ports:
      - "3000:3000"
    depends_on:
      - db
volumes:
  mysql_vol:

rails app作成

rails new

rails new を行います。

$ docker-compose run app rails new . --force --database=mysql

db設定を変更します。

database.yml
  username: root
  password: root #docker-compose.ymlのMYSQL_ROOT_PASSWORD
  host: db #docker-compose.ymlのサービス名

今回も gem react-rails を利用するのでGemfileに追記します。

Gemfile
gem 'react-rails'

再度 build して

$ docker-compose build

reactを使うので下記コマンドを実行

$ docker-compose run app rails webpacker:install
$ docker-compose run app rails webpacker:install:react
$ docker-compose run app rails generate react:install

model 作成

$ docker-compose run app rails g model List title:string description:string
$ docker-compose run app rails db:create
$ docker-compose run app rails db:migrate

かなりの数の warning 出てきた。
Ruby 2.7.0に対応していないgemが存在することに起因しているようで非表示にすることもできる* が必要なwarningも見逃す可能性があるのでスルーすることにする。
*bash_profileにexport RUBYOPT='-W:no-deprecated -W:no-experimental'を追加

controller 作成

lists controller と view を作成

$ docker-compose exec app rails g controller Lists index
lists_controller.rb
class ListsController < ApplicationController
  def index
    @lists = List.all
  end
end
index.html.erb
<%= react_component 'ListsIndex', lists: @lists %>

react_component タグを用いてreactを呼び出します。

react file 作成

viewから呼び出すreact fileを実装していきます。

$ rails g react:component ListsIndex

コマンドで app/javascript/components/ListsIndex.js が作成されるので編集します。

ListsIndex.js
import React from "react"
import PropTypes from "prop-types"
export default class Lists extends React.Component {
  constructor(props){
    super(props)
    this.state = {
      lists: []
    };
  }
  componentDidMount(){
    this.setState({
      lists: this.props.lists
    })
  }
  render () {
    return (
      <div>
        <table>
          <thead>
            <tr>
              <th>ID</th>
              <th>Title</th>
              <th>Description</th>
            </tr>
          </thead>
          <tbody>
            {this.state.lists.map((list) => {
              return (
                <tr key={list.id}>
                  <td>{list.id}</td>
                  <td>{list.title}</td>
                  <td>{list.description}</td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
    );
  }
}

動作確認

List モデルに適当な値を保存して動作確認をしてみます。
ss1.png

無事に一覧が表示されました。

simple CRUD の実装

railsにapiを追加します。
apiで行うアクションは index, create, update, destroy です。

/api/v1/xxxでアクセスできるようにrouteを設定し、controllerを追加します。

routes.rb
Rails.application.routes.draw do
  get 'lists/index'
  namespace :api do 
    namespace :v1 do 
     resources :lists, only: [:index, :create, :update, :destroy]
    end 
  end 
end
app/controllsers/api/v1/lists_controllser.rb
class Api::V1::ListsController < ApplicationController
  protect_from_forgery with: :null_session
  def index
    render json: List.all
  end
  def create
    list = List.create(list_params)
    render json: list
  end
  def update
    list = List.find(params[:id])
    list.update(list_params)
    render json: list
  end
  def destroy
    List.destroy(params[:id])
  end
  private
  def list_params
    params.require(:list).permit(:id, :title, :description)
  end
end

controllerには基本的なメソッド、そしてprotect_from_forgery with: :null_sessionを記述しました。

http://localhost:3000/api/v1/listsでindexが呼び出されリストが取得できるはずです。

index

reactからapiを利用してlists を取得します。
componentDidMountを書き換えます。

ListsIndex.js
  componentDidMount(){
    this.getIndex();
  }
  getIndex(){
    fetch('/api/v1/lists.json')
    .then((response) => {return response.json()})
    .then((data) => {this.setState({ lists: data }) });
  }

delete

delete機能を実装します。
ボタンを追加

    return (
      <div>
        <div>this is list</div>
        <table>
          <thead>
            <tr>
              <th>ID</th>
              <th>Title</th>
              <th>Description</th>  
              <th>function</th>
            </tr>
          </thead>
          <tbody>
            {this.state.lists.map((list) => {
              return (
                <tr key={list.id}>
                  <td>{list.id}</td>
                  <td>{list.title}</td>
                  <td>{list.description}</td>
                  <td>
                    <button onClick={() => this.handleDelete(list.id)}>delete</button>
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>

    );

ボタンから呼び出されるhandleDeleteを実装します。

  handleDelete(id){
    fetch(`http://localhost:3000/api/v1/lists/${id}`, 
      {
        method: 'DELETE',
        headers: {
          'Content-Type': 'application/json'
        }
    })
    .then((response) => { 
        console.log('List was deleted');
        this.deleteList(id);
    })
  }
  deleteList(id){
    let lists = this.state.lists.filter((list) => list.id != id)
    this.setState({
      lists: lists
    })
  }

apiでのdestroyだけではstateの値は変わらないので、画面は更新されません。
そのためdeleteListにてstateの値を変更しています。

constructorに下記も追記します。

  constructor(props){
...
    this.getIndex = this.getIndex.bind(this);
    this.handleDelete = this.handleDelete.bind(this);
    this.deleteList = this.deleteList.bind(this);
  }

画面を確認すると deleteボタンが追加されており、要素の削除が行えます。
ss2.png

create

要素追加のformを作成します。
stateにてformの値を管理するために下記のように追記します。

  constructor(props){
    super(props)
    this.state = {
      // lists: this.props.lists
      lists: [],
      form: {
        title: "",
        description: "",
      }
    };
...

各inpuフォームとaddボタンを追加

    return (
      <div>
        <div>this is list</div>
        <table>
          <thead>
            <tr>
              <th>ID</th>
              <th>Title</th>
              <th>Description</th>  
              <th>function</th>
            </tr>
          </thead>
          <tbody>
            {this.state.lists.map((list) => {
              return (
                <tr key={list.id}>
                  <td>{list.id}</td>
                  <td>{list.title}</td>
                  <td>{list.description}</td>
                  <td>
                    <button onClick={() => this.handleDelete(list.id)}>delete</button>
                  </td>
                </tr>
              );
            })}
            <tr>
              <td></td>
              <td><input type="text" value={this.state.form.title} onChange={e=>this.handleChange(e,'title')} /></td>
              <td><input type="text" value={this.state.form.description} onChange={e=>this.handleChange(e,'description')} /></td>
              <td><button onClick={() => this.handleCreate()}>add</button></td>
            </tr>
          </tbody>
        </table>
      </div>

    );
  }

ここで利用するhandleChangehandleCreateを実装します。
handleChangeではinputフォームの入力値をstateにて管理させています。

  handleChange(e,key){
    let target = e.target;
    let value = target.value;
    let form = this.state.form;
    form[key] = value;

    this.setState({
      form: form
    });
  }

handleCreateではapiのcreateメソッドを呼び出して要素の追加を行います。
追加後はstateのlistsの更新と
inputフォームの値のリセットを行なっています。

  handleCreate(){
    let body = JSON.stringify({
      list: {
        title: this.state.form.title, 
        description: this.state.form.description
      } 
    })
    fetch('http://localhost:3000/api/v1/lists', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: body,
    })
    .then((response) => {return response.json()})
    .then((list)=>{
      this.addList(list);
      this.formReset();
    })
  }
  addList(list){
    this.setState({
      lists: this.state.lists.concat(list)
    })
  }
  formReset(){
    this.setState({
      form:{
        title: "",
        description: ""
      }
    })
  }

constructorに下記を追記

    this.handleChange = this.handleChange.bind(this);
    this.addList = this.addList.bind(this);
    this.formReset = this.formReset.bind(this);

画面を確認するとcreate用のinputフォームが追加され、addボタンのクリックにより要素の追加を行えます。
ss3.png

完成したListIndex.js

ListIndex.js
import React from "react"
import PropTypes from "prop-types"
class ListsIndex extends React.Component {
  constructor(props){
    super(props)
    this.state = {
      // lists: this.props.lists
      lists: [],
      form: {
        title: "",
        description: "",
      }
    };
    this.getIndex = this.getIndex.bind(this);
    this.handleDelete = this.handleDelete.bind(this);
    this.deleteList = this.deleteList.bind(this);
    this.handleChange = this.handleChange.bind(this);
    this.addList = this.addList.bind(this);
    this.formReset = this.formReset.bind(this);
  }
  componentDidMount(){
    this.getIndex();
  }
  getIndex(){
    fetch('/api/v1/lists.json')
    .then((response) => {return response.json()})
    .then((data) => {this.setState({ lists: data }) });
  }
  handleDelete(id){
    fetch(`http://localhost:3000/api/v1/lists/${id}`, 
      {
        method: 'DELETE',
        headers: {
          'Content-Type': 'application/json'
        }
    })
    .then((response) => { 
        console.log('List was deleted');
        this.deleteList(id);
    })
  }
  deleteList(id){
    let lists = this.state.lists.filter((list) => list.id != id)
    this.setState({
      lists: lists
    })
  }
  handleChange(e,key){
    let target = e.target;
    let value = target.value;
    let form = this.state.form;
    form[key] = value;

    this.setState({
      form: form
    });
  }
  handleCreate(){
    let body = JSON.stringify({
      list: {
        title: this.state.form.title, 
        description: this.state.form.description
      } 
    })
    fetch('http://localhost:3000/api/v1/lists', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: body,
    })
    .then((response) => {return response.json()})
    .then((list)=>{
      this.addList(list);
      this.formReset();
    })
  }
  addList(list){
    this.setState({
      lists: this.state.lists.concat(list)
    })
  }
  formReset(){
    this.setState({
      form:{
        title: "",
        description: ""
      }
    })
  }
  render () {
    return (
      <div>
        <table>
          <thead>
            <tr>
              <th>ID</th>
              <th>Title</th>
              <th>Description</th>  
              <th>function</th>
            </tr>
          </thead>
          <tbody>
            {this.state.lists.map((list) => {
              return (
                <tr key={list.id}>
                  <td>{list.id}</td>
                  <td>{list.title}</td>
                  <td>{list.description}</td>
                  <td>
                    <button onClick={() => this.handleDelete(list.id)}>delete</button>
                  </td>
                </tr>
              );
            })}
            <tr>
              <td></td>
              <td><input type="text" value={this.state.form.title} onChange={e=>this.handleChange(e,'title')} /></td>
              <td><input type="text" value={this.state.form.description} onChange={e=>this.handleChange(e,'description')} /></td>
              <td><button onClick={() => this.handleCreate()}>add</button></td>
            </tr>
          </tbody>
        </table>
      </div>

    );
  }
}

export default ListsIndex

まとめ

Ruby2.7, Rails6
Docker
react
での環境構築
reactからのrails api利用の実装を行いました。
自分用のまとめですが、誰かの助けとなれば幸いです。

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

componentDidMountでReflow

chromeブラウザでReflowがCSSアニメーションと相性悪いようで、余計なアニメーションが走ってしまうことがある。

画面内容

画面遷移が発生する時に、次のように遷移先の画面が右側から左に移動するようCSSアニメーションが実装されている。

    &SlideFromRight-enter,
    &SlideFromRight-appear {
        left: 100%;
        transition: all 300ms cubic-bezier(.165, .84, .44, 1);
        pointer-events: none;
        opacity: 0;

        &-active {
            left: 0;
            opacity: 1;
        }
    }

    &SlideFromRight-exit {
        left: 0;
        transition: all 300ms cubic-bezier(.165, .84, .44, 1);
        pointer-events: none;
        opacity: 1;

        &-active {
            left: -100%;
            opacity: 0;
        }
    }

問題点

低スペックのandroid端末で、偶に真白な画面が描画されてしまって、アニメーションが止まってしまったように見える。

調査

React.ComponentのcomponentDidMountでwindow.innerHeightなどをアクセスしている為、Reflowが発生してしまう。その時、余計なアニメーションが走っていることがわかる。

window.innerHeightをアクセスする処理を削除すると、余計なアニメーションがなくなり、真白画面もなくなる。

image.png

対応

Reflowを発生させる上記処理をsetTimeoutで遅延することで、余計なアニメーションがなくなる。

参考記事

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

PhpStormでReact+TypeScript+styled-componentsをはじめる

はじめに

Reactとstyled-componentsが美味しいのかどうかも知りませんが、とりあえず試食コーナーの構築手順です。

プロジェクト作成

  • Create New Projectをクリックします。
    image.png

  • 下記を選択します。

    • 「React App」 を選択します。
    • テキトウにプロジェクト名を付けます。
    • 「Create TypeScript project」にチェックします。 image.png
  • 「Happy hacking!」って言われるまで、しばし待ちます。

  • 「package.json」を右クリックして、「Show npm Scripts」を表示します。
    image.png

React動作確認

  • npmパネルのstartをダブルクリックすると、ブラウザが起動します。
    image.png

  • 試しにsrc/App.tsxに何か追記してみましょう。

  • Ctrl+SするとCompileされブラウザに即時反映されます。
    image.png

styled-componentsインストール

  • Settingsを開きます。
    image.png

  • 「Node.js and NPM」を選択し、「+」をクリックします。
    image.png

  • 「styled-components」を選択し、「Install Package」をクリックします。
    image.png

  • Package 'styled-components' Installed successfullyと表示されたら、Settingsをすべて閉じます。

  • terminalでnpm install @types/styled-componentsを実行します。1
    image.png

styled-componentsでApp.tsxを書き直す

  • 下記のようにsrc/App.tsxを書き直します。
src/App.tsx
import React, { Component } from 'react';
import logo from './logo.svg';
import styled, { keyframes } from 'styled-components';

const ReactApp = styled.div`
    text-align: center;
`;
const AppHeader = styled.header`
    background-color: #282c34;
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    font-size: calc(10px + 2vmin);
    color: white;
    & code {
        color: #aaeeff;
    }
`;
const AppLogoSpin = keyframes`
    from {
        transform: rotate(0deg);
    }
    to {
        transform: rotate(360deg);
    }
`;
const AppLogo = styled.img`
    animation: ${AppLogoSpin} infinite 20s linear;
    height: 40vmin;
`;
const AppLink = styled.a`
    color: #61dafb;
    &:hover {
        color: #00ffff;
    }
`;

class App extends Component {
    render() {
        return (
            <ReactApp>
                <AppHeader>
                    <AppLogo src={logo} alt="logo" />
                    <p>
                        Edit <code>src/App.js</code> and save to reload.
                    </p>
                    <AppLink
                        href="https://reactjs.org"
                        target="_blank"
                        rel="noopener noreferrer"
                    >
                        Learn React
                    </AppLink>
                    <h2>React+styled-componentsはじめました</h2>
                </AppHeader>
            </ReactApp>
        );
    }
}

export default App;
  • ブラウザ上に下記のように表示されていることを確認できます。2 image.png

おわりに

Happy hacking!


  1. 「Node.js and NPM」ダイアログで@types/..をインストールする方法ご存じのかた教えてくださいmm 

  2. npm startを実行中の場合 

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

【React】チュートリアルの三目並べをやる #1

前回まではProgateの無料レッスンを一通りこなしました。

今回からはアプリを作成するアウトプットを行っていこうと思います。
または、自分が新たに習得した知識の共有等も行えたら良いなと思います。

次回
【React】チュートリアルの三目並べをやる #2

React公式チュートリアル

Reactの公式チュートリアルに三目並べゲームがあり、なぜかこれが気になってしまうので、作っていきたいと思います。

最終成果物は → 三目並べゲーム

これをチュートリアルに沿って作るのが目標です。

前提知識

アロー関数、クラス、letおよびconstが理解できていることが前提らしい。
完璧には理解できていないが、わからなければググるので問題なし。

チュートリアルの準備

開発環境は以下の2つ

  • ブラウザで書く
  • ローカルに開発環境を構築して書く

以前Progateの無料レッスンをやっているので、ローカルに構築済みです。
Progate無料版をやってみる【React】

以下のソースコードを元に作成する模様。
https://codepen.io/gaearon/pen/oWWQNa?editors=0010

イメージ
image.png

3つのコンポーネントで構成される

  • Square: マス目を表し、buttonタグをレンダー
  • Board: 9個Squareをreturnしている。マス目全体
  • Game: あれ、これ何を表しているんだ?  チュートリアルには「後ほど埋めることになるプレースホルダーを描画しています」とのこと。後ほど書くのね。

とりあえず元となるソースをコピペまたは模写していきます。
index.jsを変更しちゃいます。

index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import * as serviceWorker from './serviceWorker';

class Square extends React.Component {
  render() {
    return <button className="square">{/* TODO */}</button>;
  }
}

class Board extends React.Component {
  renderSquare(i) {
    return <Square />;
  }

  render() {
    const status = 'Next palayer: X';

    return (
      <div>
        <div className="status">{status}</div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}

class Game extends React.Component {
  render() {
    return (
      <div className="game">
        <div className="gmae-board">
          <Board />
        </div>
        <div className="game-info">
          <div>{/* status */}</div>
          <div>{/* TODO */}</div>
        </div>
      </div>
    );
  }
}

ReactDOM.render(
  <React.StrictMode>
    <Game />
  </React.StrictMode>,
  document.getElementById('root')
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

一番下のserviceWorker.unregister();を消すとエラーになっていた。
以前のインストール時のHello World的なチュートリアルで入ってしまった模様。
キャッシュらしいです。よくわからない・・・。
参考にさせていただきました。

index.css
body {
  font: 14px "Century Gothic", Futura, sans-serif;
  margin: 20px;
}

ol, ul {
  padding-left: 30px;
}

.board-row:after {
  clear: both;
  content: "";
  display: table;
}

.status {
  margin-bottom: 10px;
}

.square {
  background: #fff;
  border: 1px solid #999;
  float: left;
  font-size: 24px;
  font-weight: bold;
  line-height: 34px;
  height: 34px;
  margin-right: -1px;
  margin-top: -1px;
  padding: 0;
  text-align: center;
  width: 34px;
}

.square:focus {
  outline: none;
}

.kbd-navigation .square:focus {
  background: #ddd;
}

.game {
  display: flex;
  flex-direction: row;
}

.game-info {
  margin-left: 20px;
}

Propsでデータを渡す

Props(properties)を用いて親から子に値を受け渡す。
Board(親)からSquare(子)に値を受け渡します。
渡す値は、Board.renderSquareの引数iです。

Boad側
class Board extends React.Component {
  renderSquare(i) {
    return <Square value={i} />;
  }
Square側
class Square extends React.Component {
  render() {
    return <button className="square">{this.props.value}</button>;
  }
}

プロジェクトトップに移動し(cd 移動先)

npm start

します。

そしてhttp://localhost:3000にアクセスします。
image.png
おお!でた。
Boardから数字が渡ってきてマスの中に表示されているんですね。

マス目をクリックされたときのイベントを設定する

jQueryだと

$('button.square').click(() => {
  // 何か処理
});

素のJavaScriptだと

document.querySelectorAll('button.square').forEach((node) => {
  node.addEventListener('click', () => {
      // 何か処理
  });
});

となり、HTMLとは別個に記述します。

JSXの場合HTMLと一緒に書くようです。

class Square extends React.Component {
  render() {
    return (
      <button
        className="square"
        onClick={() => {
          // 何か処理
        }}
      >
        {this.props.value}
      </button>
    );
  }
}

た、多分JSXのほうがイイヨネ・・・。
今はこの独特の書き方が慣れてないんで微妙ですが、たくさん書けば良さを実感できるんでしょうか。

マス目の中身をXに変更する処理は、jQueryや素のJavaScriptのようにDOMAPIを呼ぶ書き方ではなく、stateを用いる方法で実現します。(コンパイル後には結局DOMAPIを呼ぶ書き方に変わりますが)

Square
constructorを追加し、引数にpropsを設ける
super(props)で継承元のコンストラクターを呼ぶ。お決まり
statevalueプロパティを持つオブジェクトを設定。valueプロパティの値はprops.valueを設定
onClick内ではsetStateする
・レンダー時のマス目の値はprops.valueからstate.valueに変更する

Square
class Square extends React.Component {
  constructor(props) {
    super(props);
    this.state = { value: this.props.value };
  }

  render() {
    return (
      <button
        className="square"
        onClick={() => {
          this.setState({ value: 'X' });
        }}
      >
        {this.state.value}
      </button>
    );
  }
}

結果
ダウンロード (1).gif
ブラウザで確認時、Reactのコンポーネントツリーを調べる拡張機能があるらしい。
React Devtools 拡張機能

マス目の制御を行う

現在のマス目の内容の状態を取得するには、Board側から各Squareに取りに行くイメージでやればいいと思いがちですが、可読性が落ちたり、バグが起きやすいとのことです。
Board側で各Square側の状態を保持しておいて、propsを用いてやり取りするのがベストらしいです。

具体的にはまず、Board側にSquareに渡す値の情報を保持します。
→ マス目情報を長さ9つの配列として保持します。

Boad側

Board
class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = { squares: Array(9).fill(null) };
  }

Array.prototype.fillの説明

renderSquareメソッドで配列のi番目を渡すようにします。

Board
  renderSquare(i) {
    return <Square value={this.state.squares[i]} />;
  }

そしてSquareからBoardを更新してもらうために、Squareに対して更新用の関数を渡してあげます。
上のvalue={this.state.squares[i]}に続いてonClickを定義。

Board
renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]} // クロージャでiが保持されている
        onClick={() => {
          const squares = Object.create(this.state.squares); // 別の配列としてクローン
          squares[i] = 'X';
          this.setState({ squares: squares });
        }}
      />
    );
  }

更新処理が書かれたこの関数ごとSquareに渡るイメージです。
Square側のpropsに入っているイメージ。

iについて
renderSquare(i)のところのiはクロージャによって、最初にレンダーされた時の各Squareごとの値を保持しています。
クロージャ

onClick内の更新処理について
Reactのstateは直接更新は推奨されていないので、一度Object.createでコピーを作ってから、そのコピーを書き換えて、setSateで更新しています。
なぜ推奨されないか

チュートリアルではコピーのところはthis.state.squares.slice()でしたが、これは配列の中身がプリミティブ値(数値、文字列、真偽値)の1次元配列の時のみ有効です。配列の中にオブジェクトや、さらに配列が入っていたら、完全なコピーができません。(ディープコピー)

コピー元の配列(state)と同じメモリの場所を参照してしまい、この後これに変更を行ってしまうと、「元のstateを変更しない」に反します。
このチュートリアルでは1次元の数値の配列なのでOKですが、今後それ以外のケースが出てきたときに誤ってsliceを使わないようにObject.createで慣れておこうと思います。
Array.prototype.slice()

Square側

・onClickの中で自身のstateを更新していたのをBoardから渡ってきたpropsのonClickを呼ぶように変更します。
・さらに、マス目に表示していたthis.state.valuethis.props.valueに変更します。
 これを行うことにより、ボタン押下時のBoard側で変更されたstateの値がこのpropsに反映されます。
・stateを持つ必要がなくなったので、constructorを削除します。

Square
class Square extends React.Component {

  // constructorは不要になったので削除

  render() {
    return (
      <button
        className="square"
        onClick={() => {
          this.props.onClick(); // BoardのonClickを呼び出す。
        }}
      >
        {this.props.value} // state → propsに変更
      </button>
    );
  }
}

ここまでのBoardとSquareの全文

SquareとBoard
class Square extends React.Component {
  render() {
    return (
      <button
        className="square"
        onClick={() => {
          this.props.onClick();
        }}
      >
        {this.props.value}
      </button>
    );
  }
}

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = { squares: Array(9).fill(null) };
  }

  renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => {
          const squares = Object.create(this.state.squares);
          squares[i] = 'X';
          this.setState({ squares: squares });
        }}
      />
    );
  }

  render() {
    const status = 'Next palayer: X';

    return (
      <div>
        <div className="status">{status}</div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}

関数コンポーネント

Squareはstateを持たなくてよくなったので、React.Componentを継承する必要もなくなります。
もっと簡潔に書けるようになるみたいです。

Square
function Square(props) { // 継承がなくなった
  return (
    // onClickのところがシンプルに
    // あとpropsにthisがいらない。引数で来ているので
    <button className="square" onClick={props.onClick}>
      {props.value}
    </button>
  );
}

手番の処理(XやOを実装)

先手はXにします。
Boardに「次の手番はだれか」を判定するためのフラグを設けます。
→「次の手番はXだ」というフラグを設けて、その値のtrue falseで判定します。
その判定により、XOを代入します。

Board

Board
class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
      xIsNext: true, // 先手はXなので初期値true
    };
  }

  renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => {
          const squares = Object.create(this.state.squares);

          // xIsNextがtrueならX、それ以外なら○
          squares[i] = this.state.xIsNext ? 'X' : 'O';

          // xIsNextは現在の反対を代入することで手番が変わる
          this.setState({ squares: squares, xIsNext: !this.state.xIsNext });
        }}
      />
    );
  }

ついでに画面に表示していた文字のところのNext palayer:も動的に書き換わるようにします。

Board
  render() {
    const status = 'Next palayer: ' + (this.state.xIsNext ? 'X' : 'O');

あれ?でもこれここのstatusを書き換えて意味あるの?って思っちゃいました。
なぜなら動的に変更するものはstateに入れておかないとダメとおもっていたので・・・。
このrenderがマス目を毎回クリックするたびに毎回動くなら大丈夫ですけど・・・。

→ 確認したら毎回動いてました・・・。
でも毎回動くのって無駄なような気が・・・。
毎回このhtmlを作成しているってことですよね。
このチュートリアルは要素数が少ないからいいですけど、普通のwebアプリを作ろうとしたらめちゃめちゃ要素数多いページとかあるのにレスポンス大丈夫なんでしょうか・・・。
後日調べたいと思います。

ゲーム勝者の判定

動かしてみると分かりますが、今のままだとゲームが終わった後に何度でも入力が出来てしまいます。
なので、勝敗が着いたら何もさせないような制御を実装します。
また、どっちが勝ったかを分かり易くする為に、「勝者: ○○」の文字を表示させようと思います。

チュートリアルでは勝敗を判定してくれる関数を用意してくれています。これをコピーして貼り付けます。

チュートリアルではBoardの外に書いてますが、私は中に入れました。(renderの下)

Board
// 勝敗判定関数
  calculateWinner(squares) {
    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],
    ];
    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;
  }

勝者を表示させるNext palayer:の部分を変更します。

Board
render() {
    const winner = this.calculateWinner(this.state.squares);

    let status;
    if (winner) {
      status = '勝者:' + winner;
    } else {
      status = '次の手番: ' + (this.state.xIsNext ? 'X' : 'O');
    }

最後に

  • マス目が押された時に勝敗が決まっていたら
  • 既にマス目が埋まっていたら

何も処理しないようにします。

Board
renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => {
          const squares = Object.create(this.state.squares);
          if (this.calculateWinner(squares) || squares[i]) {
            // 何もせずにreturn
            return;
          }

          squares[i] = this.state.xIsNext ? 'X' : 'O';
          this.setState({ squares: squares, xIsNext: !this.state.xIsNext });
        }}
      />
    );
  }

ifのところは
左の式は、this.calculateWinner(squares)の戻り値がnull以外なら勝敗が決したということです。
右の式は、suare[i]null以外なら既にマス目に値が入っているということです。

以下、完成した全文

index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import * as serviceWorker from './serviceWorker';

function Square(props) {
  return (
    <button className="square" onClick={props.onClick}>
      {props.value}
    </button>
  );
}

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
      xIsNext: true,
    };
  }

  renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => {
          const squares = Object.create(this.state.squares);
          if (this.calculateWinner(squares) || squares[i]) {
            return;
          }

          squares[i] = this.state.xIsNext ? 'X' : 'O';
          this.setState({ squares: squares, xIsNext: !this.state.xIsNext });
        }}
      />
    );
  }

  render() {
    const winner = this.calculateWinner(this.state.squares);

    let status;
    if (winner) {
      status = '勝者:' + winner;
    } else {
      status = '次の手番: ' + (this.state.xIsNext ? 'X' : 'O');
    }

    return (
      <div>
        <div className="status">{status}</div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }

  // 勝敗判定関数
  calculateWinner(squares) {
    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],
    ];
    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;
  }
}

class Game extends React.Component {
  render() {
    return (
      <div className="game">
        <div className="gmae-board">
          <Board />
        </div>
        <div className="game-info">
          <div>{/* status */}</div>
          <div>{/* TODO */}</div>
        </div>
      </div>
    );
  }
}

ReactDOM.render(
  <React.StrictMode>
    <Game />
  </React.StrictMode>,
  document.getElementById('root')
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

動作結果
ダウンロード (1).gif

おわりに

ここまでで一旦、三目並べゲームが完成です。
Qiitaに書きながらなのでなかなか苦労しました・・・汗

チュートリアルではこの後「タイムトラベル機能」の追加の手順があるので、次回はそちらをやっていこうと思います。
履歴から手順を戻すやつですね。

GitHub・・・公式にソースあるしいらないかw

→ 次回

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

【React】チュートリアルの三目並べゲームをやる

前回まではProgateの無料レッスンを一通りこなしました。

今回からはアプリを作成するアウトプットを行っていこうと思います。
または、自分が新たに習得した知識の共有等も行えたら良いなと思います。

React公式チュートリアル

Reactの公式チュートリアルに三目並べゲームがあり、なぜかこれが気になってしまうので、作っていきたいと思います。

最終成果物は → 三目並べゲーム

これをチュートリアルに沿って作るのが目標です。

前提知識

アロー関数、クラス、letおよびconstが理解できていることが前提らしい。
完璧には理解できていないが、わからなければググるので問題なし。

チュートリアルの準備

開発環境は以下の2つ

  • ブラウザで書く
  • ローカルに開発環境を構築して書く

以前Progateの無料レッスンをやっているので、ローカルに構築済みです。
Progate無料版をやってみる【React】

以下のソースコードを元に作成する模様。
https://codepen.io/gaearon/pen/oWWQNa?editors=0010

イメージ
image.png

3つのコンポーネントで構成される

  • Square: マス目を表し、buttonタグをレンダー
  • Board: 9個Squareをreturnしている。マス目全体
  • Game: あれ、これ何を表しているんだ?  チュートリアルには「後ほど埋めることになるプレースホルダーを描画しています」とのこと。後ほど書くのね。

とりあえず元となるソースをコピペまたは模写していきます。
index.jsを変更しちゃいます。

index.js

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import * as serviceWorker from './serviceWorker';

class Square extends React.Component {
  render() {
    return <button className="square">{/* TODO */}</button>;
  }
}

class Board extends React.Component {
  renderSquare(i) {
    return <Square />;
  }

  render() {
    const status = 'Next palayer: X';

    return (
      <div>
        <div className="status">{status}</div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}

class Game extends React.Component {
  render() {
    return (
      <div className="game">
        <div className="gmae-board">
          <Board />
        </div>
        <div className="game-info">
          <div>{/* status */}</div>
          <div>{/* TODO */}</div>
        </div>
      </div>
    );
  }
}

ReactDOM.render(
  <React.StrictMode>
    <Game />
  </React.StrictMode>,
  document.getElementById('root')
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

一番下のserviceWorker.unregister();を消すとエラーになっていた。
以前のインストール時のHello World的なチュートリアルで入ってしまった模様。
キャッシュらしいです。よくわからない・・・。
参考にさせていただきました。

index.css

body {
  font: 14px "Century Gothic", Futura, sans-serif;
  margin: 20px;
}

ol, ul {
  padding-left: 30px;
}

.board-row:after {
  clear: both;
  content: "";
  display: table;
}

.status {
  margin-bottom: 10px;
}

.square {
  background: #fff;
  border: 1px solid #999;
  float: left;
  font-size: 24px;
  font-weight: bold;
  line-height: 34px;
  height: 34px;
  margin-right: -1px;
  margin-top: -1px;
  padding: 0;
  text-align: center;
  width: 34px;
}

.square:focus {
  outline: none;
}

.kbd-navigation .square:focus {
  background: #ddd;
}

.game {
  display: flex;
  flex-direction: row;
}

.game-info {
  margin-left: 20px;
}

Propsでデータを渡す

Props(properties)を用いて親から子に値を受け渡す。
Board(親)からSquare(子)に値を受け渡します。
渡す値は、Board.renderSquareの引数iです。

Board側

class Board extends React.Component {
  renderSquare(i) {
    return <Square value={i} />;
  }

Square側

class Square extends React.Component {
  render() {
    return <button className="square">{this.props.value}</button>;
  }
}

プロジェクトトップに移動し(cd 移動先)

npm start

します。

そしてhttp://localhost:3000にアクセスします。
image.png
おお!でた。
Boardから数字が渡ってきてマスの中に表示されているんですね。

マス目をクリックされたときのイベントを設定する

jQueryだと

$('button.square').click(() => {
  // 何か処理
});

素のJavaScriptだと

document.querySelectorAll('button.square').forEach((node) => {
  node.addEventListener('click', () => {
      // 何か処理
  });
});

となり、HTMLとは別個に記述します。

JSXの場合HTMLと一緒に書くようです。

class Square extends React.Component {
  render() {
    return (
      <button
        className="square"
        onClick={() => {
          // 何か処理
        }}
      >
        {this.props.value}
      </button>
    );
  }
}

た、多分JSXのほうがイイヨネ・・・。
今はこの独特の書き方が慣れてないんで微妙ですが、たくさん書けば良さを実感できるんでしょうか。

マス目の中身をXに変更する処理は、jQueryや素のJavaScriptのようにDOMAPIを呼ぶ書き方ではなく、stateを用いる方法で実現します。(コンパイル後には結局DOMAPIを呼ぶ書き方に変わりますが)

Square
constructorを追加し、引数にpropsを設ける
super(props)で継承元のコンストラクターを呼ぶ。お決まり
statevalueプロパティを持つオブジェクトを設定。valueプロパティの値はprops.valueを設定
onClick内ではsetStateする
・レンダー時のマス目の値はprops.valueからstate.valueに変更する

class Square extends React.Component {
  constructor(props) {
    super(props);
    this.state = { value: this.props.value };
  }

  render() {
    return (
      <button
        className="square"
        onClick={() => {
          this.setState({ value: 'X' });
        }}
      >
        {this.state.value}
      </button>
    );
  }
}

結果
ダウンロード (1).gif
ブラウザで確認時、Reactのコンポーネントツリーを調べる拡張機能があるらしい。
React Devtools 拡張機能

マス目の制御を行う

現在のマス目の内容の状態を取得するには、Board側から各Squareに取りに行くイメージでやればいいと思いがちですが、可読性が落ちたり、バグが起きやすいとのことです。
Board側で各Square側の状態を保持しておいて、propsを用いてやり取りするのがベストらしいです。

具体的にはまず、Board側にSquareに渡す値の情報を保持します。
→ マス目情報を長さ9つの配列として保持します。

Boad側

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = { squares: Array(9).fill(null) };
  }

Array.prototype.fillの説明

renderSquareメソッドで配列のi番目を渡すようにします。

  renderSquare(i) {
    return <Square value={this.state.squares[i]} />;
  }

そしてSquareからBoardを更新してもらうために、Squareに対して更新用の関数を渡してあげます。
上のvalue={this.state.squares[i]}に続いてonClickを定義。

renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]} // クロージャでiが保持されている
        onClick={() => {
          const squares = Object.create(this.state.squares); // 別の配列としてクローン
          squares[i] = 'X';
          this.setState({ squares: squares });
        }}
      />
    );
  }

更新処理が書かれたこの関数ごとSquareに渡るイメージです。
Square側のpropsに入っているイメージ。

iについて
renderSquare(i)のところのiはクロージャによって、最初にレンダーされた時の各Squareごとの値を保持しています。
クロージャ

onClick内の更新処理について
Reactのstateは直接更新は推奨されていないので、一度Object.createでコピーを作ってから、そのコピーを書き換えて、setSateで更新しています。
なぜ推奨されないか

チュートリアルではコピーのところはthis.state.squares.slice()でしたが、これは配列の中身がプリミティブ値(数値、文字列、真偽値)の1次元配列の時のみ有効です。配列の中にオブジェクトや、さらに配列が入っていたら、完全なコピーができません。(ディープコピー)

コピー元の配列(state)と同じメモリの場所を参照してしまい、この後これに変更を行ってしまうと、「元のstateを変更しない」に反します。
このチュートリアルでは1次元の数値の配列なのでOKですが、今後それ以外のケースが出てきたときに誤ってsliceを使わないようにObject.createで慣れておこうと思います。
Array.prototype.slice()

Square側

・onClickの中で自身のstateを更新していたのをBoardから渡ってきたpropsのonClickを呼ぶように変更します。
・さらに、マス目に表示していたthis.state.valuethis.props.valueに変更します。
 これを行うことにより、ボタン押下時のBoard側で変更されたstateの値がこのpropsに反映されます。
・stateを持つ必要がなくなったので、constructorを削除します。

class Square extends React.Component {

  // constructorは不要になったので削除

  render() {
    return (
      <button
        className="square"
        onClick={() => {
          this.props.onClick(); // BoardのonClickを呼び出す。
        }}
      >
        {this.props.value} // state → propsに変更
      </button>
    );
  }
}

ここまでのBoardとSquareの全文

class Square extends React.Component {
  render() {
    return (
      <button
        className="square"
        onClick={() => {
          this.props.onClick();
        }}
      >
        {this.props.value}
      </button>
    );
  }
}

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = { squares: Array(9).fill(null) };
  }

  renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => {
          const squares = Object.create(this.state.squares);
          squares[i] = 'X';
          this.setState({ squares: squares });
        }}
      />
    );
  }

  render() {
    const status = 'Next palayer: X';

    return (
      <div>
        <div className="status">{status}</div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}

関数コンポーネント

Squareはstateを持たなくてよくなったので、React.Componentを継承する必要もなくなります。
もっと簡潔に書けるようになるみたいです。

Square

function Square(props) { // 継承がなくなった
  return (
    // onClickのところがシンプルに
    // あとpropsにthisがいらない。引数で来ているので
    <button className="square" onClick={props.onClick}>
      {props.value}
    </button>
  );
}

手番の処理(XやOを実装)

先手はXにします。
Boardに「次の手番はだれか」を判定するためのフラグを設けます。
→「次の手番はXだ」というフラグを設けて、その値のtrue falseで判定します。
その判定により、XOを代入します。

Board

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
      xIsNext: true, // 先手はXなので初期値true
    };
  }

  renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => {
          const squares = Object.create(this.state.squares);

          // xIsNextがtrueならX、それ以外なら○
          squares[i] = this.state.xIsNext ? 'X' : 'O';

          // xIsNextは現在の反対を代入することで手番が変わる
          this.setState({ squares: squares, xIsNext: !this.state.xIsNext });
        }}
      />
    );
  }

ついでに画面に表示していた文字のところのNext palayer:も動的に書き換わるようにします。

  render() {
    const status = 'Next palayer: ' + (this.state.xIsNext ? 'X' : 'O');

あれ?でもこれここのstatusを書き換えて意味あるの?って思っちゃいました。
なぜなら動的に変更するものはstateに入れておかないとダメとおもっていたので・・・。
このrenderがマス目を毎回クリックするたびに毎回動くなら大丈夫ですけど・・・。

→ 確認したら毎回動いてました・・・。
でも毎回動くのって無駄なような気が・・・。
毎回このhtmlを作成しているってことですよね。
このチュートリアルは要素数が少ないからいいですけど、普通のwebアプリを作ろうとしたらめちゃめちゃ要素数多いページとかあるのにレスポンス大丈夫なんでしょうか・・・。
後日調べたいと思います。

ゲーム勝者の判定

動かしてみると分かりますが、今のままだとゲームが終わった後に何度でも入力が出来てしまいます。
なので、勝敗が着いたら何もさせないような制御を実装します。
また、どっちが勝ったかを分かり易くする為に、「勝者: ○○」の文字を表示させようと思います。

チュートリアルでは勝敗を判定してくれる関数を用意してくれています。これをコピーして貼り付けます。

チュートリアルではBoardの外に書いてますが、私は中に入れました。(renderの下)

// 勝敗判定関数
  calculateWinner(squares) {
    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],
    ];
    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;
  }

勝者を表示させるNext palayer:の部分を変更します。

render() {
    const winner = this.calculateWinner(this.state.squares);

    let status;
    if (winner) {
      status = '勝者:' + winner;
    } else {
      status = '次の手番: ' + (this.state.xIsNext ? 'X' : 'O');
    }

最後に

  • マス目が押された時に勝敗が決まっていたら
  • 既にマス目が埋まっていたら

何も処理しないようにします。

renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => {
          const squares = Object.create(this.state.squares);
          if (this.calculateWinner(squares) || squares[i]) {
            // 何もせずにreturn
            return;
          }

          squares[i] = this.state.xIsNext ? 'X' : 'O';
          this.setState({ squares: squares, xIsNext: !this.state.xIsNext });
        }}
      />
    );
  }

ifのところは
左の式は、this.calculateWinner(squares)の戻り値がnull以外なら勝敗が決したということです。
右の式は、suare[i]null以外なら既にマス目に値が入っているということです。

以下、完成した全文

index.js

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import * as serviceWorker from './serviceWorker';

function Square(props) {
  return (
    <button className="square" onClick={props.onClick}>
      {props.value}
    </button>
  );
}

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
      xIsNext: true,
    };
  }

  renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => {
          const squares = Object.create(this.state.squares);
          if (this.calculateWinner(squares) || squares[i]) {
            return;
          }

          squares[i] = this.state.xIsNext ? 'X' : 'O';
          this.setState({ squares: squares, xIsNext: !this.state.xIsNext });
        }}
      />
    );
  }

  render() {
    const winner = this.calculateWinner(this.state.squares);

    let status;
    if (winner) {
      status = '勝者:' + winner;
    } else {
      status = '次の手番: ' + (this.state.xIsNext ? 'X' : 'O');
    }

    return (
      <div>
        <div className="status">{status}</div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }

  // 勝敗判定関数
  calculateWinner(squares) {
    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],
    ];
    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;
  }
}

class Game extends React.Component {
  render() {
    return (
      <div className="game">
        <div className="gmae-board">
          <Board />
        </div>
        <div className="game-info">
          <div>{/* status */}</div>
          <div>{/* TODO */}</div>
        </div>
      </div>
    );
  }
}

ReactDOM.render(
  <React.StrictMode>
    <Game />
  </React.StrictMode>,
  document.getElementById('root')
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

動作結果
ダウンロード (1).gif

おわりに

ここまでで一旦、三目並べゲームが完成です。
Qiitaに書きながらなのでなかなか苦労しました・・・汗

チュートリアルではこの後「タイムトラベル機能」の追加の手順があるので、次回はそちらをやっていこうと思います。
履歴から手順を戻すやつですね。

GitHub・・・公式にソースあるしいらないかw

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

DockerでDjango+Reactの環境を構築する

目標

将来的にDocker 環境下で Django と React を連携させて Google Kubernetes Engine にデプロイしてポートフォリオを作りたい。
完全に独学で進めていますので間違いがあれば何卒ご指摘を。。

環境

Windows10 home で Docker Toolbox を使用しています。
DjangoRestFrameworkとReactで超簡単なTodoアプリケーションを作成します。
アプリケーションのコードはDJANGO for APIsのChapter3を参考にしています。

まずはローカルで始める

まずはローカルで Django と React の連携を ToDO アプリを作成しながら考えてみます。

ディレクトリを作成する

Docker Toolbox は Virtualbox を使って docker ホストを立てています。
コンテナのボリュームのマウントはC:Users/環境下がデフォルトで設定されているので、
Docker toolbox を使用する場合は User ディレクトリ下をおススメします。

# プロジェクトフォルダの作成
mkdir gke-django
cd gke-djagno
# ディレクトリを作成する
mkdir backend
mkdir frontend

Backend の開発を進める

backend は Django-rest-framework で API を作成します。
まずは backend から環境を作成してみます。

settings.py

cd backend
# 仮想環境の作成
python -m venv venv
# 仮想環境の有効化
venv\Scripts\activate
# Pythonパッケージのインストール
python -m pip install --upgrade pip setuptools
python -m pip install django djangorestframework python-dotenv
# Djangoのプロジェクトを始める。
django-admin startproject config .

backend ディレクトリ下でdjango-admin startproject config .とすることで、
backend下にconfigというプロジェクトフォルダが作成されました。

config/settings.py を編集していきます。
まずは基本的なことだけ編集します。
SECRET_KEY は.env に追記するのでコピーしておきましょう。

# config/settings.py

"""
Django settings for config project.

Generated by 'django-admin startproject' using Django 3.0.4.

For more information on this file, see
https://docs.djangoproject.com/en/3.0/topics/settings/

For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.0/ref/settings/
"""

import os
from dotenv import load_dotenv  # 追加

# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROJECT_DIR = os.path.basename(BASE_DIR)

# .envの読み込み
load_dotenv(os.path.join(BASE_DIR, '.env'))  # 追加

# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv('SECRET_KEY')

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

ALLOWED_HOSTS = ['*']


# Application definition

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'config.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'templates')],  # 変更
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'config.wsgi.application'


# Database
# https://docs.djangoproject.com/en/3.0/ref/settings/#databases

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
    }
}


# Password validation
# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]


# Internationalization
# https://docs.djangoproject.com/en/3.0/topics/i18n/

LANGUAGE_CODE = 'ja'

TIME_ZONE = 'Asia/Tokyo'

USE_I18N = True

USE_L10N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.0/howto/static-files/

STATIC_URL = '/static/'

# 開発環境下で静的ファイルを参照する先
STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')]

# 本番環境で静的ファイルを参照する先
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')

# メディアファイルpath
MEDIA_URL = '/media/''

settings.py 内で参照している.envを作成します。

# .envファイルの作成
type nul > .env

# .envにコピペしておいたSECRET_KEYを追加する
SECRET_KEY = '+_f1u^*rb8+%cn-4o*kjn_(15*wspz0*!c)@=ll08odexo88a4'

todo アプリを始める

python manage.py startapp todos

settings.py にアプリケーションを追加します。

# conig/settings.py

# Application definition

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'todos.app.TodosConfig'  # 追加
]

model をつくって migration して admin に登録します。

# todos/models.py
from django.db import models


class Todo(models.Model):
    title = models.CharField(max_length=200)
    body = models.TextField()

    def __str__(self):
        return self.title
$ python manage.py makemigrations todos
Migrations for 'todos':
  todos\migrations\0001_initial.py
    - Create model Todo

$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions, todos
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying sessions.0001_initial... OK
  Applying todos.0001_initial... OK
# todos/admin.py
from django.contrib import admin
from .models import Todo


admin.site.register(Todo)

管理ユーザーを作成して admin にログインして todo を 3 つほど登録します。

$ python manage.py createsuperuser
ユーザー名 (leave blank to use 'yourname'): yourname
メールアドレス: youraddress@mail.com
Password:
Password (again):
Superuser created successfully.

$ python manage.py runserver
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
March 10, 2020 - 23:41:26
Django version 3.0.4, using settings 'config.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CTRL-BREAK.

http://127.0.0.1:8000/adminにアクセスすると django-admin のログインページが開かれるので
createsuperuser で登録した内容でログインしてみましょう。
Todos を 3 つほど登録しておきましょう。

djangorestframework をはじめる

最初にpipでインストールしたrestframeworkを使用できるように config/settings.py を更新します。

# config/settings.py

# Application definition

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    # 3rd party
    'rest_framework',

    # Local
    'todos.apps.TodosConfig',
]

# 追加
REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.AllowAny',
    ]
}

rest_framework.permissions.AllowAnyは django-rest-framework が暗黙的に決めているデフォルトの設定'DEFAULT_PERMISSION_CLASSES'を解除するためのものです。
この設定はまだよくわかってないのですがとりあえず前に進みます。

todos/urls.py, todos/views.py, todos/serializers.pyを作成します。

URLs

config/urls.pyから各アプリケーションのurls.pyを追加します。

# config/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('todos.urls'))
]

todos/urls.pyを追加します。

$ type nul > todos\urls.py
# todos/urls.py
from django.urls import path
from .views import ListTodo, DetailTodo

urlpatterns = [
    path('<int:pk>/', DetailTodo.as_view()),
    path('', ListTodo.as_view())
]
Selializers

モデルインスタンスを json 形式へ変換するためのserializers.pyを追加します。

type nul > todos\serializers.py
# todos/serializers.py
from rest_framework import serializers
from .models import Todo


class TodoSerializer(serializers.ModelSerializer):
    class Meta:
        model = Todo
        fields = ('id', 'title', 'body')

fields = ('id', 'title', 'text')でのidは PrimaryKey を指定しない場合、
Django によって自動的に追加されます。

Views

Django Rest Framework でviews.pyを作成する場合はrest_framework.genericsの APIView を継承します。

# todos/views.py

from django.shortcuts import render
from rest_framework import generics
from .models import Todo
from .serializers import TodoSerializer


class ListTodo(generics.ListAPIView):
    queryset = Todo.objects.all()
    serializer_class = TodoSerializer


class DetailTodo(generics.RetrieveAPIView):
    queryset = Todo.objects.all()
    serializer_class = TodoSerializer

router など設定できていませんが、とりあえずは Todo アイテムを API として使用できるようになりました。
開発サーバーでhttp://127.0.0.1:8000/api/にアクセスすると APIview を確認することができます。

ここまでは Django でよくあるローカル環境での開発です。

CORS

CORS(Cross-Origin Resource Sharing)は React と Django を連携させる場合、React を起動したlocalhost:3000は Django の API サーバーlocalhost:8000
json のやり取りを行わせる必要があります。
django-cors-headersをインストールしましょう。

python -m pip install django-cors-headers

config/settings.pyを更新します。

# config/settings.py

# Application definition

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    # 3rd party
    'rest_framework',
    'corsheaders',

    # Local
    'todos.apps.TodosConfig',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'corsheaders.middleware.CorsMidddleware',  # 追加
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

##################
# rest_framework #
##################

REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.AllowAny',
    ]
}

CORS_ORIGIN_WHITELIST = (
    'http://localhost:3000',
)
Tests

テストを書きます。

# todos/test.py

from django.test import TestCase
from .models import Todo


class TodoModelTest(TestCase):

    @classmethod
    def setUpTestData(cls):
        Todo.objects.create(title="first todo", body="a body here")

    def test_title_content(self):
        todo = Todo.objects.get(id=1)
        excepted_object_name = f'{todo.title}'
        self.assertEqual(excepted_object_name, 'first todo')

    def test_body_content(self):
        todo = Todo.objects.get(id=1)
        excepted_object_name = f'{todo.body}'
        self.assertEqual(excepted_object_name, 'a body here')

$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.007s

OK
Destroying test database for alias 'default'...

うまくいったようです。

Frontend の開発を進める

Nodejs は予めインストールしておきましょう。

$ cd frontend
$ npx create-react-app .
$ yarn start

yarn run v1.22.0
$ react-scripts start
i 「wds」: Project is running at http://192.168.56.1/
i 「wds」: webpack output is served from
i 「wds」: Content not from webpack is served from
C:\--you-path--\gke-django-tutorial\frontend\public
i 「wds」: 404s will fallback to /
Starting the development server...
Compiled successfully!

You can now view frontend in the browser.

  Local:            http://localhost:3000
  On Your Network:  http://192.168.56.1:3000

Note that the development build is not optimized.
To create a production build, use yarn build.

フロントエンドのプロジェクトを React で開始することができました。
ブウラウザーでhttp://localhost:3000にアクセスすると React の Welcome ページが確認できます。

App.js

api のエンドポイントは以下のような形で api を返してくるので、これを意識しておきましょう。
まずは mock となるデータで試してみます。

[
  {
    "id": 1,
    "title": "test_title",
    "body": "body of test_title"
  },
  {
    "id": 2,
    "title": "test_title2",
    "body": "body of test_title2"
  },
  {
    "id": 3,
    "title": "test_title3",
    "body": "body of test_title3"
  }
]
// src/App.js

import React, { Component } from "react";
import axios from "axiso";
import "./App.css";

const list = [
  {
    id: 1,
    title: "test_title",
    body: "body of test_title"
  },
  {
    id: 2,
    title: "test_title2",
    body: "body of test_title2"
  },
  {
    id: 3,
    title: "test_title3",
    body: "body of test_title3"
  }
];

class App extends Component {
  constructor(props) {
    super(props);
    this.state = { list };
  }

  render() {
    return (
      <div>
        {this.state.list.map(item => (
          <div key={item.id}>
            <h1>{item.title}</h1>
            <p>{item.body}</p>
          </div>
        ))}
      </div>
    );
  }
}

export default App;

http://localhost:3000にアクセスするとモックデータが表示されました。
これを backendから取得したデータで表示させたいです。

axios

frontend でリクエストを叩くには build-in の Fetch APIaxios を使う方法がありますが、
今回は axios を使うことにします。

npm install axios --save
yarn start

App.js を書き換えます。

// src/App.js

import React, { Component } from "react";
import axios from "axios";
import "./App.css";

class App extends Component {
  state = {
    todos: []
  };

  componentDidMount() {
    this.getTodos();
  }

  getTodos() {
    axios
      .get("http://127.0.0.1:8000/api/")
      .then(res => {
        this.setState({ todos: res.data });
      })
      .catch(err => {
        console.log(err);
      });
  }

  render() {
    return (
      <div>
        {this.state.todos.map(item => (
          <div key={item.id}>
            <h1>{item.title}</h1>
            <p>{item.body}</p>
          </div>
        ))}
      </div>
    );
  }
}

export default App;

これでローカル環境で frontend から backend へ api をたたいて todo リスト一覧を表示させることができました。

超超単純な形ですが一応は Django と React の連携が取れました。

次はこれを Docker 化していきたいと思います。

Docker 化を進める

frontend, backend それぞれに Dockerfile を作成して backend コンテナ、frontend コンテナを作成してみます。

まずは Docker-compose で立ち上げられるところまでを考えていきます。

Backend の Docker 化

Dockerfile を書く前に Django 側でやっておきたいことがいくつかあります。

# 静的ファイ用のディレクトリ
$ mkdir backend\static
# 静的ファイルを全部集めてstaticifilesディレクトリに集められ
$ python manage.py collectstatic

本来であればデータベースの内容や settings.py を local と production で分けたりしますが、
まずは現在の形をそのまま Docker 化できることを考えます。

Dockerfile を backend ディレクトリ内に作成します。

$ type nul > backend\Dockerfile
# backend/Dockerfile

# set base image
FROM python:3.7

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# set work directory
RUN mkdir /code
WORKDIR /code

# install dependencies
COPY requirements.txt /code/
RUN python3 -m pip install --upgrade pip setuptools
RUN pip install -r requirements.txt

# Copy project
COPY . /code/

EXPOSE 8000

次にプロジェクトディレクトリに docker-compose.yml を設置して
docker-compose up で backend コンテナを起動できるようにしてます。

# docker-compose.yml
version: "3.7"

services:
  backend:
    build: ./backend/.
    command: python /code/manage.py runserver 0.0.0.0:8000
    volumes:
      - ./backend:/code
    ports:
      - "8000:8000"
$ docker-compose up

これでhttp://localhost:8000/api/にアクセスすると backend コンテナの DRF ビューにアクセスすることができました。
DockerToolbox を使っている場合は docker ホストの IP アドレスでアクセスしてください。

Frontend の Docker 化

続いて frontend 側を Docker 化していきます。

参考ページ
Dockerizing a React App
Creating an app with Docker Compose, Django, and Create React App
Using Docker for Node.js in Development and Production

frontend は React で構築しています。これを Docker 化するにはどうしたら良いでしょうか。
backend と同じように frontend ディレクトリに Dockerfile を作成します。

type nul > frontend\Dockerfile
# frontend/Dockerfile
FROM node:12.2.0-alpine

RUN mkdir /code
WORKDIR /code

# Install dependencies
COPY package.json /code/
COPY package-lock.json /code/
RUN npm install

# Add rest of the client code
COPY . /code/

EXPOSE 3000

これで node コンテナ内に package.json を使って同じ環境を構築することができます。
docker-compose.yml に frontend サービスを追加します。

# docker-compose.yml
version: "3.7"

services:
  backend:
    build: ./backend/.
    volumes:
      - ./backend:/code
    ports:
      - "8000:8000"
    stdin_open: true
    tty: true
    command: python /code/manage.py runserver 0.0.0.0:8000
    environment:
      - CHOKIDAR_USEPOLLING=true
  frontend:
    build: ./frontend/.
    volumes:
      - ./frontend:/code
      - /code/node_modules
    ports:
      - "3000:3000"
    command: npm start
    stdin_open: true
    tty: true
    environment:
      - CHOKIDAR_USEPOLLING=true
      - NODE_ENV=development
    depends_on:
      - backend

environment にCHOKIDAR_USEPOLLING=trueを追加することでイメージを再ビルドすることなく
ホットリローディングしてくれるようになります。

frontend に関しては node_modules が巨大であるため、これをマウントしたりコピーしたりすると
かなりの時間を要します。
したがって、.dockerignore を追加して node_modules をイメージビルドに使用しないようにしておきます(あってる?)。

$ type nul > frontend\.dockerignore
/node_modules

docker-compose up する前に

これで docker-compose up する準備が整いました、が、docker-toolbox を使っている場合は
ポートフォワーディングしているホスト名がlocalhostではありません。これをホスト IP に書き換える必要があります。
docker-machine ls コマンドを使って使用しているホスト IP を確認します。

backend/settings.py

手元のブラウザから frontend コンテナ ⇒backend コンテナにアクセスするため、
CORS_ORIGIN_WHITELISTに docker ホスト IP を追加する必要があります。

# backend/settings.py

CORS_ORIGIN_WHITELIST = (
    'http://localhost:3000',
    'http://192.168.99.100:3000',  # 追加
)

frontend/src/App.js

api のエンドポイントは docker ホスト IP になります。ここでは192.168.99.100:8000としています。

// src/App.js

import React, { Component } from "react";
import axios from "axios";
import "./App.css";

class App extends Component {
  state = {
    todos: []
  };

  componentDidMount() {
    this.getTodos();
  }

  getTodos() {
    axios
      .get("http://192.168.99.100:8000/api/") //変更
      .then(res => {
        this.setState({ todos: res.data });
      })
      .catch(err => {
        console.log(err);
      });
  }

  render() {
    return (
      <div>
        <h1>mother fucker!!?? </h1>
        {this.state.todos.map(item => (
          <div key={item.id}>
            <h1>{item.title}</h1>
            <p>{item.body}</p>
          </div>
        ))}
      </div>
    );
  }
}

export default App;

docker-compose up

docker-compose.yml のあるディレクトリ下で docker-compose up します。

$ docker-compose up --build

React のコンパイル完了には時間がかかります。

起動できたらhttp://localhost:3000にアクセスするとローカルで表示されていた内容が
再現されているはずです。

そしてGKEへ

できたら追加します。

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

ScaffoldHubってすごいのでは?

はじめに

最近のWebアプリを作るために様々な技術が登場しています。
結局どれで作るのが良いのだろうとふと疑問に思った時、思わず目に入ってきたサイトを見つけました。それがScaffoldHubでした。
より多くの人にScaffoldHubを知ってもらいたいと思い
(実は既に知っている人大多数説あり)、恥ずかしながら記事の初投稿を決意しました。

この記事はScaffoldHubの公式サイトに書いてあることを記載しているだけなので、詳しくは以下のURLの公式サイトや公式ブログを参照してください。
https://scaffoldhub.io/

ScaffoldHubとは

ScaffoldHubとは、JavaScriptで書かれたWebアプリのコード自動生成サービスです。選べるものとして、フロントエンドには御三家(React, Vue, Angular)、バックエンドはNodeJSのみ、DBにはSQL, MongoDB, Firebase Firestoreが選べるようです。Webアプリ生成した後は、生成されたソースを基に機能拡張ができるので、自分好みに改良できます。一からWebアプリを作らなくて良いのは魅力的です。

ScaffoldHubの価格

サンプルとしてWebアプリを動かすのは無料ですが、ソースコードのダウンロードはもちろん有料です。気になる価格ですが、なんと39ドル!!
これは安いのでは!?と思いました。公式サイトを見る限り、これ以上かかる費用はなさそうですが、購入を検討されている方は購入前に必ず詳細を確認してください。

ScaffoldHubの特徴

いくつか抜粋して、ScaffoldHubの特徴を記載します。

  • 認証機能
    ユーザ名、パスワード認証やfacebook, Twitter, Google認証が実装されているようで、これらの認証機能を一から実装する必要がないようです。

  • ユーザ・ロール、パーミッション管理
    それぞれを管理でき、権限制御ができるようです。

  • 監査ログ
    ユーザのアクション(ログイン、ページ遷移等)がログに記録されるようです。

  • モバイルとの親和性
    ScaffoldHubから生成したWebアプリはモバイル用のデザインも用意されているようです。

まだまだ、ありますが公式サイトを見ると全容がわかるので、詳細はそちらを参照しください、

終わりに

ScaffoldHubは、2019年5月からサービスを開始していたようですが、
ScaffoldHubに関する記事が見当たらなかったので、記事投稿をしてみました。(今回の記事がQiitaデビュー)
まだ、次回の記事はScaffoldHubをしっかり見れていないのでこれからデモを動かしたときの使用感を試していきたいと思います。

ScaffoldHubについて、知っている方や気になる方がいらっしゃればこの記事を機会にぜひ公式サイトをみて欲しいです。また、気軽なコメントもぜひお待ちしております。
https://scaffoldhub.io/

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