この記事について
以前 MPA でつくったプロダクトを途中で SPA に移行したことがあったんですが、また別のプロジェクトでやることになるかもしれない状況になったので、当時のことを思い出しつつ、手元で動かしながらメモを取ったのでその記録です。
はじめに
文脈
- 開発当初は MPA でつくりはじめたが、事情により SPA へ移行して、使用言語も JS から TS へ変えたい
- できれば一気に置き換えたいが、新規機能開発なども同時並行でやりたいので、緩やかに移行したい
- メンバーに TypeScript 経験者が少ないため、学習と並行しながら重要なところにだけ型をつけたい
定義
- MPA: Multi Page Application - クライアントからリクエストがきたら、サーバは HTML ドキュメントを返す
- SPA: SIngle Page Application - ベースとなる HTML はフロントエンドで生成し、サーバは JSON を返す
環境
バックエンドは Laravel を使いました。
- Vue.js: 2.6.10
- TypeScript: 3.6.3
- Laravel: 5.8.32
手順
- Vue.js を MPA で使えるようにする
- データを API から渡すようにする
- すべてのページを API 方式に移行できたら一気に Vue Component に置き換える
- TypeScript を導入する & エントリポイントのみ移行する
- 好きなところから TypeScript へ書き換える
ステップ0: 現状の構成
Laravel ではインストール直後から Vue.js が使える状態ですが、現状は使用しておらず、Laravel 付属の Blade テンプレートエンジンから HTML を出力し、DOM 操作は jQuery または Vanilla JS で行っています。
ステップ1: Vue.js を MPA で使えるようにする
一般的な Laravel + Vue.js で SPA するときの連携
app.js
const app = new Vue({
el: '#app',
});
layouts/default.blade.php
<body>
<div id="app">
@yield('content')
</div>
<script src="{{ mix('js/app.js') }}"></script>
</body>
tasks/index.blade.php
@extends('layouts.default')
@section('content')
<div class="p-tasks">
@foreach (tasks as $task)
...
@endforeach
</div>
@endsection
MPAで使う場合
各ページにIDを振り、それに応じて Vue インスタンスを生成してやります。
app.js
const initVue = (page) => {
if (!document.querySelector(page.id)) {
return null
}
return new Vue({
el: page.id,
components: page.components
})
}
const pages = [
{ id: '#page-tasks-index' { TaskIndex }},
{ id: '#page-tasks-create', { TaskCreate }},
]
for (let i = 0; i < pages.length; i++) {
initVue(pages[i])
}
layouts/default.blade.php
<body>
<div>
@yield('content')
</div>
<script src="{{ mix('js/app.js') }}"></script>
</body>
tasks/index.blade.php
@extends('layouts.default')
@section('content')
<div id="page-tasks-index" class="p-tasks">
<task-index tasks="{{ tasks.toJson() }}" />
</div>
@endsection
プロパティには、JSON オブジェクトを渡すようにします。
ステップ2: データを API から渡すようにする
ステップ 1 でデータを JSON で渡すようにしたので、次にそのエンドポイントを JS 側から叩くように変更します。
tasks/index.blade.php
@extends('layouts.default')
@section('content')
<div id="page-tasks-index" class="p-tasks">
<task-index/>
</div>
@endsection
pages/tasks/index.vue
<template>
<div>
<ul>
<li v-for="task in tasks" :key="`task_${task.id}`">
{{ task.title }}
</li>
</ul>
</div>
</template>
<script>
import Vue from 'vue'
import taskApi from '@/api/task'
export default Vue.extend({
data() {
return {
tasks: [],
}
},
created() {
this.tasks = taskApi.all()
},
})
</script>
taskApi は内部で axios 経由でバックエンドの API を叩いてレスポンスを返すモジュールです。
ステップ3: すべてのページを API 方式に移行できたら一気に Vue Component に置き換える
pages ディレクトリにコンポーネントが揃ったら、Vue Router を使ったルーティングに一気に置き換えます(ここだけは一気にやってしまったほうがいいと思います)。
TypeScript 導入のあとでも大丈夫です。
このステップにはいくつかやることがあるので、それを書いておきます。
フロントエンドとサーバサイドを分けるか決める
分ける → API 以外のルーティングはすべて消す
分けない → サーバサイドのルーティングは / だけ残す
認証方式を決める
- セッション
- 独自トークン
- JWT
分離する前はセッションを共有し、認証もセッション経由でやって、分離できたらトークン方式(Authorization ヘッダに入れる形)にするのがいいかな、と思います。
変更後
app.js
import Vue from 'vue'
import router from './router'
import App from './App.vue'
new Vue({
router,
render: h => h(App)
}).$mount("#app")
App.vue
<template>
<div id="app">
<router-view />
</div>
</template>
router.js
import Vue from 'vue'
import Router from 'vue-router'
import TaskIndexPage from './pages/task/index.vue'
import TaskCreatePage from './pages/task/create.vue'
Vue.use(Router)
export default new Router({
mode: 'history',
base: process.env.BASE_URL,
routes: [
{
path: '/',
name: 'home',
component: TaskIndexPage
},
{
path: '/tasks/create',
name: 'tasks-create',
component: TaskCreatePage
},
]
})
ステップ4: TypeScript を導入する & エントリポイントのみ移行する
$ yarn add -D typescript ts-loader
$ mv resources/js/app.js resources/js/app.ts
$ mv resources/js/router.js resources/js/router.ts
app.ts, router.ts は拡張子を変えるだけで大丈夫です。
tsconfig.json, shims-vue.d.ts を用意します。
tsconfig.json
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"strict": true,
"allowJs": true,
"jsx": "preserve",
"importHelpers": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"baseUrl": ".",
"paths": {
"@/*": [
"resources/js/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
},
"include": [
"resources/js/**/*.ts",
"resources/js/**/*.tsx",
"resources/js/**/*.vue"
],
"exclude": [
"node_modules"
]
}
緩やかな移行のために "allowJs": true
を入れておきます。
Vue.js のための型定義ファイルをルート(Laravel を使っていて、かつフロントエンドを分離していなければ /resources/js の直下)に置きます。
resources/js/shims-vue.d.ts
declare module "*.vue" {
import Vue from "vue";
export default Vue;
}
webpack.config.js の変更点は割愛します。.ts を ts-loader で解決するようにすればOKです。
ちなみに今回 Laravel(Mix)を使ったので以下の変更だけで済みました。
diff --git a/webpack.mix.js b/webpack.mix.js
index 8a923cb..963d738 100644
--- a/webpack.mix.js
+++ b/webpack.mix.js
@@ -11,5 +11,5 @@ const mix = require('laravel-mix');
-mix.js('resources/js/app.js', 'public/js')
+mix.ts('resources/js/app.ts', 'public/js')
.sass('resources/sass/app.scss', 'public/css');
ステップ5: 好きなところから TypeScript へ書き換える
lang を ts にすれば、コンポーネントごとに TypeScript を書き始めることができます。新規のコンポーネントからやるのもよし、複雑なドメインの処理があるコンポーネントからやるのもよし、自由に選択できるのも Vue.js in TypeScript のいいところです。
<script lang="ts">
おわりに
私の観測範囲だと、新規でつくるなら最初から SPA でつくることがほとんどで、過去のプロジェクトで MPA を採用したものはページ数が多くて移行ができにくくなっているのが現状で、従ってこの記事のニーズはほとんどないと思っていますが、現時点でそれほどページ数が多くなく、SPA への移行を検討しているプロジェクトがあれば、ぜひともチャレンジしていただきたいな、と思っています。
間違いなどあれば、コメント欄にてお知らせください