アプリエンジニアがReactに入門した際に調べたことまとめ - プロジェクト作成〜単体テスト実行
背景
個人でつくっているアプリ(※)のAPIサーバーに管理画面を入れたいなと思い、せっかくなのでモダンなWEBフロントエンド技術をやってみたかったので、Reactに入門しました。
もうすでに導入されてから随分経つようですが、ReactにHooksというものがあり、すごくいいらしく(語彙力)、チャレンジしました。
※ 2021.7時点 総DL数 50 😇
対象読者
WEBはJavaScriptを触ったことがあるけど、現代ウェブフロントエンドはわからない、というような方がReactをやってみるときの一助となれば幸いです。あと自分はアプリエンジニア(主にiOS)なので、同じアプリエンジニアの方々の理解の助けになればと思い、ところどころアプリでは何に該当する技術か考えてみました。
環境
知らないツールの名前がたくさんでてきて、それだけで圧倒されてやる気を失いそうになりましたが、今回はReactが今後も使える技術だろうということで踏ん張りました。
- React (17.0.2)
- npm (7.8.0)
- webpack (5.33.2)
- Tailwind CSS (2.1.2)
概要
ReactはJavascriptフレームワークで、コンポーネントと呼ばれる部品でUIを組み立てることができます。データをAPIで取得し、それをもとにUI部品(Component)を出力するので、ネイティブアプリにおけるデータフローとざっくり一緒かなと思います。
Reactプロジェクト作成・導入
既存プロジェクトに導入する場合
背景に書きましたが、自分は一からプロジェクトを作成したのではなく、既存アプリのAPIサーバーに導入しました。Reactを導入するために追加したファイルは、以下です。
- package.json
npm(JavaScriptパッケージ管理ツール)のパッケージ管理ファイル - tsconfig.json
TypeScriptコンパイル設定ファイル - webpack.config.js
webpack(ビルドツール)のビルド設定ファイル
package.json
npm init
とコマンドをうつと生成され、そこにプロジェクトで使用したいパッケージ群を定義してきます。
なのでiOSのpod init
と同じ(?)です。
手でいじることはなく、コマンドでパッケージをインストールしていきます。
-
npm install --save <package>
(npm i -S
)
で、プロダクションコードでもつかうパッケージをインストール -
npm install --save-dev <package>
(npm i -D
)
で、開発時にのみにつかうパッケージをインストールできます。
(コマンドで追加できるのはCocoapodsより便利・・・)
reactやwebpackなどインストールします。
npm i -S react react-dom
npm i -D webpack webpack-cli typescript ts-loader
ブラウザではTypeScriptのままだと動かず、Javascriptへ変換しないといけないのですが、ts-loader
というパッケージが変換してくれるようです。その変換はwebpackで実行するようにします(後で)。
次項で書きますが型チェックを強制するので、その場合は、型定義ファイルがないとコンパイルに通らないようです。なので型定義パッケージというものをインストールします。
npm i -D @types/react @types/react-dom
なので、パッケージを追加してコンパイルが通らなければ、パッケージ名の頭に@types/
をつけた型定義パッケージを入れ忘れていないかチェックするといいかもです...。
また、package.jsonにスクリプトを登録して、webpackでビルドができるようにします。
{
"scripts": {
"build": "webpack --mode=production",
"start": "webpack -w --mode=development"
},
これで npm run build
で本番用ビルド、
npm run start
で開発用ビルドします。webpackの-w
オプションはファイルを変更したら差分ビルドしてくれるようです。また、--mode
はdevelopmentだとソースのマッピングが有効になるようです。
tsconfig.json
TypeScriptのコンパイル設定ファイルです。
{
"compilerOptions": {
"sourceMap": true,
"target": "es5",
"module": "es2015",
"strict": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"jsx": "react",
"lib": ["es2020", "dom"]
}
}
型チェックを強制する(strict
)ことや、null安全にする(strictNullChecks
)などの設定ができます。他にも不要なコードがあるとエラーにしてくれる設定もあるので、この辺は全部trueにしちゃった方が良さそうなのでそうしました(上記のstrict
〜noImplicitReturns
)。
noUnusedParameters
は使ってない引数があるとコンパイルエラーになっちゃいますが、その引数の頭にアンダースコア(_
)をつけると回避できます。
target
は出力されるJavaScriptの形式を、module
は出力されるJavaScriptがどのようにモジュールを読み込むかを指定します。
導入手順を参考にしたサイト( https://www.webopixel.net/javascript/1642.html ) を真似て、target
はes5
という形式を指定してます。新規でプロジェクトを作成した場合もes5
でした。こちらのサイトによると、2009/12公開のバージョンのようです。
tsconfig.jsonのtarget
・module
の解説は以下のサイトがわかりやすかったです。
"jsx": "react"
となってますが、これはJSXという書式を有効にしているようでして、以下のようにJavaScript(本記事ではTypeScript)にタグを直接かけるようにするものです。(便利!)
const element = <h1>Hello, world!</h1>;
webpack.config.js
ビルドツールの設定です。エントリーポイントをsrc/App.tsx
というファイルにしてます。
ビルドした結果はapp.js
というファイルに書き出され、ビルドはmodule.rules
に記載があるように、ts-loader
というパッケージを使用して行います。resolve.extentions
はパッケージやファイルなどをimportするときに省略できる拡張子を指定します。
const path = require('path')
const assetsDir = path.resolve(__dirname, 'path/to/src');
const distDir = path.resolve(__dirname, 'path/to/dist');
module.exports = {
entry: assetsDir + "/App.tsx",
output: {
path: distDir,
filename: 'app.js'
},
module: {
rules: [
{
test: /\.tsx?$/,
use: "ts-loader",
},
],
},
resolve: {
extensions: [".ts", ".tsx", ".js"]
},
};
ファイル名は大文字始まりのキャメルケースにしました。命名規則はAirbnbのものが参考になるらしいので、それにあわせました。
新規でつくる場合
プロジェクトを作るためのコマンドが用意されてます。
npx create-react-app exzennple --template typescript
--template typescript
がなければ、javascriptになります。
npx
は打ち間違いじゃないみたいです。
package.json
が自動で作られてるので、中を覗いてみると、
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
}
となっているので、npm run start
と叩いてみると、勝手にlocalhost:3000
がブラウザで開きました。すごいです。あとはApp.tsx
をいじるだけですね。
デプロイするときは、npm run build
と叩いたら、publicフォルダができたので、それをサーバーにアップするだけでしょうか...。めっちゃ楽...。
tsconfig.json
新規でつくると、tsconfig.json
が既存プロジェクトに導入する場合
に記載したもの少しと違ってました。
"jsx": "react-jsx"
特に気になったのはこれです...。上記のように指定すると、jsへの変換結果が新しくなったようで、エラーメッセージの情報量が多くなったり、コードサイズが小さくなったりとメリットがあるようですので、こちらを使った方がいいかと思います。
コンポーネント
UI部品は、関数コンポーネントとクラスコンポーネントという2パターン実装方法があるようですが、クラスコンポーネントを使うとコードが複雑になりがちということで、関数コンポーネントのみ使いました。以下のようなやつです。
export const Login: React.VFC<Props> = () => {
return (
<>
<h1>Login</h1>
<form>
...
</form>
</>
);
}
まず、<></>
って何?と思ったのですが、これはReactのフラグメントというものの短い記法で、ちゃんと書くと<React.Fragment></React.Fragment>
となるようです。
JSXではひとつのコンポーネントが複数の要素を返すことができないので、<div>
タグなどでまとめたりしますが、別のコンポーネントから参照した際にそのようなまとめるためだけのタグは出力したくない、というような場合に使います。
// 親のコンポーネント Parent
return (
<ul>
<AppleList />
</ul>
)
// 子のコンポーネント AppleList
return (
<>
<li>りんご</li>
<li>Apple</li>
</>
)
上記の場合、描画される時は以下になります。
<ul>
<li>りんご</li>
<li>Apple</li>
</ul>
もしAppleListの <>
を <div>
とかにすると、以下になってしまいます。
<ul>
<div>
<li>りんご</li>
<li>Apple</li>
</div>
</ul>
フック
関数コンポーネントが状態を保つための仕組みです。とりあえず、以下だけ覚えました。超ざっくりそれぞれの説明です。
基本のフック
- useState
関数コンポーネントが状態(変数)を持てるようにする。 - useEffect
コンポーネントが画面に表示された後に実行したいときに使う。 - useContext
同じ変数をいろんなコンポーネントから参照するときに使う。
追加のフック
- useReducer
useStateとほぼ同じだが、変数のセットが単純なセッターではなく、関数をカスタマイズして定義することができる。複雑なロジックを切り出したい時に使う。 - useMemo
不必要な再描画がされないようにする。
基本のフック・追加のフックという言い方は以下を参考にしました:
基本のフックについての具体的な例は、エラーハンドリングの集約
のところで書きます。
認証情報の保存場所
アプリだとグローバル変数に持っちゃうか、暗号化して永続ストレージに入れる場合もあるかなと思いますが、SPAの場合はどこにいれるのでしょうか?
まず最初に思いついたのが、LocalStrageです。でもJavaScriptからアクセス可能なので安全ではないということらしく...おとなしくCookieにしました。なのでログインのAPIが成功したら、サーバー側でCookieにセッションIDをセットして、ログイン後のAPIではセッションに紐づくユーザーを取得するようにしました。
フロント(React)側は認証情報の取得やヘッダにセットを自前でしなくていいので楽です。
エラーハンドリングの集約
Cookieにセッションがない状態で、各コンポーネントからAPIを実行したときに、認証失敗してログイン画面へ遷移するのはどのように共通化するんだろう、と調べていたら以下の記事にぶつかりました。
上記を参考に以下のようなコンポーネントを用意しました。
type ErrorStatusCodeDispatch = React.Dispatch<React.SetStateAction<number>>
export const ErrorStatusDispatchContext = React.createContext({} as ErrorStatusCodeDispatch)
interface Props {
children: React.ReactNode
}
export const ErrorHandler: React.VFC<Props> = ({ children }) => {
const history = useHistory()
const [errorStatusCode, setErrorStatusCode] = React.useState(0)
React.useEffect(() => {
const unlisten = history.listen(() => setErrorStatusCode(0))
return unlisten
}, [])
const renderContent = () => {
if (errorStatusCode === 401) {
return <Login />
} else if (errorStatusCode === 404) {
return <NotFound />
}
return children
}
return (
<ErrorStatusDispatchContext.Provider value={setErrorStatusCode}>
{renderContent()}
</ErrorStatusDispatchContext.Provider>
)
}
知らないことが盛りだくさんで理解に時間がかかりましたが、以下のようなことだと思います。
interface Props {
children: React.ReactNode
}
export const ErrorHandler: React.VFC<Props> = ({ children }) => {
// ...
}
関数コンポーネントはReact.FC
かReact.VFC
の型のどちらか、もしくは、どちらも使わなくてもいいらしいですが(選択肢が多くてつまずく...)、今いまはReact.VFC
にしとくと、childrenの有無がコンポーネントを呼び出す側からわかって嬉しいらしいので、とりあえずそうしました。(将来React.VFC
は削除予定で、いつかはReact.FC
に置換しないといけないらしい...つまずく...)。
コンポーネントに渡す引数は、型を定義して(ここではProps
)、それをReact.VFC
のジェネリクスの型に指定するみたいです。ここで文字列とかも引数で渡したければ、Props
にそれを追加していくようです。
// たとえば引数 greeting を追加
interface Props {
children: React.ReactNode
greeting: string
}
次に状態を保持する箇所(useState)です。
const [errorStatusCode, setErrorStatusCode] = React.useState(0)
関数コンポーネントは関数なので、普通は状態を持つことはできないですが、これで持てるようになります。useState(初期値)
で取得できるのは配列で、ひとつ目が変数の値で、ふたつ目がその変数に値をセットする関数です。この関数を呼んで値をセットすると、再度この関数コンポーネントの描画が行われます。
次にHTTPステータスコードを扱う部分です。
type ErrorStatusCodeDispatch = React.Dispatch<React.SetStateAction<number>>
export const ErrorStatusDispatchContext = React.createContext({} as ErrorStatusCodeDispatch)
export const ErrorHandler: React.FC<Props> = ({ children }) => {
// ...
return (
<ErrorStatusDispatchContext.Provider value={setErrorStatusCode}>
{renderContent()}
</ErrorStatusDispatchContext.Provider>
)
}
あらゆるコンポーネントからAPI呼び出しを行いますが、そこで受けとるHTTPステータスコードを一元管理するための仕組みになってます。ErrorStatusDispatchContext
というオブジェクトをつくってます。HTTPステータスコードのエラーコードをセットする関数です。子要素からはuseContext
というフックを使ってにそれにアクセスします。
次に画面遷移時のハンドリングです。
const history = useHistory()
React.useEffect(() => {
const unlisten = history.listen(() => setErrorStatusCode(0))
return unlisten
}, [])
まず、useHistory
ですが、これはURLを変えたり、その変更を検知したりできるオブジェクトをつくってくれます。
useEffect
は、コンポーネントが描画されたら実行される処理を定義してます。(iOSのviewDidAppearみたいなものと理解)
なので、URLが変更されたことを検知して、エラーステータスコードを初期値にリセットしてます。
最後にコンテンツを出力している部分です。
const renderContent = () => {
if (errorStatusCode === 401) {
return <Login />
} else if (errorStatusCode === 404) {
return <NotFound />
}
return children
}
そのままですね。HTTPステータスコードがエラーだった場合に、それ用のコンポーネントを出力し、それ以外だったら子要素をそのまま出力しています。
上記ErrorHandler
コンポーネントを、Routerの各ページのコンポーネントの親にしました。
const App = () => {
return (
<BrowserRouter>
<ErrorHandler>
<Switch>
<Route path="/dashboard" component={Dashboard} />
<Route component={Login} />
<Route component={NotFound} />
</Switch>
</ErrorHandler>
</BrowserRouter>
)
}
ErrorHandler
コンポーネントのchildren
が<Switch>〜</Switch>
部分に該当します。
スタイル
TailwindというCSSフレームワークを使いました。
CSSのスタイルを別ファイルで定義することなく、すべてHTML上のタグに指定することで、スタイルを適用できるらしいです。わかりやすいなと思ったので使ってみました。
サンプルやテンプレートが豊富に用意されていて、以下のサイトで検索してそれをコピペするだけで大分いい感じに仕上がります。
たとえば、login
と検索すると、最近っぽいログインフォームのサンプルがいくつも出てきます。
以下はログイン画面のテンプレートをコピペしてつくったものです。(React.memoなどは使用していないのでパフォーマンスの最適化はしていないですが...Tailwindの雰囲気だけお伝えしたかったので...)
<div className="container">
<div className="w-full max-w-xs mx-auto mt-8">
<form className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4" onSubmit={handleSubmit(username, password, history, setErrorStatusCode)}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="username">
Username
</label>
<input id="username" className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:outline-shadow" type="text" placeholder="User name" onChange={e => setUserName(e.target.value)} />
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
Password
</label>
<input id="password" className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" type="password" onChange={e => setPassword(e.target.value)} placeholder="******************" />
</div>
<div className="flex items-center justify-between">
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="submit">
Sign in
</button>
</div>
</form>
<p className="text-center text-gray-500 text-xs">
©2021 favolabo. All rights reserved.
</p>
</div>
</div>
見た目の実装中は、表現したい見た目を決める → docを見る → HTMLタグのclassに指定して確認、という流れを繰り返しました。
1点だけデメリットというか気になった点は、実装中はストレスなく進めることができるので嬉しいのですが、あとでソースを見返した時にclassNameに大量にクラスを指定するので、少し読みにくくなりません?まあ、HTML上で完結させるためなので仕方ないんですが...。
テスト
Reactではテストはどのように書くのでしょうか。以下の記事を参考にしました。
大きく分けて2つテストがあるかなと思いました。
- UIテスト
コンポーネントの描画結果に対してテストする。上記の記事によると、RITEway というライブラリを使えば楽になるらしいですが(というかそのライブラリの作者の記事なのですが...)、UIのテストはしないので使いませんでした。 - ロジック/ビジネスルールのテスト
ロジック(※)部分はuseReduer
などを使用してテストしたいロジックを切り出して、それに対してテストを書く。
※ ざっくりした言い方ですが、他に思いつきませんでした。原文ではProgram logic
と書いてあるのですが...。プレゼンテーションロジックや、データアクセスの呼び出しなどのユースケースなど、UI部分以外のロジックを指してると思います
Reduxというアーキテクチャがあるらしく、これを使うとまた違ったテストの書きかたになりそうですが、まだ勉強してません。。。
Jest
というテストフレームワークを使用します。
Typescriptでの導入手順は以下を参考にしました。
# Jest
npm i jest @types/jest ts-jest -D
# fetchでAPI通信してるので、その通信をモックするため
npm i -D fetch-mock node-fetch
コンフィグファイルをつくります。基本的に上記サイトのコピペですが、roots
のところのディレクトリは適宜修正します。これで、ソースファイルから.test.tsx
のようなテストを表すファイル名にマッチしたものをテスト実行してくれるようになります。
module.exports = {
"roots": [
"<rootDir>/src"
],
"testMatch": [
"**/__tests__/**/*.+(ts|tsx|js)",
"**/?(*.)+(spec|test).+(ts|tsx|js)"
],
"transform": {
"^.+\\.(ts|tsx)$": "ts-jest"
},
}
テストを実行するためのスクリプトを登録します。console.log
もターミナルに出力されるようにオプションをつけています。(--silent=false --verbose false
の部分)
"scripts": {
"test": "jest --silent=false --verbose false"
}
これで、テストが実行できます!
npm t
(npm test
と同じ)
今回はログインボタンを押してAPI通信する箇所の処理書いてみます。
以下のようなコンポーネントとクリック時の処理を分けて作成しました。(一部省略してます)
export const Login: React.VFC<Props> = () => {
const history = useHistory()
const [username, setUserName] = useState("")
const [password, setPassword] = useState("")
const setErrorStatusCode = React.useContext(ErrorStatusDispatchContext);
// ...
return (
<form onSubmit={handleSubmit(username, password, history, setErrorStatusCode)}>
<div>{userNameField}</div>
<div>{passwordField}</div>
<div>
<button type="submit">Sign in</button>
</div>
</form>
)
import * as H from 'history'
async function loginUser(username: string, password: string) {
return fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
},
body: new URLSearchParams({
'username': name,
'password': password,
})
}).then(data => {
return data.status
})
}
const handleSubmit = (
username: string,
pass: string,
history: H.History,
setErrorStatusCodeDispatch: ErrorStatusCodeDispatch
) => async (e: any) => {
e.preventDefault()
const status = await loginUser({
username: username,
password: pass
})
if (status == 200) {
history.push("/dashboard")
} else {
setErrorStatusCodeDispatch(status)
}
}
export { handleSubmit }
HTTPステータスの戻りが200だったらログイン成功し画面遷移、それ以外だったら、コンテキストのHTTPステータスをセットして失敗のハンドリングを行うようにしています。handleSubmit
のみテストしました。以下のようになりました。テストファイルはテスト対象のファイルと同階層に置きました。( https://ja.reactjs.org/docs/faq-structure.html を参考に )
import { createMemoryHistory } from 'history'
import { handleSubmit } from './HandleSubmit'
import fetchMock from 'fetch-mock'
const loginUrl = '/login'
test('handle submit: login success', async () => {
// arrange
const history = createMemoryHistory()
const setStatusCode = jest.fn()
const e = { preventDefault: jest.fn() }
fetchMock.post(loginUrl, { status: 200 }, { overwriteRoutes: true })
// act
await handleSubmit('username', 'pass', history, setStatusCode)(e)
// assert
expect(history.location.pathname).toBe('/dashboard')
});
test('handle submit: login failure', async () => {
// arrange
const history = createMemoryHistory()
const setStatusCode = jest.fn()
const e = { preventDefault: jest.fn() }
fetchMock.post(loginUrl, { status: 401 }, { overwriteRoutes: true })
// act
await handleSubmit('username', 'pass', history, setStatusCode)(e)
// assert
expect(setStatusCode.mock.calls[0][0]).toBe(401)
});
ポイントは画面遷移のhistory/失敗時のエラーステータスコードをセットする関数にモックを使用している点です。
expect(setStatusCode.mock.calls[0][0]).toBe(401)
[0][0]
がわかりにくいなと思ったのですが、モックが1回目に呼ばれた時の1つ目の引数ということみたいです。fetch
したときに401
が応答するようにしたので、その値がセットされたことを確認しています。
あと地味にハマったところとしては、onSubmitに渡す関数が引数e
じゃないといけないみたいなので、
const handleSubmit = (
username: string, /* ... */
) => async (e: any) => { /* ... */ }
のように関数を戻り値とするような定義にしないといけなかったのと、e.preventDefault()
のモック方法です。stackoverflow先生に聞きました。いつもありがとうございます。
以上です!ありがとうございました。
参考
Reactに入門してみるきっかけとなった記事
導入手順
コンポーネント
- https://qiita.com/sangotaro/items/3ea63110517a1b66745b
- https://qiita.com/tttocklll/items/c78aa33856ded870e843
Hooks
セッションをどこに保存するか
パフォーマンス
Discussion