巨大化したSPAのフレームワークを少しづつVueに移行しはじめたお話
3行で
- 絶賛スケール中の Backbone.js 製 SPA を Vue.js に置き換えたい!
- でも一度に全ては置き換えできない...。試行錯誤な工程とサンプルコードをご紹介!
- 一緒に働くメンバーを大募集中!
はじめに
こんにちは。株式会社ペライチ開発チームの関(@nekoneko_wan2)と申します。新潟からフルリモートで働いております🍙 。
今回はペライチのコア機能、フロントエンドの一大リファクタプロジェクトを開始したお話をご紹介いたします。実際に手を動かすフロントエンドエンジニアの自分自身が参考になる、と思えるような記事を目指して書いてみました。
同じ様な課題に直面している方、これから立ち向かおうとしている方、この記事が何かしらの役に立てば幸いです。
古く複雑化したコア機能
ペライチはサービス開始より ホームページを誰でもかんたんに作成できる機能
をコア機能として備えています(以降、ページ編集機能と呼びます)。コア機能というだけあり要望は多く、開発・改善は継続的に行われています。
そしてそれはペライチが持つ多数のサービスの中でも、トップクラスに量が多く複雑なコードに支えられています。その難易度から採用されているフロントエンドのフレームワークは見直しのタイミングを逃し、今ではすっかり古いものとなってしまいました。
属人化、オンボーディング工数、開発者体験の低下など、古くなったシステム上で開発し続けることの辛みは、あれこれ言葉を並べなくてもご想像いただけることでしょう...。
開発環境は次々と刷新されていますが、大きな SPA として複雑化したページ編集機能には中々メスを入れることができませんでした。一方会社やプロダクトは順調にスケールしていき、いよいよ看過できなくなったというタイミングで、リファクタプロジェクトが立ち上がりました。[1]
※ペライチのフロントエンド開発環境の刷新のお話は以下にもありますので、よろしければご覧ください。
リファクタの方針を決める
長期戦になることは目に見えているため、途中で迷子にならないよう方針を決めてチームで認識を合わせておきます。フレームワークを変えるだけでは解決できなそうな課題も感じており、まず何がどうなることを目標とするか、こういった場合はどうするか、以下のような流れで考えていきます。
- 改めて今起きている問題を整理する
- 現状の作りを整理する
- 今思いつく理想の作りを考える
- 理想と現状の差分から課題を見つける
- 課題を元に実現可能な方針を立てる
詳細は割愛しますが、結果としてページ編集機能はビッグバンリリースせず、リファクタ中は複数のフレームワークが共存することを許容した上で少しづつ UI を Vue.js で作り直す方針を立てました。[2]
戦術を立てる
ここからは具体的にページ編集機能にどう Vue.js を組み込んでいくかを考えていきます。
まずは現在のページ編集機能について
ページ編集機能を簡単に説明すると以下のようになります。
- Backbone.js, Marionette.js, jQuery, etc...で構成された SPA で URL は基本1つ
- 1つのエントリーファイルを起点としツリー状に View ファイルが展開
- 執筆時点でファイル約 350 個、コード約 24,000 行
イメージ図(勿論ここまで単純な構造ではないですが)
(そもそもBackbone.jsとは)
採用されているフレームワーク Backbone.js について動きがイメージできると話が早いので、ごく簡単なサンプルを用意しました。[3](Backbone.js をご存知の方は飛ばしていただいて構いません)
// Modelとしてデータ格納先を用意
const BbModel = Backbone.Model.extend({
defaults: {
text: '',
},
});
const BbView = Backbone.View.extend({
// (略)
initialize() {
this.model = new BbModel();
// Modelにchangeイベントがおきたらrenderを実行
this.listenTo(this.model, 'change', this.render);
},
render() {
// 対象DOMを丸ごと再描画
this.$el.html(this.template(this.model.attributes));
return this;
},
onSubmit(e) {
//(略)
this.model.set('text', val); // changeイベントがおきる
},
});
View から参照されるデータを Model として用意し、Model の変更を見てレンダリングを行います。実際には Marionette.js で書かれていたり、他 View との連携があったりと異なりますが、こういった View の組み合わせでページ編集機能は作られています。
子→親の順番でViewを移行していく
前述のように View はツリー構造になっており必ず関連する他 View が存在します。そのため影響範囲が少ない末端から移行していくと作業範囲を狭くしていくことができます。子を移行したら子の兄弟を移行し、子が一通り移行されたら親を移行、親が移行されたら親の兄弟を〜と徐々に登っていきます。
冗長な修正になってくる箇所もありますが、常に差し込みで改修が入るリスクを考えると、スピード感を持って随時リリースできた方が安全だと考えました。
なお UI やコード量によってはある程度のまとまりで作業した方がやりやすい事も多いため、「末端」はどこからを指すかは場所によって柔軟に判断しています。
異なるフレームワークでできたViewの連携を実現する
さて移行時には Backbone と Vue でできた View の連携をどうするかという問題が出てきます。データだったりイベントだったり View 間でのやり取りはまず発生します。
Vue の中に Backbone を登場させることもできなくはないですが(逆も然り)、それをやってしまうと結局お互いが強く関心を持ってしまい、改修難易度が倍増してしまいます。
そこで採用したのがACL(Anti corruption layer:腐敗防止層)というアプローチです。異なるフレームワーク間でやり取りをする際には必ず専用の変換ファイルを通し、お互いの関心を最小限に抑えるようにします。ACL 経由で Vue を参照すると今までの Backbone と同じ様なアウトプットが提供されるイメージです。
なお Vue の範囲は段階的に変化していくため、ACL はそのつど役割が変わってきます。子だけが移行されたら子専用の ACL が増え、親も移行されたら子の ACL は不要になり親専用 ACL が必要に〜といった次第です。
「基本的に」ModelはViewと合わせて移行する
Vue で作成するには、データはリアクティブで管理されていなければなりません。そのため Backbone Model も同時に移行する必要があります。ところが Model と View が1対1になっていない UI というものが存在します。大きく分けると以下2パターンありました。
- 同じ Model を複数 View でシェアしている
- 巨大なシングルトンになっており複数 View で参照している
Model に依存する View の数が多く、結局同時に複数の View を移行しないとリリースできないのはリスクが高くなりがちです。そこで危険を感じた場合は、無理せずに Model は一旦そのまま、関連 View が全て移行してから改めて取り掛かることも許容しました。
このケースも ACL が活躍してくれます。ACL を通すことで Model からリアクティブなデータに変換して取り出し Vue で扱える形にします。
(もちろん Model が複数 View でシェアされているなら、それらの View はまとめて移行の方がやりやすい、という場合もあります。Model の構造が対象範囲を決める材料にもなってきます)
戦術まとめ
- 移行は小さく段階的に
- Backbone と Vue をつなぐ処理は ACL という変換レイヤーを通す
- Model と View は一緒に移行したいが無理はしない
サンプルコードでイメージを補完
ここからはサンプルコードを用いて移行のイメージをお伝えしていきます。
Backbone View で Vueを利用する
Vue のイベントを検知して Backbone で UI を操作するサンプルです。子のみを先に移行し、そこから親や兄弟と整合性を合わせるパターンを想定しています。
import VueApp from './VueApp';
export default class {
constructor(element) {
// 呼び出し側でイベントハンドラが設定できるようobserverパターンを組み込む
this.observers = {};
createApp(VueApp, {
// Vueコンポーネントで 'blur' がemitされると呼ばれる
onBlur: (value) => this.emit('blur', value),
// レンダリング
}).mount(element);
}
emit(name, ...args) {
if (name in this.observers) {
this.observers[name].forEach((cb) => cb(...args));
}
}
on(name, cb) {
if (!(name in this.observers)) {
this.observers[name] = [];
}
this.observers[name].push(cb);
return this;
}
// offを用意することも
}
import BackboneView from './BackboneView';
import VueAppACL from './VueAppACL';
const backboneView = new BackboneView({ el: '#app-backbone' });
const vueApp = new VueAppACL('#app-vue');
backboneView.render();
// Vueでblurが発生したときのイベントハンドラ
vueApp.on('blur', (text) => {
// backboneに変更とデータを伝える
backboneView.model.set('text', `Model 「${text}」`);
});
ACL に Observer パターン導入することでイベント購読と呼び出し側でのハンドラ設定を実現しています。どこまで頑張るかはありますが、抽象的な Class として切り出し Backbone が提供している振る舞いを一通りエミュレートできると使い勝手が良くなるかもしれません。
VueでBackbone Modelを利用する
Backbone Model のイベントを検知して Vue で UI を操作するサンプルです。Model がシングルトンで作成され簡単に移行できず、先に View だけ移行するパターンを想定しています。
// modelはシングルトンの想定
import model from './BackboneModel';
export default class {
constructor() {
// Backbone.Model.toJSON()を利用し、データを取り出しreactive(proxy)オブジェクトを作成
// [補足]
// Backbone Model では値が変更された後にchangeイベントが発火されるため、
// オブジェクト参照が切れていないと、reactiveが反応してくれません(changed時にはthis.stateが変わっている)
this.state = reactive(model.toJSON());
// Backboneのchangeイベントを利用しリアクティブ値を同期する
model.on('change', (model) => {
Object.keys(model.changed).forEach((k) => {
if (k in this.state) {
this.state[k] = model.changed[k];
}
});
});
}
}
<script>
import Store from './BackboneModelACL';
export default defineComponent({
name: 'vue component',
setup() {
const store = new Store();
return {
state: store.state,
};
},
});
</script>
Backbone Model では変更時にイベント発火してくれるのでそれを利用して無理やりデータを同期させています。いずれ Model が移行された時に Vue 側での変更は最小限になることを期待しています。
おわりに
少しづつですが、実際に移行プロジェクトを始めることができました。
一方、ストアをどう管理しよう、テストはどうしよう、そもそも Backbone にすらなっていないのがあるけど... etc 考えることは山積みです。未だ見えていない課題もどんどん増えてくることは想像できます。
しかし、リファクタを考え始めた頃、途方に暮れていた頃に比べると見えている景色は随分変わりました。決して易しくないプロジェクトであるもののリターンも大きいと感じています。
ペライチでは一緒に働くメンバーを絶賛募集中です!
少しでも興味を持たれた方は下記の採用情報をご覧ください!
以上、長々とありがとうございました。
採用情報
▼ 選考をご希望の方はこちら(募集職種一覧)。
▼ まずはカジュアル面談をご希望の方はこちら
募集中の職種についてご興味がある方は、お気軽にお申し込みください(CTO がお会いします)
-
エンジニア間でもページ編集機能は大変といった声がより挙がるようになりました。せっかくご縁があり会社のビジョンやカルチャーに共感し、ジョインいただけたメンバーにそう思わせてしまうのは、何となく申し訳なく歯がゆい気持ちがありました。そういった声を何とかしたいという思いもモチベーションの1つにあります。 ↩︎
-
Vue3, TypeScript で作り完了箇所は随時本番へ反映していきます。 ↩︎
-
サンプルコードを stackblitz にまとめています。埋め込んだ先の画面幅が小さいと?ファイル構造が表示されないようなので、細かく確認される際は直接 stackblitz にアクセスください mm。 ↩︎
Discussion