Vue CLI で作成した Vuetify アプリを Vite / Vitest に移行する

[Vuetify 2](https://github.com/vuetifyjs/vuetify) と [@vue/composition-api](https://github.com/vuejs/composition-api) を使用した TODO アプリを題材に [Vue CLI](https://cli.vuejs.org/) から [Vite](https://vitejs.dev/) / [Vitest](https://vitest.dev/) への移行を試して、とりあえず動くようになったので手順と設定を共有したいと思います。

ビルド速度やサイズの変化についてはこの記事の後ろにまとめてあります。
使用したコードは次のリポジトリにあります。

https://github.com/sg0hsmt/vuetify-todo-example

なお、この記事に記載している情報は 2022 年 1 月 30 日時点のものです。
記事の内容はあくまで一例であり、手順や設定が正しいものであるという保証はありませんのでご注意ください。

## 移行を試した背景

2022 年 1 月 18 日に Vue CLI がメンテナンスモードに入ったことが宣言されました。 [^1]
Vue CLI 自体は引き続き利用可能で v5 安定版リリースに向けて作業が続けられています。 [^2]

[^1]: https://github.com/vuejs/vue-cli/commit/7f3d51133635114528848b29e27084ee89d53e1c
[^2]: https://github.com/vuejs/vue-cli/issues/6064#issuecomment-1016473985

とはいえ今後の主流は Vite になると思われますので Vue CLI で作成したアプリを移行できるか試してみました。

テストフレームワークに Vitest を選んだのは趣味です。
Vitest は鋭意開発中であり本番利用は推奨されていません。
しかし Vite と組み合わせて Vue (SFC) を使う前提だとあまり選択肢はないような気もします。

## 初期バージョン

Vue CLI によるプロジェクト作成時の設定は次のような感じです。

```console
$ vue create vuetify-todo-example

Vue CLI v4.5.15
? Please pick a preset: Manually select features
? Check the features needed for your project: Choose Vue version, Babel, TS, Router, Linter
? Choose a version of Vue.js that you want to start the project with 2.x
? Use class-style component syntax? No
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? Yes
? Use history mode for router? (Requires proper server setup for index fallback in production) Yes
? Pick a linter / formatter config: Prettier
? Pick additional lint features: Lint on save
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? No
```

これに Vuetify と @vue/composition-api を追加して TODO アプリの体裁を整えた [v0.1.0](https://github.com/sg0hsmt/vuetify-todo-example/tree/v0.1.0) が初期バージョンです。
vue-router も導入していますがルートは 1 個だけなので導入しているだけで意味はありません。

アプリの設計が微妙とかは見逃してください。

## 事前準備

事前準備として依存パッケージをできる限り更新しておきました。
最終的に Vuetify [^3] と Vue CLI [^4] の依存関係で更新できないパッケージがいくつかありました。

- vuetify
  - sass
- vue-cli
  - webpack
    - sass-loader
  - eslint
    - eslint-plugin-prettier
    - @vue/eslint-config-prettier

[^3]: https://github.com/vuetifyjs/vuetify/issues/13694
[^4]: https://github.com/vuejs/vue-cli/issues/6064

ESLint 本体は更新できませんでしたがプラグインやルールが更新された結果、新しくエラーが出るようになったので修正しています。
[@typescript-eslint/no-loss-of-precision](https://eslint.org/docs/rules/no-loss-of-precision) だけは ESLint v7.1.0 以上を必要とするため無効にしています。

このタイミングでテストを書いていないことに気が付いて Jest でテストを書きました。
テストの書き方が微妙とかは見逃してください。

ついでに GitHub Actions で GitHub Pages にデプロイするようにしたり不具合を修正したりした [v0.2.2](https://github.com/sg0hsmt/vuetify-todo-example/tree/v0.2.2) が移行前のバージョンになります。

## Vue CLI から Vite / Vitest に移行する

いよいよ Vite / Vitest への移行です。

### 不要になるパッケージの削除

移行する前に `dependencies` と `devDependencies` から不要になるパッケージを削除します。
vite / vitest への置き換え対象となる vue-cli / babel / webpack / jest の関連のパッケージが主な削除対象です。

```console
npm uninstall core-js @types/jest @vue/cli-plugin-babel @vue/cli-plugin-eslint @vue/cli-plugin-router @vue/cli-plugin-typescript @vue/cli-plugin-unit-jest @vue/cli-service sass-loader vue-cli-plugin-vuetify vuetify-loader
```

- core-js
- @types/jest
- @vue/cli-plugin-babel
- @vue/cli-plugin-eslint
- @vue/cli-plugin-router
- @vue/cli-plugin-typescript
- @vue/cli-plugin-unit-jest
- @vue/cli-service
- sass-loader
- vue-cli-plugin-vuetify
- vuetify-loader

続いて不要になる設定ファイルも削除します。
トランスパイルは [esbuild](https://esbuild.github.io/) に頼る形になる [^5] ため [Browserslist](https://github.com/browserslist/browserslist) の設定も削除しています。

[^5]: https://vitejs.dev/config/#build-target

- `.browserslistrc`
- `babel.config.js`
- `jest.config.js`
- `vue.config.js`

ただ、記事を書いている途中で気が付いたのですが後の手順で入れる Vite プラグインの依存関係で babel や Browserslist が再導入されているようでした。

### Vite / Vitest のインストール

続いて Vite / Vitest をインストールします。
Vue 2 + Vuetify を動かすために追加のプラグインもインストールしています。

```console
npm install --save-dev vite vue-tsc vite-plugin-vue2 unplugin-vue-components vitest jsdom
```

[vue-tsc](https://github.com/johnsoncodehk/volar) はプラグインではないのですが vite で Vue の型チェックをするために必要なツールです。

[vite-plugin-vue2](https://github.com/underfin/vite-plugin-vue2) は vite で Vue 2 をサポートするプラグインです。
非公式プラグインですが [公式ドキュメントで紹介されています。](https://ja.vitejs.dev/guide/features.html#vue)

[unplugin-vue-components](https://github.com/antfu/unplugin-vue-components) は SFC でのコンポーネント import を自動的に行ってくれるプラグインです。
非公式プラグインですが Vue 周りで多くの貢献をしている方が開発しています。
vuetify-loader (Vuetify 2.x 用) は Vite に対応していない [^6] ため代わりに使用しています。

[^6]: https://github.com/vuetifyjs/vuetify/issues/13229

[jsdom](https://github.com/jsdom/jsdom) はテスト用に DOM をエミュレートしてくれるパッケージです。
Vue CLI で Jest を設定した場合は導入済みかもしれません。
Vitest では [happy-dom](https://github.com/capricorn86/happy-dom) という実装が推奨っぽい感じなのですが実際にテストを動かしたところエラー [^7] が出たため jsdom を使用しています。

[^7]: https://github.com/vuejs/vue-test-utils/issues/1886

### 設定と修正

Vite / Vitest で動かすために修正と追加が必要です。

プロジェクトルートに `vite.config.ts` を用意します。
設定の説明は日本語のコメントで記載しておきました。

```ts
// vite.config.ts
/// <reference types="vitest" />
import { defineConfig } from "vite";
import { createVuePlugin } from "vite-plugin-vue2";
import Components from "unplugin-vue-components/vite";
import { VuetifyResolver } from "unplugin-vue-components/resolvers";
import path from "path";

// https://vitejs.dev/config/
export default defineConfig({
  // デプロイ時と serve で動かした際のパスを変更しています。
  base: process.env.NODE_ENV === "production" ? "/vuetify-todo-example/" : "/",
  resolve: {
    // Vite 単体だと無くても問題ないのですが Vitest を動かした際に動かないため追加します。
    // https://vitejs.dev/config/#resolve-alias
    alias: [
      {
        find: "@/",
        replacement: `${path.resolve(__dirname, "./src")}/`,
      },
    ],
  },
  // 大量の警告が出力されるため一部のルールを無効化しています。
  // https://github.com/vitejs/vite/issues/6333#issuecomment-1003318603
  css: {
    postcss: {
      plugins: [
        {
          postcssPlugin: "internal:charset-removal",
          AtRule: {
            charset: (atRule) => {
              if (atRule.name === "charset") {
                atRule.remove();
              }
            },
          },
        },
      ],
    },
  },
  plugins: [
    // Vite で Vue 2 を動かすために必要なプラグインです。
    // https://github.com/underfin/vite-plugin-vue2
    createVuePlugin(),

    // SFC でのコンポーネント import を自動的に行ってくれるプラグインです。
    // https://github.com/antfu/unplugin-vue-components
    Components({
      // Vuetify コンポーネントを import するための設定です。
      resolvers: [VuetifyResolver()],
      // プロジェクトルートに型定義ファイルを出力してくれるのですが
      // 型チェックのエラーを解消できなかったため無効化しています。
      dts: false,
    }),
  ],
  test: {
    // describe や it などを import なしで使えるようにします。
    globals: true,
    // happy-dom だとエラーが出るため jsdom を指定します。
    environment: "jsdom",
    // テストの初期化コードです。
    // Jest 向けに作成したものと同じです。
    setupFiles: ["tests/setup.ts"],
    // devtools 周りのコンソールメッセージが多数出力されるため抑制しています。
    silent: true,
    // Vuetify は Node で ESM として読み込めないため変換が必要な依存関係として指定します。
    deps: {
      inline: ["vuetify"],
    },
  },
});
```

そして `public/index.html` をプロジェクトルートに移動します。
Vite では public ディレクトリがそのままデプロイされるため外に出す必要があります。

```console
mv public/index.html ./index.html
```

プロジェクトルートに移動した `index.html` を書き換えます。
具体的には webpack で置換していた部分と body 内部です。

書き換えたらいよいよ起動です。

```console
npx vite
```

おそらくブラウザには真っ白な画面が出ているはずです。
開発者コンソールを確認すると例外が出ており、クリックすると vue-router の初期化処理で `process.env.BASE_URL` の参照に失敗していることがわかります。

```
Uncaught ReferenceError: process is not defined
    at index.ts:17:9
```

Vite では環境変数の公開方法が変更されているため [ドキュメント](https://ja.vitejs.dev/guide/env-and-mode.html) に記載されている `env.d.ts` を追加したのち、ソースコード内の `process.env` に対する参照を修正します。
(`vite.config.ts` 内で行っている参照はそのままで問題ありません)

最後に `package.json` の `scripts` を Vue CLI を使わない形に修正します。
新しく追加した行もありますが Vue CLI が裏で色々やってくれていたことがよく分かります。

```json
  "scripts": {
    "serve": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "preview": "vite preview",
    "typecheck": "vue-tsc --noEmit",
    "test:unit": "vitest run",
    "lint": "eslint \"./src/**/*.{ts,tsx,vue}\" \"./tests/**/*.ts\" && prettier --check \"./src/**/*.{ts,tsx,vue}\" \"./tests/**/*.ts\"",
    "lint:fix": "eslint \"./src/**/*.{ts,tsx,vue}\" \"./tests/**/*.ts\" --fix && prettier --write \"./src/**/*.{ts,tsx,vue}\" \"./tests/**/*.ts\""
  },
```

ここまでの変更は [1 コミット](https://github.com/sg0hsmt/vuetify-todo-example/commit/03498fa2e3eefabdd40d47d90ac0531fffc28242) で行いました。

## 依存パッケージの更新 (2 回目)

最後に依存パッケージを更新します。

ESLint を更新するとルール名が変更されていたり、ルールが追加されていることがあるので対応します。
自動修正可能なものについては `npm run lint:fix` で対応可能ですが、ルール名も変更されていたので `.eslintrc.js` の修正が必要でした。

依存パッケージ更新やそれにともなう Lint エラーを修正したのが [v0.3.0](https://github.com/sg0hsmt/vuetify-todo-example/tree/v0.3.0) です。

## 移行前後の比較

移行前 ([v0.2.2](https://github.com/sg0hsmt/vuetify-todo-example/tree/v0.2.2)) と移行後 ([v0.3.0](https://github.com/sg0hsmt/vuetify-todo-example/tree/v0.3.0)) の比較です。

処理時間については GitHub Actions 上で実行された 1 回の結果なので精度が高いものではありません。
処理内容も同じではないのであくまで参考値としてください。

### 依存パッケージ数

依存パッケージ数は大幅に減少しました。

|                       | Before  | After  |
| --------------------- | ------- | ------ |
| 依存パッケージ数      | 1751 個 | 439 個 |
| 脆弱性件数 (high)     | 12 件   | 0 件   |
| 脆弱性件数 (moderate) | 87 件   | 3 件   |
| `npm ci` に要した時間 | 37 秒   | 13 秒  |

### ビルドなどの処理時間

ビルドは速くなりましたがテストは大幅に遅くなりました。

|        | Before | After |
| ------ | ------ | ----- |
| Lint   | 4 秒   | 5 秒  |
| テスト | 6 秒   | 22 秒 |
| ビルド | 27 秒  | 21 秒 |

実際の動作を見ていると Vitest 起動時に毎回 Vuetify を変換しており、そこで時間がかかっているようです。
`npm run serve` した場合も Vite 自体はすぐに起動するのですが Vite 起動後の初回アクセスで変換が行われ、毎回待たされます。

### ビルド成果物のサイズ

JavaScript は大幅に小さくなった一方、 CSS は大幅に大きくなりました。

|                   | Before     | After      |
| ----------------- | ---------- | ---------- |
| JavaScript (合計) | 325.29 KiB | 215.58 KiB |
| CSS               | 351.38 KiB | 515.47 KiB |

JavaScript についてはターゲットブラウザを厳密に合わせていないため難しいところですが想像していたより小さくなりました。
CSS については設定が不十分なために Tree Shaking されていないと思われます。

## まとめ

Vue CLI で作成した Vuetify アプリを Vite / Vitest に移行できました。
残念ながら世間一般で言われているような高速化は確認できませんでしたが規模が大きなプロジェクトであれば確認できそうな気配はあります。

前述のとおり Vue CLI 自体は引き続き利用可能で v5 安定版リリースに向けて作業が続けられており、 Vitest も鋭意開発中であり本番利用は推奨されていない状態です。
Vuetify 自体も Vue 3 および Vite に対応した Vuetify 3 が計画されていますのでそれを待ってもよいと思います。 [^8]

[^8]: https://vuetifyjs.com/en/introduction/roadmap/

それでも今後主流になりそうな環境ということで移行を試す価値はあると思いました。