はじめに
株式会社スペースリー フロントエンドエンジニアの宮坂と申します。 ふだんは3Dビューアやその編集画面のDOM部分をReactやVueで書きつつ、たまにフロントエンド開発環境構築おじさんとして他チームへ出しゃばったりして生きています。 今回はその環境構築に関わるところ、Vue 2 から 3 へ移行しようとしたらいちばん大変なのが Vuetify 2 から 3 への移行だった話を書きます。 レガシーを生かしつつアップグレードする苦労話としてニッチに刺されば幸いです。
経緯と背景
Vue 2 のEOLまで1年を切って
スペースリーはサービスインから6年以上経つこともあり、技術スタックはプロジェクトによって新しいものもあれば、今となっては古いものもあります。 リリースから日が浅いパノラマ変換3Dプレイヤーは2023年6月時点でエンドユーザー向けの部分がReact 18で、事業者向けの部分がVue 3 + Composition APIで作られており、モダンな環境で開発できています。 しかし、スペースリーの管理画面ではまだVue 2 + Options APIが使われ続けている部分もあります。
さて、この記事を書いているのは先述の通り2023年6月です。Vue 2のEOLは2023年12月。セキュリティ面のリスクを鑑みるとそろそろ3に移行しないといけません。 しかし、Vue 2から3への移行は私自身経験があるものの、スペースリーは何年も運用されていることからコード量も多く、おいそれと移行できないことは想像できていました。どうしよう。
NES Vue 2 ではなく Vue 3 へアップグレードする道へ
Vue 2のEOLは2023年12月ですが、有料でNever-Ending Supportを受けられます。 しかし今回はこれを選択しませんでした。Vue 2本体のサポートを受けられても、Vue 2に依存するライブラリのサポートまでは受けられないからです。 Vue 2にのみ対応するライブラリがいつまで保守されるかを考慮すると、遅かれ早かれ素直にVue 3に移行するのが望ましいだろうなと。 そう決めた時点ではまだEOLまで半年以上ありましたので、分担すればまあ無理ではないかなと考えました。
移行方針
Vue 2 の環境を活かしつつ、段階的に 3 へ移行する
移行期間中も開発を止めないようにするために、今回は既存のコードを一気に Vue 2 から 3 へと移行するのではなく、段階的に移行することにしました。 先述の通りコード量が多く、中間ブランチで移行しつつ一気にmainへマージするには変更の規模が大きすぎると考えたからです。きっと中間ブランチがコンフリクトの嵐になるでしょう。 また、スペースリーがMPAであることから、エントリーポイント単位で移行するのは現実的に思えました。 トレードオフではありますが、この判断が後の環境構築を少し面倒にしてしまうことになります。
Options APIのまま移行する
これを機に Composition API で書き直す説も考えましたが、今回は Options API のまま移行することにしました。 忘れられがちだと思うのですが、Vue 3でも Options API は動作します。公式ドキュメントによれば、 Options API が非推奨になる予定がないことが明言されています。 Piniaも使えますので、将来的にVuexから離れることもできます。
こうする一番の理由はやはりコード量が多いことです。あと半年という限られた時間の中で書き換えるよりも、先に3へ移行してから順次書き換えていくほうが現実的に思えました。 それに、ある日突然コードが Composition API で書き換えられていて「今日からこっちで書いてね」というのもチームメンバーに対して乱暴なように思えました。 ふだん script setup で書いている私としましてはTypeScriptの恩恵を十分に受けられないのは残念ですが……今はまだその時ではない……
Webpackで作った環境をViteで作り直す
Vue 3の開発環境ですが、ライブラリをひとつひとつアップデートしていくよりCLIで一気に新しく作り直すほうが楽です。 それならViteで作ればいいんじゃないかなと私は思いました。 Webpackに対するViteの利点やVueとの親和性、Webpackの展望については巷で十分に語られ尽くしていると思いますのでここでは特に言及しません。 しかしスペースリーの特別な事情として、歴史的経緯によりVueとともに部分的にReactが使われているのです。 Vue CLIで環境構築すると webpack.config.js が隠匿されるがゆえに厄介なことになりそうなので、どちらの環境構築も容易なViteのほうが都合が良かったという面もあります。
また、Vite Rubyで環境構築すれば環境構築が手っ取り早いなという目論見もありました。しかし、これはうまくいきませんでした。 今回はレガシーのリソースをそのまま生かして移行する都合上、新しい環境を作るには新しいサブディレクトリ内で完結させる必要があります。 Vite Ruby は root オプションによってサブディレクトリをプロジェクトルートとして扱えるのですが、そうすると今回はビルドがうまくいかないという問題が生じました。 何が悪いのか特定できておらず、うまくすれば使える可能性もありますが、Vite Rubyの機能で欲しいのはタグヘルパーくらいでしたので、無理に使わなくてもいいかと割り切れました。 Vite RubyはWebpackerほどではないですがリッチすぎる印象もあります。私はWebpackerから受けた古傷がまだ痛むのです。
環境構築
スペースリーはバックエンドがRailsで、フロントエンドアセットはWebpackでビルドしSimpackerを介して配布していますが、先述の通りサブディレクトリに新たな環境を作ります。
npm init vue@latest で作っていきます。
いくつか質問されますが、今回は以下の通り選択しました。
$ npm init vue@latest
Vue.js - The Progressive JavaScript Framework
✔ Project name: vue3
✔ Add TypeScript? … Yes
✔ Add JSX Support? … No
✔ Add Vue Router for Single Page Application development? … Yes
✔ Add Pinia for state management? … No
✔ Add Vitest for Unit Testing? … No
✔ Add an End-to-End Testing Solution? › No
✔ Add ESLint for code quality? … Yes
✔ Add Prettier for code formatting? … Yes
まあまあ No と答えていますが、以下の理由があります。
- JSX: Vue SFC + Options APIで書いているので不要
- Pinia: 既にVuexが使われており、今回はそのまま流用したい。ただしVuexとPiniaを一緒に入れたら共存できたので、新しいページはPiniaをストアにする方針でもよかったかも
- Vitest: Jestを使いたいから。既にJestで書かれているテストがあり、今回はそこまで変えたくない
- E2E Test: RSpecで書いているので今のところ不要
結果的にこういう形でファイルが生まれます。
spacely_web
├── vue3
│ ├── README.md
│ ├── env.d.ts
│ ├── index.html
│ ├── package.json
│ ├── public
│ ├── src
│ ├── tsconfig.app.json
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
このうち src ディレクトリ内とpublicディレクトリ内は不要ですので、中のファイルを削除します。
続いて、別の適当なディレクトリで yarn create vuetify を実行します。
これで作られる環境は先ほどのものより少し古いのですが、設定ファイルを目diffして悪魔合体させるのに使えます。
設定もさっきのものと同様です。
レガシーを生かすための設定変更
vite.config.js
npm init vue@latest で作られたものに yarn create vuetify で作られたもののVuetify関係の部分だけ取り込み、エントリーポイントをひとつ足したものがこちらです。
import { fileURLToPath, URL } from "node:url";
import { resolve } from "path";
import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";
import vuetify, { transformAssetUrls } from "vite-plugin-vuetify";
import checker from "vite-plugin-checker";
import outputManifest from "rollup-plugin-output-manifest";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, resolve(process.cwd(), "./"), "");
return {
base: env.VITE_BASE,
build: {
rollupOptions: {
input: {
sign_in: resolve(__dirname, "src/packs/users/sign_in/main.js"),
},
plugins: [
outputManifest({
outputPath: "dist",
}),
],
},
},
plugins: [
vue({
template: {
transformAssetUrls,
compilerOptions: {
isCustomElement: function (tag: string): boolean {
return /^a-/.test(tag);
},
},
},
}),
vuetify({
autoImport: true,
}),
checker({
typescript: true,
eslint: {
lintCommand:
"eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --ignore-path .gitignore",
dev: {
overrideConfig: {
overrideConfig: {
rules: {
"no-console": "off",
},
},
},
},
},
vueTsc: true,
overlay: true,
}),
],
resolve: {
alias: {
"~": fileURLToPath(new URL("./src", import.meta.url)),
},
extensions: [".js", ".json", ".jsx", ".mjs", ".ts", ".tsx", ".vue"],
},
define: {/* 省略 */},
server: {
host: true,
port: 3040,
strictPort: true,
cors: true,
origin: env.VITE_DEV_SERVER_ORIGIN,
},
};
});
それなりにいじっていますので、今ある資産を生かすために必要だった部分を説明します。
base
ローカル開発では開発サーバのポート番号が異なりますので、Dynamic Importを動作させるために必要でした。 それ以外の環境では不要なので、ローカル開発時のみ設定する形に変更しても良さそうです。
build.rollupOptions.input
エントリーポイントとなるファイルをここで指定します。 とりあえずログインページだけ書いておきました。移行しやすそうだったからです。
build.rollupOptions.plugins
rollup-plugin-output-manifest を追加しています。 Simpackerでロードできる形式で manifest.json を出力するためです。 ローカル開発時にはエントリーポイントのファイルを直接読みますので使われません。
define
環境変数をここで定義します。 ここで定義したkeyがソース内にあれば、自動的にvalueで置換されます。そのため、keyがソース内に出力されることもありません。
.env ファイルに書かれているものがそのまま評価されますので、文字列値の場合は "" で囲む必要があるようです。
plugins
vue
compilerOptions に isCustomElement を追加します。
Vue 2における Vue.config.ignoredElements に相当します。
この機能で A-FrameのタグをVueのComponentとして解釈しないようにされていましたので、ここで引き継いでいます。
なお createApp() で生成した app に設定することもできますが、Vueランタイムだけ配布する場合にうまく動かない旨が warning として出力されましたので、ここで設定するほうが良さそうでした。
checker
vue-tsc で検証してくれますので vueTsc: true を加えます。
tsconfig.app.json
compilerOptions.allowJs
今回の環境ではまだTypeScriptではなくJavaScriptが使われている部分がありますので、 true にしないとコンパイルできません。
eslintrc.cjs
さらにESLintの設定を少し変更します。これを行うとVuetifyのアップグレードが比較的楽になります。それでもまだ大変なのですが。
extends
"plugin:vuetify/recommended" を追加します。これによって、Vuetify 2から3への移行がやりやすくなります。
eslint --fix である程度自動修正してくれますし、修正されなくても無効になったコンポーネントやプロパティがわかるからです。
まあそれでも一筋縄ではいかないわけですが、Vuetifyがまだalpha版だった頃はこれすら使わずドキュメントもなかったのでもっと大変でしたし……
加えて "plugin:vue/vue3-essential" を "plugin:vue/vue3-recommended" に書き換えても良さそうです。
開発サーバを起動する
ここまで設定できていれば、 yarn dev で開発サーバを起動できることを確認しておきます。
その肝心のエントリーポイントはこの時点ではまだ移行していませんので、これからで移行していきます。
ログイン画面を移行してみる
これから作業を分担するために、サンプルも兼ねて1エントリーポイントを移行することにしました。 ログイン画面を選んだのは、ぱっと見で依存コンポーネントが少なそうだったからです。環境構築と同時に重たい移行作業をしたくなかったのです。
ログインページに対応するERBファイルを編集する
エントリーポイントに加え、HMRとvite-plugin-checkerが動作するように追記します。 とりあえず動作確認も兼ねてローカル開発できれば良いと割り切り、ビルド後のアセットをロードするタグヘルパーをまだ用意していません。 Vite Rubyが使えればここが楽だったのですが、manifest.jsonさえパースできれば良いため、自前で書いてもいいかなと思い始めています。
before
<%= javascript_packs_with_chunks_tag "users/sign_in/main" %>
<%= stylesheet_packs_with_chunks_tag "users/sign_in/main" %>
after
<script type="module" src="<%= ENV.fetch('VITE_DEV_SERVER_ORIGIN') %>/@vite/client"></script>
<script type="module" src="<%= ENV.fetch('VITE_DEV_SERVER_ORIGIN') %>/src/packs/users/sign_in/main.js"></script>
<script type="module" src="<%= ENV.fetch('VITE_DEV_SERVER_ORIGIN') %>/@vite-plugin-checker-runtime-entry"></script>
app/javascript ディレクトリ以下から関係するファイルをコピーする
かつてWebpackerの配下にいたファイル達を、新しい vue3/src ディレクトリにコピーして使います。 移行前のファイルのうちログイン画面に関係するもの(packs/users/sign_in/main.jsからロードされているものすべて)を app/javascript ディレクトリから移すとこんな感じになりました。
src
├── assets
│ └── images
│ └── (省略)
├── functions
│ └── axios.ts
├── icons
│ ├── components
│ │ └── (省略)
│ └── icons.js
├── packs
│ └── users
│ └── sign_in
│ └── main.js
├── plugins
│ ├── honeybadger.ts
│ ├── index.ts
│ ├── vuetify.ts
│ └── webfontloader.ts
└── users
└── sign_in
├── App.vue
├── router.js
└── views
├── UserSignIn.vue
└── UserSignInSso.vue
エントリーポイントを書き換える
エントリーポイントである packs/users/sign_in/main.js を編集します。
before
import Vue from "vue";
import VueRouter from "vue-router";
import Vuetify from "vuetify/lib";
import "@mdi/font/css/materialdesignicons.css";
import "material-design-icons-iconfont/dist/material-design-icons.css";
import ja from "vuetify/src/locale/ja";
import App from "~/users/sign_in/App";
import router from "~/users/sign_in/router";
import icons from "~/icons/icons";
import logging from "~/plugins/logging";
Vue.mixin(logging);
Vue.use(Vuetify);
Vue.use(VueRouter);
Vue.config.ignoredElements = [/^a-/];
const vuetify = new Vuetify({
lang: {
locales: { ja },
current: "ja"
},
theme: {
themes: {
light: {
spacely_pink: "#ff3366",
spacely_lightgray: "#F4F6F8"
}
}
}
});
window.addEventListener("load", function () {
new Vue({
el: "#app",
vuetify,
router,
icons,
render: h => h(App)
});
});
after
import { createApp } from "vue";
import { registerPlugins } from "~/plugins";
import App from "~/users/sign_in/App";
import router from "~/users/sign_in/router";
window.addEventListener("load", function () {
const app = createApp(App);
registerPlugins(app);
app.use(router);
app.mount("#app");
});
かなりシンプルになりました。
大きな違いは new Vue() を createApp() に書き換えるところです。
SFCをcreateAppの引数として与え、HTML要素にmountしています。
addEventListenerは DOMContentLoaded で良いような気もしますが、一応変えていません。
基本的にはこれをコピペして import するファイルだけ変えれば多くは問題ないのではと。
かなり行数が減っているのは、共通の処理を plugins ディレクトリ内に押し込んだからです。 エントリーポイントの数だけ量産されるファイルですので、 app.use() を繰り返し書くことで保守性を損なわないよう極力小さくしたかったのです。 ただしRouterは各エントリーポイントごとに設定が異なるため、共通化しません。
plugins を追加する(必要なら)
マウント時に行う共通の処理があれば、pluginsディレクトリ内にファイルを追加していきます。
index.ts
import HoneybadgerVue from "@honeybadger-io/vue";
import { loadFonts } from "./webfontloader";
import vuetify from "./vuetify";
import logging from "~/functions/logging";
import type { App } from "vue";
export function registerPlugins(app: App) {
loadFonts();
app.use(vuetify);
app.mixin(logging);
}
使用する共通の処理一式をここでまとめています。
今でも app.mixin() でグローバルミックスインを注入できるなんて今回初めて知りました。実際機能としては残っているものの、非推奨になっていますね。
そもそもmixinがおすすめされていません。移行元に合わせる形で使いましたが、見通しが悪くなりますので、新しくmixinを書くのは今後は避けるほうが良いと思います。
webfontloader.ts
export async function loadFonts() {
const webFontLoader = await import(
/* webpackChunkName: "webfontloader" */ "webfontloader"
);
webFontLoader.load({
google: {
families: ["Roboto:100,300,400,500,700,900&display=swap"],
},
});
}
Vuetifyの初期構築時に生成されたものをそのまま使用しています。
vuetify.ts
import "@mdi/font/css/materialdesignicons.css";
import "material-design-icons-iconfont/dist/material-design-icons.css";
import "vuetify/styles";
import { createVuetify } from "vuetify";
import { ja } from "vuetify/locale";
import icons from "~/icons/icons";
export default createVuetify({
locale: {
locale: "ja",
fallback: "ja",
messages: { ja },
},
theme: {
themes: {
light: {
colors: {
spacely_pink: "#ff3366",
spacely_lightgray: "#F4F6F8",
"water-blue": "#1976d2",
},
},
},
},
icons,
});
基本的にはVuetifyの初期構築時に生成されたものなのですが、 theme に独自に定義された色を追加しています。 アイコンのCSSや独自定義のアイコンコンポーネントもここでロードしています。 こういう設定オブジェクトの微妙な破壊的変更が積み重なって精神を蝕むので、この手のものはボイラープレートから書き写して編集するのが一番です。動作が確認された設定がそのまま配布されているのは本当に助かります。
ルーティングを書き換える
before
import VueRouter from "vue-router";
import UserSignIn from "./views/UserSignIn";
import UserSignInSso from "./views/UserSignInSso";
export default new VueRouter({
mode: "history",
routes: [
/* 中略 */
]
});
after
import { createRouter, createWebHistory } from "vue-router";
import UserSignIn from "./views/UserSignIn";
import UserSignInSso from "./views/UserSignInSso";
const routes = [
/* 中略 */
];
const router = createRouter({
history: createWebHistory("/"),
routes,
});
export default router;
routes の書式は同じですが、 createRouter() を使用するように書き換えます。
また mode がなくなっています。今回はルートをベースパスとし History APIで遷移するよう history: createWebHistory("/") を指定しています。
各コンポーネントのscriptを書き換える
App.vue を例として書き換えてみます。基本的にはコンポーネントなら全部同じです。
<script>
export default {
/* 中略 */
};
</script>
<script>
import { defineComponent } from "vue";
export default defineComponent({
/* 中略 */
});
</script>
おわかりいただけただろうか……。
export default が defineComponent() に変わりましたね。
それ以外何も書き換えませんでしたので中略してしまいました。
つまりこれだけです。これだけ! 中身の書き換え一切なし!
Options APIが使えるというだけでコンポーネントの移行をだいぶ省力化できることが目に見えてわかりますね。
おっこれ書き換えそんなしんどくないんじゃないのって気分がしてきますね。やったぜ。
いったんプレビューしてみたら世界が崩壊していた
これでビルドは通るんじゃないかなーと思います(Vuetifyに関係する lint error を直せば)。どれどれ yarn dev してログイン画面を見てみましょうどうかなー

やったぜじゃないよ。
表示崩れが激しいですね。一瞬Options APIのせいかと思ってしまいそうになりますが、そうではありません。この表示崩れはほぼVuetify 2 から 3 にメジャーアップデートされるにあたり、破壊的変更が多かったことに起因するものです。
eslint --fix だけじゃ直しきれないのかと下唇を噛みつつ、移行前の見た目と見比べながら直していきます。
(なおアイコンが表示できていませんが、前の環境構築のとおりであればこの時点で見えていると思います)
Vuetifyのコンポーネントを手直しする
Vuetify 3のコンポーネントには破壊的な変更が大いに入っていますので、そこだけ抜粋します。
あらかじめ eslint --fix しておけば自動的に変更できるところはしてくれますが、それだけではエラーが残る上、そのエラーを直してもご覧の通りの崩れ。
今までは application.scss にいたBootstrapから何かしらちょっかいが入っていたのかもしれませんが、今回はその影響がなくなっているため、もしそれに起因する誤認があったらすみません。
一部機能していなかったヘルパークラス
before
<v-main class="col-sm-6 mx-auto my-12">
<v-row>
<v-col cols="12" sm="6" offset-sm="3">
after
<v-main class="col-xs-12 mx-auto my-12">
<v-row>
<v-col cols="12" sm="6" offset-sm="3">
今まで v-main に col-sm-6 が入っており、 v-col に sm="6" が入っていましたが、片方が機能していなかったように見えました。
しかし今は両方とも機能し始め、カラム幅が半分の半分になり、画面幅の25%にまで狭くなってしまっていました。
<v-main> は常に画面幅いっぱいに広がってほしいので col-sm-6 を消して col-xs-12 クラスを追加しました。
<v-card> の flat プロパティは boolean で渡す
before
<v-card flat>
after
<v-card :flat="true">
flat プロパティが boolean を受け取ることを求められました。Reactに慣れると違和感がありますが、Booleanで渡していきます。
<v-card-title> が display: block; になっている
before
<v-card-title class="justify-center">
after
<v-card-title class="d-flex justify-center">
<v-card-title> 要素がFlexBoxからBlockに変更されていましたので、ヘルパークラスとして justify-center を使うなら d-flex も必要になります。
ほかのコンポーネントにも同様の変更が入っていれば、それらでもレイアウトの乱れが生じていそうです。
<img> の height 属性に単位が不要
before
<img :src="logoImage" alt="Spacely" height="36px" />
after
<img :src="logoImage" alt="Spacely" height="36" />
height属性に単位 px があると 0 としてレンダリングされ、画像が見えなくなっていたので削除しました。
<v-text-field> のpropsが変わった
before
<v-text-field
v-model="password"
hide-details="false"
label="パスワード"
outlined
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword = !showPassword"
/>
after
<v-text-field
v-model="password"
hide-details="false"
label="パスワード"
variant="outlined"
color="water-blue"
:append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
:type="showPassword ? 'text' : 'password'"
@click:append-inner="showPassword = !showPassword"
/>
outlined prop はなくなりましたので、 variant="outlined" に変更します。
これだけではアウトライン色が黒になってしまうので、デザインを維持するために color="water-blue" を追加しました。
Vuetifyのthemeに漏れがあったかもしれません。
加えて append-icon を append-inner-icon に変更しました。
アイコンがフィールドの外に描画されていたのはこれが理由です。
同じ属性名でも挙動が変わるような破壊的変更はなかなか罠ですね。見ないと気づかないやつです。
<v-btn> のpropsが変わった
<v-btn
outlined
width="100%"
class="spacely_pink"
dark
@click="submit"
>ログイン</v-btn
>
<v-btn
type="submit"
variant="flat"
color="spacely_pink"
width="100%"
theme="dark"
>ログイン</v-btn
>
色を class="spacely_pink" で指定していましたが、これを color="spacely_pink" に置き換えます。
また dark は無効になりましたので theme="dark" に変更します。文字を白抜きにするために dark を使うのは本来は良くありませんが。
そして outlined が指定されていましたが、これを愚直に variant="outlined" と書き換えると、ボタンの背景が透明となってしまいます。
元のデザインと一致する(背景がピンクで塗りつぶされる)のは variant="flat" でした。機械的に書き換えると見た目が変わるとはこれも罠……
正しい変更だとは思うものの、エラーにはならず機械的に検出できない変化があるとしんどさが増しますね。
ついでにログイン処理の呼び出しトリガーを v-button の @click から v-form の @submit.prevent へ移動し、 v-btn の type を submit にしました。テキストフィールドのフォーカス中にもEnterキーでログインできるほうが素直な挙動かなと思いまして。
ロジックをなるべく変えないとはどの口が言ったのか。
他のコンポーネントも書き換えてからプレビューする
上記の書き換えと同様の変更を他の要素にも施し、だいたい元通りの見た目となりました。 さらっと書きましたがVuetifyのコンポーネントを使っている数だけ上記のような変更が必要になるということです。地味に時間がかかります。 それに今後まだいろいろ不慮の崩れが見つかるでしょう。 それと「だいたい」としたのは、余白が少し変わっていたり、非表示に見えるコンポーネントの高さが 0 になっていたりすることに起因しています。ここに時間をかけてもコスパが悪く、あまり頑張りたくないです。新たなVuetifyの摂理に従いましょう。

まとめ
- レガシーを生かすのは痛みを伴うが、規模が大きいと一気にモダンにするより現実的
- サブディレクトリでの開発は Vite Ruby がうまく使えず、タグヘルパーを用意する一手間がかかる
- Vue自体のロジックの移行はほんとに簡単
- Vuetifyの破壊的変更に悩まされる。ESLintのルールで楽にはなったものの、それでも目視での修正が必要になると時間がかかる
- ピクセルパーフェクトなレベルで見た目を維持するのは限界がある
もうちょっとうまくできたんじゃないかなと思いつつ、レガシーな環境ともうまく付き合っていきたいものです。 レガシーは常に我々を追いかけてくる……今のモダンもいつかはレガシーになるのです……
おわりに
スペースリーでは故きを温めて新しきを知れるフロントエンドエンジニアを募集中です。 といってもこの件が済めばレガシーの生き残りから脱出できますので、モダンな環境での開発に集中したい方もぜひご応募ください。ここは俺に任せて先に行けー!



