【Vue 2.x】Vue CLI環境にViteを導入して開発ビルドを爆速にする

[2021/02 に Vite 2.0 正式版がリリース](https://dev.to/yyx990803/announcing-vite-2-0-2f0a)され、開発サーバーの起動や HMR (Hot Module Replacement) の動作が軽快なフロントエンド開発環境を手軽に構築できるようになってきました。

これとほぼ同時期に、既存の Vue 2.x + [Vue CLI](https://cli.vuejs.org/) 環境に Vite 環境を同居させ、開発ビルドにだけ Vite を活用する [`vue-cli-plugin-vite`](https://www.npmjs.com/package/vue-cli-plugin-vite) がリリースされました。このプラグインは「コードベースの変更なしに」を謳っていて、Vue CLI 向けの設定ファイル `vue.config.js` を Vite 向けの設定に変換[^1]することで Vite 開発サーバーを起動させる機能を持っています。
実際に Vue CLI 環境へ導入してみたところ、「コードベースの変更なしに」は概ね正しかったものの `vue.config.js` などに修正が必要でした。

[^1]: どのオプションが変換の対象となるかについては [README.md](https://github.com/IndexXuan/vue-cli-plugin-vite/blob/main/README.md) を参照

設定に若干難しさはあったものの、**開発サーバーの起動時間が 3 秒程度になって HMR の動作も大幅に改善されました**。
本記事では、設定ファイルの修正方法や Vite 開発サーバーにおける注意点などを共有します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/137045/ab7af70e-f98f-a540-2a54-8019464989dc.png)

# 動作確認環境
- macOS Catalina
- Chrome 89
- Node.js 14
- npm 6
- Vue CLI v4.5.11
    - `vue` 2.6.12
    - `vue-cli-plugin-vite` 0.3.2
    - `typescript` 3.9.9
    - `sass` (dart-sass) 1.32.8
    - `sass-loader` 8.0.2

# プラグインの導入
Vue 2.x + Vue CLI 環境のディレクトリ上で `vue add vite` または `npx @vue/cli add vite` を実行してプラグインを導入します。

プラグインを導入すると、`package.json` に `npm run vite` のタスクが追加され、 `bin/vite` という[シェバン](https://ja.wikipedia.org/wiki/%E3%82%B7%E3%83%90%E3%83%B3_(Unix))付きの JavaScript ファイルが作成されます。

```json:package.json
{
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "vite": "node ./bin/vite"
  }
}
```

# 各種設定
## 開発サーバー立ち上げ時にブラウザを起動しないようにする設定
環境変数 `BROWSER=none` をセットします:

```json:package.json
{
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "vite": "BROWSER=none node ./bin/vite"
  }
}
```

## テンプレート上での空白の取り扱いの設定
Vue CLI 環境では `whitespace` のデフォルト設定が `condense` でしたが、Vite 環境では `preserve` になっていました。

| ![condense](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/137045/c11699c7-7e8c-6e56-edfe-5930bb4b36de.png) | ![preserve](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/137045/8d8c5b2f-c9eb-3b61-c2c6-0dcf3558f3e2.png) |
| :-: | :-: |
| `condense` でのレンダリング結果 | `preserve` でのレンダリング結果 |

この違いによって、ブラウザ上での余白の表示が変化することもあるため、Vite 開発環境においても `whitespace` を `condense` にする設定を追加します:

```js:vue.config.js
module.exports = {
  pluginOptions: {
    vite: {
      vitePluginVue2Options: {
        vueTemplateOptions: {
          compilerOptions: {
            whitespace: 'condense',
          },
        },
      },
    },
  },
};
```

ネストが深くなっていてわかりにくいですが、各種オプション名は以下ような対応となっています:

- `vite`: `vue-cli-plugin-vite` の設定
- `vitePluginVue2Options`: `vite-plugin-vue2` の設定
- `vueTemplateOptions`: `@vue/component-compiler-utils` の設定
- `compilerOptions`: `vue-template-compiler` の設定

## `<style lang="scss">` ブロックへのコードの自動挿入設定
Vue CLI 環境には `.vue` ファイルの `<style lang="scss">` ブロック[^2]に[変数等を自動挿入する設定](https://cli.vuejs.org/guide/css.html#passing-options-to-pre-processor-loaders)があります。
これを Vite 環境でも利用するには、`vue.config.js` を以下のように設定します:

[^2]: scss + Scoped CSS (`<style lang="scss" scoped>`) や scss + CSS Modules (`<style lang="scss" module>`) も対象

- `css.loaderOptions.sass` で設定している場合は `css.loaderOptions.scss` に変更する
- `sass-loader` v8 の場合、Vue CLI 環境では `css.loaderOptions.scss.prependData` として振る舞い、 Vite 環境では `css.loaderOptions.scss.additionalData` として振る舞うように設定する[^3]

[^3]: `sass-loader` v7 の場合は `css.loaderOptions.scss.data`, v9 の場合は `css.loaderOptions.scss.additionalData` を用います。v9 以降であれば三項演算子と [Computed property names](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Object_initializer#new_notations_in_ecmascript_2015) による分岐は不要だと思われます。Vue CLI で環境構築した場合のデフォルトは v8 になっています(記事投稿時点)

```js:vue.config.js
// Vite 開発環境であるかを判定するための変数を用意する。
// ここでは「開発サーバー立ち上げ時にブラウザを起動しないようにする」の設定した上で、その設定の有無にて判定します。
const IS_VITE_ENV = 'BROWSER' in process.env;

module.exports = {
  css: {
    loaderOptions: {
      scss: {
        [IS_VITE_ENV ? 'additionalData' : 'prependData']: `
          $main-color: #123456;
        `,
      },
    },
  },
};
```

## バンドル結果を別のサーバーで配信している環境の場合
Vue CLI 環境の開発ビルド(`npm run serve`)でディスク書き込みを有効にして、バンドル結果の js ファイルや css ファイルを別のサーバー環境で配信している場合もあるかと思います。
この場合は Vite 開発サーバー起動と同時に、Vite 開発サーバーが配信するモジュールを読み込むためのコードをディスクに書き込む必要があります。

ここでは以下の状況を仮定します:

- `src/main.ts` がエントリーポイント
    - 出力先は `dist/app.js`
- `npm run serve` 時のディスク書き込みが有効
- Vue CLI / Vite 開発サーバーのポート番号は `33333`
- `http://localhost:8080/foo/bar/` で `dist/` の内容を配信している
- `localhost:8080` 側で配信する HTML に `<script src="/foo/bar/app.js"></script>` が埋め込まれている

```js:vue.config.js
module.exports = {
  configureWebpack: {
    output: {
      filename: '[name].js',
    },
  },
  devServer: {
    port: 33333,
    writeToDisk: true,
  },
  publicPath: `/foo/bar/`,
};
```

`vue-cli-plugin-vite` は `devServer.writeToDisk` までは考慮しないので、`dist/app.js` に Vite 開発サーバー側のモジュールを参照させるコードを書き込む必要があります。

### `dist/app.js` の書き込み
`http://localhost:33333/foo/bar/src/main` を読み込むように設定します。

この `dist/app.js` は[モジュール](https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Modules)ではなく通常のスクリプトであるため、`import` 文ではなく動的 `import()` を利用します[^4]:

[^4]: 参照: https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/import

```js:dist/app.js
import('http://localhost:33333/foo/bar/src/main');
```

`npm run vite` 実行時に上記内容を書き込むため、`bin/vite` に以下の内容を追記します:

```js:bin/vite
// 以下をファイル下部に追記する

const fs = require('fs').promises;
// const path = require('path'); は既に定義されている

const getPath = pathSegment => path.resolve(__dirname, '../', pathSegment);

(async () => {
  // dist ディレクトリがない場合は作成する
  await fs.mkdir(getPath('dist/')).catch(() => {});

  // dist/app.js を書き込む
  await fs
    .writeFile(getPath('dist/app.js'), `import('http://localhost:33333/foo/bar/src/main');`)
    .catch(e => console.error(e));
})();
```

### 画像ファイルの配信するためにシンボリックリンクを作成
`.vue` ファイルのテンプレートに `<img src="@/assets/logo.png">` のように書くと `<img src="/foo/bar/src/assets/logo.png">` のような img タグがレンダリングされるため、`http://localhost:33333/foo/bar/src/assets/logo.png` 
 ではなく `http://localhost:8080/foo/bar/src/assets/logo.png` を参照しようとしてしまいます。

記事投稿時点では適切な設定オプションが無かった[^5]ため、やむを得ず `dist/` に `src/` へのシンボリックリンクを作成して `localhost:8080` 側で画像ファイルを配信します:

[^5]: 参照: https://github.com/vitejs/vite/pull/2254

```shell-session
$ cd dist/
$ ln -s ../src/ src
```

`npm run vite` 実行時に上記の操作をするため、 以下のコードを `bin/vite` の `await fs.mkdir(...);` の下に追記します:

```js
await fs.symlink('../src/', getPath('dist/src')).catch(() => {});
```

### `/` から始まるパスの画像ファイルを読み込めるようにする
`.vue` ファイルのテンプレートに `<img src="/img/photo.jpg">` のように書いて `http://localhost:8080/img/photo.jpg` を参照しようとすると、Vue CLI 環境では問題なくコンパイルできるものの Vite 開発環境では以下のようなコンパイルエラーが出てしまいます:

> [vite] Internal server error: Failed to resolve import "/img/photo.jpg". Does the file exist?

`vite-plugin-vue2` の実装を追っていったところ、以下のようにして `transformAssetUrlsOptions.forceRequire` の設定を追加する必要がありました:

```js:vue.config.js
module.exports = {
  pluginOptions: {
    vite: {
      vitePluginVue2Options: {
        vueTemplateOptions: {
          compilerOptions: {
            whitespace: 'condense',
          },
          transformAssetUrlsOptions: {
            forceRequire: false,
          },
        },
      },
    },
  },
};
```

# 注意事項
## ES モジュールを用いていること
Vite 開発サーバーは[モジュール](https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Modules)の利用を前提としています。
特に IE11 はモジュールに非対応のため、IE11 で動作確認するには従来通り Vue CLI 環境を用いてバンドルする必要があります。

## 型エラーやフォーマットエラーはターミナル上のログとして表示されない
Vite は TypeScript による型チェックや Linter によるフォーマットチェックを行いません。エディタでの保存時に自動でフォーマットをする設定や、CI でエラーを検知できる仕組みなどを導入しておく必要があるでしょう。

## esbuild を利用している
[esbuild](https://esbuild.github.io/) をトランスパイラとして用いているため、OS と CPU アーキテクチャに依存するバイナリが `node_modules` にインストールされます。
特に Docker や仮想マシンを利用している場合は、異なる OS 間で `node_modules` ディレクトリを共用できないことに注意が必要です。

```shell-session
$ file node_modules/esbuild/bin/esbuild
node_modules/esbuild/bin/esbuild: Mach-O 64-bit executable x86_64
```

# まとめ
Vue CLI 環境ではプロジェクトの規模が大きくなればなるほど開発ビルドが重くなり、開発体験が悪くなっていく傾向がありましたが、`vue-cli-plugin-vite` のおかげでこの問題を解決できました。
設定に若干難しさがありますが、本記事を参考に Vite 開発環境を試していただければ幸いです。