はじめに
■ご案内■
本連載の背景/作成できるアプリケーション/進め方をご理解頂く上でも【環境構築編】 をご一読頂けると幸いです。
- 【環境構築編】
- 【Next.js編】 👈いまここです
- 【Go編】
- 【AWS編】
これからも頑張ってハンズオン系の記事を書いていきたいと思っているので、いいねっと思って頂けたらLGTM押していただけると励みになります...!
環境構築
本サンプルアプリの環境構築方法は【環境構築編】に記載しているので、そちらをご参照ください。
掲載しているコードは割愛している箇所が多数あります。お手元でクローンいただき適宜、不足部分をご確認いただけると幸いです。
Yarn .ver3/Berry
今回Next.js
のパッケージ管理としてYarnv3
系を利用しています。
v1
系との大きな違いは以下2つです。
(1)Plug'n'Play (PnP):
PnPはノードモジュールの依存関係の解決方法をv1
系の仕組みとは異なる方法で実現しています。その結果、パフォーマンス向上、リソース節約を行うことができます。また、v1
系まで使用されてきた、node_modules
フォルダを利用しません。
(2)Zero-Installs:
Zero-Installsを利用することで、リポジトリ内にパッケージの依存関係を直接含めることができます。依存関係のファイル群もgit管理することで、クローン時には開発者がyarn install
を実行する必要ありません。
プロジェクトのセットアップを迅速化し、開発の効率化を実現できます。
※ただし、pnp非対応なライブラリを利用する場合等は、yarn install
などの依存関係インストールが必要になるのでご注意ください。
公式のベースイメージを確認しても分かるとおり、デフォルトでは1.22
がインストールされています。
したがって、本サンプルアプリのフロントのDockerfileでは、以下のように個別で指定しています。
FROM node:19.9.0-bullseye-slim AS dev
WORKDIR /app
RUN apt-get update && \
apt-get upgrade -y && \
apt-get autoremove -y && \
# Yarnのバージョンを上げる
yarn set version berry && \
以下の記事がわかりやすいのでおすすめです。
マルチステージビルド
マルチステージビルドはイメージサイズの削減、ビルド過程の分離、安全性向上を図る事ができるそうです。
また、From
句を重ね、前ステージから必要なファイルのみを取得することで、不要なビルドツールや依存性を最終イメージから除くことが可能です。
今回はAWSへのデプロイを考慮しているため、通常のnode
イメージではなく、軽量なbullseye-slim
も利用しています。
# 開発ステージ
FROM node:19.9.0-bullseye-slim AS dev
## ...省略
# ベースイメージ
FROM node:19.9.0-bullseye-slim AS base
## ...省略
# ビルドステージ
FROM base AS builder
## ...省略
# プロダクション用ステージ
FROM builder AS runner
WORKDIR /app
ENV NODE_ENV=production
## ...省略
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["yarn", "start"]
GitHubActionsを用いたCI環境構築
本リポジトリではpush時にGitHubActions上でWorkflowを実行しています。
- テスト(
jest
) - 静的解析(
eslint/prettier
)
また、今回は同Workflow上でdocker-compose
を用いてCIを実施しています。
docker-compose
を用いて実行することで、ローカルマシンと同様なCI環境を実現しています。
name: next-front-app
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
defaults:
run:
working-directory: ./
env:
# CIで利用するファイルを指定
DOCKER_COMPOSE_FILE: docker-compose.ci.yml
jobs:
Linter:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: List files
run: |
ls -la
- name: Docker Set Up
run: |
docker-compose -f ${{ env.DOCKER_COMPOSE_FILE }} build front
docker-compose -f ${{ env.DOCKER_COMPOSE_FILE }} up -d front
- name: Container Status
run: |
docker-compose -f ${{ env.DOCKER_COMPOSE_FILE }} ps
- name: Install
run: |
docker-compose -f ${{ env.DOCKER_COMPOSE_FILE }} exec -T front yarn
- name: Run TEST
run: |
docker-compose -f ${{ env.DOCKER_COMPOSE_FILE }} exec -T front yarn jest
- name: Run CI
run: |
docker-compose -f ${{ env.DOCKER_COMPOSE_FILE }} exec -T front yarn prettier
docker-compose -f ${{ env.DOCKER_COMPOSE_FILE }} exec -T front yarn lint
本記事ではGitHubActions
自体の初期設定は割愛いたします。以下を参考にしてみてください。
フロントエンドアーキテクチャ
UI
部分とロジック
部分を切り分け、Container層/presenter層を意識したComponent設計を試してみました。
カスタムフックもhooks
ディレクトリに詰め込むのではなく、Component
に閉じる内容は同じ階層のディレクトリに配置しています。
src/
├ Component/
│ └ List/
│ ├─ index.module.scss
│ ├─ index.test.tsx
│ ├─ index.tsx
│ ├─ presenter.tsx
│ └─ useGetMessages.ts
また、以下の食べログさんの記事が非常に参考になりました。
Next.jsのLayout機能を使ったレイアウトの共通化
レイアウトの共通化、ページ単位でのレイアウトの切り替えも行う事ができます。
import { Presenter } from '@/components/Layout/presenter';
import useLogout from '@/components/Layout/useLogout';
import type { ReactNode } from 'react';
type LayoutProps = {
children: ReactNode;
};
export function Layout({ children }: LayoutProps) {
const { logout } = useLogout();
return (
<>
<Presenter logout={logout} />
{children}
</>
);
}
レイアウトを利用するページでは以下のように記述します。
import { Layout } from '@/components/Layout';
import { List } from '@/components/List';
import type { NextPageWithLayout } from '@/pages/_app';
import React from 'react';
const Page: NextPageWithLayout = () => {
return (
<>
<List />
</>
);
};
Page.getLayout = function getLayout(page) {
return <Layout>{page}</Layout>;
};
export default Page;
React Hook Formを使ったフォーム管理
同ライブラリを使うことで、エラーハンドリング、バリデーションを簡単に行う事ができます。
また、非制御コンポーネントを使用することで、再レンダリングを最小限に抑えつつパフォーマンスを向上させることができます。
// ...省略
export function Post() {
// ...省略
// React Hook Formのhooksの呼び出し
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Create>();
const onSubmit: SubmitHandler<Create> = async (data) => mutation.mutate(data);
// ...省略
return (
<>
<Presenter
handleSubmit={handleSubmit}
onSubmit={onSubmit}
register={register}
errors={errors}
userId={user.userId}
router={router}
/>
</>
);
}
// ...省略
export function Presenter(props: Props) {
return (
<>
<div className={styles.inputWrapper}>
<div className={styles.error}>
{/* React Hook Formでのエラーハンドリング */}
{props.errors.userId && <span>※Please login again</span>}
{props.errors.text && <span>※Please input text</span>}
</div>
<div className={styles.textareaWrapper}>
<input
type='hidden'
defaultValue={props.userId}
{...props.register('userId', { required: true })}
/>
{/* React Hook Formでのバリデーション */}
<textarea className={styles.textarea} {...props.register('text', { required: true })} />
</div>
{/* ...省略 */}
</div>
</>
);
}
Recoilを使ったグローバル状態管理
最近では、かなり有名になってきた状態管理ライブラリですね。本アプリでもグローバル状態管理として利用してみました。
Recoil
はアトム(atom
)と呼ばれる単位で定義・管理していきます。
また、recoilPersist
というライブラリを利用することで、値の格納先をセッションストレージやローカルストレージに指定することで永続化することができます。
import { useMemo } from 'react';
import { atom, useRecoilState, RecoilEnv } from 'recoil';
import { recoilPersist } from 'recoil-persist';
// `Duplicate atom key` Error対策
RecoilEnv.RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED = false;
type UserState = { userId: string } | null;
// 永続化
const { persistAtom } = recoilPersist({
key: 'recoil-persist',
storage: typeof window === 'undefined' ? undefined : localStorage,
});
// Atomの定義
const userState = atom<UserState>({
key: 'userId',
default: null,
effects_UNSTABLE: [persistAtom],
});
export const useUserState = () => {
const [user, setUser] = useRecoilState<UserState>(userState);
return { user, setUser };
};
Duplicate atom key
Errorを消すことができると最近知りました。
react-cookieを使ったクッキー管理
react-cookie
を使用すると、Reactアプリにクッキー管理機能を簡単に実装することができます。
本アプリではcsrf
トークンのハンドリングのために利用しています。
// ..省略
export const useLogin = () => {
const { setUser } = useUserState();
const { setCsrf } = useSetCsrf();
const router = useRouter();
const params = useMemo(() => new URLSearchParams(), []);
// "_csrf"という名前のクッキーをセット
const [cookies, setUseCookies] = useCookies(['_csrf']);
useEffect(() => {
setUseCookies('_csrf', cookies._csrf);
}, [cookies._csrf, setUseCookies]);
// ..省略
GraphQL
GraphQLでは必要なデータ以上に情報を取得する「オーバーフェッチ」、または必要なデータを一度のリクエストで完全に取得できない「アンダーフェッチ」という、REST APIで頻発する問題を低減できるといったメリットがあります。
GraphQL Code Generator
このツールは、GraphQLスキーマから型定義などを自動生成することができるツールです。
TypeScriptを利用する場合、GraphQLのSchemaを元に対応する型定義を都度作成する必要があります。
一方で、このツールを利用することで、型定義ファイル等を自動コード生成を行ってくれ、効率的で一貫性のあるGraphQL開発が可能になります。
Schema file
input NewMessage {
text: String!
userId: String!
}
type Mutation {
createMessage(input: NewMessage!): Message!
}
type Query {
getMessages: [Message!]!
}
type Message {
id: ID!
text: String!
user: User!
created_at: String!
updated_at: String!
}
type User {
id: ID!
name: String!
email: String!
password: String!
}
コード自動生成
上記のスキーマファイルを定義してコマンドを実行すると当該ファイルが自動生成されます。
# docker-compose exec front yarn generate
$ make generate
/graphql/
├ generated/
│ └ fragment-masking.ts
│ └ gql.ts
│ └ graphql.ts
ファイル名 | 内容 |
---|---|
fragment-masking.ts | GraphQLのフラグメントに関する型情報 |
gql.ts | query, mutationに関する型情報 |
graphql.ts | GraphQLスキーマから生成された型情報 |
GraphQL Document
ここでは、自動生成されたスキーマファイルに対応する型定義を元にquery
やmutation
を実行するための、GraphQL Documentを定義しています。
import { graphql } from '@/graphql/generated/gql';
export const query = graphql(/* GraphQL */ `
query getMessagesQueryDocument {
getMessages {
id
text
created_at
user {
name
}
}
}
`);
export const create = graphql(/* GraphQL */ `
mutation createMessage($userId: String!, $text: String!) {
createMessage(input: { userId: $userId, text: $text }) {
id
text
user {
id
}
created_at
}
}
`);
開発の中で、取得や更新対象のフィールドが変更になった場合はこのドキュメントに追記を行います。
上記の$ make generate
コマンドを実行すると、変更が監視され自動的に該当するデータ型が自動的に変更&自動修正されます。
graphql-request
graphql-request
というライブラリを使ってGraphQLクライアントを作成しています。
ここでは、GraphQLサーバーに対してクエリやミューテーションを送信するためのclient
関数を定義しています。
フロントとAPIを別ドメインで運用するため、cors
サポートと リクエストへのcookie
付与の設定も行っています。
import { GraphQLClient } from 'graphql-request';
export const client = (token: string) =>
new GraphQLClient(`${process.env.NEXT_PUBLIC_API_URL}/query`, {
headers: {
'X-CSRF-TOKEN': token,
},
mode: 'cors',
credentials: 'include',
});
Tanstack Query(旧React Query)
Tanstack Query
はReactのデータ管理ライブラリで、サーバーサイドの状態管理、データのフェッチ、キャッシュ、エラーハンドリングをシンプルかつ宣言的な方法で行うことができます。
GraphQLにもサポートしており、公式ドキュメントにもサンプル例が載っています。
本アプリでは、メッセージの取得と作成にて、同ライブラリを用いています。また、各処理自体はカスタムフックを使ってロジックを切り出しています。
import { client } from '@/graphql/client';
import { query } from '@/graphql/document';
import { useQuery } from '@tanstack/react-query';
import { useCookies } from 'react-cookie';
export const useGetMessages = () => {
const [cookies] = useCookies(['_csrf']);
const requestQuery = async () => client(cookies._csrf).request(query);
const { isLoading, isError, data, error } = useQuery({
queryKey: ['messages'],
queryFn: requestQuery,
});
return {
isLoading,
isError,
data,
error,
};
};
isLoading
やisError
を使うことで、データフェッチ時のハンドリングもわかりやすく記述することができます。
import { useUserState } from '@/atoms/userAtom';
import { Presenter } from '@/components/List/presenter';
import { useGetMessages } from '@/components/List/useGetMessages';
import { useRouter } from 'next/router';
import React from 'react';
export function List() {
const { setUser } = useUserState();
const router = useRouter();
const { isLoading, isError, data, error } = useGetMessages();
if (isLoading) <span>Loading...</span>;
if (isError) {
console.error('Error: useGetMessages', error);
setUser(null);
router.push('/');
}
return <>{data && <Presenter data={data} router={router} />}</>;
}
useMutation
を使うことでデータ更新時のハンドリングを細かく指定する事ができます。
ここでは、新規メッセージ作成が成功した場合、キャッシュをクリアして、/timeLine
へ遷移しています。また、エラーが発生した場合は、コンソールにログを出力しています。
import { client } from '@/graphql/client';
import { create } from '@/graphql/document';
import { Create } from '@/types/form';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import Cookies from 'react-cookies';
export const useCreateMessages = () => {
// ...省略
const mutation = useMutation({
mutationFn: requestQuery,
// 成功
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['messages'] });
router.push('/timeLine');
},
// 失敗
onError: (error, variables) => console.error(`error: ${error} variables: ${variables}`),
});
return { mutation };
};
フロントエンドのテスト戦略
フロントエンドのテストコードはAPI側のテストと比較して対照範囲やテスト戦略について悩む事が多いですよね。
私も改めて学習する中で、以下の記事が参考になったのでリンクを貼らしていただきます。
コンポーネントテスト
ここでは、List
コンポーネントが適切にデータを取得し、表示する機能をテストしてみました。
また、msw
ライブラリを使ってGraphQLクエリのモックサーバーを設定し、react-queryを使ってデータ取得を行い、testing-library
を使ってレンダリング結果の検証を行っています。
msw
msw(Mock Service Worker)は、ブラウザとNode.jsの両方で動作するAPIモッキングライブラリです。
mswはService Workerを利用してネットワークリクエストをインターセプトし、あらかじめ定義したレスポンスを返すことができます。これにより、テストや開発時にリアルなAPIサーバーと同じような動作をエミュレートすることができます。
React Testing Library
React Testing Libraryは、Reactコンポーネントのテストを行うためのライブラリで、DOMに対するクエリを提供します。ユーザー視点でUIコンポーネントをテストするのに役立ちます。
import '@testing-library/jest-dom/extend-expect';
import { List } from '@/components/List';
import { GetMessagesQueryDocumentQuery } from '@/graphql/generated/graphql';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import { graphql } from 'msw';
import { setupServer } from 'msw/node';
import { RecoilRoot } from 'recoil';
// 対策: NextRouter was not mounted. https://nextjs.org/docs/messages/next-router-not-mounted
jest.mock('next/router', () => ({
useRouter: jest.fn(),
}));
// Mock Serverの設定
const server = setupServer(
graphql.query<GetMessagesQueryDocumentQuery>('getMessagesQueryDocument', (req, res, ctx) => {
return res(
ctx.data({
getMessages: [
{
id: '1',
text: 'test message',
user: {
name: 'test-user1',
},
created_at: '2023-01-30T12:07:06Z',
},
],
}),
);
}),
);
// set up
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Test
describe('mocking API', () => {
it('Fetch success Should display fetched data correctly', async () => {
// 対策: No QueryClient set, use QueryClientProvider to set one
const queryClient = new QueryClient();
render(
<RecoilRoot>
<QueryClientProvider client={queryClient}>
<List />
</QueryClientProvider>
</RecoilRoot>,
);
// アサート実行
expect(screen.queryByText(/test-user1/)).toBeNull();
expect(await screen.findByText(/test-user1/)).toBeInTheDocument();
// テスト下でのレンダリング表示内容確認用
// screen.debug();
});
});
この後は
■ご案内■
- 【環境構築編】
- 【Next.js編】
- 【Go編】 👈次はこちらへ
- 【AWS編】
これからも頑張ってハンズオン系の記事を書いていきたいと思っているので、いいねっと思って頂けたらLGTM押していただけると励みになります!