JavaScriptでString.prototype.reverseを作る
はじめに
こちらの記事ではJavaScriptにおけるStringオブジェクトを拡張してreverseメソッドを追加する過程をまとめています。 JavaScript以外の言語では文字列クラス(オブジェクト)のビルトインメソッドとしてreverseが備わっているものもありますが、それをJavaScriptにも実装してみようという、ちょっとした遊び心から生まれた記事です。
尚、JavaScriptではビルトインオブジェクトのprototypeを改変することはプロトタイプ汚染と呼ばれ(モンキーパッチと呼ばれることもある)、あまり好まれません。
今回はあくまでも学習の一環で実装していますのでご容赦ください。
概要
JavaScriptではStringオブジェクトにreverseメソッドが存在しないため、以下を実行するとエラーとなります。
'hoge!'.reverse() // => Uncaught TypeError: "hoge".reverse is not a function
そして今回は以下の結果が得られるようにプログラムを作ります。
'hoge!'.reverse() // => !egoh
ただしここで1つ制約を設けます。 Array.prototype.reverseは使用してはいけないという制約です。
そもそもこの記事を書こうと思ったきっかけはこちらの記事を読んで、「JavaScriptで同じ事をするには🤔」と考えたのがはじまりです。reverseを使わないという制約がないと大変簡単な実装となってしまうため、この制約を設けています。
Array.prototype.reverseを使用した場合
'hoge!'.split('').reverse().join('') // => !egoh
これで終了です😅
そもそもprototypeって?
prototypeについてまとめるにはかなりの根気がいるためここでは簡単にまとめます。 詳しく学びたい方は下記を参考にしていただければと思います。
オブジェクトのプロトタイプ - ウェブ開発を学ぶ | MDN
さて、今回の実装に必要最低限な説明のため、Stringオブジェクトと文字列を使ってprototypeの中身を確かめてみます。
まずはお使いのブラウザでdeveloper toolを開きコンソールにString.prototype
と打ってみてください。
そうすると以下のような結果が得られるはずです。
この結果からprototypeの中身には文字列に使用できるメソッドが格納されていることがわかります。(slice、repeatなど) つまりprototypeとはそのオブジェクトで使用できるメソッドやプロパティが格納されたオブジェクトであると言えます。
次にdeveloper toolのコンソールに'hoge'.prototype
と打ってみましょう。
これはundefined
が返ってきます。ここで、先の「prototypeとはそのオブジェクトで使用できるメソッドやプロパティが格納されたオブジェクト」という説明が正しい場合、String.prototype
と同様の結果が得られないのはおかしいと思いませんか?なぜなら'hoge'
という文字列に対してもsplice
やrepeat
メソッドは使用できるため、矛盾が生じています。
ではなぜ、文字列がStringオブジェクトのメソッドを使用できるのかというと、JavaScriptではプリミティブな値に対してプロパティアクセスを行った場合、ラッパーオブジェクトへ自動変換されるという仕組みがあるためです。
ラッパーオブジェクト · JavaScript Primer #jsprimer
つまり、文字列へプロパティアクセスがあった場合はStringオブジェクトのprototypeが参照されているということになります。
この仕組みから、String.prototypeにreverseメソッドを追加してあげることで'hoge!'.reverse()
が実現できることがわかります。具体的には下記のようにします。
String.prototype.reverse = function () { // ここに処理を実装 }
実装
まずはいきなりString.prototype
に実装するのではなく、「受け取った文字列を反転して返す関数(Array.prototype.reverse
は使用禁止)」を作ります。
尚、筆者は普段TypeScriptを使用しているためTypeScriptのコードも記載します。但し、TypeScriptの場合はString.prototype.reverse =
とした時点でコンパイルエラーが発生するため、関数の実装のみとして、Stringオブジェクトの拡張は割愛します。
function reverse(string) { const chars = [...string] const reversedChars = [] for (let i = 0; i < string.length; i += 1) { reversedChars.push(chars.pop()) } return reversedChars.join('') }
TypeScript
function reverse(string: string) { const chars = [...string] const reversedChars: string[] = [] for (let i = 0; i < string.length; i += 1) { const char = chars.pop() if (char) reversedChars.push(char) } return reversedChars.join('') }
実装のポイントは以下です。
const chars = [...string]
として受け取った文字列を1文字づつ配列に展開(...
スプレッド演算子本当に便利)chars.pop()
で後ろから順番に要素を取得して新しい配列に追加
Array.prototype.pop
については配列の一番後ろの要素から要素を1つ削除することに注目されがちですが、実は返り値がその削除された要素になります。
console.log([1,2,3,4,5].pop()) // => 5
この特性を利用して、ループ内で配列の後ろから一個づつ要素を取得して新しい配列に追加しています。
TypeScriptの場合はpopの返り値は対象となる配列の要素の型とundefinedのユニオン型になるため(今回の場合はstring | undefined
)、if (char)
とすることでpush時のコンパイルエラーを防いでいます。
そして注意点としては以下です。
- for文の条件式を
i < chars.length
とせずにstring.length
とする
ループ内でcharsに対してpopを呼び出している(charsの要素数が減少していく)ため、i < chars.length
としてしまうと期待通りに繰り返しが行われません。
次にこれをString.prototype.reverse
に代入していきます。
String.prototype.reverse = function () { const chars = [...this] const reversedChars = [] for (let i = 0; i < this.length; i += 1) { reversedChars.push(chars.pop()) } return reversedChars.join('') }
先に実装した関数との変更点は以下です。
- 引数を削除
- 関数内で使用していた引数stringを全てthisに変更
このthisは具体的にはレシーバーとなる文字列を指します。 prototypeメソッド内でthisを呼び出した場合はレシーバーとなるオブジェクトが渡ってくるためです。
以上で実装が完了です。
最後のコードをdeveloper tool内で実行した後にお好きな文字列に対してreverse()
を呼び出してあげましょう。
期待通りの結果が得られることでしょう。
まとめ
ひょんなことから取り組んだ問題ではありましたが、付随して以下の様々な事を学べました。
- Array.prototype.popの挙動
- prototype
- this
- ラッパーオブジェクト
Webアプリケーション開発には直接関係のない部分ですが、JavaScriptの機構を知っておくことは必要なことかと思いますので、今後も折りを見て学習していきたいと思います。
eslintとprettierを連携して開発効率を上げる
はじめに
フロントエンド開発では必須のeslint、prettierですが皆さんはうまく連携できてますか? 今回はeslintとprettierの連携方法とファイル保存時に自動でフォーマッタが走るようにVSCodeを設定する方法についてまとめます。
prettierとeslintを連携させる理由
この2つのルールに関する設定項目が一部被っているため、どちらか一方のルールに統一することが目的です。
(クォーテーションを""
か''
か設定するquotes
や文末の;
の有無を設定するsemi
が該当。)
今回はeslint-config-prettier
というnpmを使用してルールが被っている場合はprettierのルールを優先してeslintのルールをオフにするという設定をしていきます。
事前準備
eslintのインストール
今回はeslintの細かい設定を省略するためにcreate-next-app
を使用します。
ターミナルでcreate-next-app
を実行すると対話形式で設定項目を問われます。
ESLintを使用するか聞かれるのでYes
と回答しましょう。
その他の項目についてはプロジェクトに合わせて適宜回答します。
プロジェクトの作成が完了すると.eslintrc.json
ファイルが自動で作られるので各種pluginのインストールなど.eslintrc.json
ファイルの編集を行います。今回は特にeslintの設定をせずに進めるため、eslintの設定について気になる方は下記を参考にしていただければと思います。
https://umatsui.hatenablog.com/entry/2023/07/10/102109
さて、eslintのインストールはこれにて完了です。
create-next-app
によって自動生成された.eslintrc.json
ファイルの内容を載せておきます。
.eslintrc.json
{ "extends": "next/core-web-vitals" }
prettierのインストール
下記コマンドでprettierをインストールします。
% yarn add -D prettier
package.json
ファイルにprettierが追加されていることを確認しましょう。
"devDependencies": { "@typescript-eslint/eslint-plugin": "^5.61.0", "@typescript-eslint/parser": "^5.61.0", "eslint-plugin-react": "^7.32.2", "prettier": "^3.0.0" }
Next.jsを使用しているのでprettier以外のパッケージも記載されていますが、今回は特に気にしなくて大丈夫です。
eslint-config-prettierのインストール
次にeslintとprettierを組み合わせて使うためのeslint-config-prettier
をインストールします。
% yarn add -D eslint-config-prettier
eslintとprettierの連携
次にeslintとprettierの連携を行います。
とはいっても.eslintrc.json
ファイルのextendsにprettier
を追加するだけです。
.eslintrc.json
{ "extends": ["next/core-web-vitals", "prettier"] }
連携するときの注意点
これまでの方法で連携を行った場合でも.eslintrc.json
ファイルのrulesにsemi
やquotes
を追加してしまうとそちらが優先されてしまうので気をつけましょう。
prettierで設定されているルールは下記で確認ができます。
https://prettier.io/docs/en/options.html
VSCodeの設定
最後にファイル保存時にコードが自動でフォーマットされる設定を行います。
VSCodeの設定手順は以下です。
右下の設定アイコンから設定ページへ遷移
画面左上のタブから「ワークスペース」を選択
画面右上の「設定」アイコンから
settings.json
ファイルを開く下記設定を行う
{ // ファイル保存時にeslintの指摘箇所が自動修正される "editor.codeActionsOnSave": { "source.fixAll.eslint": true }, // VSCodeのデフォルトのフォーマッターをprettierに指定 "editor.defaultFormatter": "esbenp.prettier-vscode", // ファイル保存時に自動でprettierが実行される "editor.formatOnSave": true }
設定が効いているか試してみる
実際に自動修正がされるか試してみましょう。
まずはeslintの自動修正を確認します。確認のために一度、.eslintrc.json
に下記を追加します。
.eslintrc.json
{ "extends": ["next/core-web-vitals", "prettier"], "rules": { "quotes": "error" } }
そして_app.tsx
ファイルで""
を''
に変えてみましょう。
すると次のようなエラーになるはずです。
この状態でCommand + s
でファイルを保存してみてください。
クォーテーションがダブルクォーテーションに修正されてエラーが消えたのではないでしょうか?
次にprettierの自動修正を試してみましょう。.eslintrc.json
のrulesを削除して、今度は_app.tex
ファイルで;
を消してみましょう。
eslintのruleを追加していないのでエラーは出ていませんが、この状態でCommand + s
でファイルを保存してみてください。
自動で;
が補完されたのではないでしょうか。
prettierのルール設定
prettierのルールを別途設定したい場合は設定ファイルを作成することで変更が可能です。
例えばデフォルトの設定ではsemi
がtrue、singleQuotes
がfalseで指定されています。これらを変更したい場合はルートディレクトリにprettier.config.js
を用意して下記のように記述することで変更ができます。
module.exports = { singleQuote: true, semi: false, }
この状態でいずれかのファイルを保存してみてください。
全てのセミコロンが取り除かれ、""
が''
に変わったのではないでしょうか?
コマンド一つで一気に修正できてしまうのは爽快ですよね。
その他のルールについても下記で確認ができます。
https://prettier.io/docs/en/options.html
おわりに
はじめに書きましたがeslint、prettierはフロント開発には必須です。個人開発をしているけどeslint、prettierをまだ使用していない方やこれから個人開発をする方の参考になれば幸いです。
eslintの設定ファイルに関するまとめ
はじめに
eslintの設定ファイルってかなりややこしいと思いませんか?
項目が多いので今までそれぞれが何を意味しているのかあまり深く考えずに使用してきました。
そんな設定ファイルについて簡単にまとめてみます。
ファイル形式
eslintの設定ファイルは.yml
、.json
、.js
、.cjs
のいずれかを指定できます。
個人的にはコメントアウトが行えて馴染みのある.js
が好きです。
公式ドキュメントはこちら。
v 9.0.0以降について
Warning We are transitioning to a new config system in ESLint v9.0.0. The config system shared on this page is currently the default but will be deprecated in v9.0.0. You can opt-in to the new config system by following the instructions in the documentation.
ドキュメントにも記載がありますがファイル形式はeslint.config.js
へ移行中でv 9.0.0
からはeslint.config.js
を使用する予定とのこと。現在の最新版は8.4.4
なので次のメジャーアップデートからはeslint.config.js
を使用することになりそうです。
eslint.config.js
の場合の記述方法についても既にドキュメントが用意されていました。
https://eslint.org/docs/latest/use/configure/configuration-files-new
sourceTypeがmoduleのプロジェクトではこちらが使用できるようですが、exportを使えるようになるのはいいですよね。
設定項目
それぞれの設定項目についてまとめます。
env
global変数を事前に読み込むための項目です。
https://eslint.org/docs/latest/use/configure/language-options#specifying-environments
下記の場合はブラウザで定義されている変数(documentなど)やES2021で用意されている変数をeslintが事前に読み込んでくれます。
"env": { "browser": true, "es2021": true, }
これだけではなんのこっちゃという感じなので例を挙げます。
後述するrurlesで"no-undef": "error"
(未定義の変数をいきなり呼び出すとエラーになる)を指定している場合にenvのbrowserをtrueに指定していないとdocumentを呼び出した際にerrorが発生します。
指定できる項目は多いですがbrowser、esXXXX、jestなどが使用頻度として高いのではないでしょうか。
parser
parserは下記のいずれかを使用しているプロジェクトの場合に設定が必要になります。
- Prisma
- TypeScript
- Babel
それぞれに指定する値が決まっていて下記で確認ができます。
https://eslint.org/docs/latest/use/configure/parser
parserOptions
https://eslint.org/docs/latest/use/configure/language-options#specifying-parser-options
構文解析時のオプションを設定できます。
主に使用するものはecmaVersion
とsourceType
です。
"parserOptions": { "ecmaVersion": "latest", "sourceType": "module" }
ecmaVersion
eslintがES5がまでの構文しかサポートしていないため、ES5以降の構文を使用している場合にはecmaVersionの指定が必要です。基本的にlatest
またはenvで指定した同じバージョンを指定しておけば良い。
sourceType
import/export文を使用している場合はsourceTypeにmodule
を指定する必要がある。
plugins
rulesを拡張するための設定項目。 eslintの拡張パッケージをインストールした場合はこちらに追加していく。
例えばeslint-plugin-react
をインストールして使用する場合は下記のように記載する。
"plugins": ["react"]
eslintのpluginは基本eslint-plugin-xxxx
で命名されているのでpluginsへ追加する際はeslint-plugin
の省略が可能。
extendsとの関係
後述するextendsに追加した項目から暗黙的にpluginsが追加されることもある。
eslint-plugin-react
はまさにその例でextendsにplugin:react/recommended
を追加した場合にpluginsへreact
の追加は不要となる。
※npmのインストールは必要
"plugins": [], "extends": ["plugin:react/recommended"]
extendsと併せてpluginsへの追加が必要か不要かはpluginによって変わるため、pluginのドキュメントを確認する必要がある。 また、こういった背景からpluginsとextendsは混同しがちなので、それぞれの役割を下記にまとめます。
項目 | 意味 |
---|---|
plugins | rulesで使用できるruleを拡張するための設定。 |
extends | eslintrcファイル自体を拡張するための設定。 |
extends
eslintrcファイル自体を拡張するための設定。 ここで追加された項目からeslintrcの各項目のデフォルト値が暗黙的に追加される。
例えばplugin:react/recommended
を追加した場合はparcerOptionsのecmaFeaturesが暗黙的に{ "jsx": true }
になる。
"parserOptions": { // 暗黙的に追加 // "ecmaFeatures": { "jsx": true } }, extends: ["plugin:react/recommended"]
extendsへの追加によって追加される項目については各pluginのドキュメントを確認する必要がある。
rules
eslintにwatchさせる項目を設定できる。 各ルールについては公式ドキュメントで確認ができます。
https://eslint.org/docs/latest/rules/
pluginで追加されるルールについては各pluginのドキュメントで確認ができます。
設定方法についても公式に詳細が記載されているので説明は割愛します。
さいごに
理解をややこしくしている原因は主に以下2点だと思いました。
- pluginsとextendsの違いがわかりづらい
- extendsの挙動(暗黙的に他の設定項目が追加されたりされなかったりする点)
また、pluginの数が多くてドキュメントを追いきれないのも理由としてあげられそうです。 概要だけ押さえてあとは都度、ドキュメントを確認するのが良いのではないかと思いました。
createContextを使用したグローバルステート管理の注意点⚠️
はじめに
7月3日にTasting Noteというサービスをリリースしまして、こちらのフロントエンドをReactで開発をしたのですが、グローバルステートの管理方法に問題があったので、その問題点と改善方法についてまとめたいと思います。
ステートは参照系と更新系で分けて管理しよう
まずは変更前のコードを記載します。
RequestingContext.ts
import { Dispatch, SetStateAction, createContext } from 'react' const RequestingContext = createContext<{ requesting: boolean setRequesting: Dispatch<SetStateAction<boolean>> }>({ requesting: false, setRequesting: () => {} }) export default RequestingContext
RequestingProvider.tsx
import { FC, useMemo, useState, ReactNode } from 'react' import { RequestingContext } from '../contexts' const RequestingProvider: FC<{ children: ReactNode }> = ({ children }) => { const [requesting, setRequesting] = useState(false) const requestingState = useMemo(() => ({ requesting, setRequesting }), [requesting]) return <RequestingContext.Provider value={requestingState}>{children}</RequestingContext.Provider> } export default RequestingProvider
RequestingProviderはAPIリクエスト中の状態管理をしており、APIリクエストを行うロジックの中でsetRequestingを呼び出すことで、リクエスト中の画面にLoadingコンポーネントを表示するといった使い方をしています。
問題点
requestingが更新された場合にsetRequestingしか呼び出していないコンポーネントも再レンダリングされてしまうことです。
例えば複数コンポーネントでAPI通信を行う処理があり、その処理の中でsetRequestingを使用したいとします。setRequestingのみ必要なので、const { setRequesting } = useContext(RequestingContext)
といった具合にsetRequestingのみを取り出しますが、この場合、requestingが更新されたタイミングでrequestingを呼び出していないにも関わらず、setRequestingを呼び出しているすべてのコンポーネントが再レンダリングされてしまいます。
理由は同じコンテキストでrequestingとsetRequestingを管理しているからです。
Reactコンポーネントが再レンダリングされる条件は主に以下です。
前述の例ではrequesting(ステート)が更新されたため、同じコンテキストで管理しているsetRequestingを呼び出しているコンポーネントも再レンダリングの対象になったということです。
不要な再レンダリングを起こすとサービスの規模が大きくなった場合にパフォーマンスへ大きな影響を及ぼすため、可能な限り避けるべきです。
解決方法
requesting、setRequestingを別のコンテキストで管理することで解決します。 先に変更後のコードを記載します。
RequestingContext.ts
import { createContext } from 'react' const RequestingContext = createContext<boolean>(false) export default RequestingContext
RequestingDispatchContext.ts
import { Dispatch, SetStateAction, createContext } from 'react' const RequestingDispatchContext = createContext<Dispatch<SetStateAction<boolean>>>(() => { throw Error('No default value!') }) export default RequestingDispatchContext
RequestingProvider.tsx
import { FC, useState, ReactNode } from 'react' import { RequestingContext, RequestingDispatchContext } from '../contexts' const RequestingProvider: FC<{ children: ReactNode }> = ({ children }) => { const [requesting, setRequesting] = useState(false) return ( <RequestingContext.Provider value={requesting}> <RequestingDispatchContext.Provider value={setRequesting}> {children} </RequestingDispatchContext.Provider> </RequestingContext.Provider> ) } export default RequestingProvider
requestingをRequestingContext、setRequestingをRequestingDispatchContextで管理するように変更しています。 こうすることでsetRequestingしか呼び出していないコンポーネントの不要な再レンダリングを防げます。
補足
例えばsetRequestingを呼びだす関数をRequestingProvider内で定義して、その関数をステートとして管理するケースを考えます。
RequestingDispatchContext.ts
import { Dispatch, SetStateAction, createContext } from 'react' const RequestingDispatchContext = createContext<(function: () => void) => void>((() => {}) => { throw Error('No default value!') }) export default RequestingDispatchContext
RequestingProvider.tsx
import { FC, useState, ReactNode } from 'react' import { RequestingContext, RequestingDispatchContext } from '../contexts' const RequestingProvider: FC<{ children: ReactNode }> = ({ children }) => { const [requesting, setRequesting] = useState(false) const startRequesting = (function: () => void) => { setRequesting(true) try { function() } catch { throw Error('Something went wrong.') } finally { setRequesting(false) } } return ( <RequestingContext.Provider value={requesting}> <RequestingDispatchContext.Provider value={startRequesting}> {children} </RequestingDispatchContext.Provider> </RequestingContext.Provider> ) } export default RequestingProvider
上記コードではrequestingが更新されるタイミングでstartRequestingが再生成されてしまうため、コンテキストを分けているにも関わらず、requestingが更新されるとstartRequestingを使用しているコンポーネントも再レンダリングの対象になってしまいます。
この問題の解決方法はuseCallbackを使用してstartRequestingの再生成を防ぐことです。
RequestingProvider.tsx
import { FC, useState, useCallback, ReactNode } from 'react' import { RequestingContext, RequestingDispatchContext } from '../contexts' const RequestingProvider: FC<{ children: ReactNode }> = ({ children }) => { const [requesting, setRequesting] = useState(false) const startRequesting = useCallback((function: () => void) => { setRequesting(true) try { function() } catch { throw Error('Something went wrong.') } finally { setRequesting(false) } }, []) return ( <RequestingContext.Provider value={requesting}> <RequestingDispatchContext.Provider value={startRequesting}> {children} </RequestingDispatchContext.Provider> </RequestingContext.Provider> ) } export default RequestingProvider
また、RequestingDispatchContextで管理する関数が複数になった場合はRequestingDispatchProviderへ渡すvalueがオブジェクトになるかと思いますが、この場合も対象のオブジェクトをuseMemoを使用して再生成を防がないと同様のことが発生します。
最後に
修正を行なった際のPRを掲載しておきます。
自作サービスの規模であれば大きな問題にはなりませんが、これから仕事をする上でパフォーマンスを意識することは重要だと思い修正を行いました。 createContexを使用したステート管理は気をつけるべきことが多いため、状態管理ライブラリを使用することで、こういったことを意識せずにグローバルステートを使用するのが一番だと思います。 それをいってしまってはこの記事の立つ瀬がないのですが...。
React 〜制御・非制御コンポーネント〜
自作サービスの開発でフォームを使用したのですが、自分が実装したコンポーネントが制御か非制御かを全く理解せずに開発をしていたので、少しまとめてみようと思います。
一応公式でも説明されていますが内容が古く、コーディング例もクラスコンポーネントで書かれているので正直分かりづらいです。
制御 vs 非制御コンポーネント
この2つはReactにおいてフォームを扱うコンポーネントの分類。
制御コンポーネント
useState、onChangeを使用してフォームの入力値をstateで管理する。
const ControlledForm: FC = () => { const [inputValue, setInputValue] = useState<string>(''); const handleChange = (e: ChangeEvent<HTMLInputElement>) => setInputValue(e.target.value); const handleSubmit = (e: FormEvent<HTMLFormElement>) => { e.preventDefault(); console.log(inputValue); setInputValue = ''; }; return ( <form onSubmit={handleSubmit}> <input type="text" value={inputValue} onChange={handleChange} /> <input type="submit" value="確定" /> </form> ); };
メリット
- 入力に応じたバリデーションを設定するなど、リアクティブな実装が可能
デメリット
- 入力値が変更される度に再レンダリングが発生するのでパフォーマンスが落ちる
- 記述が多くなりがち
Input、Formのそれぞれ、引数のe
に型を定義しないといけないのが少し手間で直感的に分かりづらいと思いました。
ですが、Reactに入門するとまずはこの方法でフォームの状態管理を説明しているものが多いと思います。
非制御コンポーネント
フォームの入力値をDOMで操作する。
onSubmit関数に型ComponentProps<"form">["onSubmit"]
を指定することで引数eの型定義が不要になり、また、eからフォームの入力値へアクセス可能。
const UncontrolledForm: FC = () => { const handleSubmit: ComponentProps<"form">["onSubmit"] = (e) => { e.preventDefault(); // 入力された値を取得 console.log(e.currentTarget.text.value); e.currentTarget.reset() }; return ( <form onSubmit={handleSubmit}> <input type="text" name="text" /> <input type="submit" value="確定" /> </form> ); }
メリット
- 記述量がグッと減る
- 再レンダリングが発生しないので動きが軽くなる
デメリット
- リアクティブなバリデーションが行えない
submitされないと入力値の参照ができないため、submitの前にバリデーションをかけることや、バリデーションにかかった場合にボタンを非活性にするといった処理が行えないのが少々痛い。
react-hook-form
フォームのバリデーションを簡単に実装できるnpm。 非制御コンポーネントが用いられているがリアクティブにバリデーションを行うことも可能。
制御・非制御コンポーネントかを意識せずに使用できるのでおすすめ。
まとめ
Reactの学習が不十分だったため、サービス開発中は制御コンポーネント、非制御コンポーネントという言葉すら知りませんでした...。
そしてreact-hook-form
を使用していたので全く意識せずとも実装ができてしまっていました。
とりあえず動くものを作れるようになったので次はちゃんとReactを書けるようになりたい。 基礎概念の理解に努めようと思います。
テイスティングの記録が行えるサービス「Tasting Note」をリリースしました
はじめに
2023年7月3日に自作サービス Tasting Note をリリースしました!
サービスリポジトリ
https://github.com/yuma-matsui/tasting_note
この記事ではサービスの概要、開発を通して得た経験についてまとめています。
対象読者
自己紹介
@yuma_matzui です。 昨年、2022年2月からFJOROD BOOT CAMP(フィヨルドブートキャンプ)(以下FBC)でWebプログラマーになるための学習をしています。
Tasting Noteのリリースをもって卒業となり、現在は就職活動中です。
Tasting Noteの紹介
Tasting Noteとは
ワインテイスティングの記録が行えるサービスです。 試験で使用されるマークシートを模した回答フォームを用意しており、 本番さながらのコメントシートでテイスティングの記録が行えます。
こんな人に使って欲しい
- ソムリエ呼称資格試験・2次試験の対策をしている方
- ワインが好きな方
- ブラインドテイスティングをされる方
試験対策のために記録をつけるもよし。
趣味で飲んだワインの記録をつけるもよし。
ワインバーで目の前のワインを評価するもよし。
右手にワイングラス、左手にスマートフォンをもって気軽にテイスティングの記録をしていただけたら嬉しいです。
使い方
登録をしないで使用
「登録しないではじめる」を押下
シート名、テイスティング時間、ワインの色を入力
外観、香り、味わい、まとめの順に回答を入力する(前へ戻ることも可能)
- 回答を確認
登録をして使用
テイスティング記録後にワインの登録が行えます
記録したテイスティングの個別ページ内「ワインの登録」を押下
ワインの情報を登録(ワイン名、ぶどう品種、生産国、ラベルの写真など)
このサービスが解決できること
- 記録を行う際に必要な紙、ペン、タイマーを準備する手間が省ける
- 記録した紙を持ち運ぶ手間が省ける
- 振り返りが簡単に行える
筆者がソムリエ資格を取得した際は自宅で紙とペン、スマートフォンのタイマーを使用して模擬試験を行いました。 本番も紙とペンを使用するのでそれが正しい姿ではありますが、以下のデメリットがありました。
- コメントシートを毎回印刷しないといけない
- 外出先で復習をしたい場合に記録した紙を持ち運ぶ必要がある
Tasting Noteではワインとグラスさえあれば手軽に記録が行えて、持ち運びにも困りません。 また、振り返りの際も絞り込み検索を使用することで目的の記録まであっという間に辿り着けます。
技術スタック
- フロントエンド
- React 18.2.0
- TypeScript 4.9.3
- バックエンド
- Ruby 3.1.2
- Ruby on Rails 7.0.4
- インフラ
- CI/CD
- GitHub Actions
- 開発環境
- Docker 23.0.5
- docker-compose 2.17.3
開発中にやって良かったこと
もくもく自作サービス開発会
FBCではチャットツールにDiscordを使用しており、様々なチャンネルが存在しているのですが、「もくもく自作サービス開発会」というチャンネルを自ら立ち上げて、自作サービス開発のプラクティスに取り組む他のFBC生と一緒に開発を行いました。 作業中は特に会話はありませんが、インターバル中に作業内容や詰まったポイントを共有することで、サービス開発に対するモチベーションを高く維持できました。
こちらについてはFBC内のイベントにて登壇、発表も行いました!
開発後半は中々実施をすることができず、一人での開発がほとんどになってしまいましたが、今振り返るとやはり仲間と時間を共有していた時の方が開発スピード、モチベーション共に高かったように思います。
また、就職に向けた情報・意見交換ができたのがとても良かったです!
Issueをできるだけ小さくした
カンバン
https://github.com/users/yuma-matsui/projects/11
Issueを立てる際は細かいTaskに切り分けることを意識しました。 例えば「ログインページを実装する」というIssueがあった場合は以下のように切り分けます。
Taskを細分化することのメリット・デメリットは以下です。
メリット
- Issueをcloseする機会が多くなりモチベーションが上がる
- 作業時間が見積りやすくなる
- 詰まった場合でも自分が何に詰まっているのか特定しやすい
- 次にやることが明確で迷わない
- PRが小さくなりやすいのでレビューの負担を減らすことができる
デメリット
- Issueを立てるのに時間がかかる
- ぱっと見のTask数が膨大なので先が思いやられる
自作サービス開発はとにかく長い旅路です。 地図は少しでも細かい方がゴールまでのイメージが湧きやすく、モチベーションを高く維持できるのでおすすめです!
また、今回は基本セルフレビューでしたが、PRを作った際には必ず変更を一つ一つ確認していたので、Taskを小さく分けることで変更があまり多くならず、レビュー時の負担が小さくなったのも良かったと思います。
インフラ構築
今回のサービス開発で一番楽しかったことがインフラ構築です。 Tasting NoteのインフラはAWS環境をTerraformで構築しています。
AWSは料金が割高なので、自作サービスには敬遠されがちですが、前から興味があり、また、IaCにも興味があったのでこの機会に頑張ってみようと採用しました。
インフラ構成図
インフラをコードで管理するメリット
- 再利用が可能になる
- 高速に構築・変更が行える
- コードレビューを行うことでミスなく構築・変更が行える
実装をしてみて一番強く感じたメリットはやはり構築の速さです。
今回筆者はステージング環境をマネジメントコンソールから作成し、本番環境をTerraformで構築するという手順を取りました。 いきなりTerraformで本番環境を構築するのはハードルが高いと感じたためです。 その過程でマネジメントコンソールからの構築よりもTerraformでの構築の方が圧倒的にスピードが速いことを体感しました。
コストの観点から本番環境構築後にステージング環境を手作業で削除しましたが、これにも少なからず時間がかかり、また、漏れなく不要なリソースを削除することに多少なりともストレスを感じました。 Terraformでリソースを管理していれば不要になったリソースの削除もコマンド1つで済むので、こういったストレスからも解放されます。
また、ステージング環境を再度構築したいと思った場合は本番環境を再利用することであっという間に環境が構築できてしまいます。
インフラ構築において最も大事だと思うこと
インフラ構築で最も大事なことは設計ではないでしょうか。
インフラをコード化する作業はテストコードを書くことに似ています。 インフラ構成をドキュメントとして残す作業なので、設計が固まっていないと手が動きません。
この点はテスト駆動開発をする際にある程度の設計が固まっていないとテストが書けない点と似ていると感じました。
また、Terraformではリソースの依存関係があり、これを把握していないとリソースが構築できません。 設計ができている状態であればそれぞれのリソースの依存関係も把握できている状態なので詰まることなく実装を進められるはずです。
FBC生の自作サービスリポジトリを見る
自分と似通った技術スタックを採用しているサービスのリポジトリを確認することで、実装のイメージをもつことができたり、便利なnpmを発見できたりと、とても有益な情報を得ることができました。
仕事の際はオープンソースや他の人のコードを読む機会が多いと聞くので、人のコードを読むことに少しでもなれることができたのは良かったと思います。
開発中に苦労したこと
フロントエンドのビルド時間がとにかく遅い
create-react-appはビルドにとても時間がかかるので開発中のストレスが大きかったです。 また、今回は開発環境にDockerを使用しており、バックエンドとの連携を行う場合はdocker-composeで複数コンテナを起動する必要があったため、CPUへの負荷がさらに掛かり最悪でした...。 サーバー起動後にコードを変更した場合も、変更が反映されるまでの時間がとにかく遅く、開発体験はあまりよくなかったように思います。
開発後半にViteの存在を知りましたが移行するにはハードルが高く、今回は諦めてしまいました。
後述の「開発中にやっておけばよかったと思うこと」でも述べておりますが、技術調査を事前に行なっていれば防げた問題なので技術選定時はその時に流行っているものを一通り確認するなど、調査を怠らないようにしたいです。
コンポーネントの単体テストの数がものすごかった...
今回の開発はまずは動くものをつくって最後にテストを書く、テストラストで進めました。 バックエンドはそれで全く問題なかったのですが、フロントエンドはコンポーネント、カスタムフックの量がとても多く、テストを書くことにとても根気が必要でした。
テストを書き始めた時は楽しかったのですが、何日も同じ作業となると段々と辛くなり... モチベーション管理の観点からも1つのコンポーネントを実装する毎にテストも書いておけば良かったと反省しました。
開発中にやっておけば良かったと思うこと
技術ブログの更新
自作サービス開発では開発環境の構築や本番環境へのデプロイ、新しく使用するnpmやgemなど、何かを学びながら即実装の連続でした。 こういった新しく触れる技術については開発を一時中断してでも技術ブログにまとめておけば良かったと後悔しています。
理由は実装を行ったその時が一番その技術に詳しくなっている状態だと思うからです。
とは言え全くできなかったわけではなく、開発初期にまとめたものもいくつかあります。
- React SPA 〜S3 Web hostingで環境変数を仕込む方法〜 - Qiita
- Wizard形式のフォームをつくる with react-hook-form - Qiita
- React + Typescript 〜react-hook-formを使って超簡単にフォームvalidationを実装する〜 - Qiita
- Terraform + AWS 〜ECSを無停止で切り替えるPublic ↔︎ Private〜 - Qiita
アウトプットまで行って初めて知識という自分の血肉になるということをFBCでの学習を通して学んだので、今後はマメにブログや記事にまとめていきたいと思います。
念入りな技術調査
技術調査が不十分であったという話です。
create-react-app(以下CRA)について、ビルド時間がとても遅いと前述しましたが、CRAの開発者も将来的に非推奨になる可能性を含め、CRAの今後について下記にて触れています。
普段からこういった技術的なトレンドにアンテナを貼っておけば、開発初期の段階でViteを導入することを検討できましたし、そもそもNext.jsを使ってしまおうという選択もできました。
もちろん調査に時間を使い過ぎるのもよくないと分かっていますが、今回の課題は調査の時間を十分に確保しなかったことより、最新技術の情報収集に対するアンテナが低かったことだと考えています。
情報収集力もプログラマーにに求められる重要なスキルの1つだと思うので、公式サイトのブログを購読したり、発信力のあるプログラマーをTwitterでフォローするなど、常に最新の情報を取得できる環境に身を置くようにします。
サービスの改善点
認証周り
Tasting Noteは認証にFirebase Authenticationを採用しており、メールアドレスとパスワードでの認証を行なっているのですが、これをGoogle認証に切り替えたいと考えています。
このサービスのコンセプトが手軽にテイスティングを記録できることであり、登録の手間を極力減らしたいと考えたためです。
そもそも何故、Googleログインを採用していないのかというと、iOS16.1
やSafari16.1
など、主要なブラウザ、OSでサードパーティクッキーがブロックされる仕様になっており、セッション管理が行えず、ログイン状態が保持できないというバグが発生したためです。
サードパーティークッキーのブロックについて簡単にまとめると、同一ドメインでないサイトからのクッキーを全てブロックするというものです。
解決方法についてはこちらの公式ドキュメントに明示されているのですが、サービスの仕様上、いずれも採用ができず、やむを得ずメールアドレスとパスワードでの認証を採用しました。
唯一、解決策として採用できそうなのが、アプリケーションサーバーにプロキシを設定して、特定のパスにリクエストが来たらFirebaseの認証ドメインへリクエストを転送させるという方法でした。 これであればサードパーティークッキーと判断されず、ブロックを免れることができます。 (公式ドキュメントの緩和策3に当たる。)
ですが、Tasting NoteはSPAを採用しており、S3のWebHostingを利用して動かしているため、プロキシの設定が行えませんでした。
また、バックエンドはECSを採用しており、ECSの前段にサービスのサブドメインを割り当てたALBを配置しているので、Railsが動いているTask内にNginxコンテナを用意して、特定のパスへのリクエストが来たらFirebaseの認証ドメインへリクエストを転送させるという試みもしましたが、これもうまくいきませんでした...。
開発に費やせる時間も限られていたため、今回はメールアドレスとパスワードでの認証という消極的な選択肢をとってしまいましたが、これを改善したいと考えています。
CRAではなく、Next.jsを採用していた場合はNextが動くサーバーにプロキシの設定を行えば簡単に解決ができるので、Next.jsへの移行を検討しています。
Next.jsの導入
CRAからNext.jsへの移行をしたいと考えています。 コードの量がそこそこあるので根気が入りますが、前述のCRAと認証の課題を踏まえ、また、最近とても流行っているということも鑑みて学習後のアウトプットとして挑戦したいと思っています。
今すぐにとはいきませんが、就職活動が落ち着き、Next.jsの基礎学習が済んだら始めてみようと思います。
さいごに
これまでサポートをしてくださった@komagataさん、 @machidaさん、FBCメンターの方々、切磋琢磨してくれたFBC生徒の皆さん、本当にありがとうございました。
不思議と途中で諦めそうになることはなく、必ずやり切るという強い思いでここまできましたが、その思いを持ち続けることができたのはひとえに心強いメンターの皆様と同じ強い思いを持つ生徒の皆さんの存在があったからだと言えます。
FBCで皆さんから学ばせていただいた集大成として自分のキャリアを活かしたサービスをリリースできたことは大きな自信になりました。
この自信をもって一人前のWEBプログラマーになれるように引き続き切磋琢磨していきたいと思います。
大変長くなりましたが最後までお付き合いいただきありがとうございました。