こんにちは。EC事業部でカラーミーショップ アプリストアの開発をしています @gatchan です。
カラーミーショップでは、フロントエンド開発の技術要素の一つとしてVue.jsを採用しています。 以前、ショップ運営を支えるプロダクトにフロントエンド開発環境を薄く導入している という記事で、Vue.jsとCustom Elementsを活用したUIコンポーネントの開発事例を紹介しました。
しかしながら、Vue 2系のサポートが2023年末までであることから、そろそろカラーミーショップのVue.jsアプリケーションもVue 3系にマイグレーションしていかなければなりません。
このようなマイグレーション業務は継続して一定の品質を担保するようにサービスを提供する上では欠かすことのできないものですが、一方で進行中の施策に追われてどうしても後回しになってしまいがちです。 そこで、EC事業部のエンジニアがチームの垣根を越えて技術的な交流をする「Chapter」という組織たちのうち、「Frontend Chapter」のメンバーでこの作業を行う試みをしました。
日々のFrontend Chapterの活動では、気になった技術について共有したり、カジュアルに話しています。 この場に今回ご紹介する手法を提案した上で実現し、一部のタスクをChapterの取り組みとして実現していくことで、本番環境へのデプロイに漕ぎつけました。
チームメンバーだけでは解決が難しい課題を、事業部内外の知見を共有しながら解決した様子をお伝えできればと思います。
Vue 3系におけるWeb Comopnent Build
カラーミーショップの管理画面では、Vue CLIが提供するAsync Web Componentを使い、Vue.jsのコンポーネントをビルドします。
成果物として、エントリポイントとなるJavaScriptファイル1つと、各コンポーネントごとにビルドされたJavaScriptファイルが生成されます。 エントリポイントとなるJavaScriptファイルを読み込むことで、ページ内に存在するCustom Elementsを探し出して、必要なコンポーネントのJavaScriptファイルをサーバから取得します。
しかしながら、Vue CLIでVue 3をビルドターゲットにしたWeb Componentビルドは現時点ではサポートされていません。そこで、ビルドに用いるツールをVue CLIからViteに置き換え、同様のビルド結果が得られるようにしました。
Vue 3系では、Custom Elementsを公式にサポートしています。以下のように defineCustomElement
を使うことで、Vue.jsコンポーネントをCustom Elementでラップできます。
import { defineCustomElement } from 'vue'
import { FooBarComponent } from '/path/to/foo-bar-component.vue'
const wrappedFooBarComponent = defineCustomElement(FooBarComponent)
customElements.define(wrappedFooBarComponent)
続いて、Viteを構成します。Viteの Glob Import 機能で、特定のディレクトリ配下のモジュールを一括してImportできます。
const components = import.meta.glob('./components/*.vue')
components
の中身は以下のように、pathがキー、モジュールをImportする関数が値として格納されています。Dynamic Importの形式なので、モジュールが必要になったときにコンポーネントをサーバから取得できます。
components = {
'./components/button.vue': () => import('./components/button.vue'),
'./components/number.vue': () => import('./components/number.vue')
}
エントリポイントのJavaScriptとして以下のように記述します。
import { defineCustomElement } from 'vue'
// comopnents 配下のすべてのVueファイルを読み込む
const modules = import.meta.glob('./components/**/*.vue')
for (const [path] of Object.entries(modules)) {
const el = document.getElementsByTagName(convertPathToElementName(path))
// タグがHTMLの中から見つかったらモジュールをFetchする
if (el.length > 0) {
modules[path]().then((mod) => {
const el = defineCustomElement(mod.default)
customElements.define(`${convertPathToElementName(path)}`, el)
})
}
}
/**
* パスからCustom Elementのタグ名を決定する
* @param {string} path パス ex) './components/HogeFuga.vue'
* @return {string} タグ名 ex) 'cms3-hoge-fuga'
*/
function convertPathToElementName(path) {
// パスから必要な部分を取り出してsnake-caseになるようにしている
return 'cms3-' + path.split('/')
.pop()
.replaceAll(/([A-Z])/g, '-$1')
.toLowerCase()
.substr(1)
.replace(/\.vue/, '')
.replace(' ', '')
}
Glob Importでコンポーネントが格納されているディレクトリ内のすべてのVue.jsファイルをビルドターゲットにします。Dynamic Importの対象となるVue.jsファイルは分割されて出力されます。
さらに、コンポーネントに対応するタグ名を検索し、見つかった場合だけモジュールをサーバから取得し、 defineCustomElement
でラップした上でCustom Elementとして登録します。
Vue CLIが提供するAsync Web Componentと同様の仕組みをVue 3 + Viteで提供できました。
既存のUIコンポーネントの段階的なマイグレーション
カラーミーショップ管理画面向けのコンポーネントの数は既に100を超えています。これを一度にマイグレーションせず、徐々に移行することでVue 3へのマイグレーションの知見を貯めつつ、安全に作業を行うことを目指しました。 管理画面向けのUIコンポーネントはそれぞれの依存関係が少ないうえ、Custom Elementでラップして利用していることから、コンポーネントを一つずつマイグレーションすることが比較的容易に行えます。
移行に際して、一部のコードは機械的に置換できることから、コードの置換および旧コンポーネントから新コンポーネントへのファイルコピーを行う簡単なシェルスクリプトを提供することで、より多くのエンジニアやデザイナーが気軽に移行を試すことができる環境を作りました。
たとえば、Vue.jsコンポーネントをTypeScriptで記述する場合、これまでは Vue.extend
でVueオブジェクトを包むようにしていましたが、Vue 3では defineComponent
で包むようになりました。
- import Vue, { PropOptions } from 'vue'
- export default Vue.extend({
+ import { defineComponent, PropType } from 'vue'
+ export default defineComponent({
ほかにも、Propsの型は PropOptions
から PropType
を使う形に変更されています。
props: {
- value: {
- type: String
- } as PropOptions<SomeType>
+ value: {
+ type: String as PropType<SomeType>
+ }
}
以下のようなシェルスクリプトで上記のような置き換えに対応しました。 (ソースコードのフォーマットが統一されていないと機械的に置き換えるスクリプトの作成を困難にするので、lintツールなどでソースコードの整形をできるようにしておくことをお勧めします)
#!/bin/bash
#
# upgrade.bash
#
# ディレクトリ構成は以下を想定
#
# application
# |-- components-v2 (Vue 2 UIコンポーネント)
# | `-- src
# `-- components-v3 (Vue 3 UIコンポーネント)
# `-- src
cd `dirname $0`
TARGET=$1
DEST=${TARGET//components-v2/components-v3}
DESTDIR=`dirname $TARGET`
mkdir -p ${DESTDIR//components-v3/components-v3}
cp $TARGET $DEST
# Convert typescript support
sed -i '' -e 's/Vue.extend/defineComponent/g' $DEST
sed -i '' -e "s/import Vue from 'vue'/import { defineComponent } from 'vue'/g" $DEST
sed -i '' -e "s/import Vue, { PropOptions } from 'vue'/import { defineComponent, PropType } from 'vue'/g" $DEST
perl -0pe 's/ type:(.*?)(,?)\n(.*?)} as PropOptions<(.*?)>(?!( |>))/ type:$1 as PropType<$4>$2\n$3}/gs' -i $DEST
以下のようなコマンドを実行することで、ファイルの配置と置換処理を実行します。
./upgrade.bash /path/to/src/SomeComponent.vue
これである程度は機械的にマイグレーションを行えるようになりました。 とはいえ、旧環境から新環境への移行は手間であることは確かです。 今後は移行によるメリットや、移行しないことによるデメリットをFrontend Chapterの中から徐々に事業部全体のエンジニアやデザイナーに展開していき、移行を完遂できるようにしていきます。
作業を通じて貯まった知見がカラーミーショップの他のVue.jsアプリケーションのマイグレーションにも生かせると期待しています。
Chapterで取り組んだことによる効果
当初、前述した基本方針と実装がFrontend Chapterで共有され、マイグレーションが実現可能であるとChapter内で話題になりました。
いっぽうで、その時点では本番環境へのリリースまでに解決しなければいけない周辺課題がまだ残ってました。 また、マイグレーションに関わっているのは少数のエンジニアのみで、他の施策との兼ね合いでなかなか時間がとりづらいという背景を抱えていました。
そこで、10人弱のメンバーが常時参加しているFrontend Chapterで、残りの課題を手分けして継続的に実装することとしました。 フロントエンドの知識に明るいメンバーが集まり定期的に開催される場ですすめることで、特定の人への負担を軽減し、また少しずつ安定して進捗を重ね、リリースすることができました。
以下では、Frontend Chapterで解決した2つの課題「ビジュアルリグレッションテストの環境の改善」と「デプロイ時のビルド」を紹介します。
ビジュアルリグレッションテストの環境の改善
ビジュアルリグレッションテスト(以下VRTと呼ぶ)の環境の改善についてはEC事業部の @ku00 より紹介致します。
カラーミーショップの管理画面ではVue.jsのコンポーネントをリファクタリングするときなどHTMLやCSSのレイアウトの変更を検証できるようにするためにVRTを導入しています。
VRTにはreg-suitとStorybook、Storycapを利用して実現しています。VRTはVue.jsのコンポーネントに変更があったときのみGitHub Actions上で実行する設定となっています。
これらVRTの環境はカラーミーショップの各プロダクトを担当しているチームがそれぞれ作っていました。これによりVRTを実行するGitHub Actionsで動かすDockerのコンテナイメージやそのイメージを構築するためのビルドスクリプト、VRTの出力結果を格納するオブジェクトストレージのバケットなどがチームごとに作られていたためVRTの環境の管理が煩雑になっていました。 また、VRTの出力結果はセキュリティの観点から社内ネットワークでないと閲覧できないような問題もありました。このためリモートワークで社外からアクセスしたい場合には都度VPNを繋ぐ必要がありました。
これらの技術的課題に対して、プロダクトを横断して利用できる社内の共通基盤(VRTの環境)を利用することで解決できることがChapterの活動を通して移行作業を進めていく中で判明しました。
この共通基盤を利用することでVRTを利用するための環境が一通り揃うだけでなく付属するGitHub Actionsを利用することでVRTの実行まで実現できました。共通のバケットやDockerのコンテナイメージ(それに伴うビルドスクリプト)を利用することでプロダクトごとに作る手間やソースコードの管理を省くことができました。 さらにVRTの出力結果が社内ネットワークからしか閲覧できない認証の問題についても、この共通基盤がSAMLを用いた社内共通のSSOサービスの認証を採用していたことでVPNを不要にして閲覧する場所を気にする必要がなくなりました。 また、共通基盤を利用したVRTの環境の改善にあたっては社内の基盤システムの作成者にサポートしていただきモブプログラミング形式で作業を進めることで迅速に完成させることができました。
このような改善は移行作業の本筋から逸れる内容となるため既に利用されているソースコードをコピー&ペーストするなどして省略しがちでしたが、Chapterという特定の技術領域について議論できる場で作業することで技術的課題に対して新たな解決方法が発見されることもあると再認識させられました。
デプロイ時のビルド
移行に必要な実装のうち、Vue 3への移行におけるビルドとデプロイについて、EC事業部のやんまーより紹介致します。
管理画面アプリケーションのVue.jsコンポーネントは、もともとデプロイ時に、コンポーネントのビルドやビルド結果ファイルの設置を行っていました。 Vue 2からVue 3への移行では、Vue 2のビルドやファイル設置と同様のタイミングで、Vue 3のビルドやファイル設置をあわせて実行しています。
具体的には、デプロイフローのなかで次のコマンドに示すような処理を実行しています。
# デプロイフローの一部を取り出した例
# 既に実装されている既存のフロー
# デプロイ時に実行されるVue 2のビルドとファイル設置
cd /path/to/project/components-v2
npm run build
cp /path/to/project/components-v2/dist /path/to/public/dist-v2
# 移行に伴って新たに追加したフロー
# デプロイ時に実行されるVue 3のビルドとファイル設置
cd /path/to/project/components-v3
npm run build
cp /path/to/project/components-v3/dist /path/to/public/dist-v3
上述の構成にすることで、既存のVue 2のコンポーネントに影響を与えることなく、Vue 2のコンポーネントとVue 3のコンポーネントをどちらも利用でき、既存のUIコンポーネントの段階的なマイグレーションを実現しています。
デプロイ時のビルドに関する実装は、Frontend Chapterの時間をつかって、モブプログラミング形式で行いました。 チームを横断したメンバー同士でのモブプログラミングは、お互いのもつ知見を共有するきっかけとなりました。 たとえば、今回変更をくわえたデプロイフローは普段は読むことがなく、どのようにデプロイされているかを把握していないメンバーもいます。 こういった場所の変更をモブプログラミング形式で行うことは、効率的に実装を把握する機会になります。結果として、参加メンバーのアプリケーションの開発運用に関するブラックボックスを減らすことにつながりました。
おわりに
本記事では、Vue.jsのメジャーバージョンアップに際して、段階的なマイグレーションを実施していることを紹介しました。 技術課題や解決策を持ち寄り、議論した上で実装した一連の流れは、組織的な連携の枠組みであるChapterという仕組みをうまく活用して実現されました。