主にスキル向上を目的に、ポートフォリオとしてタスク管理アプリを作成しました。このページでは、主にフロントエンドを構成するために必要な実装とそのために利用したパッケージ及びそれらの初期設定などについて触れていきます。
アプリケーションや作成したコード、バックエンドの実装過程の説明については、以下のリンクからアクセスできます。
- アプリケーション:
https://www.miwataru.com/ - GitHub: https://github.com/zuka-e/laravel-react-task-spa
- 全体像: https://qiita.com/zuka-e/items/9a985f0dd5db21bc48d7
- バックエンド実装過程: https://qiita.com/zuka-e/items/3faf100cbcdf7ec40ee6
目次
- 開発環境
- 主要使用技術
- ディレクトリ構成
- React
- Create React App
- 静的型付け
- ルーティング
- HTMLタグ更新
- 状態管理
- UIデザイン
- HTTPクライアント
- フォーム
- テスト
- Markdown
- まとめ
- 各種リンク
開発環境
フロントエンドの開発言語としてTypeScriptを使用し、ライブラリとして使用したのはReactです。またこれらを基本とした開発環境の構築にはCreate React App (CRA)を用いており、これによってReactを実行し結果を確認できるサーバーなどのReactの利用に必要な環境が容易に整います。その他実行環境は以下のようになっています。(括弧内の数字はバージョン)
- Create React App (4.0.3)
動作確認の際のブラウザには、Chrome (Mac、Android) を使用しています。
主要使用技術
主に使用した技術、パッケージを以下に列挙します。(括弧内の数字はバージョン)
- TypeScript (4.2.4) - 開発言語、 静的型付け
- React (17.0.2)
- React Router Dom (5.2.0) - ルーティング
- React Helmet Async (1.0.9) - HTMLタグ更新
- Redux (4.0.5) - 状態管理
- React Redux (7.2.3) - 状態管理 (Reactバインディング)
- Redux Toolkit (1.5.1) - 状態管理 (Redux簡便化ツール)
- Marerial-UI (4.11.3) - UIデザイン
- Axios (0.21.1) - HTTPクライアント
- React Hook Form (7.1.1) - フォーム生成
- Yup (0.32.9) - スキーマ構築
- React DnD (14.0.2) - ドラッグ&ドロップ
- Jest (26.6.0) - テスト
- React Testing Library (11.2.5) - UIテスト
- Mock Service Worker (0.28.2) - APIモック
- markdown-to-jsx (7.1.2) - Markdown
ディレクトリ構成
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テーブルに相当) 関連のディレクトリなど一部はアイコンが付与されるような命名ができていません。thunks
やboards
などがこれに該当します。
React
Reactではコンポーネントとして分割されたJavaScriptのコードを組み合わせることでUIを構築していきます。種別としてクラスを利用した方法と関数を使った方法が存在しますが、関数型の方がHooks (フック) によって簡単に扱うことができるなどの理由から、今では専ら関数型が使用されておりここでもそれに従います。ただ公式サイトではクラス型で説明されている項目も多く、参考にするにはやや困惑することがありました。
Vueと比較されることが多いReactですが、こちらの方が使い慣れたJaveScriptの記法に近い使い方ができる他、TypeScript (後述)を扱いやすいという理由からこちらを使用しています。
Create React App
Create React Appを利用することで、依存パッケージの導入から設定まで面倒な操作も行うことなくReactを使用したプロジェクトを始めることができます。これにはwebpackやBabel、ESLintなども含まれています。
CRAの代わりとして、Next.jsを使用するという選択肢も考慮しています。しかし、公式サイトでSPAの作成にはこちらを推奨している記述があることや、CRAの方は経験があり手早く開発できそうだったこと、また後にNext.jsを使用することになってもCRAの経験やコードが活用できそうだったことなどを勘案の上でCRAを採用しました。
テンプレート
CRAを導入するにあたって、同時にテンプレートを選択することができます。これにより必要なパッケージを個別にインストールする手間が省けます。今回はTypeScriptで開発するためのテンプレートを使用するため、実行するコマンドは以下のようになります。
# `frontend`部は任意のプロジェクト名
npx create-react-app frontend --template typescript
この際、後述のReduxも同時にインストールするテンプレートも存在していましたが、一部のパッケージのバージョンがやや古めであったことから別途インストールする方法を採っています。
tsconfig.json
CRAでは設定用のファイルtsconfig.json
が初めから作成されています。ここに一つ追加の設定としてbaseUrl
を加えておきます。
{
"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.
同時に型定義ファイルも忘れずインストールします。
yarn add react-router-dom @types/react-router-dom
ルーティング設定
最も基本的な用法はBrowserRouter
の中にRoute
を内包したSwich
コンポーネントを配置することです。ここではApp.tsx
にBrowserRouter
を作成し、実際のルーティングは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.ts
をApp.tsx
側で読み込みます。尚、ドキュメントに従ってimport
の際にRouter
という別名を付けています。
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>
クエリパラメータ取得
クエリパラメータの取得には、公式ドキュメントに従って独自Hooks (カスタムフック) を作成します。
まず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') || '';
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
を用いたコンポーネントの囲い込みが必要です。
import { HelmetProvider } from 'react-helmet-async';
...
<HelmetProvider>
<App />
</HelmetProvider>
次に、HTMLタグを変化させるコンポーネントでHelmet
を使用し、その中でHTMLタグを定義します。
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で提供される useContextやuseReducerを代替手段としても目的を達成することができ、この場合追加パッケージをインストールする必要がなくなるという利点があります。しかし、これはReduxの完全な代替になるものではありません。特にReduxを利用する目的の一つとしてブラウザ拡張機能のRedux DevToolsの存在があります。
これは現在の状態の表示や状態を変化させるアクションや、その時変化した状態の差分などの情報を提供してくれるツールで、状態を把握しデバッグを行う際に重宝します。よって、基本的にReduxを利用するスタイルを採用しています。
Redux
Reduxは、前述のようにSPAとして不可欠な機能を提供し事実上Reactとセットで利用することも多いですが、コード量が多く複雑化しやすいという問題を抱えていました。しかし公式に提供されているRedux Toolkitを併用することで簡潔な記述が可能で容易に状態管理を利用できるようになります。
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 ThunkやRedux-Sagaなどを利用することになると思いますが、Redux Thunkが既に内包されているので追加でインストールする必要はありません。
Redux Toolkitには他にもいくつかのライブラリが含まれており、こちらのページから確認ができます。
Storeの構成
グローバルな状態としてのRedux Storeを作成し、利用できるようにするため設定を行っていきます。
まず初めに、Redux Storeに関するファイルを配置するためのディレクトリとしてsrc/store
を作成し、次にindex.ts
を作成します。次にconfigureStore
を利用し、プロパティにreducer
を指定することでstore
を作成します。このstore
は単なる状態としての値を持つだけでなく、状態を参照するためのgetState
メソッドやその状態を更新するためのdispatch
メソッドなどが含まれています。
import { configureStore } from '@reduxjs/toolkit';
const store = configureStore({ reducer: {} });
export default store;
reducer
の指定は個別に行う方法と、作成した各reducer
の集合であるrootReducer
というものを定義してからこれを渡す方法がありますが、コードの重複を避けるため後者を採用します。この場合、上記のstore
作成コードは以下のようになります。
import { configureStore, combineReducers } from '@reduxjs/toolkit';
export const rootReducer = combineReducers({
// ここに`reducer`を追加する
});
const store = configureStore({ reducer: rootReducer });
export default store;
作成したstore
をアプリケーション全体で利用できるようにするため、コンポーネントのトップレベルに<Provider>
を配置し、プロパティとしてstore
を渡します。
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
にはまだ何も登録されていないので何らかの状態を参照や更新することはできませんが、状態管理のための準備としてはこれで完了です。
Storeの構成 (TypeScript)
TypeScriptでも上記と設定自体は同じです。configureStore
などメソッドは返り値の型が決まっているので、変数としてのstore
は型推論によって型が決定され、明示的な型を指定する必要はありません。
一方、状態の参照時に利用されるuserSeletor
Hooks及び更新時に利用されるuseDispatch
Hooksについては、型を与える必要があります。公式ドキュメントに従って、これらHooksで使用される型の定義を初めに行います。
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
ディレクトリを作成してその配下に置く方法を採ります。
まず先程定義したAppDispatch
をimport
して、useDispatch
に型を指定して新たなHooks useAppDispatch
を作成します。
import { useDispatch } from 'react-redux';
import type { AppDispatch } from 'store';
// `useDispatch`使用時、'middleware'(Redux Thunkを含む)を適用する
export const useAppDispatch = () => useDispatch<AppDispatch>();
export default useAppDispatch;
これによって、すぐに何か効果を実感するものではありませんが、ドキュメントには、必要になったときにAppDispatch
をimport
するのを忘れることを防ぐと述べられています。ここで必要なときとは例えば非同期処理を行う場合などです。
次にRootState
をimport
して、useSelector
に型を指定して新たなHooks useAppSelector
を作成します。
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
文を記述します。
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
によってテーマを作成します。
import { createTheme } from '@material-ui/core/styles';
const theme = createTheme({
palette: {},
});
export default theme;
このcreateTheme
のプロパティとしてpalette
を追加することでカスタマイズされたテーマを作成することができます。同じファイル内にそのまま記述する方法もありますが、palette.ts
という別ファイルを用意することにします。関数内での記述ではない場合そのままでは補完機能が働かなくなりますが、TypeScriptの型を指定することで問題なく動作します。
具体的にはこのpalette
は以下のように作成しています。
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;
createTheme
のpalette
プロパティにはPaletteOptions
という型が与えられているのでそれをimport
して付与しています。尚、使用されている型が不明な場合でもcreateTheme
の補完機能を利用することで判断可能です。(VSCodeでは、palette
プロパティにマウスオーバーします。)
primary
はメインの配色に関するプロパティで、その内main
は最も基本的に使用される配色、light
は明るめの配色です。逆に暗めはdark
ですが、どちらも指定しなかった場合は自動で計算された色が決定されます。その時の基準として利用されるのがtonalOffset
プロパティで、0〜1の範囲で値を設定し、1に近いほどlight
ならより明るくなりdark
はその逆となります。
これによってindex.ts
を以下のように修正します。
import { createTheme } from '@material-ui/core/styles';
import palette from './palette';
const theme = createTheme({
palette,
});
export default theme;
プロパティ名と変数名が同一なのでここでpalette: palette
のようにする必要はありません。
作成したテーマを適用させるにはThemeProvider
にtheme
を渡すことが必要ですが、これはsrc/index.tsx
に記述します。以下のようにtheme
プロパティとして指定します。
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コンポーネントが対象だったのに対し、こちらはa
やli
などの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.
このような利点があるにも関わらず、MUIが一つ目の構文をコード例として基本的に用いている理由としてはゼロコンフィグを実現するためのようです。
ここでは、必要な設定を行って二つ目の記述法を採用することにします。手順としてはドキュメントの内容そのままです。
まずは以下のパッケージをインストールします。
yarn add -D babel-plugin-import react-app-rewired customize-cra
次に.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
ファイルをルートディレクトリに作成します。
/* eslint-disable react-hooks/rules-of-hooks */
/* config-overrides.js */
const { useBabelRc, override } = require('customize-cra');
module.exports = override(useBabelRc());
最後に、package.json
のstart
コマンドを以下のように修正します。
"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
を作成することでこれらを同時に行うことが可能です。
作成にはaxios
のcreate
メソッドを使用し、baseURL
にAPIサーバーのURLを指定します。次にwithCredentials
オプションをtrue
にすることによって、異なるオリジン間でのリクエストを有効にします。
import axios from 'axios';
const apiClient = axios.create({
baseURL: 'http://localhost',
withCredentials: true,
});
例えばGETリクエストを行う場合には、axios.get()
の代わりにapiClient.get()
を使用することで、設定したオプションが機能した状態でのリクエストとなります。
フォーム
ユーザーから何らかの入力値を受け取って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
を作成します。
まずは、入力項目の型を定義します。
type FormData = {
email: string;
password: string;
remember?: string;
};
上記では、メールアドレスとパスワードでログインする場合の入力項目です。remember
はログイン状態を維持するか決定するオプションで、フォームのチェックボックスにチェックを入れると"on"
が送信され、外すと何も送信されません。よって、型はstring | undefined
となりますが、ここでは?
を付与することでそれを表しています。
次に、Yupによるスキーマを構築します。ここでバリデーションに利用できるAPIはYupのGitHubから確認できます。
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;
これらを基にフォームの機能を作成します。(本稿の要点以外は省略)
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
も準備されており、すぐにテストを開始することができます。
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.json
にjest
の項目を設け、ここに設定を記述することで対応します。(ただしサポートされているコンフィグに制限あり)
Jest CLI
先述のとおり、CRAにおいてはyarn test
コマンドによってJestを実行しますが、この実体はpackage.json
のscripts
のtest
に記述されています。
{
"scripts": {
"test": "react-scripts test",
}
}
内部ではJestが利用されているので同等のコマンドオプションが利用できます。利用可能なオプションは、Jestのドキュメントの他、--help
オプションによっても確認可能です。
常に利用するオプションについては上記のpackage.json
のscript
に記述することで、yarn test
コマンドの挙動を変更することができます。
{
"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ではテスト実行コマンドにオプションを付与することでカバレッジを表示することができ、さらにこの時、テストされていないファイルとその対象コードの行まで把握することが可能です。これはテスト実行状況を確認し今後の方針を決定することに役立ちます。
React Testing Library
UIテストでは、実際のユースケースに従ってユーザーの操作 (クリックやフォーム入力など) を再現し、その結果期待した画面表示がされているかを確認します。React Testing Libraryはそのために有用な機能を提供するパ
ッケージです。
テスト状態初期化
React Testing Library によって、コンポーネントをレンダリングするにはrender
関数を使用しますが、この時テスト対象のコードでReduxやReact 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 StoreやReact Routerなどの状態はリセットされないことに注意が必要です。例えば先行するテストでページ移動を行っていた場合には、次のテストで上記のrender
を再度行ったとしても、ページ移動後のコンポーネントがレンダリングされることになります。つまりテスト内で何らかの変更を加えた場合には以降のテストに影響を及ぼす恐れがあります。
テスト毎にこれらを初期状態に戻すには、beforeEach
などを用いて各状態の初期化を行いますが、ここで行うべき処理はそれぞれ異なります。
例えばRedux Storeをリセットするには、テスト毎にconfigureStore
から再度生成することが一つの解決策です。
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
) に配置します。
用意したstore
をimport
し、再生成用の関数を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
に記述します。
import { rest } from 'msw'
export const handlers = [
rest.post('http://backend/login', () => {}),
]
上記のコードは、http://backend/login
に対するPOST
リクエストを捕捉する Request handlerをhandlers
に格納しています。第二引数で何も返していないのでこれはまだ動作しません。
第二引数として渡されるのは、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
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({
...
})
);
}),
]
作成したhandler
を利用するには、ブラウザ環境とNode (テスト) 環境で異なるプロセスが必要です。
ブラウザ環境
ブラウザ環境で実行する場合はService Workerを起動します。そのために必要なコードは以下のコマンドを実行することで生成することができます。
npx msw init public/ --save
次に、src/mocks/browser.ts
を作成し、handler
からworker
を構成します。
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
を開発環境の条件の下に実行します。
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
を構成します。
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
// Setup requests interception using the given handlers.
export const server = setupServer(...handlers);
次に、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によって捕捉されるようになりました。
GitHub Actions
GitHub Actionsとは、事前に規定したイベントが発生した際に自動的に任意のコマンドを実行することができるサービスです。イベントに指定可能なものとして、リポジトリへのPushやPull Requestなどがあり、特定のBranchの場合に限定してイベントとみなすといった条件を指定することも可能です。
導入や基本的な使用方法などについては、バックエンドの実装過程で説明しています。
以降では今回作成したテストを実行する手順を確認していきます。方針としては、まず依存関係のインストールを行い、このときキャッシュが存在すれば手順をスキップします。次に.env
ファイルを用意し、ビルド、テストを順に行います。
キャッシュを利用した依存関係インストールを行うコードは以下のようになります。
- 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
ファイルの作成、ビルド、テストを行います。
- name: Set environment variables
run: mv .env.example .env
- run: yarn build --if-present
- run: yarn test
実行するコマンド自体は以上となります。次に、これらを複数のNodeバージョンで実行するように設定を加えます。
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
としています。
defaults:
run:
working-directory: ./frontend
最終的には以下のようなコードを.github/workflows/test.yml
に作成します。尚、複数のjob
が存在する場合のコードを示すためにバックエンド側の記述も一部含めました。
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のバージョンで実行されることになります。
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の各要素 (h1
やp
など) を任意のコンポーネントに変換することが可能で、これによってMaterial-UIとの併用も容易に実現できます。
利用する際には型定義ファイルも必要になるので同時にインストールを行います。
yarn add markdown-to-jsx @types/markdown-to-jsx
Markdownへの変換はMarkdown
コンポーネントによって行われ、この時options
プロパティによってどのようなコンポーネントに変換するか指定することができます。
Markdown
を利用する度に毎回このような指定を行うのはコードの重複になるので、options
を指定したコンポーネントを新たに作成し、JSXに変換する際にはこちらを利用することにします。
Markdown.tsx
というファイルを作成し、単にoptions
指定したMarkdown
をexport
するような実装を行います。まずoptions
が空の状態のコードは以下のようになります。
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
ではなくMarkdownToJsx
をimport
しています。これはdefault export
されているので任意の名前にすることが可能で、ここではMarkdownToJsx
という名前にしています。Markdown
コンポーネントとして作成しており名前が衝突するのでこのような方法を採っています。
次に、MarkdownToJsx
をマウスオーバーして得られた型情報から、options
の型はMarkdownToJSX.Options
であることが判明したので、そのためのnamespace
をimport
しています。
そして、MarkdownToJsx
に与えるchildren
はstring
であることが求められるので、as string
記述して型アサーションを行うことで対処します。
それでは次にoptions
を指定してMaterial-UIを使用できるようにしていきます。
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
に以下のような指定を行います。
const options: MarkdownToJSX.Options = {
slugify: (str) => str, // 自動生成されるid属性を日本語で利用
overrides: {
...
参考: options.slugify - probablyup/markdown-to-jsx - GitHub
以上で、MarkdownをReactで扱うための準備は完了です。
まとめ
以上、SPAのフロントエンドを構成する上で必要となる要素 (状態管理やルーティングなど) 及びそれらを実現するための技術 (ReduxやReact Routerなど) について、その意義を確認しつつ初めに行うべき実装を説明してきました。
しかし、序盤のほんの一部しか触れられていないのでまだ言及すべきことが残っています。今回はここまでとなりますが、また機会があれば追記していきたいと思います。