Vite + Vue 3 のプロジェクトをライブラリモードで出力してパッケージ化するまでにしたことメモ
Vite+Vue+Vuetifyを使ったプロジェクトを複数制作しており、規模が大きくなるにつれて共通処理や共通コンポーネントが増えてきた。ライブラリにして共有できるようにしようと思ってパッケージ化してみた。
公式でViteのライブラリモードのドキュメントはあるが、実例があまり見つからなかったので解決に時間がかかってしまった。それはその解決までのメモ。
前提条件
- Vue: 3.5.3
- Vuetify: 3.7.1
- Vite: 5.4.2
- Vitest: 2.0.5
その他の依存パッケージについては必ずしも必須ではないので出てきた時点で後述する。
ディレクトリ構成
自作したパッケージでは以下のようなファイル構成になっている。
|-- dist
|-- src
| |-- components
| | |-- index.ts
| | |-- AnimatedClock.vue
| | |-- AppBase.vue
| | `-- ThemeToggleButton.vue
| `-- lib
| |-- use
| | |-- index.ts
| | |-- computedJSON.ts
| | `-- useIntervalFnWithPauser.ts
| |-- array.ts
| `-- (省略)
|-- test
| |-- components
| | `-- (省略)
| `-- lib
| `-- (省略)
|-- package.json
|-- tsconfig.json
|-- tsconfig.node.json
|-- vite.config.ts
`-- yarn.lock
./src
以下がライブラリとなるファイル群で、その中に components/
と lib/
がある。前者には Vue + Vuetify を使ったコンポーネントがあり、後者はそれ以外のプログラムだ。コンポーネントが lib/
のライブラリを使うことはありえるが、その逆はない。
./test
以下にも components/
と lib/
があり、./src
とだいたい対応している。
ゴール
上記の構成のパッケージを、以下のように import したい。
import { mergeArrayBy } from '@nanase/alnilam/array';
import { computedJSON } from '@nanase/alnilam/use';
import { AppBase, AnimatedClock } from '@nanase/alnilam/components';
./lib
以下にあるものはモジュールを細分化して export された関数や定数が衝突しないようにしたい。コンポーネントについてはコンポーネント名が衝突するとは思えないので、ひとつのモジュールで公開したい。今後数が増えたら細分化するかもしれないが、その手順は ./lib
でやることと変わらないはずである。
以下、どうやって設定を作っていくかを見ていく。
tsconfig.json
Viteでビルドをするのであまり効果はないと思われるが、VSCodeで開発する際にこの設定が読み取られるので一旦この設定でやる。ポイントは paths
, types
, 'include' あたり。
paths
で import をしたとき(外部からパッケージを使うときとは別の話)に 'src/lib/array'
と打たねばならないところを '@/lib/array'
と記述できる。どこまでディレクトリが深くても @
から始まれば ./src
を指すので、絶対パス指定のような感覚で使える。ビルドが通るのにVSCode上の警告でモジュールが見つからない際はここをチェックすればいい。
types
で Vitest のテスト用関数を import なしで使うことができるが、実際には後述の vite.config.ts にも設定が必要になる。
include
にはコンパイルの対象になるファイルのパターンを入れる。env.d.ts
を入れる場合はそれも必要。コンポーネントも対象になるので src/**/*.vue
も入れている。
{
"compilerOptions": {
"target": "es2022",
"module": "es2022",
"lib": ["es2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"jsx": "preserve",
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"downlevelIteration": true,
"skipLibCheck": false,
"noErrorTruncation": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"esModuleInterop": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"types": ["vitest/globals"]
},
"exclude": ["node_modules"],
"include": ["src/**/*", "src/**/*.vue", "test/**/*"],
"vueCompilerOptions": {
"jsxSlots": true
}
}
tsconfig.node.json
ビルド時の型チェック時に使う設定。こちらにも paths
の設定は入れておく。vite.config.ts自体のチェックも行うので include
に追加しておく。
{
"extends": "@vue/tsconfig/tsconfig.json",
"include": [
"src/**/*",
"src/**/*.vue",
"test/**/*",
"test/**/*.vue",
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"types": ["node"],
"paths": {
"@/*": ["./src/*"]
}
}
}
vite.config.ts
最終的に採用した vite.config.ts は以下の通り。
/// <reference types="vitest" />
import { resolve } from 'path';
import { defineConfig } from 'vite';
import Vue from '@vitejs/plugin-vue';
import dts from 'vite-plugin-dts';
import { libInjectCss } from 'vite-plugin-lib-inject-css';
const root = resolve(__dirname);
const srcDir = resolve(root, 'src');
export default defineConfig({
plugins: [
Vue(),
dts({
exclude: ['test/**/*.ts', '**/*.test.ts'],
}),
libInjectCss(),
],
test: {
root,
include: ['test/**/*.test.ts'],
globals: true,
coverage: {
reporter: ['text', 'json'],
include: ['src/**/*.{ts,vue}'],
exclude: ['**/index.ts'],
},
server: {
deps: {
inline: ['vuetify'],
},
},
},
resolve: {
alias: [{ find: '@', replacement: srcDir }],
},
build: {
lib: {
entry: {
'components/index': resolve(srcDir, 'components', 'index.ts'),
'lib/use/index': resolve(srcDir, 'lib', 'use', 'index.ts'),
'lib/array': resolve(srcDir, 'lib', 'array.ts'),
},
name: 'alnilam',
formats: ['es'],
},
rollupOptions: {
external: ['vue'],
output: {
globals: {
vue: 'Vue',
},
chunkFileNames: 'chunks/[name].[hash].js',
assetFileNames: 'assets/[name][extname]',
},
},
outDir: resolve(root, './dist'),
emptyOutDir: true,
},
});
plugins
最低限必要なのは3つ。
yarn add -D @vitejs/plugin-vue vite-plugin-dts vite-plugin-lib-inject-css
@vitejs/plugin-vueについてはVueをViteで扱っていれば必ず使っているはずなので解説しない。
vite-plugin-dtsは .ts
と .vue
から型定義ファイルを作るためのプラグイン。ライブラリモードでのみ有効。公開する型からのみ作られるのでテストコードから作る必要はなく、./test
以下と *.test.ts
のファイルは除外している。
vite-plugin-lib-inject-cssはコンポーネント内で使われているCSSを、コンポーネントを import すれば自動でCSSも import されるように加工してくれるプラグイン。ライブラリモードでのみ有効。このプラグインを用いない場合は dist/style.css
にCSSがまとめられて出力される。後述の assetFileNames
も指定することで dist/assets/style.css
に出力される。
test
Vitest で使う設定。冒頭の /// <reference types="vitest" />
で test
が defineConfig
に渡せるようになる。globals: true
を指定することで Vitest のテスト用関数がグローバル関数として import なしに使えるようになる。
詳細は省略。
resolve
tsconfig.json で設定したものと同じく、ライブラリ内の import
で @
から始まるものを ./src
で置き換えられるようにするためのもの。
build
これがライブラリモードの設定本体。build.lib
が存在することでライブラリモードになる。
build.lib.entry
ライブラリのエントリポイント。複数設定ができるが、マルチエントリポイントは対応している形式が限られているので build.lib.formats
で限定する必要がある(未指定だとエラーになる)。
エントリポイント名をキーに、エントリポイント先のパスを値として与える。index.ts
をエントリポイントにする場合は **/index
の形式にする必要がある。
// ✅️ 正しい例
'components/index': resolve(srcDir, 'components', 'index.ts'),
// ❌️ うまくいかない
'components/': resolve(srcDir, 'components', 'index.ts'),
// ❌️ うまくいかない
'components/index': resolve(srcDir, 'components'),
このエントリポイントの設定は後述の package.json と対応させる必要がある。
build.rollupOptions
build.rollupOptions.external
を指定することでビルドに含めない外部パッケージを明示的に指定できる。たとえば Vue は依存パッケージとして要求されるのでビルドに含める必要がない。ちなみにこの指定は正規表現を使うことができる。
build.rollupOptions.output.globals
はグローバル変数がどのモジュールと対応しているかを指示する設定。ここで、グローバル変数 Vue
は vue
モジュールと対応していることを指定している。
build.rollupOptions.output.chunkFileNames
は assetFileNames
と似ているが、こちらはチャンクファイル用。エントリポイントに指定された .ts
ファイルは対応する .js
にコンパイルされるが、それ以外のファイルはチャンクファイルとして扱われる。指定をしないと ./dist
以下に作られるがこの chunkFileNames
を指定することで任意のファイルパスに変更できる。今回は chunks/
と先頭につけているのでチャンクファイルはすべて ./dist/chunks/
以下に生成される。
build.outDir
出力先ディレクトリ。今回は ./dist
に指定。
build.emptyOutDir
ビルド時、出力先ディレクトリに存在しているファイルを全部削除してからビルド後の生成物を作るかを指定できる。ビルドの都度削除→生成が発生するが、私の場合は出力先ディレクトリも git で管理しているので true
にして全削除している。
package.json
全てを書き出すと長いので、必要部分のみ掲載する。
{
"name": "@nanase/alnilam",
"version": "0.4.4",
"private": true,
"type": "module",
"files": [
"dist"
],
"exports": {
"./components": {
"types": "./dist/components/index.d.ts",
"import": "./dist/components/index.js"
},
"./use": {
"types": "./dist/lib/use/index.d.ts",
"import": "./dist/lib/use/index.js"
},
"./array": {
"types": "./dist/lib/array.d.ts",
"import": "./dist/lib/array.js"
},
},
"scripts": {
"build": "run-p type-check build-only",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.node.json",
"test": "vitest",
"coverage": "vitest run --coverage"
},
...
}
files
パッケージが外部のプロジェクトでインストールされたとき、どのファイルをコピーすればよいかの指定。今回の場合は ./dist
にビルドされたものがすべて存在しているので、dist
を指定した。
この項目がない場合はパッケージのインストール時にソースファイルを含めた全てのファイルがコピーされる。詳しく解説している記事もあるので参照のこと。
exports
外部のプロジェクトから import したとき、どのようにモジュールが見え、どのファイルを読み込めばいいのかを指定できる。
たとえば
"./components": {
"types": "./dist/components/index.d.ts",
"import": "./dist/components/index.js"
}
の記述により、
import { ... } from '@nanase/alnilam/components'
の import が実現される。このとき外部プロジェクトは types
に指定された型定義ファイルを参照して型を特定し、import
に指定された js ファイルで実行ができるようになる。
今回のようにマルチエントリポイントではなく、ビルド形式に ['es', 'umd']
を指定した場合は *.umd.js
というUMD (Universal Module Definition) パターンに従ったファイル形式が出力される。この場合は require
で読み込むことになるので、
"./components": {
"types": "./dist/components/index.d.ts",
"import": "./dist/components/index.js",
+ "require": "./dist/componets/index.umd.js"
}
という指定が増える。
なお、この package.json の設定と vite.config.ts のエントリポイントの設定は対応している必要がある上、ビルド時に両者の記述が一致しているかは自動では検証されない。不足がある場合は外部プロジェクトからの import 時または実行時にエラーとなる。
scripts
Vite でのビルド方法を指定。type-check で tsconfig.node.json を使って型のチェックを行っている。