Help us understand the problem. What is going on with this article?

MPA から少しずつ SPA w/ Vue.js in TypeScript へ移行していく流れ

この記事について

以前 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

手順

  1. Vue.js を MPA で使えるようにする
  2. データを API から渡すようにする
  3. すべてのページを API 方式に移行できたら一気に Vue Component に置き換える
  4. TypeScript を導入する & エントリポイントのみ移行する
  5. 好きなところから 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 への移行を検討しているプロジェクトがあれば、ぜひともチャレンジしていただきたいな、と思っています。

間違いなどあれば、コメント欄にてお知らせください :bow:

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away