👶

アプリエンジニアがReactに入門した際に調べたことまとめ - プロジェクト作成〜単体テスト実行

2021/07/25に公開

背景

個人でつくっているアプリ(※)の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にしちゃった方が良さそうなのでそうしました(上記のstrictnoImplicitReturns)。
noUnusedParametersは使ってない引数があるとコンパイルエラーになっちゃいますが、その引数の頭にアンダースコア(_)をつけると回避できます

targetは出力されるJavaScriptの形式を、moduleは出力されるJavaScriptがどのようにモジュールを読み込むかを指定します。
導入手順を参考にしたサイト( https://www.webopixel.net/javascript/1642.html ) を真似て、targetes5という形式を指定してます。新規でプロジェクトを作成した場合もes5でした。こちらのサイトによると、2009/12公開のバージョンのようです。

tsconfig.jsonのtargetmoduleの解説は以下のサイトがわかりやすかったです。

"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.FCReact.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">
            &copy;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の部分)

packages.json
  "scripts": {
    "test": "jest --silent=false --verbose false"
  }

これで、テストが実行できます!

npm t

npm testと同じ)

今回はログインボタンを押してAPI通信する箇所の処理書いてみます。
以下のようなコンポーネントとクリック時の処理を分けて作成しました。(一部省略してます)

Login/Index.tsx
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>
)
Login/HandleSubmit.tsx
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 を参考に )

Login/HandleSubmit.test.tsx
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に入門してみるきっかけとなった記事

導入手順

コンポーネント

Hooks

セッションをどこに保存するか

パフォーマンス

Discussion