🏘️

既存 Vue 2 アプリケーション を monorepo へ移行して、既存コンポーネントを Vue 3 アプリケーションで利用する

2021/05/09に公開

はじめに

単一リポジトリで運用しているモノリシックな 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

自動でできた HelloWorldMyButton コンポーネントも composition-api に合わせて 適当に変えておく。

src/components/HelloWorld.vue
<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>
src/components/MyButton.vue
<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>

こんなかんじ。

これ時点でのソースはこれ。

https://github.com/sterashima78/vue-monorepo-example/tree/v1.0.0

移行後

以下のパッケージに分ける

  • components
    • 既存アプリケーションから切り離した Vue コンポーネントライブラリ
  • vue-app
    • Vue 2 の既存アプリケーション
  • vue3-app
    • Vue 3 の新しいアプリケーション

componentsvue-appvue3-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 をプロジェクトルートに追加する。

package.json
{
  "name": "vue-monorepo-example",
  "workspaces": [
    "packages/*"
  ]
}

vue-app 側の package.jsonname を変更しておく

packages/vue-app/package.json
{
  "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

この時点では以下。

https://github.com/sterashima78/vue-monorepo-example/tree/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 に置き換えておく。不要になったパッケージも依存から削除する。

packages/components/package.json
{
  "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 コンポーネントをビルド対象に追加する。

packages/components/src/lib-components/index.ts
/* eslint-disable import/prefer-default-export */
export { default as ComponentsSample } from './components-sample.vue';
export { default as MyButton } from './MyButton.vue';

型情報も追加

packages/components/components.d.ts
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 に依存を追加しておく。

packages/vue-app/package.json(追加部分のみ)
{
  "dependencies": {
    "components": "^1.0.0"
  }
}
packages/vue-app/src/views/About.vue (script部分のみ)
<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-apivue-demi に置き換える

依存パッケージの変更

packages/components/package.json (変更部分のみ)
{
  "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 へ変更

packages/components/src/lib-components/MyButton.vue (script部分のみ)
<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 で切り替えれるようにする。

packages/components/package.json (変更部分のみ)
{
"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 ベースのプロジェクトを作成して差分を確認するのがかんたん。

packages/components/build/rollup.config.js (変更部分のみ)
// 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-appvue3-app のビルドスクリプトを components の変更に対応させる

vue-app をビルドするときに、components は Vue 2 用にビルドされていて、 vue3-app をビルドするときに Vue 3 用にビルドされていないといけない。

vue-app について、servebuild 前に components をビルドする。

packages/vue-app/package.json(変更部分のみ)
{
  "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

ここまでの状態が以下。
https://github.com/sterashima78/vue-monorepo-example/tree/v1.2.0

終わりに

本当はここから、設定の共通化とか VSCode の設定とか GitHub Actions の設定をやりたかったのだけど、少し長くなってしまったので一旦ここまで。

実際は中心になるコアロジックなどが存在するため、それらをまとめたパッケージを切り出したり、開発をサポートするための処理をまとめたパッケージをまとめるなりすると思うが、あとは同じ要領でプロジェクトを作っていけばよいはず。

nodejs の monorepo というと、バックエンドアプリケーションとの共通化がモチベーションになりがちだけど、一般的にソフトウェアのモジュール分割を明確に行うことはいくつかのメリットがあるので、フロントエンドで閉じているアプリケーションでも、規模に応じて分割すると良いと思う。

開発フローが変わり、ビルド単位まで分割できるようになればモジュールごとのバンドル、デプロイも視野に入るため、より開発がスケールできるようになると思う。

また、こういう手順は、Vue 2 ベースの大規模アプリケーションを Vue 3 ベースにマイグレーションしたいとき、部分的に置き換えて行くということにも利用できると思うので活用していきたい。

Discussion