ノールックマージアプリをリファクタリングしてみる

この記事は[桜花極彩大蛇斬 - 毎日誰かのプルリクをレビュー無しでマージする Advent Calendar 2023](https://qiita.com/advent-calendar/2023/dena-orochi)の18日目の記事です。

こんにちは。mizuki_rです。
一応フロントエンドエンジニアではありますが、ここ1年はマネジメント業にかかりっきりであまりコードのコミットがありません。

さーたいへんだ、キャッチアップするぞい！
てな感じで、この記事では新しいプロジェクトに参加した新人の気持ちでコードを追いつつコミットもしていきたいと思います。

※長々と書いてますがほとんどワークログ的に作業しながら書いたものです。読みにくさはあしからず。

# キャッチアップ

まずはリポジトリを自分のリポジトリにForkしてcloneしてきます。

```
git clone https://github.com/rymizuki/orochi-noreview-app.git
```

### 依存モジュールを確認
まずはpackage.jsonを確認してどんな依存モジュールがあるかをチェックします。

```
> cat package.json
{
  "name": "orochi-noreview-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "prepare": "panda codegen",
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "format": "yarn format:check --write",
    "format:check": "prettier './**/*.{js,jsx,ts,tsx,json}' --check",
    "test": "vitest"
  },
  "dependencies": {
    "next": "14.0.3",
    "react": "^18",
    "react-dom": "^18",
    "react-vfx": "^0.5.0",
    "use-konami": "^1.0.1"
  },
  "devDependencies": {
    "@pandacss/dev": "^0.20.1",
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "eslint": "^8",
    "eslint-config-next": "14.0.3",
    "eslint-config-prettier": "^9.0.0",
    "prettier": "^3.1.0",
    "react-py": "^1.10.6",
    "typescript": "^5",
    "vitest": "^1.0.4"
  }
}
```

なるほど。特徴的なのは以下ですね。最近のトレンドを詰め込んでるのはGoodです。
- Next.js v14
- panda css
- vitest

一部気になる依存がありますが、ぜひ触って確かめて頂きたいところです。

### ディレクトリ構造の確認

次はディレクトリ構造を追っていきたいと思います。

```
> tree -L 2
.
├── Dockerfile
├── README.md
├── compose.yml
├── next.config.js
├── package.json
├── panda.config.ts
├── postcss.config.cjs
├── public
│   ├── 1f40d_apple.png
│   ├── 1f40d_google.png
│   ├── 1f40d_meta.png
│   ├── 1f40d_microsoft.png
│   ├── 1f40d_microsoft_prev.png
│   ├── 1f40d_openmoji.png
│   ├── 1f40d_samsung.png
│   ├── 1f40d_twitter.png
│   ├── 1f40d_twitter_prev.png
│   ├── DSEG7ClassicMini-BoldItalic.woff
│   ├── chanabe.png
│   ├── next.svg
│   ├── nice-orochi.jpg
│   └── vercel.svg
├── src
│   ├── app
│   ├── components
│   ├── functions
│   └── utils
├── styled-system
│   ├── css
│   ├── global.css
│   ├── helpers.mjs
│   ├── patterns
│   ├── reset.css
│   ├── tokens
│   └── types
├── tsconfig.json
├── vitest.config.ts
└── yarn.lock

12 directories, 27 files
```

`public`, `src/app` はNextの既定ディレクトリですね。早速App Routerを使っている。
`styled-system` はpanda cssのシステムディレクトリです。

`Dockerfile`, `compose.yml` があることにもポイントです。
フロントエンド環境でDockerが使われるケースは少ない（個人的な観測）ですが、dbなどのエコシステムと併用する場合など利用されるケースもあります。

### 動作環境の確認

`app` ディレクトリも気になるところですが、ひとまず実行環境の確認から入りましょう。
```
> cat compose.yml

version: "3.9"
services:
  orochi-app:
    build:
      context: ./
      dockerfile: Dockerfile
      target: development
    ports:
      - "${OROCHI_PORT}:3000"
    volumes:
      - type: bind
        source: ./
        target: /app/src
      - type: volume
        source: app-node_modules
        target: /app/src/node_modules
      - type: volume
        source: app-next
        target: /app/src/.next
    tty: true
    stdin_open: true
    init: true
volumes:
  app-node_modules:
  app-next:
```

どうやらほんとにnodeの実行環境としてのみ利用しているようですね。

dockerを使って環境を動かすのは主に以下のようなメリットがあります。
- 環境を汚さない
- 他のネットワーク・システムと連携がしやすい
- 環境毎の差異を作りにくい

### 勝手にnodenvでホスト環境で動作させる

**でも僕はそれを無視して勝手に[nodenv](https://github.com/nodenv/nodenv)で実行しようと思います。**

今回であればNext.jsのサーバ一つだけであり、僕の環境は最低限、フロントエンド開発が可能なアセットが揃っているので特段汚すことなく動かすことができそうです。
また、dockerを使わないメリットして以下のようなものがあります。
- 立ち上がりが早い
- 手間が少ない
- vscodeでcontainerの中を見に行くのようなステップを踏まなくて良い

なので、シンプルなフロントエンド開発ではホスト環境上での動作が好まれることが多いと思っています。


それでは、環境をnodenvにして動かしてみましょうか。
すでにインストール済みなのでバージョンを設定するだけですが、すでに定義されているDockerfileのバージョンと揃えましょうか。

```
> nodenv local `head -n1 Dockerfile | gawk 'match($0, /node:([0-9\.]+)/, a){ print a[1]}'`
> node -v
v20.10.0
```

これで実行できそうです。
問題なく動くか試していきましょう。

### プロジェクトを実行する

`yarn.lock`ファイルがあることから変わる通り、まず`yarn` をインストールしてからプロジェクトをビルドしていきます。

```
> npm install -g yarn

> yarn install
// 略

> yarn build
yarn run v1.22.21
$ next build
   ▲ Next.js 14.0.3

🐼 info [hrtime] Extracted in (30.92ms)
 ✓ Creating an optimized production build
 ✓ Compiled successfully
 ✓ Linting and checking validity of types
 ✓ Collecting page data
 ✓ Generating static pages (8/8)
 ✓ Finalizing page optimization

Route (app)                              Size     First Load JS
┌ ○ /                                    164 kB          253 kB
├ ○ /_not-found                          870 B          85.2 kB
├ ○ /block                               2.1 kB         91.4 kB
├ ○ /bomb                                3.32 kB        92.6 kB
└ ○ /rsa                                 7.78 kB        97.1 kB
+ First Load JS shared by all            84.3 kB
  ├ chunks/472-d251cf82407c5381.js       28.9 kB
  ├ chunks/fd9d1056-8a2aa66ad577490e.js  53.3 kB
  ├ chunks/main-app-39a80593f534570b.js  219 B
  └ chunks/webpack-e009cc337c5306f8.js   1.96 kB


○  (Static)  prerendered as static content

✨  Done in 9.54s.
```

問題なくビルドできましたね。
ではローカル環境で実行します。

```
> yarn dev
yarn run v1.22.21
$ next dev
   ▲ Next.js 14.0.3
   - Local:        http://localhost:3000

 ✓ Ready in 2.3s
```

`localhost:3000` で動作しました。
`:3000`のポートはよく利用されるので、うっかり別のプロジェクトでポートを使ってないか確認しておくと良いです。

```
> open localhost:3000
```

ページが開くことが確認できました。
いや全く、Next.jsやpanda cssといったモダンライブラリで動いているとは思えないページですね。
~~全部バニラで書き直してやろうかしら~~

さて、時間も限られているのでサクサクプロジェクトの中身を見ていきましょうか。


### ページ構造を把握する

まずはルーティングをおさらいします。
Next.js v14から入ったApp Routerを利用しているため、ルーティング用のファイルは `src/app` 以下に配置されています。
`next.config.js` を見る限り特別カスタマイズもされていないので標準的な構成と言えるでしょう。

```
> tree src/app
src/app
├── (bg)
│   ├── block
│   │   └── page.tsx
│   ├── bomb
│   │   └── page.tsx
│   ├── layout.tsx
│   ├── page.module.css
│   ├── page.tsx
│   └── rsa
│       └── page.tsx
├── favicon.ico
├── globals.css
├── layout.tsx
└── libs
    └── rsa.py.ts

6 directories, 10 files
```

このサイトは以下のようなページ構成になっています。

```
/ ... page.tsx
├── RSA暗号を作ってみよう！ ... rsa/page.tsx
├── マインスイーパー ... bomb/page.tsx
└── ブロック崩し ... block/page.tsx
```

さて、だいたいのサイトストラクチャーを確認しましたが、こんなシンプルかつ少数の機能でないことはページを開いた瞬間にわかっています。今も蛇と人との戦いが続いていますね。頑張れ人間。

なので続いてはコンポーネントの構成を見ていきましょう。


### コンポーネントの構成を確認する

```
> tree src/components
src/components
├── ascii-art
│   ├── ascii-art.tsx
│   └── index.ts
├── background
│   ├── background.tsx
│   └── index.ts
├── index.ts
├── mine-sweeper
│   ├── block.tsx
│   ├── electronic-sign.tsx
│   ├── get-init-blocks.ts
│   ├── index.ts
│   ├── mine-sweeper.tsx
│   ├── open-blocks.ts
│   └── smily.tsx
└── mouse-stalker
    ├── index.ts
    └── mouse-stalker.tsx
```

...思ったより多いぞ。

どこで使ってるんだよ`ascii-art.tsx`...  ぜひ探してみてくださいね。

ぼく「`background.tsx` いやいや、CSSで良いでしょ。DOMいる？」
ファイルを開く
ぼく「完全に理解した」
――遊び心って大事ですよね。

`mouse-stalker` もこれコンポーネントである必要あまりなさそう。

# 目標を定める

一通り実装とコードの環境をを確認しました。
僕の趣味としてはDSLを作ったり、モジュール化したりのリファクタリングがぜひともやりたいところです。

その観点で見てみると、このプロジェクトは割りと共通性みたいなものがなく、自由度が非常に高い。
その一方で、コピペされているであろう処理、共通であるべきだが仕組みがないため個別に実装されている処理など、あるあるな課題が見当たります。

2-3時間と限られた時間でのアウトプットが求められるので、手早くリファクタリングをできる範囲で行うことを目標としようと思います。


それでは、ターゲットを以下に定めます。
- ページ構造など汎用的なパターンをテンプレート化する
- app/*.tsxからなるべく生生しい実装を削除する
- ぱっと見のコード量を減らす

やっていきましょう。

## `app/(bg)/pages.tsx` のリファクタリング

コミット前時点でのオリジナルのコードは以下の内容です。

```tsx
'use client'
import Image from 'next/image'
import styles from './page.module.css'
import * as VFX from 'react-vfx'
import { MouseStalker } from '@/components'
import { useState, useEffect } from 'react'
import useKonami from 'use-konami'

const shine = `
    precision mediump float;
    uniform vec2 resolution;
    uniform vec2 offset;
    uniform float time;
    uniform sampler2D src;

    void main (void) {
        vec2 uv = (gl_FragCoord.xy - offset) / resolution;

        vec2 p = uv * 2. - 1.;
        float a = atan(p.y, p.x);

        vec4 col = texture2D(src, uv);

        float level = 1. + sin(a * 10. + time * 3.) * 0.5;

        gl_FragColor = vec4(2, .3, .6, col.a) * level;
    }`
export default function Home() {
  const [title, setTitle] = useState('桜花極彩大蛇斬')
  const [accessCount, setAccessCount] = useState(0)

  useEffect(() => {
    // アクセス数表示を偽造するため、適当な4桁の数字を生成
    // 現在のUNIX時間（エポック秒）を取得し、10秒単位に丸める
    const intNow = Math.floor(Date.now() / 10000)
    const strNow = intNow.toString()
    // 残りから下4桁を取得
    const count = parseInt(strNow.substring(strNow.length - 4))
    setAccessCount(count)
  }, [])

  useKonami({
    onUnlock: () => {
      setTitle('ちゃなべ')
    },
  })
  return (
    <main className={styles.main}>
      <MouseStalker />
      <div className={styles.center}>
        <div>
          <VFX.VFXProvider>
            <VFX.VFXSpan
              shader={shine}
              style={{
                fontSize: '100px',
                fontWeight: 'bold',
              }}
            >
              {title}
            </VFX.VFXSpan>
          </VFX.VFXProvider>
        </div>
      </div>

      <div>
        <a href="rsa" className={styles.card}>
          <h2>
            RSA暗号を作ってみよう！<span>-&gt;</span>
          </h2>
        </a>
      </div>
      <div>
        <a href="bomb" className={styles.card}>
          <h2>
            マインスイーパー<span>-&gt;</span>
          </h2>
        </a>
      </div>
      <div>
        <a href="block" className={styles.card}>
          <h2>
            ブロック崩し<span>-&gt;</span>
          </h2>
        </a>
      </div>

      <div>
        <p className={styles.accessCount}>
          ⭐️⭐️⭐️あなたは<span>{accessCount}</span>人目の訪問者です！⭐️⭐️⭐️
        </p>
      </div>
    </main>
  )
}
```
https://github.com/ayumu-1212/orochi-noreview-app/blob/main/src/app/(bg)/page.tsx


生々しいHTML構造が見て取れますが、一方で実際このコードから何をやってくれているのか、意図が見えにくくなっています。
ページ情報を構造化して、コンポーネントやhooksに切り出していきましょう。

1. MouseStalkerをhooksへ
1. accsessCountをコンポーネントへ
1. ロゴをコンポーネントへ
1. メニューをコンポーネントへ

...

ここまで削ることができました。

```tsx
'use client'
import { AccessCount, Hero, Menu } from '@/components'
import { useMouseStalker } from '@/hooks/mouse-stalker'
import styles from './page.module.css'

export default function Home() {
  useMouseStalker()

  return (
    <main className={styles.main}>
      <div className={styles.center}>
        <Hero />
      </div>

      <div>
        <Menu
          items={[
            { href: 'rsa', label: 'RSA暗号を作ってみよう！' },
            { href: 'bomb', label: 'マインスイーパー' },
            { href: 'block', label: 'ブロック崩し' },
          ]}
        />
      </div>

      <div>
        <AccessCount />
      </div>
    </main>
  )
}
```

https://github.com/ayumu-1212/orochi-noreview-app/blob/2715bf61282e2e9e9d85f9b1082fb099ed8fa08b/src/app/(bg)/page.tsx

構造的にだいぶわかりやすくなりましたし、視覚的なイメージにも即している状態になりました。

## `app/(bg)/**/page.tsx` のリファクタリング

ここは結構厄介です。似たような構造を持っているように見えて実はちょっとずつ違う。
まあ、レビューされないらしいのでえいやって直しちゃいましょう。

...

以下はブロック崩しのページのリファクタリング結果です。
```tsx
'use client'
import { BlockBreaker } from '@/components/block-breaker/block-breaker'
import { Body, Header, Template } from '@/components/templates/entry'

/*
誰かが完成させてくれることを願う
*/

export default function Home() {
  return (
    <Template>
      <Header>ブロック崩し</Header>
      <Body>
        <BlockBreaker />
      </Body>
    </Template>
  )
}
```
https://github.com/ayumu-1212/orochi-noreview-app/blob/a41e413898e28cf58e4d6b92a303b4c2aba1c1d1/src/app/(bg)/block/page.tsx

しれっとブロック崩しの本体ロジックをコンポーネントに切り出して、記事エントリ用のTemplateでページ自体の構造を隠蔽しました。これで他のページとフォーマットを揃えることができます。

## 調子にのってトップページからもhtmlタグを消し去る
htmlタグが見えてないと気持ちいいですね←

```tsx
'use client'
import { AccessCount, Hero, Menu } from '@/components'
import {
  Body,
  Template,
  Hero as TemplateHero,
} from '@/components/templates/root'
import { useMouseStalker } from '@/hooks/mouse-stalker'

export default function Home() {
  useMouseStalker()

  return (
    <Template>
      <TemplateHero>
        <Hero />
      </TemplateHero>

      <Body>
        <Menu
          items={[
            { href: 'rsa', label: 'RSA暗号を作ってみよう！' },
            { href: 'bomb', label: 'マインスイーパー' },
            { href: 'block', label: 'ブロック崩し' },
          ]}
        />
        <AccessCount />
      </Body>
    </Template>
  )
}
```

# むすび

――というわけで、お遊び企画ではありますが割りと真剣（）にリファクタリングを行いました。
個人的には仕事終わりにこんな取り組みはもう二度としたくないですね。だって仕事でやってるんだもの。

今回は特に面白みのないキャッチアップとリファクタリングを記事にしてみました。
正直いえばもっと深くキャッチアップしていくべきなんですが、限られた時間となるとなかなか難しい。
継続的に記事を各練習をしておくとこのあたりは改善していくと思いますが、記事書くの何年ぶりでしょうね...

あとは、コンポーネントを隠蔽してリファクタリングゥ〜なんて言ってますが、合意形成の無いDSLは混乱を生むだけなので、マージする前にしっかり確認と合意を取るのが吉です。
3ヶ月ぐらいで自分が後悔するので、しっかりレビューを受けましょうね。

明日は @sotabkw さんです！
なにがマージされるのか楽しみです。

# 追記 12/18

一番大事なものをわすれていた。
以下が提出したPRです！
https://github.com/ayumu-1212/orochi-noreview-app/pull/20