Next.js + Vercel + TypeScript + Firebaseでアプリを作る。GraphQL 死合終了編
前回の記事からだいぶ間が空いてしまった。
ブログの管理画面を見るまで2月には記事を書いてるもんだと思ってた。
最後に作ってたブランチは以下:
サーバー側:https://github.com/Ryu-u/soundfind_app/tree/feature/create-posts-new-page
フロント側:
https://github.com/Ryu-u/soundfind_web/tree/feature/create-posts-new-page
以下は敗者コメントです。
終了の原因
フロント側からApollo Clientでファイルをあげる場合、サーバー側でApollo Serverを使ってる場合はgraphql-uploadというライブラリを使わないといけなさそうだが、このissueを見る限り、Firebase Cloud Functionsにはまだ対応してなさそうだった。
待てば良さそうではあったけど、いい加減この素振りに飽きてきたので終わることにした。
使った技術所感
Firebase
Firebase全体として、本番レベルで使うには結構鍛錬がいるように思った。
当たり前だけど、エイヤ!で使えるようになるモノではないと思う。
Firebase Auth
いいね!楽ちん!
Firebaseのローカルエミュレーターを参照しないみたいな問題があったり、currentUser
のチェックにはonAuthStateChanged
を事前に使ってないと取れない、みたいなのがあったりと、割と癖があるなとも思ったけど、全体としてなかなか使いやすかった。
Firebase Cloud Functions(FCF)
まあまあ。
ただ、今回の死合終了になった原因のように、Expressとかではできるけど、FCFではできないんだよなー、みたいなのは割とありそう。
使うライブラリ次第では、FCFに対応しきれてないみたいなのもありそうな気がした。知らんけど。
Firestore
微妙。
どうしてもRDBの考え方でデータ設計しちゃって結構手こずった。
あとは、クエリの制限があったりするのも微妙だと思う。
今回はサービスの成長とか考えてなかったけど、実際のサービスに使うときは割と早い段階でFirestoreを使うことに限界がくるんじゃないかなあって気がした。
次何か作るときはPostgreSQLとかを使いたい。
GraphQL
最初のうちはGraphQLがどんなものかよくわかってなかったけど、REST APIのラッパーみたいな感じだなと思ったら結構すんなり理解できた。
GraphQLの仕組みは好きだけど、今のところは本番で使うと痒いところに手が届かなかったりして結構てこずりそうだなって思った。
フロントにはApollo Client、サーバーにはApollo Serverを使って、両サイドのスキーマの共有にはgraphql-codegenを使った。
graphql-codegenを使うと、Typescriptの型まで生成してくれるから楽。
ただ、生成された型の取り回しに難儀した記憶がある。
空で返る時のresolverでの戻り値の型指定はどうすればいいのか分からなかったから、ひとまず各プロパティをoptionalにしたんだよな。
Next.js
ReactでSPA作ってる時と基本的には変わらなかったから割と使いやすいんじゃないかと思う。
ただ、どういうデータをSSR時に取得しておくか、とかはいまいちよく分からないなあ。
表示するページで使う不変なデータ(マスターデータ等)はgetServerSideProps
で取得しておけば良さそうだけど、どうなんだろう。
そこが一番悩みどころだったな。
React Tool Kit
これ、ネットで見ると賛否両論だけど、個人的には使いやすくてよかった。
初めは「Slice...?」ってなってたけど、慣れたら楽。
非同期のアクションまで作れるのはいいなと思う。
全体を通しての感想
今回作ってたアプリは、元々は知り合いの「音作りのためのSNSがあったらいいな」っていうアイデアが元になってて、そいつが中身を作って俺がその中身を入れる箱( = アプリ)を作るって話だった。
だけど、コロナの影響などでそいつの生活が厳しくなってきたらしく途中からフェードアウトしていった。
なので、代わりに俺の勉強のためにこれまで使ったことがなかった技術やライブラリを使って作ってみるか、と言う感じで作り始めた。
ただ、まあよく分からんエラーが出まくって、それが俺の実装じゃなくてライブラリ自体に問題があったりとかが結構あって途中で結構消耗していった。
あとは、途中で体調を崩したり、仕事の依頼が来たり(結局流れたが)、その他プライベートで色々あったりもしてモチベーションも下がっていってた。
冒頭に載せたリポジトリは正直ブラッシュアップもしてないし、今のままだとエラーで動かないまであるしで全然ダメダメなんだけど、まあ記念ということで載せておくことにする。
でもまあ、これまでの現場経験で使ったことがなかった技術を色々触れ、小並感ではあるもののそれらに対する自分の感想を持てたからよかったってことにする。
次何かを勉強するときは、もっと細かく刻んで触っていこうと思います。
Next.js + Vercel + TypeScript + Firebaseでアプリを作る。GraphQL事前準備編
0. 今回やること
前回の記事はこちら。
今回は、GraphQLを使うための前準備をする。
やることとしては、Cloud Firestoreに入っているデータをGraphQLを使って返す、と言う感じ。
1. 事前準備
データの設定
まずはFirestoreの設定から。
と言っても、今回はまだデプロイしたりはしないので、どちらかと言うと開発用のデータの設定からやっていく。
今回使うデータは以下:
userProfiles: [ { id: 1, bio: "hogehoge" }, { id: 2, bio: "fugafuga" } ]
このデータを、Dockerコンテナの中で firebase emulators:start
をして立ち上がるローカル用コンソール画面 http://localhost:4000
の Firestore
の項目から入力。
その後、一度コンテナ内で
$ firebase emulators:export ./seed
をして、プロジェクトルートディレクトリ直下の /seed
というディレクトリにこのデータをエクスポートしておく。
その後、package.json
のスクリプトにあるserve
を
"serve": "npm run build && firebase emulators:start --only functions"
から
"serve": "firebase emulators:start --import=../seed",
にしておく。
こうすることで、エミュレータ立ち上げ時に、エクスポートしたデータがインポートされる(npm run build
を無くした理由は後述)。
ただ、これだと手動でエクスポートする羽目になるので、前回の記事にも書いたこの方法をしたほうがいいかも。
<2020/11/26 追記>
Firebase Firestoreのエミュレーターを使ってローカルで開発する←こちらを読ませてもらったところ、--export-on-exit
というすごく便利なoptionがあることがわかった。
/hogehoge # firebase emulators:start -h Usage: firebase emulators:start [options] start the local Firebase emulators Options: ...中略... --import [dir] import emulator data from a previous export (see emulators:export) --export-on-exit [dir] automatically export emulator data (emulators:export) when the emulators make a clean exit (SIGINT), when no dir is provided the location of --import [dir] is used -h, --help output usage information
とのことらしいので、このオプションもつけておけばよさそう。
感謝。
公式を見てもエミュレータの細かい使い方がいまいちわからなかったんだけど、-h
やるのは大事だね。
Cloud Functionsで使うTypeScriptファイルの自動再コンパイル設定
やることとしては、
tsc --watch
を追加するだけなのだが、package.json
のスクリプトを
"serve": "tsc --watch && firebase emulators:start --import=../seed"
という風にしても、tsc --watch
と firebaseエミュレータの両方でターミナルを使うため、うまく行かない。
なので 、
"watch": "tsc --watch", "serve": "firebase emulators:start --import=../seed",
という風にして、watch
とエミュレータを異なるターミナルで実行できるようにした。
ターミナルのタブを2つ使うのがめんどくさいけど、もっと良い方法があるのかな。
これで事前準備は終わり。
2. Apollo Serverの設定
次はApollo Serverの設定をしていく。
Apollo ServerはGraphQLを用いてのデータのやり取りをするためのサーバー。
Cloud Functions向けのライブラリも出てるので、これを使うことにした。
と言っても、今の段階では
Cloud Functions for Firebaseを使ってApollo Serverを構築する - 雑食日誌
Apollo ServerからFireStoreのデータを取得する - 雑食日誌
こちらの記事を参考にさせてもらった感じ。
まずは必要なライブラリのインストールから。
# npm i apollo-server-cloud-functions
で、現時点でのfunctions/src/index.ts
は
const { ApolloServer, gql } = require('apollo-server-cloud-functions'); const typeDefs = gql` type UserProfile { id: Int! bio: String! } type Query { userProfiles: [UserProfile]! } `; const resolvers = { Query: { userProfiles: async () => { const snapshot = await db.collection('userProfiles').get() return snapshot.docs.map((doc) => doc.data()) }, }, } const server = new ApolloServer({ typeDefs, resolvers, }) export const helloWorld = functions.https.onRequest(server.createHandler())
という感じ。
resolvers
は別ファイルに分けてもいいかも。
これでコンパイルが通ったら、http://localhost:5001/<エミュレータ立ち上げ時に出るpath>/helloWorld
にPostmanなどを使ってPOST
で
query { userProfiles { id, bio } }
のようなリクエストを投げて、設定したデータが返ってきたらOK。
この記事を書いてて気づいたけど、resolvers
は別ファイルにしたほうがよさそう。
3. GraphQLの設定
↑の例だと、gql
に渡すテンプレートリテラルの中でGraphQLのスキーマを設定しているので他のファイルに外出ししたい。
ちなみに、Apolloのサンプルコードも大体スキーマはテンプレートリテラルに直書きとなってることが多い。
ただ、これだとスキーマが増えたりした時に可読性が下がって萎えるので、今のうちからファイルを分けておくことにした。
色々ググってみたところ、GraphQL Tools
のこの記事が役に立ちそうだったので、これでやってみる。
まずは必要なライブラリのインストールから。
# npm i graphql-tools
で、ディレクトリ構成はこんな感じにした:
. └── /project root/ └── /functions/ ├── /src/ │ └── index.ts // Cloud Functions用のファイル ├── /graphql/ │ ├── /schemas/ │ │ └── userProfile.gql │ └── /queries/ │ └── userProfiles.gql └── /lib // index.tsのコンパイル後のファイルの格納先
各gqlファイルは以下:
# functions/graphql/schemas/userProfile.gql type UserProfile { id: Int! bio: String! }
# functions/graphql/queries/userProfiles.gql type Query { userProfiles: [UserProfile]! }
その後、gqlファイルを読み込むために functions/src/index.ts
を以下のように変更:
# functions/src/index.ts import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { ApolloServer } from 'apollo-server-cloud-functions' import path from 'path' import { mergeTypeDefs, loadFilesSync } from 'graphql-tools' const typesArray = loadFilesSync(path.resolve(__dirname, '../graphql'), { recursive: true, }) const typeDefs = mergeTypeDefs(typesArray) admin.initializeApp() const db = admin.firestore() const resolvers = { Query: { userProfiles: async () => { const snapshot = await db.collection('userProfiles').get() return snapshot.docs.map((doc) => doc.data()) }, }, } const server = new ApolloServer({ typeDefs, resolvers, }) export const helloWorld = functions.https.onRequest(server.createHandler())
これでテンプレートリテラルにスキーマを書かなくても良くなった。
4. VSCodeの設定
最後に、VSCodeでスキーマの定義をしやすくするために、ファイルをまたいでの補完ができるようにしておく。
GraphQLのスキーマ定義に役立つVSCodeの拡張機能をざっと見た限り、
https://marketplace.visualstudio.com/items?itemName=GraphQL.vscode-graphql:title
https://marketplace.visualstudio.com/items?itemName=kumar-harsh.graphql-for-vscode:title
の2つが主だった拡張機能の様子。
ただ、設定方法を見ると、graphql-for-vscode
はvscode-graphql
に比べて、watchmanや@playlyfe/gqlと言ったライブラリの事前インストールが必要というところが面倒に感じたので、vscode-graphql
を使うことにした。
なので、まずVSCodeでこの拡張機能をインストール後、ルートディレクトリ直下に.graphqlrc.yml
という設定ファイルを作成し、設定手順にあるとおりに以下の内容を記述する:
# .graphqlrc.yml schema: ./functions/graphql/**/*.gql documents: null
ちなみに、この設定ファイルは、JSON形式やjsファイルでも書けるようになっている。
内部でGraphQL Config
を使っているようで、GraphQL Configのドキュメントにより設定ファイルの書き方の詳細が載っている。
その後、functions/graphql/queries/userProfiles.gql
でUserProfile
と入力して補完されればOK...となるはずだが、現時点(v0.3.12)では補完が効かない。
色々調べてみると、こういうissueが上がっており、要はconfigファイルschema
の項目にglob
を使うとダメらしい。
なので、今はこの書き方をしても補完はされない。
リポジトリのREADMEには
Note: The primary maintainer @acao is on hiatus until December 2020
とあるので、解決まではもう少し待つことになりそう。
一応回避策として、
scehma: - functions/graphql/queries/userProfiles.gql - functions/graphql/schemas/userProfile.gql
というように列挙すれば補完が効くようになるが、これはすごーくめんどくさいので待った方がよさそう。
それかgraphql-for-vscode
を使うか。
5. 所感
流石にサンプルコードレベルじゃ勉強の意味が薄いなと思ってファイル分割とか開発の利便性あたりまで踏み込んでみたけど、ここまで手こずるとは思わなかった。
特にVSCodeの拡張の謎の不具合は本当に罠だったー。
でもまあ最終的に原因が分かったからよかった。
また、今回はリクエストが来るたびに全てのスキーマファイルを読み込むようにしてるけど、ここにあるように、本番では事前にファイルに書き出したスキーマをつかうようにしてもいいのかもなあ、と思った。
まあ、この辺は追々やって行こう。
次はいよいよGraphQLの踏み込んだ使い方とFirestoreのデータ設計に入って行こうと思う。
6. その他参考にしたサイト等
GraphQL | A query language for your API
Documentation Home - Apollo Basics - Apollo GraphQL Docs
Welcome | GraphQL Tools
Firestore エミュレータのデータをローカル環境で import/export する - Qiita
Next.js + Vercel + TypeScript + Firebaseでアプリを作る。環境構築編
Next.jsの勉強をしようと思い、せっかくなので自分の知らないところややったことないことのキャッチアップをしつつプリを作ってみることにした。
構成としては、VercelとFirebaseあたりを使う想定。
今回は環境構築だが、フロントとバックの疎通などはまだ未確認なのであしからず。
今回の環境構築はとりあえずDockerに載せて動かすというところがゴールなので、このままだと疎通できないかも。
必要に応じて後で修正する予定。
0. 前提:firebaseにプロジェクトを作成しておく
Firebaseに飛んでプロジェクトを作成後、Firestoreなどの使いたいサービスについても作成しておく。
ここで各サービスを作成しておかないと、あとでfirebase init
をしてFirebaseの初期設定をする時に「そんなサービスないよ!」と怒られる。
1. Dockerの設定
今回のディレクトリ構成は
. ├── web/ │ ├── docker-compose.yml │ ├── Dockerfile │ └── Next.jsのコード諸々 └── app/ ├── Dockerfile └── Firebaseのコード諸々
という感じにしようと思っている。
web
にはNext.jsのコードを入れて、app
にはFirebase関連のコードを入れて、web
とapp
はそれぞれ別のリポジトリで管理する想定。
docker-compose.yml
は以下:
version: '3.8' services: web: container_name: xxxx build: context: ./ volumes: - ./:/usr/src working_dir: /usr/src ports: - '3000:3000' depends_on: - app tty: true app: container_name: xxxx build: context: ../app volumes: - ../app:/usr/src ports: - '9005:9005' # firebase loginのために必要 - '4000:4000' # firebase ui これはこっちで割り当てた(公式のデフォルトポート設定では4000だったが、ウィザードには出てこなかった) - '5001:5001' # Cloud Functions - '8080:8080' # Cloud Firestore - '9099:9099' # Firebase Auth working_dir: /usr/src tty: true
また、Firebaseをローカルで使うためにはfirebase-tools
とJavaを入れておく必要があるので、app
のDockerfile
にそれも記述しておく。
FROM node:14.15.0-alpine3.12 RUN apk update && \ apk upgrade RUN apk add openjdk8 RUN npm install -g firebase-tools
web
については今のところ特筆することはないが、web
のDockerfile
は一応こんな感じ:
FROM node:14.15.0-alpine3.12 WORKDIR /usr/src RUN apk update && \ apk upgrade
あとで追加するかも。
ここまでしたら
$ docker-compose build $docker-compose up -d
2. Next.jsの設定
まずはNext.jsの設定をしていく。
$ docker exec -it <webのコンテナ名> sh
でweb
コンテナに入った後、
# npx create-next-app --example with-typescript-eslint-jest
として、Next.jsの初期設定を行う。
なお、今回はTypeScriptとeslint、Jestがセットになった初期設定を行っている。
Blog - Introducing Create Next App | Next.js←ここを見ると、初期設定には他にも色々と用意されているので、必要であれば見てみるといいかも。
ここまできたら
# yarn dev
でhttp://localhost:3000
にアクセスして、画面が表示されたらOK。
ちなみに、web
で使っているnodeイメージには初めからyarnが入ってる様子。
yarnで立ち上げたのは、Next.jsの初期設定を行った後にコンソールに「yarn dev
で立ち上げて」というメッセージが表示されたからだが、npm run dev
でも立ち上がる。
npmに揃えた方がいいかもという気がしないでもない。
3. Firebase側の環境構築
$ docker exec -it <appのコンテナ名> sh
でapp
のコンテナに入った後、
# firebase login
で、あらかじめ作っておいたfirebaseプロジェクトのGoogleアカウントでログイン。
ログインできたら、その後、
# firebase init
で、Firebase向けの設定を行う。
今回使うものは
- Cloud Firestore
- Storage
- Firebase Auth
- Emulator
- Firebase UI(ローカル用のFirebaseコンソール)
いくつか質問があるが、基本的にはyes
だったりデフォルトで設定されている項目で大丈夫なはず。
ただ、Firebase UIはデフォルトのポート設定がなかったので4000
を入力。
ちなみに、ここに各サービスのデフォルトポートがまとまっている。
ここまできたら、次はfirebase.json
の編集。
firebase init
の後にapp
のディレクトリ直下にfirebase.json
が以下のような中身であるはず:
{ "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" }, "functions": { "predeploy": [ "npm --prefix \"$RESOURCE_DIR\" run lint", "npm --prefix \"$RESOURCE_DIR\" run build" ] }, "storage": { "rules": "storage.rules" }, "emulators": { "auth": { "port": 9099 }, "functions": { "port": 5001 }, "firestore": { "port": 8080 }, "ui": { "enabled": true, "port": 4000 } } }
このままだと、localhost
ではアクセスできないのでemulators:
配下の各項目に
"auth": { "host": "0.0.0.0", "port": 9099 },
と言うような感じで"host": "0.0.0.0",
を追加。
これでlocalhost
からアクセスできる。
最後に、
# firebase emulators:start
した後、http://localhost:4000
でFirebaseのコンソールにアクセスできればOK。
最後に
Docker使った環境構築ってこういうかんじでいいのかよくわからないなあ。
あと、今回Firebaseを使うのは初めてなので、Firebaseを使う環境構築ってのもこれでいいのかわからない。
ちなみに、ざっと調べた限り、Firestore Local Emulator のデータを擬似的に永続化する←こういうのもあるみたいで、必要そうなら後でやろう。
何かあれば教えて下さい。
参考
https://firebase.google.com/docs/emulator-suite/install_and_configure
https://docs.docker.com/compose/compose-file/
https://qiita.com/kyhei_0727/items/343602fa184c84d593c4
https://a2c.tech/2020/08/nextjs-container/
Jestとreact-testing-libraryを使ったTypeScript + ReactのテストをCircleCIで回す
ReactのテストをCircleCIで自動化したかったので、ネットで色々調べつつ簡単に環境を構築してみた。
やったこと
- サンプルのテストを作成(jest + react-testing-library + TypeScript)
- CircleCIで自動テスト
- テスト結果をSlackに通知
やった内容
CircleCIの設定は公式のドキュメントを読みつつ、実際に動かすテストについてはこのガイドの通りにやってみた(一部改変したりしてはいる)。
.circleci/config.yml
version: 2.1 jobs: test: docker: - image: circleci/node:10.15.3 steps: - checkout - run: yarn - run: yarn test workflows: version: 2. test: jobs: - test
job1個だけだとworkflowにする意味ってあんまりなさそうだなと思いつつ、workflowにまとめてる。
今回は使ってないが、workflowにはこういうjobの依存関係を決められるようなオプションもあるらしくて便利そうだなと思った。
Slack通知設定
公式ドキュメントのここをみながらすればOK
テストコード
テスト対象のソース
import React from 'react' import { render, fireEvent } from '@testing-library/react' import LoginForm, { Props } from '../LoginForm' const renderLoginForm = (props: Partial<Props> = {}) => { const defaultProps: Props = { onPasswordChange() { return }, onRememberChange() { return }, onUsernameChange() { return }, onSubmit() { return }, shouldRemember: true, } return render(<LoginForm {...defaultProps} {...props} />) } describe('<LoginForm />', () => { test('should display a blank login form, with remember me checked by default', async () => { const { findByTestId } = renderLoginForm() const loginForm = await findByTestId('login-form') expect(loginForm).toHaveFormValues({ username: '', password: '', remember: true, }) }) test('should allow entering a username', async () => { const onUsernameChange = jest.fn() const { findByTestId } = renderLoginForm({ onUsernameChange }) const username = await findByTestId('username') fireEvent.change(username, { target: { value: 'test' } }) expect(onUsernameChange).toHaveBeenCalledWith('test') }) test('should allow entering a password', async () => { const onPasswordChange = jest.fn() const { findByTestId } = renderLoginForm({ onPasswordChange }) const username = await findByTestId('password') fireEvent.change(username, { target: { value: 'test' } }) expect(onPasswordChange).toHaveBeenCalledWith('test') }) test('should allow toggling remember me', async () => { const onRememberChange = jest.fn() const { findByTestId } = renderLoginForm({ onRememberChange, shouldRemember: false, }) const remember = await findByTestId('remember') fireEvent.click(remember) expect(onRememberChange).toHaveBeenCalledWith(true) fireEvent.click(remember) expect(onRememberChange).toHaveBeenLastCalledWith(false) }) test('should submit the form with username, password and remember', async () => { const onSubmit = jest.fn() const { container, getByTestId } = renderLoginForm({ onSubmit, shouldRemember: false, }) // こうすれば、querySelectorを使える const username = container.querySelector('input[name="username"]') as Element const password = await getByTestId('password') const remember = await getByTestId('remember') const submit = await getByTestId('submit') fireEvent.change(username, { target: { value: 'test' } }) fireEvent.change(password, { target: { value: 'password' } }) fireEvent.click(remember) fireEvent.click(submit) expect(onSubmit).toHaveBeenCalledWith('test', 'password', true) }) })
テストコード
import React from 'react' import { render, fireEvent } from '@testing-library/react' import LoginForm, { Props } from '../LoginForm' const renderLoginForm = (props: Partial<Props> = {}) => { const defaultProps: Props = { onPasswordChange() { return }, onRememberChange() { return }, onUsernameChange() { return }, onSubmit() { return }, shouldRemember: true, } return render(<LoginForm {...defaultProps} {...props} />) } describe('<LoginForm />', () => { test('should display a blank login form, with remember me checked by default', async () => { const { findByTestId } = renderLoginForm() const loginForm = await findByTestId('login-form') expect(loginForm).toHaveFormValues({ username: '', password: '', remember: true, }) }) test('should allow entering a username', async () => { const onUsernameChange = jest.fn() const { findByTestId } = renderLoginForm({ onUsernameChange }) const username = await findByTestId('username') fireEvent.change(username, { target: { value: 'test' } }) expect(onUsernameChange).toHaveBeenCalledWith('test') }) test('should allow entering a password', async () => { const onPasswordChange = jest.fn() const { findByTestId } = renderLoginForm({ onPasswordChange }) const username = await findByTestId('password') fireEvent.change(username, { target: { value: 'test' } }) expect(onPasswordChange).toHaveBeenCalledWith('test') }) test('should allow toggling remember me', async () => { const onRememberChange = jest.fn() const { findByTestId } = renderLoginForm({ onRememberChange, shouldRemember: false, }) const remember = await findByTestId('remember') fireEvent.click(remember) expect(onRememberChange).toHaveBeenCalledWith(true) fireEvent.click(remember) expect(onRememberChange).toHaveBeenLastCalledWith(false) }) test('should submit the form with username, password and remember', async () => { const onSubmit = jest.fn() const { container, getByTestId } = renderLoginForm({ onSubmit, shouldRemember: false, }) // こうすれば、querySelectorを使える const username = container.querySelector('input[name="username"]') as Element const password = await getByTestId('password') const remember = await getByTestId('remember') const submit = await getByTestId('submit') fireEvent.change(username, { target: { value: 'test' } }) fireEvent.change(password, { target: { value: 'password' } }) fireEvent.click(remember) fireEvent.click(submit) expect(onSubmit).toHaveBeenCalledWith('test', 'password', true) }) })
このガイドでは、各input
に埋め込んだdata-testid
の値で各input
を特定しているが、個人的にはこれがすごくめんどくさかった。
コード書いてる時にテストのためだけの属性を書くのは絶対忘れる。
なので、代わりにcontainer
を使って、各input
要素のname
属性をquerySelector
で引っ掛けられるようにした。
個人的にはこっちのほうが実装が楽になりそうな気がする。
所感
- config.ymlの書き方はもっといい方法があるかもしれないのでこれから研究
- フリープランでやってるから有料プランではもっと色々できるかもしれない
- 以前は業務でEnzymeを使っていたが、react-testing-libraryも結構いいなあ。
【WIP】React + TypeScriptでOAuth認証【修正予定】
ブログのネタにと思って、ここ最近はずっと私生活でいろいろあったりして、なかなか進められていなかったReactでOAuth認証を行うサンプルコードをようやく仕上げた。
github.com
着手がブツ切りになったせいで、コードがぐちゃぐちゃしてたり、いたるところにデバッグ用のconsole.logがあったりしてマジでやばいので後で修正するつもり。
やっぱりこういうのは短期決戦で一気に終わらせるのが一番いいよなあ。
RailsのviewにReactを使ったのでメモ(React + TypeScript + webpacker)
最近仕事でRailsで新規に作るviewにReactを使うということをやったので、それについて書いておく。
前提
- これまではSprockets + CoffeeScriptで画面を作成していた
- React + TypeScriptで新規画面を作りたい
- 実際に画面を作るのはまだまだ先の話なので、現段階ではまずfeasibilityのチェックを優先してほしい
- 実際に僕が実装をするのではなくReactやTypeScriptに明るくない別の同僚が実装をする可能性もある
やったこと
webpackerの導入
Reactが動く環境をどうやって導入するかをまず悩んだ。
個人的にはRailsがフロントエンドの管理まですることがあまり好きではないため、webpackを使ってReactの動作環境をつくろうと思っていた。
だが前提に書いたように、実際には僕以外の人間が実装を行う可能性があるため、webpackの設定を一から作っていかなければならないwebpackよりも、webpackをラップしたgemであるwebpackerを使うことにした。
webpackerインストール後の手順としては以下の通り。
フロントで使うパッケージは個別にインストールしてもいいと思うが、土台作りくらいはwebpackerのみでできるっぽい。
# webpackerの初回インストール $ bundle exec rails webpacker:install # React向けの設定 $ bundle exec rails webpacker:install:react # TypeScript向けの設定 $ bundle exec rails webpacker:install:typescript # React用の型定義追加 $ yarn add @types/react @types/react-dom
ちなみに、この記事を書いてる中で気づいたが、react-railsやreact_on_railsっていうgemもあるらしい。これでもよかったかも。
RailsでAPI用コントローラーの作成
これはよくあるrender :json
でOK
React + TypeScriptで書く
ここからが本題。
ここに書いたのがベストプラクティスかは分からないが、参考になれば嬉しいので書いておく。
ディレクトリ構成
- /app - /javascripts - /packs - /entries - /someController - SomeControllerIndexEntry.tsx - SomeControllerShowEntry.tsx - /react - /someController - /pages - SomeControllerIndex.tsx - SomeControllerShow.tsx - /comp
まず、app/javascripts/packs/entries/someController
直下には、各ページで使うためのReactファイルを配置する。
これはエントリーファイルなので、中身的には/app/javascripts/react/somecontroller/pages
に配置したファイルを呼び出すだけ。
/app/javascripts/react/somecontroller/pages
直下には、そのページを構成するためのコンポーネントを書いたファイルを置く。
ページを構成する部品となる各コンポーネントは/app/javascripts/react/somecontroller/components
に置く。
あとは、この構成でビルドなどをするためにconfig/webpacker.yml
のsource_entry_path
をpacks/entries
にする。
こうすれば、
<%= javascript_pack_tag `someController/SomeControllerIndexEntry`%>
みたいにして、app/javascripts/packs/entries
以下のディレクトリ構成通りに呼び出せるようになる。
この構成だと、./bin/webpack
をしてビルドをすればpublic/packs/js/someControllers
配下にバンドルされた各ページごとのファイルが出力されるはず。
というか、開発環境でもwebpack-dev-serverを起動してない && public〜配下にバンドル済のファイルがないという場合にはwebpacker側がバンドルをするっぽいね。
csrf_tokenについて
今回はPOST処理も必要だったので、JSでよく使われてるFormData
を使ってPOSTをしようと思ったところcsrf tokenがないからPOSTを受け付けられないという旨のエラーが出た。
Railsのviewのmetaタグにはデフォルトで
<meta name="csrf-param" content="authenticity_token" /> <meta name="csrf-token" content="csrfToken" />
というものがあるので、
const csrfParam = (document.querySelector('meta[name="csrf-param"]') as HTMLMetaElement).content const csrfToken = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement).content
こういう風に取って、
const form = new FormData() form.append(csrfParam, csrfToken)
というようにしてformに混ぜ込めば大丈夫。
LoLで出会ったプレイヤーを忘れないアプリを作った
ちょっと前にReact勉強のために作り始めて、その後、社内で勉強会のネタに使ったあとそのまま放置していたが、コードだけ整えて一旦公開することにした。
作ったもの
スラム街として有名なLeague of Legends(LoL)で出会ったステキ
なプレイヤーをメモとともに記録できるアプリ。
GitHub - Ryu-u/remember-that-guy
これから追加したい機能
- 本番対応
- 認証機能を追加して多人数対応
- 例外処理
今回の学び
react-exhaustive-deps
のwarning
副作用フックの利用法 – React useEffect
についてのこのセクションの補足に
もしも副作用とそのクリーンアップを 1 度だけ(マウント時とアンマウント時にのみ)実行したいという場合、空の配列 ([]) を第 2 引数として渡すことができます。
とあるので、てっきりuseCallback
の第2引数についても同じだと思ってたから、初回以降変わる必要のない値についてはuseCallback
の第2引数に[]
を渡していた。
ただ、そうすると、react-exhaustive-deps
が
React Hook React.useCallback has a missing dependency: 'setIsModalOpen'. Either include it or remove the dependency array react-hooks/exhaustive-deps
というwarningを吐くのでおかしいなと思ってたんだけど、リファレンスをよく読んだら、useCallback
の補足に
依存する値の配列はコールバックに引数として渡されるわけではありません。しかし概念としては、この記法はコールバックの引数が何なのかを表現しています。コールバックの内部で参照されているすべての値は依存の配列内にも現れるべきです
と書いてあった。
useEffect
とuseCallback
では第2引数の意味合いが少し違うのね。
ちなみに、useCallbackを使ったコードは
https://github.com/Ryu-u/remember-that-guy/blob/master/src/components/past-games/PastGames.tsx#L21
https://github.com/Ryu-u/remember-that-guy/blob/master/src/components/past-games/DescriptionModal.tsx#L22
この2箇所なんだけど、今考えてみるとどちらもuseCallback
を使う必要がなかった...。
あとで修正しておこう。
フロントエンドとバックエンドで使用パッケージを共有する
正直、これはSPAの本来の用途とはズレているはずであまりいい方法ではないと思う。 ただ、今回はcreate-react-appとExpressをTypeScriptを使って開発する際に、型をフロントエンドとバックエンドで共有したり、そもそもマイクロサービス的に複数のサーバーを組み合わせたりしないのでnode_modulesが2つに分かれるのが嫌だなと思って、使用パッケージを共有することにした。
(此処にVSCodeのキャプチャ)
この構成にするために工夫するところは3つある。
まずはpackage.json
"scripts": { "frontend-start": "BROWSER=firefox react-scripts start", "backend-start": "nodemon", "start": "ts-node --project tsconfig.backend.json backend/index.ts", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", "heroku-postbuild": "yarn && yarn run build " }
frontend-start
でwebpack-dev-server
の立ち上げ。
backend-start
でnodemonを使って、バックエンドのソースを更新した時に自動でコンパイルを再実行させ流ような形でExpress起動(後述するがnodemonではyarn start
を実行している)。
他はcreate-react-appが作るものとかHeroku向けのコマンドとか。 Heroku向けのコマンドはまだ動かしてないので間違ってるかも。
次に、nodemonの設定はこういう感じ。
{ "watch": ["backend"], "ext": "ts", "exec": "yarn run ts-node --project tsconfig.backend.json -r dotenv/config backend/index.ts" }
バックエンド用のファイルを格納しているbackend/
ディレクトリを監視し、その中のソースが変更されたら再コンパイルする感じにしてる。
そして最後に、tsconfig.json
の設定。
今回はルートディレクトリにフロントエンド用のtsconfig.json
とバックエンド用のtsconfig.backend.json
を作成した。
tsconfig.json
を分けた理由は、フロントエンドとバックエンドでコンパイル後のJSのバージョンをフロントエンド→ES5、バックエンド→ES6という形にしたかったから。
それぞれのtsconfig.json
はこちら
# フロントエンド用tsconfig.json { "compilerOptions": { "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "preserve" }, "include": ["src", "src/types"] }
# バックエンド用tsconfig.backend.json { "compilerOptions": { "allowSyntheticDefaultImports": true, "module": "commonjs", "esModuleInterop": true, "target": "es6", "noImplicitAny": true, "moduleResolution": "node", "sourceMap": true, "outDir": "dist", "baseUrl": ".", "paths": { "*": ["node_modules/*", "src/types/*"] } }, "include": ["backend/**/*"] }
正直重要なのがimport
を使うためのallowSyntheticDefaultImports
とesModuleInterop
くらいしかわかってないので、このあたりはまたどこかのタイミングでさいきょうのtsconfig.json
を書きたい。
所感
はじめは認証機能を追加するつもりはなく勉強だし一人用でいいやと思っていたのだが、思いのほか簡単に作れてしまったので、認証機能を追加しようと考え直した。
で、前から少し気になってたReactでのOAuth認証をやろうとしたところ、これが全くうまくいかない...
色々ググってみて紹介されているやり方を片っ端から試してみたが全然ダメ。
なので、これは後でまたチャレンジすることにして、コードだけ整えつつ今の状態で公開して一区切りつけることにした。
ReactでのOAuth認証は後でまた試して記事を書きたい。
あと、最近はLoLをやらなくなってしまった。