ぼくのかんがえたNext.jsの構成
はじめに
普段開発している Next.js プロジェクトの構成がなかなかいけてるんじゃないかということで、その構成を公開しちゃおうというお話。ほんとはタイトルをぼくがかんがえたさいきょうのNext.jsの構成
にしたかったけどひよりました
(今回の記事を作るにあたり改めて一から Next.js のリポジトリ作ったら husky のバージョン上がってたり、eslint-config-prettier の v8 系になって config の書き方ちょっと変わってたり、時代は移り変わるのです・・)
意外と手順書いていくと長くなったので一部coming soon
になっているものは確固たる意思を持って、随時追記します
更新履歴
- 2021/04/01 css modules が storybook で上手く呼べてなかったので修正、あと storybook のバージョンアップ
- 2021/03/31 なんと続編(
僕のかんがえたフロントエンドアーキテクチャ
)が出ました - 2021/03/26 snapshot の方法追記
- 2021/03/26 ビジュアルリグレッションテストの方法追記
記事のターゲット
- Next.js の構成が気になる人
- 自分用の手順書メモでもある
構成
()に使ってる理由とか用途とか書いてます
- Next.js
- TypeScript (いわずもがな)
- Storybook (コンポーネント駆動で開発できるから)
- ESLint (いわずもがな)
- Prettier (いわずもがな)
- Husky (push や commit 時に lint や format や test のコマンドが実行できる)
- lint-staged (git の stage に入ったファイルに対して lint や format ができる)
- Jest (Unit テスト + Snapshot)
- Tailwind CSS(賛否両論あるかもですが個人的に書いてて楽しいので採用)
- scaffold コマンド(コンポーネントのテンプレートの自動生成)の追加
- Renovate(依存 package の定期的な更新) coming soon[1]
- storycap + reg-suit(ビジュアルリグレッションテスト)
- Cypress (E2E テスト)
環境
"dependencies": {
"next": "10.0.9",
"react": "17.0.2",
"react-dom": "17.0.2"
},
"devDependencies": {
"@storybook/addon-essentials": "6.2.1",
"@storybook/addon-links": "6.2.1",
"@storybook/addon-postcss": "2.0.0",
"@storybook/addon-storyshots": "6.1.21",
"@storybook/react": "6.2.1",
"@testing-library/react": "11.2.5",
"@types/jest": "26.0.21",
"@types/node": "14.14.35",
"@types/react": "17.0.3",
"@types/react-dom": "17.0.3",
"@typescript-eslint/eslint-plugin": "4.19.0",
"@typescript-eslint/parser": "4.19.0",
"autoprefixer": "10.2.5",
"babel-jest": "26.6.3",
"eslint": "7.22.0",
"eslint-config-prettier": "8.1.0",
"eslint-plugin-import": "2.22.1",
"eslint-plugin-react": "7.23.1",
"eslint-plugin-simple-import-sort": "7.0.0",
"husky": "5.2.0",
"jest": "26.6.3",
"jest-watch-typeahead": "0.6.1",
"lint-staged": "10.5.4",
"postcss": "8.2.8",
"postcss-nested": "5.0.5",
"prettier": "2.2.1",
"reg-keygen-git-hash-plugin": "0.10.15",
"reg-notify-github-plugin": "0.10.15",
"reg-notify-slack-plugin": "0.10.15",
"reg-publish-s3-plugin": "0.10.15",
"reg-suit": "0.10.15",
"storycap": "3.0.3",
"tailwindcss": "2.0.4",
"typescript": "4.2.3"
}
実際のコード
Let's try!
npx create-next-app {project-name}
-
yarn policies set-version
(yarn のバージョン指定) - 生成された.yarnrc に
save-prefix ""
の追加(インストールする package のバージョンを固定する) -
.node-version
の追加 -
mkdir src && mv pages src && mv styles src
(src
ディレクトリに諸々を移動する) -
yarn add --dev typescript @types/react @types/react-dom @types/node
typescript 周りを入れる - src 配下のファイルを tsx に変更する +
.babelrc
+tsconfig.json
-
eslint
+prettier
を入れる -
jest
を入れる -
lint-staged
+husky
を入れる -
Tailwind CSS
を入れる -
Storybook
を入れる -
storyshots
を入れて snapshot テストを実行する -
storycap
+reg-suit
を入れてビジュアルリグレッションテストを実行する
.babelrc
+ tsconfig.json
7.src 配下のファイルを tsx に変更する + .babelrc
{
"presets": ["next/babel"]
}
tsconfig.json
{
"compilerOptions": {
"baseUrl": "src",
"target": "es5",
"module": "esnext",
"jsx": "preserve",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"noEmit": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true
},
"exclude": ["node_modules", "deployments", ".next", "out"],
"include": [
"next-env.d.ts",
"globals.d.ts",
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.js",
"test/**/*.ts",
"test/**/*.tsx"
]
}
`_app.tsx`
import 'styles/globals.css'
import { NextPage } from 'next'
import { AppProps } from 'next/dist/next-server/lib/router/router'
const MyApp: NextPage<AppProps> = ({ Component, pageProps }: AppProps) => {
return <Component {...pageProps} />
}
export default MyApp
`index.tsx`
import { NextPage } from 'next'
import Head from 'next/head'
import styles from 'styles/Home.module.css'
const Home: NextPage = () => {
return (
<div className={styles.container}>
{/* ... */}
</div>
)
}
export default Home
js から tsx に書き直してる最中に下記部分で型のエラーが出るかもですが
import styles from 'styles/Home.module.css'
yarn dev
とかするとnext-env.d.ts
が生成されてエラー解消される
8. eslint + prettier を入れる
eslint 周りで必要なもの + 追加したい eslint rule + prettier のパッケージをインストール
yarn add --dev @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint eslint-config-prettier eslint-plugin-react eslint-plugin-import eslint-plugin-simple-import-sort prettier
.eslintrc.json
好みです
rule はその時その時で必要なものを入れましょう
{
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "simple-import-sort", "import"],
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"env": {
"es6": true,
"browser": true,
"jest": true,
"node": true
},
"rules": {
"react/react-in-jsx-scope": 0,
"react/display-name": 0,
"react/prop-types": 0,
"@typescript-eslint/explicit-function-return-type": 0,
"@typescript-eslint/explicit-member-accessibility": 0,
"@typescript-eslint/indent": 0,
"@typescript-eslint/member-delimiter-style": 0,
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/no-use-before-define": 0,
"@typescript-eslint/no-unused-vars": [
2,
{
"argsIgnorePattern": "^_"
}
],
"no-console": [
2,
{
"allow": ["warn", "error"]
}
],
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"import/no-unresolved": "off",
"sort-imports": "off",
"react/self-closing-comp": [
"error",
{
"component": true,
"html": true
}
]
}
}
.prettierrc
好みです
{
"singleQuote": true,
"semi": false,
"trailingComma": "all"
}
vscode 使ってたら vscode の設定入れる
.vscode/settings.json
{
"editor.tabSize": 2,
"editor.codeActionsOnSave": {
"source.fixAll": true
},
"css.validate": false
}
プロジェクトで必要な vscode の拡張もあれば用意しとくと深切
.vscode/extensions.json
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
]
}
jest
を入れる
9. yarn add --dev @testing-library/react @types/jest babel-jest jest jest-watch-typeahead
jest.config.js
module.exports = {
roots: ['<rootDir>'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'json', 'jsx'],
testPathIgnorePatterns: [
'<rootDir>[/\\\\](node_modules|.next)[/\\\\]',
'<rootDir>/test/Storyshots.test.ts',
],
transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(ts|tsx)$'],
transform: {
'^.+\\.(js|ts|tsx)$': 'babel-jest',
},
watchPlugins: [
'jest-watch-typeahead/filename',
'jest-watch-typeahead/testname',
],
moduleNameMapper: {
'\\.(styl|css|less|scss)$': '<rootDir>/test/__mocks__/styleMock.js',
'\\.(gif|ttf|eot|svg|png)$': '<rootDir>/test/__mocks__/fileMock.js',
},
moduleDirectories: ['node_modules', 'src'],
}
snapshot テストで mock 対応するので先に設定しとく
jest.config.js
のmoduleNameMapper
のところ
module.exports = 'test-file-stub'
module.exports = {}
package.json のコマンド追加
linter と formatter のコマンドも追加するの忘れたのでここで記載する
"scripts": {
"type-check": "tsc --pretty --noEmit",
"format": "prettier --write .",
"lint": "eslint src --ext ts --ext tsx",
"test": "jest",
"test-all": "yarn lint && yarn type-check && yarn test"
},
サンプルのテストで動作確認する
import '@jest/globals'
describe('jest 動作確認', () => {
test('true toBeTruthy', () => {
expect(true).toBeTruthy()
})
})
yarn test
で jest 実行されるかどうか確認する
lint-staged
+ husky
を入れる
10. stage に上がってるファイルに対して lint かけてくれるlint-staged
と git のコミットやプッシュ前のときにコマンドを実行してくれるhusky
を入れる
yarn add --dev husky lint-staged
lint-staged の設定を package.json に追加
"lint-staged": {
"*.@(ts|tsx)": [
"yarn lint",
"yarn format"
]
},
husky の設定追加
yarn husky install
今回は pre-commit と pre-push の設定だけしとく
- コミット前に stage にあるファイルに対して lint と format を実行する
- push 前に test と 型チェック を実行する
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn lint-staged
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn test & yarn type-check
Tailwind CSS
を入れる
11. 公式のドキュメント通りに進めればいける
yarn add --dev tailwindcss postcss autoprefixer postcss-nested
yarn tailwindcss init -p
tailwindcss を読み込む
globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
vscode の setting.json で"css.validate": false
することで@tailwind
のエラーを消す(正しいかどうかは定かではない)
tailwind.config.js
後々、js で動的な class 名を生成する場合(下記の classnames)は safelist の設定をしとく
purge の設定て下記のようなコードは tailwindcss 側で読み込まれない感じなので safelist で purge しないでって設定をする
const spanClass = classnames(`text-${fontSize}`, `text-${color}`);
module.exports = {
purge: {
content: ['./src/components/**/*.tsx', './src/pages/**/*.tsx'],
options: {
// https://purgecss.com/safelisting.html#patterns
safelist: {
standard: [/^bg-/, /^text-/],
},
},
},
darkMode: false, // or 'media' or 'class'
theme: {
extend: {},
},
variants: {
extend: {},
},
plugins: [],
}
postcss.config.js
好み
sass みたいにネストした書き方ができるpostcss-nested
を追加
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
+ 'postcss-nested': {},
},
}
src/pages/index.tsx
とかで適当な要素のclassName
を変えてみて、ちゃんと tailwindcss が使えるか確認する
- <h1 className={styles.title}>
- Welcome to <a href="https://nextjs.org">Next.js!</a>
- </h1>
+ <h1 className="text-bg-500">
+ Welcome to <a href="https://nextjs.org">Next.js!</a>
+ </h1>
.vscode/extensions.json
tailwindcss の拡張が便利なので入れる
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
+ "bradlc.vscode-tailwindcss",
]
}
Storybook
を入れる
12. Error: PostCSS plugin tailwindcss requires PostCSS 8.
tailwindcss(v2)を使う場合 postcss は v8 が必要だが storybook の 6.1 系では postcss が v7 系なのでエラーが発生するので6.2.0-alpha.23
+ @storybook/addon-postcss
をインストールしている
yarn add -D @storybook/react@6.2.0-alpha.23 @storybook/addon-links@6.2.0-alpha.23 @storybook/addon-essentials@6.2.0-alpha.23 @storybook/addon-postcss
.storybook
配下に必要な設定を入れる
.storybook の設定
- addon-postcss 入れることで postcss8 対応
- tsconfig の baseUrl の対応するために webpack.config を上書き
const path = require('path')
module.exports = {
stories: [
'../src/components/**/**/*.stories.mdx',
'../src/components/**/**/*.stories.@(js|jsx|ts|tsx)',
],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-links',
{
name: '@storybook/addon-postcss',
options: {
postcssLoaderOptions: {
implementation: require('postcss'),
},
},
},
],
webpackFinal: async (baseConfig) => {
// NOTE: tsconfigのbaseUrlの対応
baseConfig.resolve.modules = [
path.resolve(__dirname, '..', 'src'),
'node_modules',
]
// HACK: 根本解決ではない気もする
// HACK: エラー処理もおまじない程度
// NOTE: デフォルトではcss modulesは読み込まれないので読み込まれるように設定する
// ref: https://qiita.com/s6n/items/f64b2c4be580e1fc1cb8#css-%E3%81%8C%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%81%BE%E3%82%8C%E3%81%AA%E3%81%84%E5%95%8F%E9%A1%8C
const cssRule = baseConfig.module.rules.find(
(rule) => String(rule.test).indexOf('css') !== -1,
)
if (!cssRule) return { ...baseConfig }
// NOTE: 対象から.module.cssファイルを外す
cssRule.test = /^.*(?<!\.module)\.css$/
const cssLoader = cssRule.use.find(
(u) => String(u.loader).indexOf('css-loader') !== -1,
)
if (cssLoader) {
cssLoader.options = {
// NOTE: css-loaderを呼ぶ前に適用されるローダーの数 1だとpostcss-loaderを適用することになる
importLoaders: 1,
}
}
// NOTE: postcss-loaderはaddon-postcssのものをそのまま使う
const postcssLoader = cssRule.use.find(
(u) => String(u.loader).indexOf('postcss-loader') !== -1,
)
if (postcssLoader) {
baseConfig.module.rules.push({
// NOTE: .module.cssファイルのみが対象
test: /\.module\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 1,
// NOTE: css modulesを有効にする
modules: true,
},
},
postcssLoader,
],
})
}
return { ...baseConfig }
},
}
Next.js の router を storybook に載せるコンポーネントで使う場合の mock
import * as nextRouter from 'next/router'
nextRouter.useRouter = () => ({
route: '/',
pathname: '/',
push: () => {},
prefetch: () => new Promise((resolve, reject) => {}),
})
postcss の config を root 直下から取得
const postcssConfig = require('../postcss.config')
const usePlugins = {}
// NOTE: Using Next.js postcss config
// Convert a plugins format for postcss-loader.
postcssConfig.plugins.forEach((plugin) => {
// Has options?
if (Array.isArray(plugin) && plugin.length === 2) {
usePlugins[plugin[0]] = plugin[1]
} else {
usePlugins[plugin] = {}
}
})
module.exports = {
plugins: usePlugins,
}
tailwindcss の読み込みや addon の設定(addon の設定は好みです)
import style from '../src/styles/globals.css'
import router from './newRouterMock'
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
layout: 'fullscreen',
backgrounds: {
default: 'light',
values: [
{ name: 'light', value: '#F7F8FA' },
{ name: 'dark', value: '#314565' },
{ name: 'white', value: '#FFFFFF' },
{ name: 'black', value: '#000000' },
],
},
}
package.json にコマンド追加
"scripts": {
+ "storybook": "start-storybook -p 9009 -s ./public",
+ "build-storybook": "build-storybook -s ./public"
},
storyshots
を入れて snapshot テストを実行する
13. - storybook の snapshot 用の addon を入れる
yarn add -D @storybook/addon-storyshots
- snapshot テストの実行ファイルを作成する
- jest の config を修正
- package.json にコマンド追加
- snapshot テストが動くか確認する
snapshot テストの実行ファイルを作成する
import initStoryshots, {
multiSnapshotWithOptions,
} from '@storybook/addon-storyshots'
initStoryshots({
integrityOptions: { cwd: __dirname },
test: multiSnapshotWithOptions(),
})
jest の config を修正
unit テスト時の config と snapshot の config を分ける
何を分けたかというとtestMatch
プロパティで実行するテストファイルを分けた感じ
unit テスト用の config
module.exports = {
roots: ['<rootDir>'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'json', 'jsx'],
testPathIgnorePatterns: ['<rootDir>[/\\\\](node_modules|.next)[/\\\\]'],
transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(ts|tsx)$'],
transform: {
'^.+\\.stories\\.tsx$': '@storybook/addon-storyshots/injectFileName',
'^.+\\.(js|ts|tsx)$': 'babel-jest',
},
testMatch: ['<rootDir>/**/?(*.)(spec|test).(ts|js)?(x)'],
watchPlugins: [
'jest-watch-typeahead/filename',
'jest-watch-typeahead/testname',
],
moduleNameMapper: {
'\\.(styl|css|less|scss)$': '<rootDir>/test/__mocks__/styleMock.js',
'\\.(gif|ttf|eot|svg|png)$': '<rootDir>/test/__mocks__/fileMock.js',
},
moduleDirectories: ['node_modules', 'src'],
}
snapshot の config
const baseConfig = require('./jest.config')
module.exports = {
...baseConfig,
name: 'Storyshots',
displayName: 'storyshots',
testMatch: ['<rootDir>/test/test.storyshots.ts'],
moduleNameMapper: {
...baseConfig.moduleNameMapper,
},
}
package.json にコマンド追加
"scripts": {
"test": "jest",
"test-all": "yarn lint && yarn type-check && yarn test",
+ "storyshots": "NODE_ENV=test jest --config ./jest.storyshots.config.js",
+ "update-storyshots": "NODE_ENV=test jest --config ./jest.storyshots.config.js --updateSnapshot",
},
yarn storyshots
で前回の snapshot とコンポーネントの内容が変わってないかテストし、
yarn update-storyshots
で snapshot を更新する
storycap
+ reg-suit
を入れてビジュアルリグレッションテストを実行する
14. ビジュアルリグレッションテストを導入することでコンポーネントの見た目が意図しない変更をしていないかチェックできる(ライブラリのアップデートとかで)
仕組みとしては storycap で storybook に登録しているコンポーネントのスクショをとり、reg-suit で前回のスクショと比較して見た目が変わっていないかをチェックしてくれる
- storycap をインストール
yarn add --dev storycap
- package に screenshot コマンドを追加
- screenshot を実行してみる
- reg-suit をインストール
- reg-suit の github app を入れる
- slack の webhook の用意
- aws cli で s3 のバケットの作成ができるようにしておく
-
reg-suit init
で初期設定 -
reg-suit run
で ビジュアルリグレッションテストを実行してみる
package に screenshot コマンドを追加
"scripts": {
"storybook": "start-storybook -p 9009 -s ./public",
"build-storybook": "build-storybook -s ./public",
+ "screenshot": "storycap http://localhost:9009 --serverCmd 'start-storybook -p 9009 -s ./public'"
},
yarn screenshot
を実行すると root 直下に__screenshots__
が作成され storybook のスクショが保存される
reg-suit のインストール
yarn add --dev reg-suit
Repository access
からビジュアルリグレッションテストを実行したいリポジトリを選択する
reg-suit の github app を入れて、You can get client IDs here.
の here から client id をメモる
reg-suit init
で初期設定
今回は github で作成したプルリクのコメントにビジュアルリグレッションテストの結果を追加してほしいのとスクショを S3 に保存、Slack 通知は試してみたかったので下記の 4 つを選択した
`reg-suit init` での一問一答
[reg-suit] info version: 0.10.15
? Plugin(s) to install (bold: recommended) reg-keygen-git-hash-plugin : Detect the snapshot key to be compare with using Git hash., re
g-notify-github-plugin : Notify reg-suit result to GitHub repository, reg-publish-s3-plugin : Fetch and publish snapshot images to AWS
S3., reg-notify-slack-plugin : Notify reg-suit result to Slack channel.
[reg-suit] info Install dependencies to the local directory. This procedure takes some minutes, please wait.
? Working directory of reg-suit. .reg
? Append ".reg" entry to your .gitignore file. Yes
? Directory contains actual images. __screenshots__ {スクショのファイルが保存されるところの指定}
? Threshold, ranges from 0 to 1. Smaller value makes the comparison more sensitive. 0
[reg-suit] info Set up reg-notify-github-plugin:
? notify-github plugin requires a client ID of reg-suit GitHub app. Open installation window in your browser No {github appのreg suit管理画面からclient-id取得してたらNoでよし}
? This repositoriy's client ID of reg-suit GitHub app {client id貼り付け}
[reg-suit] info Set up reg-notify-slack-plugin:
? Incoming webhook URL {slackのwebhook url貼り付け}
? Send test message to this URL ? No
[reg-suit] info Set up reg-publish-s3-plugin:
? Create a new S3 bucket Yes {aws cliでs3のバケットを作成できる権限があればよしなにバケットを作ってくれる}
[reg-publish-s3-plugin] info Create new S3 bucket: {バケット名}
? Update configuration file Yes
? Copy sample images to working dir No
yarn を使ってる場合は init すると必要な依存関係が npm としてインストールされるので改めて yarn でインストールし直す
yarn add --dev reg-keygen-git-hash-plugin reg-notify-github-plugin reg-publish-s3-plugin reg-notify-slack-plugin
__screenshots__
と.reg
は gitignore しておく
reg-suit init
を実行するとregconfig.json
が生成される
また s3 にバケットが生成されている
うまくいかない場合は大抵、aws cli 周りが原因
(s3 バケットを作る権限がない aws アカウントで aws cli を実行してるとか)
__screenshots__
配下にスクリーンショットがある状態でreg-suit run
を実行する
うまくいけば s3 にスクリーンショットが保存され、webhook を指定した slack のチャネルにビジュアルリグレッションテスト結果の通知がくる
package に ビジュアルリグレッションテストのコマンドを追加
"scripts": {
+ "visual-test": "reg-suit run --quiet"
},
今回は public のリポジトリにしている関係上、regconfig.json
に client-id や slack の webhook を記載していないので github のプルリクを作成した際にreg-suit run
のレポートがコメントされるかを検証していない。
ただ、正しく client-id の設定と、github app を登録した上で、CI(circleci など) でreg-suit run --quiet
すると添付のようなレポートをコメントしてくれる
参考: circleci の config.yml
circleci の context に aws のアクセスキーとか登録してる
今回はhotfix|staging|main
ブランチ以外であればビジュアルリグレッションテストを実行するような書き方
orbs:
aws-cli: circleci/aws-cli@1.4.0
executors:
with_browsers:
working_directory: /home/circleci/src/
docker:
- image: circleci/node:14.16.0-browsers
jobs:
visual_regression:
executor: with_browsers
steps:
- checkout
- attach_workspace:
at: .
- run:
name: install jp fonts
command: sudo apt update && sudo apt-get install fonts-ipafont-gothic fonts-ipafont-mincho
- run:
name: screenshots
command: yarn screenshot
- run:
name: regression
command: yarn visual-test
workflows:
run_all:
jobs:
- visual_regression:
name: visual regression testing for stage
context: {circleciのcontext}
requires:
- build
filters:
tags:
ignore: /.*/
branches:
ignore: /(hotfix(\/.+)|staging|main)?/
-
そのうち追記します・・確固たる意思を持って・・ ↩︎
Discussion
のあたりが特に知りたいです!応援します!
ビジュアルリグレッションテストを追記しました!