- 投稿日:2020-03-23T22:28:13+09:00
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的には問題ないので、ガンガン代入しちゃってください
- 投稿日:2020-03-23T22:14:11+09:00
電卓アプリを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 typescriptnpxは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.tsximport 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.tsximport 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.tsximport 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を見たほうが早いと思うので、説明の前に見せます。
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.tsimport { 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と引数の定義を行っています。
例えば、onNumClick
はINPUT_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.tsexport 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.tsimport { 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.tsximport 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.tsximport 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で初心者でも電卓が作れる!!!ことを願う。
- 投稿日:2020-03-23T21:00:55+09:00
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種類の便利なメソッドが使えるようになるよくらいの認識で良さそうです。
- 投稿日:2020-03-23T19:27:47+09:00
リモート卒展を支える技術2020
Webサイトをリリースしました
今年はコロナの影響で卒業式が中止になったりしましたね。
それと比べるとあまり知られてないかもしれませんが、
美大・アート系の学校の卒業展示、
いわゆる 卒展 も数多くが中止を余儀なくされました。部活でいうと引退試合が中止になったようなものです。
悲しいですね。そこで、卒展で展示する予定だった作品をTwitteにアップするムーブメントが起きました。
#桑沢2020 #かってに卒制展 などのハッシュタグで様々な作品が見れます。卒業制作
— 蓮(Hasu) (@hasu_ird) February 28, 2020
くつ下の柄によって靴の表情が変わる靴を制作。
透明や半透明の素材を使っている靴が増えていているけど『くつ下を見せるため』の靴はないよなぁってとこから発想しました。
LINE(くつ下の色彩を見せる)FRAME(くつ下の柄を見せる)の2種類デザインしました。#桑沢2020 pic.twitter.com/nq0apG4uzs#桑沢2020
— 藤原光平 (@fuji_kohei) February 27, 2020
私は紙で椅子をつくりました。
桑沢の卒展は、自分にとってすごくすごく特別な想いがありました。
小学生から憧れ続けた桑沢卒展、中止。 pic.twitter.com/2Q1oZ0RvAmこれらの作品をアーカイブしたのが今回の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の真の力をお見せします
GatsbyはGraphQLを介してデータにアクセスするのですが、
ビルド時には、ローカルのGraphQLからデータを抽出して静的ファイルを出力します。
それに加えて、ブラウザでの実行時に外部のデータソースと接続してアプリにマージすることもできるのです。例えばブログアプリを作るときに、記事はマークダウンとしてソースに含めてビルドし、
スター数やコメントはDynamoDBに保存してAppSync経由でリアルタイムに取得・表示する、
といった設計とかが可能。データソースをどうするかで迷う
ブログやコーポレーションサイト、ポートフォリオなどの事例が多く、
それらではファイルシステムがデータソースとなっていました。
任意のディレクトリにマークダウンとしてデータを記述しておく形です。今回使うデータは、元ツイートと、ツイートに含まれる画像の2つです。
Twitter APIの制限もあるし、コンテンツもちゃんとフィルタリングしたかったので、
静的に固めることは早々に決めたのですが、マークダウンはちょっと抵抗があります。最初はローカルでMySQLを立てて実装しました。
データベースの接続もプラグインによって幅広くサポートされています。ただ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&ref_src=twsrc%5Etfw\">#桑沢2020</a> <br><br>私は紙で椅子をつくりました。<br><br>桑沢の卒展は、自分にとってすごくすごく特別な想いがありました。<br><br>小学生から憧れ続けた桑沢卒展、中止。 <a href=\"https://t.co/2Q1oZ0RvAm\">pic.twitter.com/2Q1oZ0RvAm</a></p>— 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を使ってたのはこういう事情もある)。
流れとしてはこんな感じ。
- それっぽいハッシュタグで検索をかける。
- 取得したツイートでサイトに展示するものを目視で選定する。
- 選定したツイートの埋め込みコードを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のような無限スクロールを実装した。
IntiniteScroll
のdataLength
が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枚の画像が含まれるので、それらを取り出してランダムに並べる画面を別で作っています。
無限スクロールで仕組みは前述のものと同じです。
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使い方には
fluid
とfixed
があって、
コンテナのサイズに応じて伸び縮みする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と一緒に使うと表示されなかったり、なかなか使い勝手が難しいライブラリです?(※ホバー時に元ツイートの情報を表示するようにしてたりする)
スタイルの問題は他にもあって、
Pinterest風にマルチカラムにするのにCSSのcolumnsプロパティを使っているんですが、
画像の高さがそれぞれ違うので次ページ読み込み時に並び替えが起こってしまいます。
columnsはコンテンツを左から埋めていくので、
画像が追加された時に前ページの画像が全て左に寄せられてしまい、
見ていたコンテンツが消える事象が発生することなります。これではユーザーが?となるので、これまで見ていたページの並びは固定するために、
ページごとにcolumnsを指定するコンテナでラップすることにしました。画像の組み合わせによっては不要な余白が生じてしまうのですが。
別の仕組みでグリッドレイアウトを作れば良い話なんですが、
時間があるときでもやろうと思います。外部画像を最適化するトリック
ソースファイルに含まれているデータは
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&ref_src=twsrc%5Etfw\">#桑沢2020</a> <br><br>私は紙で椅子をつくりました。<br><br>桑沢の卒展は、自分にとってすごくすごく特別な想いがありました。<br><br>小学生から憧れ続けた桑沢卒展、中止。 <a href=\"https://t.co/2Q1oZ0RvAm\">pic.twitter.com/2Q1oZ0RvAm</a></p>— 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
となっています。
GatsbyJSの仕様では、プロジェクトルートに
static
という名前のフォルダを置いて、
画像などのアセットを含めるのが通例です。
そうすると、ビルド時に成果物のpublic
の下にstatic
が残ります。しかし今回は
static
の下に画像を置くのではなく、JSONにリモートのURLを記述しているだけです。
これをビルドの成果物に含める、かつ、先述のgatsby-image
の最適化を施す必要があります。gatsby-imageに処理をさせるためには、なんらかの形でこのURLをGraphQLのNodeに変換する必要があるのですが、これを
createRemoteFileNode
という Gatsby Node APIs を利用して実現します。
そしてGraphQLのresolverとしてこのNodeを登録します。
(うまく説明できなくてすまぬ...)
GraphQLのリゾルバとはコードはこんな感じ。
gatsby-node.jsexports.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にあるフィールドを追加したければ、
onCreateNode
でcreateNodeField
を呼ぶのがシンプルで簡単な方法です。
実際 チュートリアル でもそうやっています。わざわざ
createResolvers
なんてややこしいのを使っているのはちょっとしたハックで、
これが、全てのスキーマの処理が完了してから最後に呼ばれるAPIだからです。
TweetsJson
というスキーマの元はJSONファイルであり、
このファイルをまずGraphQLのスキーマに変換する処理が発生します。
必ずこの作業が終わった後に処理をする必要があるため、createResolvers
にしてあるというわけです。これで無事に
gatsby-image
が処理できる形にリモートの画像URLが変換されたので、
ビルド時に様々なデバイスサイズ、解像度向けにstatic
配下に画像が生成されるようになります。作品ページの生成
作品ごとのページを作ります。
チュートリアルでは、マークダウンから記事ページを作成しています。
Programmatically create pages from data
ユースケースとしてはこういう使い方が多いと思いますが、今回のデータソースはJSONです。GatsbyJSでは、あらかじめ
src/pages
に置いてあるファイルはビルド時に自動的にページとして出力されています。それ以外に任意にページを作成したい場合は、先述の
gatsby-node.js
からcreatePage
APIを呼び出すことになります。gatsby-node.jsexports.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.jsexport default ({data, pageContext}) => { }
id
は該当記事を取得するページクエリで$id
で参照します。js/src/templates/work.jsexport 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はこんな感じ。
— 青いエンジニア? (@itmono_sakuraya) March 21, 2020通常のOGPと変わっていることがわかります。
#桑沢2020 #かってに卒制展 #岡山県立大学卒展2020 #日本工学院卒制展https://t.co/Q8Sgg3F5ot
— 青いエンジニア? (@itmono_sakuraya) March 21, 2020
(アライグマ(racoon)は Corona のアナグラムです)ホスティング
このサイトは CloudFront で配信しています。
S3のWebホスティング機能は使用していません。独自ドメイン(CNAME)とSSLも設定済み。
Origin Access Identity を指定して、バケットの直接参照は禁止しています。
以下バケットポリシー
{ "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 を最優先で設定します。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
の設定をします。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サイトを構築できる手段だと思って使うと痛い目を見るかもしれません。この記事でも紹介した通り、基本静的で動かしたい、でも動的にしたいところもある、
というユースケースでは力を発揮するんじゃないですかね。何はともあれみなさんアートを楽しんでください。
— 青いエンジニア? (@itmono_sakuraya) March 22, 2020
- 投稿日:2020-03-23T18:53:24+09:00
【WordPress】カスタムブロックの作り方を書いてみた
はじめに
WordPress 5.0からGutenbergと呼ばれるブロックエディターがデフォルトのエディターとして採用されました。
そのため、今後はこのブロックエディターによる開発が増えてくると思われます。個人的には、今までのWysiWygエディターに不満を感じていたわけではありませんが、ブロックエディターという選択肢が増えたことで、できる事の幅が増えてくる と思います。
本記事では、このブロックエディターの新規ブロック(以降、カスタムブロック)の作り方を記載して行きます。ブロックエディターとは?
ブロックエディターは、名前の通り、HTMLをブロックように積み上げてHTMLを作成していくエディターです。
詳細は、以下を参照ください。
https://ja.wordpress.org/gutenberg/
ブロックエディターの特徴の1つとして、上記リンク先のページにも記載がありますが、 実際のサイトと同様に表示されるエディター。 であることです。
これから記載するカスタムブロックも、実際のサイトと同様に表示されるよう作成して行きます。また、WordPressのブロックエディターは、大きく分けて以下の2種類のカスタムブロックを作成できます。
- 動的ブロック ・・・ 最新の投稿を表示するブロック等の動的にブロックの内容が変化するブロック
- 静的ブロック ・・・ 動的とは反対で、画像アップロードやテキストフィールド等のブロックの内容が変化しないブロック
これから記載するカスタムブロックは静的ブロックを作成して行きます。(動的ブロックは別途記載予定)
作成するカスタムブロック
以下のようなテキストが書けて、背景色をサイドナビゲーションから選択できるカスタムブロックを作成して行きます。
サンプルブロックは、エディターと画面表示(以降、フロント)にCSSが適応されるブロックと、エディターのみCSSが適応されるブロックの2種類作成します。環境準備
以下がインストールされている事が前提です。
- 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"], }プラグインとしてカスタムブロックを開発
カスタムブロックを作成する流れを図にすると以下のような感じです。
どこから作成しても問題ありませんが、カスタムブロック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_script
、editor_style
、script
、style
の4種類があります。
4つの違いは以下の通りです。
editor_script
・・・ エディター画面の時のみスクリプトを読み込むeditor_style
・・・ エディター画面の時のみスタイルを読み込むscript
・・・ エディター・フロントの両画面でスクリプトを読み込むstyle
・・・ エディター・フロントの両画面でスタイルを読み込む例えば、本記事の最初に見せたブロックをプレビューしてフロントから見てみると以下のように表示されます。
上:エディターとフロントにCSSが適応されるブロック(
script
、style
)を使用した場合
下:エディターのみCSSが適応されるブロック(editor_script
、editor_style
)を使用した場合上の方は、エディターで指定した背景色(赤色)がフロントに適用されていることがわかります。
対して、下の方はエディターで指定した背景色(緑色)がフロントには反映されていないことがわかります。最後にHookですが、
editor_script
、editor_style
を使用する場合はenqueue_block_editor_assets
を使いましょう。
script
、style
を使用する場合はenqueue_block_assets
を使いましょう。
ちゃんとブロックエディター用のHookが用意されているので、適切なHookを使用するようにしましょう。間違ってもinit
は使用しないようにしましょう。さいごに
というわけで、長々とカスタムブロックの作り方を書いてみましたが、いかがだったでしょうか。
個人的に一番苦労したのはwebpackの設定です。
私自身バックエンドのエンジニアなので、webpackの設定方法を理解するのにえらい時間が掛かりました・・・
カスタムブロックはモダンな作りになっていて面白いのですが、学習コストが高いのが辛いところですね。今度は、動的ブロックの作成にも挑戦したいと思います。
- 投稿日:2020-03-23T18:34:17+09:00
Babel Pluginでdata-testid属性を自動で付加する
昨今のReactのテスト事情は、react-testing-library が主流です。
create-react-app でデフォルトで生成されるテストテンプレートもすでに@testing-library
が使われるようになっています。また、携わっていたプロジェクトでも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-testidapi.envを用いて、test環境だけ有効にすることをオススメします。
babel.config.jsmodule.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立ててください。
使ってみていい感じだなと思ったら していただけると嬉しいです。
- 投稿日:2020-03-23T11:00:59+09:00
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
を作成します。Gemfilesource "https://rubygems.org" gem "rails", "6.0.2"Gemfile.lockFROM 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.ymlversion: '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=mysqldb設定を変更します。
database.ymlusername: root password: root #docker-compose.ymlのMYSQL_ROOT_PASSWORD host: db #docker-compose.ymlのサービス名今回も gem
react-rails
を利用するのでGemfileに追記します。Gemfilegem 'react-rails'再度 build して
$ docker-compose buildreactを使うので下記コマンドを実行
$ docker-compose run app rails webpacker:install $ docker-compose run app rails webpacker:install:react $ docker-compose run app rails generate react:installmodel 作成
$ 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 indexlists_controller.rbclass ListsController < ApplicationController def index @lists = List.all end endindex.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.jsimport 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> ); } }動作確認
無事に一覧が表示されました。
simple CRUD の実装
railsにapiを追加します。
apiで行うアクションは index, create, update, destroy です。
/api/v1/xxx
でアクセスできるようにrouteを設定し、controllerを追加します。routes.rbRails.application.routes.draw do get 'lists/index' namespace :api do namespace :v1 do resources :lists, only: [:index, :create, :update, :destroy] end end endapp/controllsers/api/v1/lists_controllser.rbclass 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 endcontrollerには基本的なメソッド、そして
protect_from_forgery with: :null_session
を記述しました。
http://localhost:3000/api/v1/lists
でindexが呼び出されリストが取得できるはずです。index
reactからapiを利用してlists を取得します。
componentDidMountを書き換えます。ListsIndex.jscomponentDidMount(){ 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ボタンが追加されており、要素の削除が行えます。
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> ); }ここで利用する
handleChange
とhandleCreate
を実装します。
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ボタンのクリックにより要素の追加を行えます。
完成したListIndex.js
ListIndex.jsimport 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利用の実装を行いました。
自分用のまとめですが、誰かの助けとなれば幸いです。
- 投稿日:2020-03-23T10:36:22+09:00
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
をアクセスする処理を削除すると、余計なアニメーションがなくなり、真白画面もなくなる。対応
Reflowを発生させる上記処理をsetTimeoutで遅延することで、余計なアニメーションがなくなる。
参考記事
- 投稿日:2020-03-23T04:31:09+09:00
PhpStormでReact+TypeScript+styled-componentsをはじめる
はじめに
Reactとstyled-componentsが美味しいのかどうかも知りませんが、とりあえず試食コーナーの構築手順です。
プロジェクト作成
下記を選択します。
「Happy hacking!」って言われるまで、しばし待ちます。
React動作確認
styled-componentsインストール
Package 'styled-components' Installed successfullyと表示されたら、Settingsをすべて閉じます。
terminalで
npm install @types/styled-components
を実行します。1
styled-componentsでApp.tsxを書き直す
- 下記のように
src/App.tsx
を書き直します。src/App.tsximport 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
おわりに
Happy hacking!
- 投稿日:2020-03-23T02:27:35+09:00
【React】チュートリアルの三目並べをやる #1
前回まではProgateの無料レッスンを一通りこなしました。
今回からはアプリを作成するアウトプットを行っていこうと思います。
または、自分が新たに習得した知識の共有等も行えたら良いなと思います。React公式チュートリアル
Reactの公式チュートリアルに三目並べ
ゲームがあり、なぜかこれが気になってしまうので、作っていきたいと思います。最終成果物は → 三目並べ
ゲームこれをチュートリアルに沿って作るのが目標です。
前提知識
アロー関数、クラス、
let
およびconst
が理解できていることが前提らしい。
完璧には理解できていないが、わからなければググるので問題なし。チュートリアルの準備
開発環境は以下の2つ
- ブラウザで書く
- ローカルに開発環境を構築して書く
以前Progateの無料レッスンをやっているので、ローカルに構築済みです。
Progate無料版をやってみる【React】以下のソースコードを元に作成する模様。
https://codepen.io/gaearon/pen/oWWQNa?editors=00103つのコンポーネントで構成される
- Square: マス目を表し、buttonタグをレンダー
- Board: 9個Squareをreturnしている。マス目全体
- Game: あれ、これ何を表しているんだ? チュートリアルには「後ほど埋めることになるプレースホルダーを描画しています」とのこと。後ほど書くのね。
とりあえず元となるソースをコピペまたは模写していきます。
index.jsを変更しちゃいます。index.jsimport 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.cssbody { 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
にアクセスします。
おお!でた。
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)
で継承元のコンストラクターを呼ぶ。お決まり
・state
にvalue
プロパティを持つオブジェクトを設定。value
プロパティの値はprops.value
を設定
・onClick
内ではsetState
する
・レンダー時のマス目の値はprops.value
からstate.value
に変更するSquareclass 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> ); } }結果
ブラウザで確認時、Reactのコンポーネントツリーを調べる拡張機能があるらしい。
React Devtools 拡張機能マス目の制御を行う
現在のマス目の内容の状態を取得するには、Board側から各Squareに取りに行くイメージでやればいいと思いがちですが、可読性が落ちたり、バグが起きやすいとのことです。
Board側で各Square側の状態を保持しておいて、propsを用いてやり取りするのがベストらしいです。具体的にはまず、Board側にSquareに渡す値の情報を保持します。
→ マス目情報を長さ9つの配列として保持します。Boad側
Boardclass Board extends React.Component { constructor(props) { super(props); this.state = { squares: Array(9).fill(null) }; }renderSquareメソッドで配列のi番目を渡すようにします。
BoardrenderSquare(i) { return <Square value={this.state.squares[i]} />; }そしてSquareからBoardを更新してもらうために、Squareに対して更新用の関数を渡してあげます。
上のvalue={this.state.squares[i]}
に続いてonClick
を定義。BoardrenderSquare(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.value
はthis.props.value
に変更します。
これを行うことにより、ボタン押下時のBoard側で変更されたstateの値がこのpropsに反映されます。
・stateを持つ必要がなくなったので、constructor
を削除します。Squareclass 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とBoardclass 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を継承する必要もなくなります。
もっと簡潔に書けるようになるみたいです。Squarefunction Square(props) { // 継承がなくなった return ( // onClickのところがシンプルに // あとpropsにthisがいらない。引数で来ているので <button className="square" onClick={props.onClick}> {props.value} </button> ); }手番の処理(XやOを実装)
先手は
X
にします。
Boardに「次の手番はだれか」を判定するためのフラグを設けます。
→「次の手番はX
だ」というフラグを設けて、その値のtrue
false
で判定します。
その判定により、X
とO
を代入します。Board
Boardclass 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:
も動的に書き換わるようにします。Boardrender() { 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:
の部分を変更します。Boardrender() { const winner = this.calculateWinner(this.state.squares); let status; if (winner) { status = '勝者:' + winner; } else { status = '次の手番: ' + (this.state.xIsNext ? 'X' : 'O'); }最後に
- マス目が押された時に勝敗が決まっていたら
- 既にマス目が埋まっていたら
何も処理しないようにします。
BoardrenderSquare(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.jsimport 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();おわりに
ここまでで一旦、三目並べ
ゲームが完成です。
Qiitaに書きながらなのでなかなか苦労しました・・・汗チュートリアルではこの後「タイムトラベル機能」の追加の手順があるので、次回はそちらをやっていこうと思います。
履歴から手順を戻すやつですね。GitHub・・・公式にソースあるしいらないかw
→ 次回
- 投稿日:2020-03-23T02:27:35+09:00
【React】チュートリアルの三目並べゲームをやる
前回まではProgateの無料レッスンを一通りこなしました。
今回からはアプリを作成するアウトプットを行っていこうと思います。
または、自分が新たに習得した知識の共有等も行えたら良いなと思います。React公式チュートリアル
Reactの公式チュートリアルに三目並べゲームがあり、なぜかこれが気になってしまうので、作っていきたいと思います。
最終成果物は → 三目並べゲーム
これをチュートリアルに沿って作るのが目標です。
前提知識
アロー関数、クラス、
let
およびconst
が理解できていることが前提らしい。
完璧には理解できていないが、わからなければググるので問題なし。チュートリアルの準備
開発環境は以下の2つ
- ブラウザで書く
- ローカルに開発環境を構築して書く
以前Progateの無料レッスンをやっているので、ローカルに構築済みです。
Progate無料版をやってみる【React】以下のソースコードを元に作成する模様。
https://codepen.io/gaearon/pen/oWWQNa?editors=00103つのコンポーネントで構成される
- 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
にアクセスします。
おお!でた。
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)
で継承元のコンストラクターを呼ぶ。お決まり
・state
にvalue
プロパティを持つオブジェクトを設定。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> ); } }結果
ブラウザで確認時、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) }; }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.value
はthis.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
で判定します。
その判定により、X
とO
を代入します。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();おわりに
ここまでで一旦、三目並べゲームが完成です。
Qiitaに書きながらなのでなかなか苦労しました・・・汗チュートリアルではこの後「タイムトラベル機能」の追加の手順があるので、次回はそちらをやっていこうと思います。
履歴から手順を戻すやつですね。GitHub・・・公式にソースあるしいらないかw
- 投稿日:2020-03-23T01:13:12+09:00
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 frontendBackend の開発を進める
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 todossettings.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 = TodoSerializerrouter など設定できていませんが、とりあえずは 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 API
かaxios
を使う方法がありますが、
今回はaxios
を使うことにします。npm install axios --save yarn startApp.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 Productionfrontend は 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: - backendenvironment に
CHOKIDAR_USEPOLLING=true
を追加することでイメージを再ビルドすることなく
ホットリローディングしてくれるようになります。frontend に関しては node_modules が巨大であるため、これをマウントしたりコピーしたりすると
かなりの時間を要します。
したがって、.dockerignore を追加して node_modules をイメージビルドに使用しないようにしておきます(あってる?)。$ type nul > frontend\.dockerignore/node_modulesdocker-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 --buildReact のコンパイル完了には時間がかかります。
起動できたら
http://localhost:3000
にアクセスするとローカルで表示されていた内容が
再現されているはずです。そしてGKEへ
できたら追加します。
- 投稿日:2020-03-23T00:25:52+09:00
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/