既存 Vue 2 アプリケーション を monorepo へ移行して、既存コンポーネントを Vue 3 アプリケーションで利用する
はじめに
単一リポジトリで運用しているモノリシックな Web フロントエンドアプリケーションがある。
Vue.js を利用していていくつかのコンポーネントを実装している。この資産を利用して別のアプリケーションを構築したい。
ソフトウェアの内部としてはしっかりとレイヤを分けているが、Webアプリケーションを構成するための構造になっているのでビルド・CIなどはそれのためのものになっている。
したがって、コンポーネントなどをライブラリとして切り出していこうとするとビルドなどが複雑になる可能性が高いためそれぞれのレイヤを npm パッケージとして定義したい。
一方で、もともとが一つのアプリケーションであるため、今後も同時に更新される頻度も高いことが想定される。そのため、独立したリポジトリで運用するよりも monorepo で運用していこうと思った。
また、Vue 2 ベースのアプリケーションだけど、せっかくなので、Vue 3 でもコンポーネントが利用できるようになるといいなと思った。
やや目標が渋滞している感があるが、それをやるための手順などをまとめておく。
前提
Vue CLI で構成された Vue 2 のアプリケーション。
ここでは、以下で作成されたものとする。
$ vue create .
Vue CLI v4.5.12
? Please pick a preset: Manually select features
? Check the features needed for your project: Choose Vue version, Babel, TS, Router, CSS Pre-processors, Linter, Unit
? 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 CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Sass/SCSS (with dart-sass)
? Pick a linter / formatter config: Prettier
? Pick additional lint features: Lint on save, Lint and fix on commit
? Pick a unit testing solution: Jest
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
それっぽさを出したいので、Storybook
と @vue/composition-api
を入れておく。
$ vue add storybook
? What do you want to generate? Initial framework
? What storybook version do you want? (Please specify semver range) ^6.2.0
$ npm i @vue/composition-api@^1.0.0-rc.8
自動でできた HelloWorld
と MyButton
コンポーネントも composition-api に合わせて 適当に変えておく。
<template>
<div class="hello">
<h1>{{ msg }}</h1>
</div>
</template>
<script lang="ts">
import { defineComponent } from "@vue/composition-api";
export default defineComponent({
name: "HelloWorld",
props: {
msg: String,
},
});
</script>
<style scoped lang="scss">
.hello {
margin: 40px 0 0;
}
</style>
<template>
<button class="button is-primary" @click="onClick">
<!-- @slot default inner button content -->
<slot></slot>
</button>
</template>
<script lang="ts">
import { defineComponent } from "@vue/composition-api";
export default defineComponent({
name: "my-button",
setup(_, { emit }) {
return {
onClick: ()=> emit("click")
}
},
});
</script>
<style scoped>
button {
border: 1px solid #eee;
border-radius: 3px;
background-color: #ffffff;
cursor: pointer;
font-size: 15pt;
padding: 3px 10px;
margin: 10px;
}
</style>
こんなかんじ。
これ時点でのソースはこれ。
移行後
以下のパッケージに分ける
- components
- 既存アプリケーションから切り離した Vue コンポーネントライブラリ
- vue-app
- Vue 2 の既存アプリケーション
- vue3-app
- Vue 3 の新しいアプリケーション
components
は vue-app
と vue3-app
から参照される。
また monorepo の管理は npm 以外を使わない。monorepo 管理にはいろいろなツールが存在するが、まずはシンプルな構成で実現し、必要に応じてツールの選定を行うほうが理にかなっていると考えている。
nodejs は14.x npm は 7.x の最新を利用している。ここではやり忘れたが、特に npm は これ未満だと workspace の機能が動かないので、package.json の engine を指定しておくと良い。
移行作業
すでに動いている生きたアプリケーションの構成を変更するにあたっては、移行作業が段階的に行えることが重要と考えている。
そのため、可能な限り既存のアプリケーションに影響が出ないような段階を踏んで移行することを意識してステップバイステップで進める。
vue-app
へ
既存アプリをそのまま 最初のステップでは構成を monorepo にするだけにして、それ以外は全く変更をしない。
はじめにソースコードを移動する。
$ mkdir -p packages/vue-app
$ mv config node_modules public src tests .browserslistrc .eslintrc.js babel.config.js jest.config.js package-lock.json package.json tsconfig.json packages/vue-app/
package.json
をプロジェクトルートに追加する。
{
"name": "vue-monorepo-example",
"workspaces": [
"packages/*"
]
}
vue-app
側の package.json
も name
を変更しておく
{
"name": "vue-app"
}
link とかをよしなにしてもらうために一旦 npm install
を実行する。
workspace
オプションを使うことで、プロジェクトルートから各プロジェクトの npm script を実行できる。
以下で移行前と同様にアプリケーションが動作することが確認できる。
npm run serve --workspace=vue-app
このステップはここまで。
npm script と同様に以下でバージョンをあげられる。
--workspace
オプションで個別パッケージを指定できるのに対して、--workspaces
オプションは全パッケージで実行される。
$ npm version minor --workspaces
vue-app
v1.1.0
この時点では以下。
vue-app
から components
へ
パッケージを分けていく。
別パッケージを定義するところまでやれば、vue-app
から新しいパッケージへ分離していくの自体は段階的に行えば良い。
なので、個別のコンポーネントなどの実装自体は、他のアプリケーションで使いたいものや、機能追加や改修のタイミングに移行するなど状況に応じて段階的に移行できる。
components
パッケージの定義
このコンポーネントはライブラリとして利用するため、 esm ビルドできることが望ましい。そのため、ここでは rollup を使ってビルドすることにする。
プロジェクトの定義は以下。
$ cd packages
$ npx vue-sfc-rollup
✔ Which version of Vue are you writing for? › Vue 2
✔ Is this a single component or a library? › Library
✔ What is the npm name of your library? … components
✔ Will this library be written in JavaScript or TypeScript? › TypeScript
✔ Enter a location to save the library files: … ./components
コンポーネントの動作確認に Storybook を使うので、利用可能にする。
$ cd packages/components/
$ npx sb init --type vue
stories
の中身はいらないので、削除する。
また、もともと定義されていた npm script の serve
コマンドを Storybook に置き換えておく。不要になったパッケージも依存から削除する。
{
"name": "components",
"version": "1.0.0",
"description": "",
"main": "dist/components.ssr.js",
"browser": "dist/components.esm.js",
"module": "dist/components.esm.js",
"unpkg": "dist/components.min.js",
"types": "components.d.ts",
"files": [
"dist/*",
"components.d.ts",
"src/**/*.vue"
],
"sideEffects": false,
"scripts": {
"serve": "start-storybook -p 6006",
"build": "cross-env NODE_ENV=production rollup --config build/rollup.config.js",
"build:ssr": "cross-env NODE_ENV=production rollup --config build/rollup.config.js --format cjs",
"build:es": "cross-env NODE_ENV=production rollup --config build/rollup.config.js --format es",
"build:unpkg": "cross-env NODE_ENV=production rollup --config build/rollup.config.js --format iife",
"storybook:build": "build-storybook"
},
"devDependencies": {
"@babel/core": "^7.12.10",
"@babel/preset-env": "^7.12.11",
"@babel/preset-typescript": "^7.12.7",
"@rollup/plugin-alias": "^3.1.1",
"@rollup/plugin-babel": "^5.2.2",
"@rollup/plugin-commonjs": "^17.0.0",
"@rollup/plugin-node-resolve": "^11.0.1",
"@rollup/plugin-replace": "^2.3.4",
"@storybook/addon-actions": "^6.2.9",
"@storybook/addon-essentials": "^6.2.9",
"@storybook/addon-links": "^6.2.9",
"@storybook/vue": "^6.2.9",
"cross-env": "^7.0.3",
"minimist": "^1.2.5",
"rollup": "^2.36.1",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-vue": "^5.1.9",
"typescript": "^4.0.0",
"vue": "^2.6.12",
"vue-template-compiler": "^2.6.12"
},
"peerDependencies": {
"vue": "^2.6.12"
},
"dependencies": {}
}
また、これで dev 以下のスクリプトもいらなくなるのでこれも削除する
コンポーネントの移行
MyButton
コンポーネントを移動する。
個人的にコンポーネントごとにディレクトリを切るのが好みだが、ここではとりあえずそのまま移動する。
$ mv packages/vue-app/src/components/MyButton.vue packages/components/src/lib-components/
story も移動する。これもとりあえずそのまま移動する。
$ mv packages/vue-app/src/stories packages/components/src/
パスが変わった部分を直して、動くことを確認しておく。
$ npm run serve --workspace=components
移動した MyButton
コンポーネントをビルド対象に追加する。
/* eslint-disable import/prefer-default-export */
export { default as ComponentsSample } from './components-sample.vue';
export { default as MyButton } from './MyButton.vue';
型情報も追加
import Vue, { PluginFunction, VueConstructor } from 'vue';
declare const Components: PluginFunction<any>;
export default Components;
export const ComponentsSample: VueConstructor<Vue>;
export const MyButton: VueConstructor<Vue>;
パッケージにビルド。
$ npm run build --workspace=components
vue-app
の方で MyButton
の依存を components
に変更する
まず、package.json
に依存を追加しておく。
{
"dependencies": {
"components": "^1.0.0"
}
}
<script lang="ts">
import { defineComponent } from "@vue/composition-api";
import { MyButton } from "components";
export default defineComponent({
name: "About",
components: {
MyButton,
},
setup(){
return {
clicked: ()=> window.alert("clicked")
}
}
});
</script>
このままだと、eslint で components
のビルド済み js が評価されてしまうので無視ファイルとして設定しておく。
**/dist/**
vue-app
が正常に動作することを確認しておく。
npm run serve --workspace=vue-app
vue3-app
の構築
Vue 3ベースのアプリケーションを作る。
以下でプロジェクトの定義をする。
$ npm init @vitejs/app vue3-app -- --template vue-ts
$ npm run dev --workspace=vue3-app // 動作確認
components
を Vue 2 Vue3 に対応させる
MyButton
コンポーネントを利用できるようにする。
ポイントが2つある。
一つは、Vue コンポーネントを定義するための API に差が存在するため埋める必要があること。Vue 2 は @composiiton-api
に依存している一方で、 Vue 3 はコアのAPI になっているため、この差を埋める必要がある。
これは vue-demi
で実現する。
もう一つは、SFC のプリビルド処理に違いがあること。 Vue 2 と Vue 3 は View 部分 (template) の実装が異なるものなので、これをビルドする方法が異なる。実装としては、Vue 2 が vue-template-compiler
を利用するのに対して、Vue 3 は@vue/compiler-sfc
を利用する。
まずは、components
の @vue/composition-api
を vue-demi
に置き換える
依存パッケージの変更
{
"dependencies": {
"vue-demi": "^0.9.0"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^2.0.0 || ^3.0.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
SFC 内のAPIの参照先を vue-demi
へ変更
<script lang="ts">
import { defineComponent } from "vue-demi";
export default defineComponent({
name: "my-button",
setup(_, { emit }) {
return {
onClick: ()=> emit("click")
}
},
});
</script>
ここまでで、APIに関する対応ができた。
次は View 部分の対応。
Vue 2 は rollup-plugin-vue
の 5 系を利用するのに対して、 Vue 3 は 6 系を利用している。そのため、これらをビルド時に切り替えれるようにしないといけない。
依存パッケージとビルドスクリプトを変更する。
環境変数 VUE_VERSION
で切り替えれるようにする。
{
"scripts": {
"prebuild:vue2": "vue-demi-switch 2",
"build:vue2": "cross-env VUE_VERSION=2 NODE_ENV=production rollup --config build/rollup.config.js",
"prebuild:vue3": "vue-demi-switch 3",
"build:vue3": "cross-env VUE_VERSION=3 NODE_ENV=production rollup --config build/rollup.config.js",
},
"devDependencies": {
"@vue/compiler-sfc": "^3.0.0",
"postcss": "^8.0.0",
"rollup-plugin-postcss": "^4.0.0",
"rollup-plugin-vue": "^6.0.0",
"rollup-plugin-vue2": "npm:rollup-plugin-vue@^5.0.0",
}
}
storybook については特に触っていないが、 Vue 2, Vue 3 いずれでも確認したい場合は、追加で Vue 3 用の storybook セットアップを行い、同様な前処理の後でビルドなりを行えば良いと思う。単体テストも同様。
rollup の設定も変更する。ベースとなる設定は、 vue-sfc-rollup
で Vue 3 ベースのプロジェクトを作成して差分を確認するのがかんたん。
// rollup-plugin-vue
import vue3 from 'rollup-plugin-vue';
import vue2 from 'rollup-plugin-vue2';
const vue = process.env.VUE_VERSION === '2' ? vue2 : vue3
// Vue 3 では postcss plugin が必要
const postCssConfig = [
// Process only `<style module>` blocks.
PostCSS({
modules: {
generateScopedName: '[local]___[hash:base64:5]',
},
include: /&module=.*\.css$/,
}),
// Process all `<style>` blocks except `<style module>`.
PostCSS({ include: /(?<!&module=.*)\.css$/ }),
]
const baseConfig = {
input: 'src/entry.ts',
plugins: {
preVue: [
alias({
entries: [
{
find: '@',
replacement: `${path.resolve(projectRoot, 'src')}`,
},
],
}),
],
replace: {
'process.env.NODE_ENV': JSON.stringify('production'),
},
vue: process.env.VUE_VERSION === '2' ? {
css: true,
template: {
isProduction: true,
},
} : {},
postVue: [
resolve({
extensions: ['.js', '.jsx', '.ts', '.tsx', '.vue'],
}),
...(process.env.VUE_VERSION === '2' ? [] : postCssConfig),
commonjs(),
],
babel: {
exclude: 'node_modules/**',
extensions: ['.js', '.jsx', '.ts', '.tsx', '.vue'],
babelHelpers: 'bundled',
},
},
};
const external = [
'vue',
'vue-demi'
];
const globals = {
vue: 'Vue',
'vue-demi': 'VueDemi'
};
これで、実行するビルドスクリプトによって、 Vue 2 用と Vue 3 用で切り替えられるようになった。 (当然実装自体が両方に対応できるようになっていないといけない)
vue-app
と vue3-app
のビルドスクリプトを components
の変更に対応させる
vue-app
をビルドするときに、components
は Vue 2 用にビルドされていて、 vue3-app
をビルドするときに Vue 3 用にビルドされていないといけない。
vue-app
について、serve
や build
前に components
をビルドする。
{
"scripts": {
"build:dep": "npm run build:vue2 --prefix ../../ --workspace=components",
"preserve": "npm run build:dep",
"serve": "vue-cli-service serve",
"prebuild": "npm run build:dep",
"build": "vue-cli-service build",
}
}
vue3-app
も同様な対応で問題ない。
それぞれ以下を実行すると動作することが確認できると思う。
$ npm run dev --workspace=vue3-app
$ npm run serve --workspace=vue-app
ここまでの状態が以下。
終わりに
本当はここから、設定の共通化とか VSCode の設定とか GitHub Actions の設定をやりたかったのだけど、少し長くなってしまったので一旦ここまで。
実際は中心になるコアロジックなどが存在するため、それらをまとめたパッケージを切り出したり、開発をサポートするための処理をまとめたパッケージをまとめるなりすると思うが、あとは同じ要領でプロジェクトを作っていけばよいはず。
nodejs の monorepo というと、バックエンドアプリケーションとの共通化がモチベーションになりがちだけど、一般的にソフトウェアのモジュール分割を明確に行うことはいくつかのメリットがあるので、フロントエンドで閉じているアプリケーションでも、規模に応じて分割すると良いと思う。
開発フローが変わり、ビルド単位まで分割できるようになればモジュールごとのバンドル、デプロイも視野に入るため、より開発がスケールできるようになると思う。
また、こういう手順は、Vue 2 ベースの大規模アプリケーションを Vue 3 ベースにマイグレーションしたいとき、部分的に置き換えて行くということにも利用できると思うので活用していきたい。
Discussion