「Webpack → Vite」載せ替え体験記 - バックエンド統合編【軽量版】

この記事は [グロービス Advent Calendar 2023](https://qiita.com/advent-calendar/2023/globis) の 20 日目の記事です。

私はGLOPLA LMSの開発チームのエンジニアをしています。
今回はWebpackのホットリロードが遅い問題で、フロントエンド開発がやり辛かった課題を解決したお話を簡単に書いてみた記事となります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/206542/ac94f793-ae3f-6cbb-2b9a-475c14db5817.png)

https://glopla.globis.co.jp/

## 読んで欲しい人

→ Webpackのホットリロードが遅すぎる！！
→ Railsなどのバックエンド統合でWebpackからViteへ載せ替えたいパターン
→ Vite 公式読んだけどよくわからん！

Backend Integration - Vite

https://vitejs.dev/guide/backend-integration

# 結果: ホットリロードの反映改善

### 簡単な変更の場合
:::note info
=> **Before 「20秒」 → After 「1秒以下」**
:::

### 複数変更を繰り返した場合
:::note info
=> **Before 「60秒以上」 → After 「1秒以下」**
:::

※ 複数変更を繰り返すと、ビルドとファイルシステムへの書き込みが何度も起きて反映時間が大きくなる状態になっていました。

## やったこと
- WebpackをViteに置き換える（Railsバックエンドとの統合）
- JestをVitestに置き換える

共存部分
- StorybookはWebpackのまま（共存分離）
- モノレポPackages複数でWebpackとVite共存

## 課題: 開発環境のホットリロードの反映が遅い


### 以前の状態イメージ
- 開発環境をDockerで立ち上げる
- Frontコンテナ側でWebpackによるビルド（`webpack serve --mode=development`）
  - `devServer: { devMiddleware: { writeToDisk: true } }`（開発サーバーのビルド結果をファイルシステムに書き出す）
  - `watchOptions: { poll: true }`（ファイルの変更を関ししてホットリロード）
- Railsコンテナ側でビルド結果を用いてReact.jsを動かす

### 以前の状態の課題
- ホットリロードの反映が遅い
- ビルドが遅い

具体的な「遅い」は計測していませんが、開発中は分単位で反映されなかったり
今いつの状態のコードが反映されているか分からなくなっていました。

## ViteのConfigでざっくりイメージ共有

- Webpackで使っていたライブラリをVite版に置き換えていく
- `build: { manifest: true }`でバックエンド統合ビルド設定
- server => Vite Serverを別コンテナのRailsから見える様にする
- test => Jestはそのまま動かないのでVitestに置き換え

```vite.config.js
import path from 'path'

import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  plugins: [
    react(),
    tsconfigPaths(), // tsconfigのエイリアスを使える
  ],
  server: {
    host: '0.0.0.0', // 外部からの接続を許可する
    port: 3000,
    open: './index.tsx',
  },
  build: {
    manifest: true, // バックエンド統合に必須
    rollupOptions: {
      input: { main: './index.tsx' },
    },
    outDir: path.resolve(__filename, '../../../', 'public/packs'),
  },
  test: {
    globals: true,
    include: ['./**/*.spec.ts?(x)'],
    environment: 'jsdom',
    setupFiles: './vitest.setup.ts',
  },
})

```

## Rails Views側のアセット読み込み
- 本番環境はBundleHelperで`javascript_vite_bundle_tag`を作成してタグを作成しています
- 開発環境ではlocalhostでFrontコンテナ側のVite serverを見に行っています
  - `react-refresh`は、ReactのHMR（ホットモジュールリプレースメント）機能を提供する為に実行しています。
  - `RefreshRuntime.injectIntoGlobalHook(window);`で、react-refreshのランタイムがグローバルフックに注入されます。
  - `window.$RefreshReg$` と `window.$RefreshSig$` は、Reactコンポーネントの状態を正しく保持するための設定です。


```index.html.slim
doctype html
html
    head
    body
        #root
        - if Rails.env.production?
          = javascript_vite_bundle_tag 'main'
        - if Rails.env.development?
          script[type="module"]
            | import RefreshRuntime from "http://localhost:3000/@react-refresh";
            | RefreshRuntime.injectIntoGlobalHook(window);
            | window.$RefreshReg$ = () => {};
            | window.$RefreshSig$ = () => (type) => type;
            | window.__vite_plugin_react_preamble_installed__ = true;
          script type="module" src="http://localhost:3000/@vite/client"
          script type="module" src="http://localhost:3000/index.tsx"
```

`javascript_vite_bundle_tag`については、軽量版じゃ無い方の記事でちゃんと書きます。

この時点で開発サーバー自体のホットリロードの速さは既に享受できます。
ここまではサクッといける噂通りですね。

## Jest → Vitest

CI上でテストが落ちました。
どうやらViteではJestがそのまま動いてくれないようです。
「Jestのために設定をいじったりするよりも、Vitest導入した方が早いんじゃない？」
という声を貰い、じゃあやっちゃおう！というノリでVitestも載せ替えました。

### 「なぜViteではそのままJestが動かないのか」

ざっくり回答
**→ViteはESM、JestはCJSで互換性がないから！**

- Viteの前提
  - Viteは、ESM（ECMAScript Modules）ベースで構築されています。
  - ESMはブラウザで直接実行可能なモジュールフォーマットです。
  - Viteは、開発時にはフルビルドプロセスをスキップし、コードを要求されたタイミングでブラウザが理解できる形式でトランスパイルします。
  - これにより、起動時間が大幅に短縮されます。
- Jestの前提
  - Jestは、CommonJSモジュールシステムに基づいて設計されています。
  - これはNode.jsのデフォルトのモジュールフォーマットです。
  - Jestは、テスト実行時にモジュールをトランスパイルするためにBabelなどのツールを使用します。
- 互換性の問題
  - Viteが採用するESMとJestが採用するCommonJSは、互換性に問題を抱えています。これは、モジュールの読み込み方法やフォーマットが異なるためです。
  - ViteのプロジェクトでJestをそのまま実行しようとすると、モジュール解決やトランスパイルの過程で問題が発生する可能性があります。 

### Vitestの設定

`vitest.config.js`を作って、`vite.config.js`とmergeするやり方もあります。
こちらの方が関心の分離が出来て良いかも知れません。

```vitest.config.js
import { defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from './vite.config'

export default mergeConfig(viteConfig, defineConfig({
  test: {
    exclude: ['packages/template/*'],
  },
}))
```

詳しくは公式を参考にしてください

https://vitest.dev/config/


今回、私は先程の`vite.config.js`の中に書いています。

```vite.config.js
import path from 'path'

import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  plugins: [
    react(),
    tsconfigPaths(), // tsconfigのエイリアスを使える
  ],
  server: {
    host: '0.0.0.0', // 外部からの接続を許可する
    port: 3000,
    open: './index.tsx',
  },
  build: {
    manifest: true, // バックエンド統合に必須
    rollupOptions: {
      input: { main: './index.tsx' },
    },
    outDir: path.resolve(__filename, '../../../', 'public/packs'),
  },
  test: {
    globals: true,
    include: ['./**/*.spec.ts?(x)'],
    environment: 'jsdom',
    setupFiles: './vitest.setup.ts',
  },
})

```

### VitestのsetupFilesの活用

こんな感じでvitest実行時に毎回行いたい共通処理系をまとめてみました。
主に、Apollo ClientのキャッシュとMSWのキャッシュ周りです。
window系はMSWとVitestで相対パスの解決が出来なかったので、テスト実行時の絶対パスを指定しています。

```vitest.setup.ts
import matchers from '@testing-library/jest-dom/matchers'
import { setGlobalOrigin } from 'undici'

import { client } from 'src/graphql/client'
import { server } from 'src/mock-serve'

// NOTE: viteにtesting-library/jest-domの拡張マッチャーを読み込ませる
expect.extend(matchers)

const { getComputedStyle } = window

beforeAll(() => {
  server.listen({ onUnhandledRequest: 'error' })
  // NOTE: VitestのjsdomのgetComputedStyleが未実装のため、window.getComputedStyleを設定して模倣する
  window.getComputedStyle = elt => getComputedStyle(elt)
})

beforeEach(async () => {
  // NOTE: MSWが相対パスを解決出来ないため、window.location.hrefを設定する
  setGlobalOrigin(window.location.href)
  // NOTE: Apolloが以前の結果を返すので、毎回cacheをクリアする
  await client.cache.reset()
})

afterAll(() => server.close())

afterEach(() => {
  vi.clearAllMocks()
  server.resetHandlers()
})
```

### Vitestの書き方の違い

モッキングの違い
`react-router-dom`のMocking例

```diff_javascript
- jest.mock('react-router-dom', () => ({
-   useNavigate: () => mockNavigate,
- }))
+ vi.mock('react-router-dom', async () => {
+   const actual = await vi.importActual('react-router-dom')
+   return {
+     ...actual,
+     useNavigate: () => mockNavigate,
+   }
+ })
```

...書こうと思ったんですが、そんなに違いなかったです。

余談ですが、`yarn vitest`は`yarn vitest watch`と同じなので
一度の実行をしたければ`yarn vitest run`を行ってください。

# 採用情報
> グロービス = みんなカチッとしたスーツを着ているお堅い企業（※僕の入社前のイメージです）と思われがちですが、比較的自由にやらせてもらっています。全方位で募集しているので、興味のある方はぜひ :muscle:

by @nabeen

https://recruiting-tech-globis.wraptas.site/

https://speakerdeck.com/globis_gdp/introduction-to-glopla-lms-globis-saas-for-engineers

## 追記（自分の言葉）

プロダクトの立ち上げフェーズを少し超えた辺りで入社して、そこから約2年。新しいプロダクトを作る模索期から売るためのフェーズに入りました。求められることも代わり、チームの人数や形もこの2年の間に全く違うものに変化しています。

### グロービス・ウェイ

:::note info
- ビジネスを通しての社会貢献
- 自己実現の場の提供
- 理想的な企業システムの実現
:::

### グロービスデジタルプラットフォーム・ミッション

:::note info
学びの未来をつくり出し、人の可能性を広げていく
:::

### GLOPLA事業開発室・ミッション

:::note info
人の可能性を解放し、イキイキ働く人を増やす
:::

このミッションに共感する方はぜひお気軽にお話しましょう！