22
29

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【TypeScript / React / CRA / Redux】タスク管理アプリ (ポートフォリオ) の実装過程 (フロントエンド編)

Last updated at Posted at 2021-08-25

主にスキル向上を目的に、ポートフォリオとしてタスク管理アプリを作成しました。このページでは、主にフロントエンドを構成するために必要な実装とそのために利用したパッケージ及びそれらの初期設定などについて触れていきます。

アプリケーションや作成したコード、バックエンドの実装過程の説明については、以下のリンクからアクセスできます。

目次

開発環境

フロントエンドの開発言語としてTypeScriptを使用し、ライブラリとして使用したのはReactです。またこれらを基本とした開発環境の構築にはCreate React App (CRA)を用いており、これによってReactを実行し結果を確認できるサーバーなどのReactの利用に必要な環境が容易に整います。その他実行環境は以下のようになっています。(括弧内の数字はバージョン)

動作確認の際のブラウザには、Chrome (Mac、Android) を使用しています。

主要使用技術

主に使用した技術、パッケージを以下に列挙します。(括弧内の数字はバージョン)

ディレクトリ構成

Reactは初期生成ディレクトリがほぼ存在せず、作成したファイルをどこに格納するかの厳密な規定がありません。そのためディレクトリやファイルの構成は開発者によって様々です。

そこで、アトミックデザインの考え方や他のリポジトリの構成を参考にしつつ、ここでは以下のような構成を採ります。これは暫定的なもので、何か不都合が発生した場合や改善点を発見した際には適宜構成の変更を行う予定です。

src/
├── __tests__/ # テスト実行ファイル
├── components/ # ページの構成要素
├── config/ # 環境変数関連
├── images/ # ロゴなど (ユーザー用は別)
├── layouts/ # ページ全体に係る部品
├── mocks/ # テスト環境構築関連
├── models/ # データ型定義
├── pages/ # ページ(URI)単位
├── store/ # Redux関連
│   ├── slices/ # Redux Action など
│   ├── thunks/ # Async Thunk (APIリクエスト)
├── templates/ # 複数ページで再利用可能なコンポーネント
├── theme/ # CSS (Material-UI テーマ)
├── utils/ # 複数ファイルで共用する関数や定数
│   ├── hooks/ # カスタムフック
├── App.tsx # アプリ全体に適用する処理など
├── Routes.tsx # ルーティング
├── index.tsx # ライブラリの設定など
├── react-app-env.d.ts
├── reportWebVitals.ts
└── setupTests.ts

その他、新規ディレクトリ名の決定を行うにあたり参考になったのがVSCode拡張のMaterial Icon Themeです。これはファイルやディレクトリにその名前に応じてアイコンを表示してくれるツールで、エディターのファイルエクスプローラーの見通しが良くする効果があります。

これを利用することで、もし命名したディレクトリ名によってアイコンが表示された場合は一般的に利用されている名前であると判断することができます。

今回の場合も基本的にはアイコンが表示されるような命名になっています。ただし、Redux関連やモデル (DBテーブルに相当) 関連のディレクトリなど一部はアイコンが付与されるような命名ができていません。thunksboardsなどがこれに該当します。

React

Reactではコンポーネントとして分割されたJavaScriptのコードを組み合わせることでUIを構築していきます。種別としてクラスを利用した方法と関数を使った方法が存在しますが、関数型の方がHooks (フック) によって簡単に扱うことができるなどの理由から、今では専ら関数型が使用されておりここでもそれに従います。ただ公式サイトではクラス型で説明されている項目も多く、参考にするにはやや困惑することがありました。

Vueと比較されることが多いReactですが、こちらの方が使い慣れたJaveScriptの記法に近い使い方ができる他、TypeScript (後述)を扱いやすいという理由からこちらを使用しています。

Create React App

Create React Appを利用することで、依存パッケージの導入から設定まで面倒な操作も行うことなくReactを使用したプロジェクトを始めることができます。これにはwebpackBabelESLintなども含まれています。

CRAの代わりとして、Next.jsを使用するという選択肢も考慮しています。しかし、公式サイトでSPAの作成にはこちらを推奨している記述があることや、CRAの方は経験があり手早く開発できそうだったこと、また後にNext.jsを使用することになってもCRAの経験やコードが活用できそうだったことなどを勘案の上でCRAを採用しました。

テンプレート

CRAを導入するにあたって、同時にテンプレートを選択することができます。これにより必要なパッケージを個別にインストールする手間が省けます。今回はTypeScriptで開発するためのテンプレートを使用するため、実行するコマンドは以下のようになります。

# `frontend`部は任意のプロジェクト名
npx create-react-app frontend --template typescript

この際、後述Reduxも同時にインストールするテンプレートも存在していましたが、一部のパッケージのバージョンがやや古めであったことから別途インストールする方法を採っています。

参考: Getting Started | Create React App

tsconfig.json

CRAでは設定用のファイルtsconfig.jsonが初めから作成されています。ここに一つ追加の設定としてbaseUrlを加えておきます。

tsconfig.json
{
  "compilerOptions": {
   ...

    "baseUrl": "src"
  },
  "include": ["src"]
}

これによって、importを行う際に、現在のファイルからの相対パスではなく、baseUrlで指定したパスからの相対パスを利用することができます。

例えば、importを行うファイルの3階層上に目的のコンポーネントが存在する場合、baseUrlを指定しない場合と指定した場合の違いは以下のようになります。

// `baseUrl`指定なし (アプリケーション上でのファイルの位置はこれだけでは不明)
import Header from '../../../layouts/Header';

// `baseUrl`指定あり (ファイルは`src/layouts/Header.ts`に存在)
import Header from 'layouts/Header';

このように、importするファイルがかなり上の階層に位置する場合にはファイルの位置判断が困難になるので、そのような場合には特に効果的です。

静的型付け

静的型付けを導入することで、変数や関数にコメントなど注釈を付け加えることなくその振る舞いを示すことができ、コードの可読性を向上させることが期待できます。またそこで定義した使用法から外れた予期しないコードの利用に対しては、IDEやエディター (ここではVSCode) がエラーを表示させ、ここから発生するバグを未然に防ぐことが可能です。

例えば動的型付けの場合、引数が必要にもかかわらず指定せずに関数を利用したとしても、また誤った引数に指定したとしても、実行するまでエラーは表示されません。一方、静的型付けの場合はその時点でエラーの内容が示されます。

また関数使用時に利用できるプロパティやメソッドの候補を表示する補完機能を利用することも可能で、入力ミスをなくし効率の良いコーディングに繋がります。特に各種ライブラリの使用時にその効力を実感することになります。

JaveScriptは動的型付け言語なので、静的型付けを行えるようにするためにTypeScriptを導入します。

TypeScript

JaveScriptで静的型付けを導入する他の方法も存在するようですが、TypeScriptが主な選択肢となっています。

利点として、導入や利用が容易であることが挙げられます。例えば今回利用しているCRAでは、コンパイラなどの設定不要で使用を開始することが可能で、型としてanyを指定することでJavaScriptと同じように利用できるので少しずつTypeScriptを取り入れていくことができます。また、VSCodeにおいては拡張機能を追加することなく補完機能が利用でき、変数や関数にマウスオーバーすることで型情報を表示させることができます。

このような事情から、特段の理由がない限りJavaScriptではなくTypeScriptを利用することが望ましいと考えています。

注意点としては、ライブラリ使用時、型定義ファイルが提供されていない場合は利用することができないことです。しかしTypeScriptの利用はだいぶ一般的になっており、基本的に型定義ファイルを利用することができるようになっています。各パッケージのドキュメントにもTypeScriptでの利用を想定した項目が存在するものが多くを占めます。

ルーティング

SPAにおいてはリンクによるページ遷移は行いません。SPAでこれを表現してUIの表示を切り替えるためにルーティングの設定が必要です。Reactでは一般的にReact Router Domを利用することでこれを実現します。

React Router Dom

初めにReact Routerのインストールから行います。react-routerというパッケージも存在しますが、Webアプリケーションの場合以下の注釈があるように、必要なものはreact-router-domです。

Note: This package provides the core routing functionality for React Router, but you might not want to install it directly. If you are writing an application that will run in the browser, you should instead install react-router-dom.

ReactTraining/react-router - GitHub

同時に型定義ファイルも忘れずインストールします。

yarn add react-router-dom @types/react-router-dom

ルーティング設定

最も基本的な用法はBrowserRouterの中にRouteを内包したSwichコンポーネントを配置することです。ここではApp.tsxBrowserRouterを作成し、実際のルーティングはRoute.tsxというファイルを作成してそこで設定を行っています。

src/Route.tsx
import { Switch, Route, Redirect, useHistory } from 'react-router-dom';
// 以下のコンポーネントは作成済みと仮定
import Home from './pages';
import NotFound from './pages/NotFound';

const Routes = () => {
  return (
    <Switch>
     {/* `exact`を付与しないと`/`以外のパスも含まれる */}
      <Route exact path='/' component={Home} />
     {/* 設定した全てのパスに該当しないアクセスを捕捉 */}
      <Route path='*' component={NotFound} />
    </Switch>
  );
};

ルートパス/にアクセスした場合はHomeコンポーネントをレンダリングし、それ以外のアクセスは404エラーとしてNotFoundを表示するというルーティングを行いました。これらのコンポーネントは後に作成を行います。

次にこのルーティングファイルRoute.tsApp.tsx側で読み込みます。尚、ドキュメントに従ってimportの際にRouterという別名を付けています。

src/App.tsx
import { BrowserRouter as Router } from 'react-router-dom';
import Routes from './Routes';

const App = () => {
  return (
    <Router>
      <Routes />
    </Router>
  );
};

リンク作成

React Routerで定義したルートへアクセスするためののリンクには、通常のaタグではなくLinkコンポーネントを使って行います。

<Link to='/'>Home</Link>

後述のMaterial-UIと組み合わせて利用する場合には、コンポーネントのプロパティにReact RouterのLinkを指定します。これによってスタイリングを行いつつルーティングも実現できます。

import { Link as RouterLink } from 'react-router-dom';
import { Button, Link } from '@material-ui/core';

<Button component={RouterLink} to='/'>
  戻る
</Button>

<Link component={RouterLink} to='/register'>
  登録する
<Link>

参考: Composition - Material-UI # Routing libraries

クエリパラメータ取得

クエリパラメータの取得には、公式ドキュメントに従って独自Hooks (カスタムフック) を作成します。

まずutilsディレクトリ及びその配下にhooksディレクトリを作成し、そこに以下のようなuseQuery.tsファイルを作成します。

src/utils/hooks/useQuery.ts
import { useLocation } from 'react-router-dom';

// クエリパラメータ用カスタムフック
export const useQuery = () => new URLSearchParams(useLocation().search);

export default useQuery;

クエリパラメータを取得するにはgetメソッドを利用し、もし取得できなかった場合にはnullが返却されます。string型として扱うならnullのときは空文字として扱う方法も可能です。

import useQuery from 'utils/hooks/useQuery';

const query = useQuery();

const token = query.get('token') || '';

参考: React Router: Declarative Routing for React.js

HTMLタグ更新

さて、Routerによってページ遷移を実現しましたが、ここでHTMLのtitleタグなど、headタグ内の情報も同時に変化しないと不都合が生じます。そのような場合に利用できるのがReact Helmetです。
しかしこれは場合によって警告が出てしまうので、代わりにReact Helmet Asyncを使用することにします。これはReact Helmetのフォークリポジトリで、基本的な用法は同じです。

React Helmet Async

実際に使用して、titleタグを挿入する例を見てみます。

まずはインストールを行いますが、この際に型定義ファイルも同時に取得します。

yarn add react-helmet-async @types/react-helmet

使用する前に、準備として上位のコンポーネント (ここではsrc/index.tsx) の中でHelmetProviderを用いたコンポーネントの囲い込みが必要です。

src/index.ts
import { HelmetProvider } from 'react-helmet-async';
...
<HelmetProvider>
  <App />
</HelmetProvider>

次に、HTMLタグを変化させるコンポーネントでHelmetを使用し、その中でHTMLタグを定義します。

src/pages/index.tsx
import { Helmet } from 'react-helmet-async';

const Home = () => {
  return (
    <React.Fragment>
      <Helmet>
        <title>{APP_NAME}</title>
      </Helmet>
      // some components
    </React.Fragment>
  );
};

以上で、コンポーネントをレンダリングする際に同時にtitleタグも変更されるようにすることができました。

参考: devias-io/material-kit-react - GitHub

状態管理

状態に応じてUIを表示するためアプリケーション上でこれらを管理する必要があります。ただ問題はその状態が多くのコンポーネントに渡って影響を与えるとき特に管理が複雑化することです。これを解決すべく通常は別途ライブラリを導入します。一般的に利用されるのはReduxで、今回もそれを利用します。

※ 補足
ここでReduxを使わないという選択肢も考えられます。特にReactで提供される useContextuseReducerを代替手段としても目的を達成することができ、この場合追加パッケージをインストールする必要がなくなるという利点があります。しかし、これはReduxの完全な代替になるものではありません。特にReduxを利用する目的の一つとしてブラウザ拡張機能のRedux DevToolsの存在があります。
これは現在の状態の表示や状態を変化させるアクションや、その時変化した状態の差分などの情報を提供してくれるツールで、状態を把握しデバッグを行う際に重宝します。よって、基本的にReduxを利用するスタイルを採用しています。

Redux

Reduxは、前述のようにSPAとして不可欠な機能を提供し事実上Reactとセットで利用することも多いですが、コード量が多く複雑化しやすいという問題を抱えていました。しかし公式に提供されているRedux Toolkitを併用することで簡潔な記述が可能で容易に状態管理を利用できるようになります。

参考: Getting Started with Redux | Redux

Redux Toolkit

Redux Toolkit とは、冗長かつ複雑になりがちであったReduxを簡単に扱えるようにするためにRedux公式として提供されているツールです。これ自体にReduxを内包しているため別途reduxはインストールする必要はありませんが、別途追加のパッケージ (React Redux) が必要になります。これは、Reduxが作成及び管理している状態 (Redux Store) をReactで利用できるようにするために利用されます。

RTKはTypeScriptで構成されており、型定義コードが組み込まれています。この場合には型定義ファイルをインストールすることなくTypeScriptで利用することができます。一方、React Reduxには型定義ファイルが用意されているのでこれを同時にインストールします。

yarn add @reduxjs/toolkit react-redux @types/react-redux

※ 補足
その他のライブラリとして、非同期処理を扱う場合に、Redux ThunkRedux-Sagaなどを利用することになると思いますが、Redux Thunkが既に内包されているので追加でインストールする必要はありません。

Redux Toolkitには他にもいくつかのライブラリが含まれており、こちらのページから確認ができます。

Storeの構成

グローバルな状態としてのRedux Storeを作成し、利用できるようにするため設定を行っていきます。

まず初めに、Redux Storeに関するファイルを配置するためのディレクトリとしてsrc/storeを作成し、次にindex.tsを作成します。次にconfigureStoreを利用し、プロパティにreducerを指定することでstoreを作成します。このstoreは単なる状態としての値を持つだけでなく、状態を参照するためのgetStateメソッドやその状態を更新するためのdispatchメソッドなどが含まれています。

src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';

const store = configureStore({ reducer: {} });

export default store;

reducerの指定は個別に行う方法と、作成した各reducerの集合であるrootReducerというものを定義してからこれを渡す方法がありますが、コードの重複を避けるため後者を採用します。この場合、上記のstore作成コードは以下のようになります。

src/store/index.ts
import { configureStore, combineReducers } from '@reduxjs/toolkit';

export const rootReducer = combineReducers({
  // ここに`reducer`を追加する
});

const store = configureStore({ reducer: rootReducer });

export default store;

作成したstoreをアプリケーション全体で利用できるようにするため、コンポーネントのトップレベルに<Provider>を配置し、プロパティとしてstoreを渡します。

src/index.ts
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from './store';
import App from './App';

ReactDOM.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>,
  document.getElementById('root')
);

rootReducerにはまだ何も登録されていないので何らかの状態を参照や更新することはできませんが、状態管理のための準備としてはこれで完了です。

参考: Quick Start | Redux Toolkit

Storeの構成 (TypeScript)

TypeScriptでも上記と設定自体は同じです。configureStoreなどメソッドは返り値の型が決まっているので、変数としてのstoreは型推論によって型が決定され、明示的な型を指定する必要はありません。

一方、状態の参照時に利用されるuserSeletorHooks及び更新時に利用されるuseDispatchHooksについては、型を与える必要があります。公式ドキュメントに従って、これらHooksで使用される型の定義を初めに行います。

src/store/index.ts
import { configureStore, combineReducers } from '@reduxjs/toolkit';

export const rootReducer = combineReducers({
  auth: authSlice.reducer,
});

const store = configureStore({ reducer: rootReducer });

export type RootState = ReturnType<typeof rootReducer>;

export type AppDispatch = typeof store.dispatch;

export default store;

次にこれらの型を使用して独自のHooksを作成しますが、公式ドキュメントの場合とは異なり、作成するHooks毎にファイルを分割し、これらファイルをsrc/utils/hooksディレクトリを作成してその配下に置く方法を採ります。

まず先程定義したAppDispatchimportして、useDispatchに型を指定して新たなHooks useAppDispatchを作成します。

src/utils/hooks/useAppDispatch.ts
import { useDispatch } from 'react-redux';
import type { AppDispatch } from 'store';

// `useDispatch`使用時、'middleware'(Redux Thunkを含む)を適用する
export const useAppDispatch = () => useDispatch<AppDispatch>();

export default useAppDispatch;

これによって、すぐに何か効果を実感するものではありませんが、ドキュメントには、必要になったときにAppDispatchimportするのを忘れることを防ぐと述べられています。ここで必要なときとは例えば非同期処理を行う場合などです。

次にRootStateimportして、useSelectorに型を指定して新たなHooks useAppSelectorを作成します。

src/utils/hooks/useAppDispatch.ts
import { TypedUseSelectorHook, useSelector } from 'react-redux';
import { RootState } from 'store';

// `useSelector`使用時、`(state: RootState)`を毎回入力する必要をなくす
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

export default useAppSelector;

これによって、useSelectorを使用する場合にRootStateを毎回セットでimportする必要がなくなります。

// Before
import { useSelector } from 'react-redux';
import { RootState } from 'store';

const { user } = useSelector((state: RootState) => state.auth);

// After
import useAppSelector from 'utils/hooks/useAppDispatch';

const { user } = useAppSelector((state) => state.auth);

今回は新たなHooksを複数のファイルに分割して作成しましたが、このように一つの機能に一つのファイルを割り当てるという方法もよく見られます。そしてこのような分割を行った場合にモジュールの再exportを利用することで、モジュールimportの際に簡潔に記述できる利点があります。

これを行うために、作成したファイルと同じ階層のutils/hooksディレクトリ配下にindex.tsを作成し、その中で以下のようにexport文を記述します。

src/utils/hooks/index.ts
export * from './useAppDipatch';
export * from './useAppSelector';

これはfromで指定したファイルでexportされているモジュールを再度全てexportするという記述になります。

これによって、先程別のファイルに作成したカスタムフックを、恰もこのindex.tsに存在しているかのようにimportすることができます。

import { useAppDispatch, useAppSelector } from 'utils/hooks';

ファイルごとに役割を分離しつつimport文が冗長になることを防ぐことができるので、可能な限り採用して行きたい手法です。

UIデザイン

CSSスタイリングはデザイン用のフレームワークを利用することで比較的容易に行うことができます。この選択肢についても、有名なBootstrapや近頃様々な場所で採用例が増えてきているTailwind CSSなど様々考えられますが、今回はMarerial-UI (MUI)を採用しています。

Material-UI

MUIはClass名を与えてスタイリングするのではなく、役割に応じたコンポーネントが用意されておりそれらをimportしつつUIを作り上げていく方式になります。都度CSSをカスタマイズして利用することも、組み合わせたものを新たなコンポーネントとしてモジュール化することも可能です。どちらにしても、このコンポーネントベースのスタイリングはコードの再利用が行いやすいという利点があります。

UIフレームワークとしてMUIを利用することを決定した主な理由としては、Reactとの組み合わせで用いられること実装例が多く参考としての情報収集が行いやすいことや、公式の実装例も豊富ですぐにコードを取り入れて実装できること、またそのカスタマイズも簡単に行えることなどがあります。またある程度経験済みだったため導入までの障壁が低く抑えられると考えました。

MUIにはパッケージが複数に分割して存在しており必要に応じてインストールが必要です。以下では主要機能用、アイコン用、追加機能用のパッケージをそれぞれインストールしています。

yarn add @material-ui/core @material-ui/icons @material-ui/lab

テーマのカスタマイズ

MUIのスタイリングは利用する際にそのモジュール毎に変更を加えることが可能です。しかし、毎回同一のスタイルを割り当てる場合も考えられます。そのような時にはテーマ (メイン及びサブの配色やフォントサイズなどの設定) をカスタマイズすることで対応を行います。詳細は公式ドキュメントを参照します。

ここでは主に配色を司るPaletteのデフォルト設定を変更します。そのためにまずテーマ管理用のディレクトリとして、src/themeを新たに作成し、配下にindex.tsファイルを加え、createThemeによってテーマを作成します。

src/theme/index.ts
import { createTheme } from '@material-ui/core/styles';

const theme = createTheme({
  palette: {},
});

export default theme;

このcreateThemeのプロパティとしてpaletteを追加することでカスタマイズされたテーマを作成することができます。同じファイル内にそのまま記述する方法もありますが、palette.tsという別ファイルを用意することにします。関数内での記述ではない場合そのままでは補完機能が働かなくなりますが、TypeScriptの型を指定することで問題なく動作します。

具体的にはこのpaletteは以下のように作成しています。

src/theme/palette.ts
import { PaletteOptions } from '@material-ui/core/styles/createPalette';

const palette: PaletteOptions = {
  primary: {
    light: '#e0fffa',
    main: '#40cbb5',
    contrastText: '#fff',
  },
  secondary: {
    main: '#ffa133',
    contrastText: '#fff',
  },
  // light, dark値の算出 0に近いほど main値に近付く (0-1)
  tonalOffset: 0.025,
};

export default palette;

createThemepaletteプロパティにはPaletteOptionsという型が与えられているのでそれをimportして付与しています。尚、使用されている型が不明な場合でもcreateThemeの補完機能を利用することで判断可能です。(VSCodeでは、paletteプロパティにマウスオーバーします。)

primaryはメインの配色に関するプロパティで、その内mainは最も基本的に使用される配色、lightは明るめの配色です。逆に暗めはdarkですが、どちらも指定しなかった場合は自動で計算された色が決定されます。その時の基準として利用されるのがtonalOffsetプロパティで、0〜1の範囲で値を設定し、1に近いほどlightならより明るくなりdarkはその逆となります。

これによってindex.tsを以下のように修正します。

src/theme/index.ts
import { createTheme } from '@material-ui/core/styles';
import palette from './palette';

const theme = createTheme({
  palette,
});

export default theme;

プロパティ名と変数名が同一なのでここでpalette: paletteのようにする必要はありません。

作成したテーマを適用させるにはThemeProviderthemeを渡すことが必要ですが、これはsrc/index.tsxに記述します。以下のようにthemeプロパティとして指定します。

src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import { ThemeProvider } from '@material-ui/core/styles';
import CssBaseline from '@material-ui/core/CssBaseline';
import theme from './theme';
import App from './App';

ReactDOM.render(
  <React.StrictMode>
    <ThemeProvider theme={theme}>
      <CssBaseline />
      <App />
    </ThemeProvider>
  </React.StrictMode>,
  document.getElementById('root')
);

ここで同時にCssBaselineというものを導入しています。これは異なるブラウザ環境の差異を解消する効果がある他、アプリケーション全体に適用させるグローバルCSSをテーマとしてカスタマイズする際にも必要となります。これまでのカスタマイズはMUIコンポーネントが対象だったのに対し、こちらはaliなどのHTMLタグを対象に取ります。

以上でMUIの基本的な設定は完了です。必要に応じてcreateThemeのプロパティとしてのモジュールをthemeディレクトリに追加していきます。

バンドルサイズ削減

MUIの一つの問題としてバンドルサイズが大きくなることが挙げられ、結果として開発環境の動作が重くなり、特にiconsパッケージを使用する場合に顕著になります。これはimport文の記述法によって左右されます。通常公式ドキュメントのコード例として掲載されているのは一つ目の方法です。

import Button from '@material-ui/core/Button';
import TextField from '@material-ui/core/TextField';

もう一つの記述法としては以下のようになり、こちらの方が速度は低下します。

import { Button, TextField } from '@material-ui/core';

公式ドキュメントによれば、二つ目の記述法を採用することでコードの重複が可読性を向上させるとしています。またBabelプラグインを導入することで速度の問題も解決することができます。

This option provides the best User Experience and Developer Experience:

  • UX: The Babel plugin enables top level tree-shaking even if your bundler doesn't support it.
  • DX: The Babel plugin makes startup time in dev mode as fast as Option 1.
  • DX: This syntax reduces the duplication of code, requiring only a single import for multiple modules. Overall, the code is easier to read, and you are less likely to make a mistake when importing a new module.

Minimizing Bundle Size - Material-UI

このような利点があるにも関わらず、MUIが一つ目の構文をコード例として基本的に用いている理由としてはゼロコンフィグを実現するためのようです。

ここでは、必要な設定を行って二つ目の記述法を採用することにします。手順としてはドキュメントの内容そのままです。

まずは以下のパッケージをインストールします。

yarn add -D babel-plugin-import react-app-rewired customize-cra

次に.babelrc.jsファイルを以下の内容でルートディレクトリに作成します。

babelrc.js
const plugins = [
  [
    'babel-plugin-import',
    {
      libraryName: '@material-ui/core',
      // Use "'libraryDirectory': ''," if your bundler does not support ES modules
      libraryDirectory: 'esm',
      camel2DashComponentName: false,
    },
    'core',
  ],
  [
    'babel-plugin-import',
    {
      libraryName: '@material-ui/icons',
      // Use "'libraryDirectory': ''," if your bundler does not support ES modules
      libraryDirectory: 'esm',
      camel2DashComponentName: false,
    },
    'icons',
  ],
];

module.exports = { plugins };

そして次に、以下のconfig-overrides.jsファイルをルートディレクトリに作成します。

config-overrides.js
/* eslint-disable react-hooks/rules-of-hooks */
/* config-overrides.js */
const { useBabelRc, override } = require('customize-cra');

module.exports = override(useBabelRc());

最後に、package.jsonstartコマンドを以下のように修正します。

package.json
  "scripts": {
-  "start": "react-scripts start"
+  "start": "react-app-rewired start"
  }

CRAではコンフィグを直接変更することができないためこのようなアプローチを取ることになります。

以上で、MUIで初めに行うべき設定が完了しました。

HTTPクライアント

SPAにおいては、動的なデータの管理はバックエンドが担っています。即ち、ユーザーによるアクションの際など、特定のタイミングでデータベースからデータを取得することが必要です。そのリクエストを行うためHTTPクライアントを用意しなければなりません。今回の場合、Axiosがその役割を果たしています。

Axios

Fetch APIであれば追加パッケージ不要で使用することができますが、代わりにAxiosを利用することで、さらに多機能かつ複雑な設定なしで容易に導入することができるので今回はこちらを導入しています。
例えば、CSRFガード施されているAPIへアクセスする際に、Cookieに保存されたCSRFトークンをHTTPヘッダーに付与する必要がありますが、これをAxiosでは自動で行ってくれます。

それでは初めにインストールから行います。尚、型定義は内包されているので追加のパッケージは不要です。

yarn add axios

Axios Instance

APIリクエストを行う際、バックエンドのURLを指定する必要がありますが、この内ルートパスまでは同一です。よって、入力の手間を省くと共にミスを防ぐためにベースとなるURLを指定することにします。またバックエンドはフロントエンドとは別オリジンとなるのでCORS用の設定が必要となります。Axios Instanceを作成することでこれらを同時に行うことが可能です。

作成にはaxioscreateメソッドを使用し、baseURLにAPIサーバーのURLを指定します。次にwithCredentialsオプションをtrueにすることによって、異なるオリジン間でのリクエストを有効にします。

import axios from 'axios';

const apiClient = axios.create({
    baseURL: 'http://localhost',
    withCredentials: true,
});

例えばGETリクエストを行う場合には、axios.get() の代わりにapiClient.get() を使用することで、設定したオプションが機能した状態でのリクエストとなります。

参考: The Axios Instance | Axios Docs

フォーム

ユーザーから何らかの入力値を受け取ってAPIリクエストを行う場合、フォームの生成が必要になります。基本的な機能として、入力値を監視し、都度バリデーションを実施して決められた入力値に沿わないものはリクエストを拒否しつつ、ユーザーに対し正しい入力を促すエラーメッセージを表示することが挙げられます。

これらを実装するとなると、特にバリデーションの構築は少々骨が折れます。そこで、フォーム生成ライブラリの React Hook Form 及び バリデーション用スキーマ構築ライブラリのYupを併用することでこの実装の問題を解決しています。

React Hook Form

React Hook Form は、フォームの主要機能である値の監視、バリデーション、Submit時の動作、またエラー情報などを担います。特に複雑な設定不要で使用を始めることができますが、デフォルトのバリデーションはやや機能が控えめで、複雑な設定をするには大変そうです。

一方ドキュメントには、別のバリデーション用ライブラリと併用する場合の実装例が載せられています。今回利用しているのはこちらの方法で、いくつかの選択肢の内、Yupを採用しました。

併用するためには、これらパッケージの他、@hookform/resolversをインストールします。

yarn add react-hook-form yup @hookform/resolvers

Yup

Yupではバリデーション実施のためのスキーマを構築します。これは入力する項目に対し制限を設けるものです。例えば、フォーム入力項目毎に、string型制限、入力必須項目、文字数制限などを直感的に指定することができます。

const schema = yup.object().shape({
  email: yup.string().email().required().min(8).max(20),
});

Matarial-UIの併用

React Hook Form 及び Yupは、フォームの機能を提供するものでした。見た目を整えるためには別途スタイリングが必要です。今回はMUIを用いているのでこれらを組み合わせた場合の実装を行います。

ここでテンプレートをMUIのページ上から取得します。ここでは"Sign In"テンプレートをベースにログインフォームの実装を行います。ディレクトリはsrc/pages/authを用意してそこにSignIn.tsxを作成します。

まずは、入力項目の型を定義します。

src/pages/auth/SignIn.tsx
type FormData = {
  email: string;
  password: string;
  remember?: string;
};

参考: Get Started | React Hook Form # TypeScript

上記では、メールアドレスとパスワードでログインする場合の入力項目です。rememberはログイン状態を維持するか決定するオプションで、フォームのチェックボックスにチェックを入れると"on"が送信され、外すと何も送信されません。よって、型はstring | undefinedとなりますが、ここでは?を付与することでそれを表しています。

次に、Yupによるスキーマを構築します。ここでバリデーションに利用できるAPIはYupのGitHubから確認できます。

src/pages/auth/SignIn.tsx
import * as yup from 'yup';

const schema = yup.object().shape({
  email: yup.string().email().required(),
  password: yup.string().required().min(8).max(20),
});

文字列制限や入力必須、最大最小文字数などを定めています。この内emailというのは、メールアドレスの形式になっているかを正規表現によって検査します。

let rEmail = /^((([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+(\.([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+)*)|((\x22)((((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(([\x01-\x08\x0b\x0c\x0e-\x1f\x7f]|\x21|[\x23-\x5b]|[\x5d-\x7e]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(\\([\x01-\x09\x0b\x0c\x0d-\x7f]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))))*(((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(\x22)))@((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))$/i;

https://github.com/jquense/yup/blob/master/src/string.ts

これらを基にフォームの機能を作成します。(本稿の要点以外は省略)

src/pages/auth/SignIn.tsx
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { TextField, Checkbox, FormControlLabel } from '@material-ui/core';

const SignIn = () => {
  const {
    register, // 入力項目の登録
    handleSubmit, // Submit時の挙動
    formState: { errors }, // エラー情報 (メッセージなど) を含む`state`
  } = useForm<FormData>({
    mode: 'onChange', // バリデーション判定タイミング (`onChange`は入力値の変化毎)
    resolver: yupResolver(schema),
  });

  const onSubmit = async (data: FormData) => {
    // 入力値を基にAPIリクエスト
  };

  return (
    ...
      <form onSubmit={handleSubmit(onSubmit)}>
        <TextField
          id='email'
          label='Email Address'
          {...register('email')} // フォーム機能の付与
          helperText={errors?.email?.message}
          error={!!errors?.email}
        />
        <TextField
          id='password'
          label='Password'
          type='password'
          {...register('password')}
          helperText={errors?.password?.message || '8-20 characters'}
          error={!!errors?.password}
        />
        <FormControlLabel
          control={ <Checkbox {...register('remember')} value='on' /> }
          label='Remember me'
        />
        <Button type='submit'>{'Sign in'}</Button>
      </form>
    ...
  )
}

useFormによって生成されたregisterからそれぞれのそれぞれの項目を取り出し、MUIのTextFieldのプロパティとして割り当てます。これにはname属性などが含まれています。

これ以外は通常のMUIのコンポーネントをそのまま利用することができます。もしバリデーションの結果エラーが発生した場合にはerrors状態にその情報が格納されるので、それに応じてerrorプロパティをtrueにする (入力枠をエラー状態を示す赤色にする) と共に、helperTextを使用してエラーメッセージを表示します。

尚、上記のパスワードフィールドでは、入力開始時などエラーが存在しない場合には別のhelperText ("8-20 characters") を表示しています。

以上のように、React Hook Form、Yup、MUIを組み合わせて利用することにそれほど複雑なことはありませんでした。基本形を完成することができれば、その後はバリデーションの設計をより堅牢なものにしたり、UIを整えたりしていきます。

テスト

実装の初期の段階では動作画面を確認しながら行うことで記述したコードの理解促進に寄与することも考えられますが、ある程度実装が進んでいくとコードが膨大になり、機能の追加や修正の度に同じような確認をすることは効率が悪く困難になります。そこでテストコードを導入することで効率の向上を図り、また想定外のエラーを事前に発見できる環境整備を目指します。

CRAにおいては、テストを導入するための環境が既に整っております。即ち、テスト実行用パッケージであるJestがインストール済みであり、そのデフォルトの設定も用意された状態となっています。また、UIテストを実行する際に有用なReact Testing Libraryも同梱されている他、全てのテスト実行前に作用するsetupTests.tsも準備されており、すぐにテストを開始することができます。

参考: Running Tests | Create React App

Jest

JestはJavaScript用のテスティングフレームワークで、テストに必要な機能があらかた網羅されています。テストが実行される環境の用意 (JSDOM) から、テストに使用するメソッドの提供、テストの実行などはJestが担います。

CRAを利用している場合、yarn testを実行するだけでJestによるテストが走ります。サンプル用のテストファイルApp.test.tsxも付属しているので、自身でテストを書いていない状態から試行することができます。

尚、このファイルがテスト実行用であることはそのファイル名から判断されています。即ち、末尾に.test.ts (tsx)又は.spec.ts (tsx)が付いているものがその対象となります。若しくは、__test__ディレクトリのファイルであれば、通常のts又はtsxファイルがテスト対象のファイルであると認識されます。

参考:
Running Tests | Create React App # Filename Conventions
Configuring Jest · Jest # testRegex

Jest Config

通常Jestではテストの際に適用させる設定をjest.config.tsの中での中で行いますが、CRAではデフォルトのコンフィグが内蔵されておりjest.config.tsによって設定を変更することができません。代わりに、package.jsonjestの項目を設け、ここに設定を記述することで対応します。(ただしサポートされているコンフィグに制限あり)

参考: Running Tests | Create React App # Configuration

Jest CLI

先述のとおり、CRAにおいてはyarn testコマンドによってJestを実行しますが、この実体はpackage.jsonscriptstestに記述されています。

src/package.json
{
  "scripts": {
    "test": "react-scripts test",
  }
}

内部ではJestが利用されているので同等のコマンドオプションが利用できます。利用可能なオプションは、Jestのドキュメントの他、--helpオプションによっても確認可能です。

常に利用するオプションについては上記のpackage.jsonscriptに記述することで、yarn testコマンドの挙動を変更することができます。

src/package.json
{
  "scripts": {
    "test": "react-scripts test --coverage --verbose",
  }
}

上のオプションはpackage.jsonに記述はしていませんが、今回よく利用したオプションです。--coverage後述のカバレッジを表示するため、--verboseはテスト結果を各テストケース毎 (デフォルトはテストファイル毎) に表示するために用います。

# verboseオプションなし
PASS  src/__tests__/store/auth/thunks/resetPassword.test.ts

# verboseオプションあり
PASS  src/__tests__/store/auth/thunks/resetPassword.test.ts
  Thunk for resetting the password
    Rejected
      ✓ should be an error with a set of email and token (25 ms)
      ✓ should receive an error if the token unmatchs (24 ms)
      ✓ should be authenticated with a original password (28 ms)
      ✓ should not be authenticated with a requested password (33 ms)
    Fulfilled
      ✓ should update the password with a valid request (11 ms)
      ✓ should be authenticated with a updated password (26 ms)
      ✓ should not be authenticated with a previous password (23 ms)

カバレッジ

カバレッジとは、テストによってどの程度コードが実行されたか表す割合です。Jestではテスト実行コマンドにオプションを付与することでカバレッジを表示することができ、さらにこの時、テストされていないファイルとその対象コードの行まで把握することが可能です。これはテスト実行状況を確認し今後の方針を決定することに役立ちます。

参考: Running Tests | Create React App # Coverage Reporting

React Testing Library

UIテストでは、実際のユースケースに従ってユーザーの操作 (クリックやフォーム入力など) を再現し、その結果期待した画面表示がされているかを確認します。React Testing Libraryはそのために有用な機能を提供するパ
ッケージです。

テスト状態初期化

React Testing Library によって、コンポーネントをレンダリングするにはrender関数を使用しますが、この時テスト対象のコードでReduxReact Helmetなどを使用している場合は実際の環境に適合させるためにそれぞれProviderを提供する必要があります。

import { render } from '@testing-library/react';
import { Provider } from 'react-redux';
import { HelmetProvider } from 'react-helmet-async';
import store from 'store';
import App from 'App';

render(
  <Provider store={store}>
    <HelmetProvider>
      <App />
    </HelmetProvider>
  </Provider>
);

ここで、Redux StoreReact Routerなどの状態はリセットされないことに注意が必要です。例えば先行するテストでページ移動を行っていた場合には、次のテストで上記のrenderを再度行ったとしても、ページ移動後のコンポーネントがレンダリングされることになります。つまりテスト内で何らかの変更を加えた場合には以降のテストに影響を及ぼす恐れがあります。

テスト毎にこれらを初期状態に戻すには、beforeEachなどを用いて各状態の初期化を行いますが、ここで行うべき処理はそれぞれ異なります。

例えばRedux Storeをリセットするには、テスト毎にconfigureStoreから再度生成することが一つの解決策です。

src/mocks/utils/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import { rootReducer } from 'store';

export let store = configureStore({ reducer: rootReducer });

export const initializeStore = () =>
  (store = configureStore({ reducer: rootReducer }));

上記のようにテスト用のstoreを新たに作成しておきます。尚、このようなテスト環境を構築するためのファイルは__test__ではなく、別のディレクトリ (ここではmocks) に配置します。

用意したstoreimportし、再生成用の関数をbeforeEach内部で実行することで状態を元に戻すことができます。

import { initializeStore, store } from 'mocks/utils/store';

describe('Thunk for a forgot password', () => {
  beforeEach(() => {
    initializeStore();
  });
  ...

次に、React Routerを初期状態に戻す場合を考えます。ドキュメントに従って、これにはBrowserRouterではなく、MemoryRouterを利用する方法に変更します。これによって状態を残さずに次のテストに移ることが可能です。

import { render } from '@testing-library/react';
import { Provider } from 'react-redux';
import { HelmetProvider } from 'react-helmet-async';
import { MemoryRouter } from 'react-router-dom';
import store from 'store';
import Routes from 'Routes';
import App from 'App';

render(
  <Provider store={store}>
    <HelmetProvider>
      <MemoryRouter initialEntries={['/login']}>
        <Routes />
      </MemoryRouter>
    </HelmetProvider>
  </Provider>
);

参考:
Setup and Teardown · Jest
Testing - React Router: Declarative Routing for React.js

Mock Server Worker (MSW)

APIリクエストを伴うテストでは、実際のサーバーに対するリクエストは行わず、代わりにAPIをモックを使用して行うことが一般的のようです。これによって、バックエンドサーバーが利用できない場合にもテストが実行可能となる他、通信速度にも影響を受けることのない高速な処理が期待できます。

Jestにはモックの機能も付随しているので、これによってもAPIモックを扱うことが可能です。ただ、HTTPヘッダーやCookieを用いたリクエストやレスポンスを再現するには準備が大変そうです。

別の方法として、モックサーバーを用意しリクエストをそこに向けるものがあります。これも場合によっては、本来のバックエンドサーバーのURLとは異なるエンドポイントにリクエストを投げるためにテストコードを修正することになります。

今回利用しているAPIモック用パッケージである Mock Service Worker (MSW) では、HTTPヘッダーやCookieを容易に扱うことができる上、それらによってテストコードを変更する必要がありません。また本来のリクエストをそのまま利用可能で、モックの使用有無に関わらずテストコードを記述することができます。

さらに、このMSWはテスト実行時だけでなくブラウザ環境でも動作させることが可能で、リクエストやレスポンスの挙動をブラウザの開発者ツールから確認することもでき、テストに問題が発生した場合にその要因の解明に役立ちます。

参考:
Stop mocking fetch
Build a ReactJS App workshop | GitHub
Comparison - Mock Service Worker Docs
Examples of Mock Service Worker usage | GitHub

MSWの構成

まずMSWを開発環境にインストールします。

yarn add msw --dev

次に、処理するリクエストとそれに対するレスポンスの定義をsrc/mocks/handlers.tsに記述します。

src/mocks/handlers.ts
import { rest } from 'msw'

export const handlers = [
  rest.post('http://backend/login', () => {}),
]

上記のコードは、http://backend/loginに対するPOSTリクエストを捕捉する Request handlerhandlersに格納しています。第二引数で何も返していないのでこれはまだ動作しません。

第二引数として渡されるのは、Response resolverで、リクエストで送られてきたデータに対するレスポンスを作り上げます。これは以下の引数を持つ関数で、下のコードのようにリクエストのヘッダーやCookieを取得することができます。

  • req, an information about a matching request;
  • res, a functional utility to create the mocked response;
  • ctx, a group of functions that help to set a status code, headers, body, etc. of the mocked response.

https://mswjs.io/docs/getting-started/mocks/rest-api#response-resolver

src/mocks/handlers.ts
import { rest } from 'msw'

export const handlers = [
  rest.post('http://backend/login', (req, res, ctx) => {
    // Cookieから、キーが`session_id`である値を取得
    const sessionId = req.cookies.session_id;
    // HTTPヘッダーから、キーが`X_XSRF_TOKEN`である値を取得
    const token = req.headers.get('X_XSRF_TOKEN');

     ...

    return res(
      ctx.status(200),
      // Set-Cookie (オプションも指定可能)
      ctx.cookie('session_id', encryptedSessionId, { httpOnly: true }),
      ctx.json({
        ...
       })
    );
  }),
]

参考: Cookies - Recipes - Mock Service Worker Docs

作成したhandlerを利用するには、ブラウザ環境とNode (テスト) 環境で異なるプロセスが必要です。

ブラウザ環境

ブラウザ環境で実行する場合はService Workerを起動します。そのために必要なコードは以下のコマンドを実行することで生成することができます。

npx msw init public/ --save

次に、src/mocks/browser.tsを作成し、handlerからworkerを構成します。

src/mocks/browser.ts
import { setupWorker } from 'msw';
import { handlers } from './handlers';

// This configures a Service Worker with the given request handlers.
export const worker = setupWorker(...handlers);

src/index.tsxに以下のコードを追加し、workerを開発環境の条件の下に実行します。

src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
...

// 開発環境 ('development')の場合に'Service Worker'を起動
if (process.env.NODE_ENV === 'development') {
  const { worker } = require('./mocks/browser');
  worker.start();
}

ReactDOM.render(
  ...

以上でブラウザ環境でMSWを利用できるようになり、以降のリクエストはMSWによって捕捉されることになります。尚、handelerに登録されていないリクエストは本来のエンドポイントに向かいます。

参考:
Browser - Getting Started - Mock Service Worker Docs
Debugging uncaught requests - Recipes - Mock Service Worker Docs

Node環境

Node環境 (Jest実行時の環境) の場合はモックサーバーを起動します。src/mocks/server.tsを作成し、handlerからserverを構成します。

src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

// Setup requests interception using the given handlers.
export const server = setupServer(...handlers);

次に、src/setupTest.tsに起動設定を追加します。

src/setupTest.ts
...
import { server } from './mocks/server';

beforeAll(() => {
  // Enable the mocking in tests.
  server.listen();
});

afterEach(() => {
  // Reset any runtime handlers tests may use.
  server.resetHandlers();
});

afterAll(() => {
  // Clean up once the tests are done.
  server.close();
});

以上で、以降yarn testを実行した場合に行われるAPIリクエストはMSWによって捕捉されるようになりました。

参考: Node - Getting Started - Mock Service Worker Docs

GitHub Actions

GitHub Actionsとは、事前に規定したイベントが発生した際に自動的に任意のコマンドを実行することができるサービスです。イベントに指定可能なものとして、リポジトリへのPushやPull Requestなどがあり、特定のBranchの場合に限定してイベントとみなすといった条件を指定することも可能です。

参考: ワークフローをトリガーするイベント - GitHub Docs

導入や基本的な使用方法などについては、バックエンドの実装過程で説明しています。

以降では今回作成したテストを実行する手順を確認していきます。方針としては、まず依存関係のインストールを行い、このときキャッシュが存在すれば手順をスキップします。次に.envファイルを用意し、ビルド、テストを順に行います。

キャッシュを利用した依存関係インストールを行うコードは以下のようになります。

.github/workflows/test.yml
- name: Cache Node.js modules
  id: yarn-cache
  uses: actions/cache@v2
  with:
    path: ./frontend/node_modules
    key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
    restore-keys: |
      ${{ runner.os }}-yarn-
- name: Install dependencies
  if: steps.yarn-cache.outputs.cache-hit != 'true'
  run: yarn --frozen-lockfile

初回は通常通りインストールを行い、キャッシュをpathに指定したパスに保存し、ifを用いることでインストール実行の条件を定めます。また、インストール時--frozen-lockfileを指定することでyarn.lockが更新されないようにします。

参考:
Node - Yarn - cache/examples.md at main · actions/cache - GitHub
Skipping steps based on cache-hit - actions/cache - GitHub
Installing dependencies - Building and testing Node.js - GitHub Docs

次に、.envファイルの作成、ビルド、テストを行います。

.github/workflows/test.yml
- name: Set environment variables
  run: mv .env.example .env

- run: yarn build --if-present
- run: yarn test

実行するコマンド自体は以上となります。次に、これらを複数のNodeバージョンで実行するように設定を加えます。

.github/workflows/test.yml
strategy:
  matrix:
    node-version: [10.x, 12.x, 14.x, 15.x]

steps:
  - name: Check out repository code
    uses: actions/checkout@v2

  - name: Use Node.js ${{ matrix.node-version }}
    uses: actions/setup-node@v1
    with:
      node-version: ${{ matrix.node-version }}

参考: Specifying the Node.js version - Building and testing Node.js - GitHub Docs

最後に、作業ディレクトリの指定を行います。今回はフロントエンドのコードがリポジトリのルートではなくfrontendディレクトリに存在するのでworking-directory./frontendとしています。

.github/workflows/test.yml
defaults:
  run:
    working-directory: ./frontend

最終的には以下のようなコードを.github/workflows/test.ymlに作成します。尚、複数のjobが存在する場合のコードを示すためにバックエンド側の記述も一部含めました。

.github/workflows/test.yml
name: CI

on: [push]

jobs:
  phpunit: # バックエンド側
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ./backend
    steps:
      ...

  build: # フロントエンド側
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ./frontend

    strategy:
      matrix:
        node-version: [10.x, 12.x, 14.x, 15.x]

    steps:
      - name: Check out repository code
        uses: actions/checkout@v2

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node-version }}

      - name: Cache Node.js modules
        id: yarn-cache
        uses: actions/cache@v2
        with:
          path: ./frontend/node_modules
          key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
          restore-keys: |
            ${{ runner.os }}-yarn-
      - name: Install dependencies
        if: steps.yarn-cache.outputs.cache-hit != 'true'
        run: yarn --frozen-lockfile

      - name: Set environment variables
        run: mv .env.example .env

      - run: yarn build --if-present
      - run: yarn test

以降は、GitHubにコードをpushすることで、作成したテストが指定したNodeのバージョンで実行されることになります。

参考: Building and testing Node.js - GitHub Docs

Markdown

ReactでMarkdownを扱うには、Markdown記法で記述された文章をJSXに変換することが求められます。これを実現する方法として、markdown-to-jsxを使用します。

参考: material-ui/Terms.js at master · mui-org/material-ui - GitHub

markdown-to-jsx

markdown-to-jsxを利用することで、Markdownの各要素 (h1pなど) を任意のコンポーネントに変換することが可能で、これによってMaterial-UIとの併用も容易に実現できます。

利用する際には型定義ファイルも必要になるので同時にインストールを行います。

yarn add markdown-to-jsx @types/markdown-to-jsx

Markdownへの変換はMarkdownコンポーネントによって行われ、この時optionsプロパティによってどのようなコンポーネントに変換するか指定することができます。

Markdownを利用する度に毎回このような指定を行うのはコードの重複になるので、optionsを指定したコンポーネントを新たに作成し、JSXに変換する際にはこちらを利用することにします。

Markdown.tsxというファイルを作成し、単にoptions指定したMarkdownexportするような実装を行います。まずoptionsが空の状態のコードは以下のようになります。

src/templates/Markdown.tsx
import { ReactNode } from 'react';
import MarkdownToJsx, { MarkdownToJSX } from 'markdown-to-jsx';

const options: MarkdownToJSX.Options = {};

const Markdown = ({ children }) => {
  return (
    <MarkdownToJsx options={options}>
      {children as string & ReactNode}
    </MarkdownToJsx>
  );
};

export default Markdown;

上記のコードでは、MarkdownではなくMarkdownToJsximportしています。これはdefault exportされているので任意の名前にすることが可能で、ここではMarkdownToJsxという名前にしています。Markdownコンポーネントとして作成しており名前が衝突するのでこのような方法を採っています。

次に、MarkdownToJsxをマウスオーバーして得られた型情報から、optionsの型はMarkdownToJSX.Optionsであることが判明したので、そのためのnamespaceimportしています。

そして、MarkdownToJsxに与えるchildrenstringであることが求められるので、as string記述して型アサーションを行うことで対処します。

それでは次にoptionsを指定してMaterial-UIを使用できるようにしていきます。

src/templates/Markdown.tsx
const options: MarkdownToJSX.Options = {
  overrides: {
    h1: {
      component: (props) => (
        <Typography gutterBottom component='h1' variant='h3' {...props} />
      ),
    },
    li: {
      component: (props) => <Typography component='li' {...props} />,
    },
  }
};

上記のように、overridesのHTML要素プロパティに対し、変換に使用するコンポーネントを指定することでデフォルトの変換機能を上書きすることができます。これでMarkdownにMaterial-UIをスタイルを適用することができるようになりました。

参考: material-ui/Markdown.js at master · mui-org/material-ui - GitHub

h1などの見出しはid属性が自動的に付与されます。しかし日本語の場合機能しないようなのでその場合はoptionsに以下のような指定を行います。

src/templates/Markdown.tsx
const options: MarkdownToJSX.Options = {
  slugify: (str) => str, // 自動生成されるid属性を日本語で利用
  overrides: {
      ...

参考: options.slugify - probablyup/markdown-to-jsx - GitHub

以上で、MarkdownをReactで扱うための準備は完了です。

まとめ

以上、SPAのフロントエンドを構成する上で必要となる要素 (状態管理やルーティングなど) 及びそれらを実現するための技術 (ReduxやReact Routerなど) について、その意義を確認しつつ初めに行うべき実装を説明してきました。

しかし、序盤のほんの一部しか触れられていないのでまだ言及すべきことが残っています。今回はここまでとなりますが、また機会があれば追記していきたいと思います。

各種リンク

22
29
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
29

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?