LoginSignup
47
49

More than 3 years have passed since last update.

「グッドバイ、クライアントサイドルーティング」 LaravelとInertiaでサーバー駆動SPA

Posted at

最近のLaravelでのSPA開発ではクライアントサイドとサーバーサイドを分離して、サーバーサイドでもクライアントサイドでもルーティングを書いたり、従来のblade等のテンプレートを用いた開発と比べるとめんどくさいことをすることが多いと感じていたので、
従来のMVCフレームワークの開発手法に近い方法で開発できればなーと思ってたのだが、同じような考えの人がいたみたいでライブラリを作ってくれていたので紹介してみる。

[Inertia] SPA開発をモノリシックに回帰してくれるライブラリ

https://inertiajs.com/

Inertia has no client-side routing, nor does it require an API. Simply build controllers and page views like you've always done!

Inertiaにはクライアント側のルーティングはなく、APIも必要ありません。いつものように、コントローラーとページビューを作成するだけです!
とのこと。

対応状況

  • 対応しているサーバーサイドフレームワーク
    • Laravel
    • Rails
  • 対応しているクライアントサイドライブラリ
    • Vue.js
    • React
    • Svelte

Svelteてなんやねん^^;
[ReactとVueを改善したSvelteというライブラリーについて]

クライアント側InertiaのTypescriptの対応

index.d.tsがライブラリ内にあるので、普通に使えると思う。

サーバーサイドレンダリングとプリレンダリング

サーバーサイドレンダリングは今後対応するかもと書いてはる。
プリレンダリングはPrerender.ioこの辺使って頑張ってねとのこと。

Inertiaを採用しているサイト

https://builtwithinertia.com/

Inertiaを試してみる

今回はLaravelアドベントカレンダーの記事なので、LaravelとVueを選択する。
作成したものは公式のサンプル(https://github.com/inertiajs/pingcrm)のminimal版、LaravelデフォルトのUserモデルを使用してUserの一覧、新規作成、編集、認証ができる。

リポジトリ
https://github.com/takashi11171117/inertia_sample

以下の説明は、これの抜粋。

必要なライブラリのインストール

# phpのライブラリをインストール
composer require inertiajs/inertia-laravel

# jsのライブラリをインストール
yarn add @inertiajs/inertia @inertiajs/inertia-vue @babel/plugin-syntax-dynamic-import vue vue-template-compiler

yarnの@babel/plugin-syntax-dynamic-importは、app.jsのcode splittingに必要ならしい。
他、Component・Css関連のライブラリをリポジトリでは使用(記事のコードでは、コードが長くなるので端折る。)

セットアップ

app.blade.php
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
    <link href="{{ mix('/css/app.css') }}" rel="stylesheet" />
    <script src="{{ mix('/js/app.js') }}" defer></script>
  </head>
  <body>
    @inertia
  </body>
</html>

app.blade.phpが初期設定でクライアントの起点になっているぽい。
変更したい場合は、ドキュメント見よう。
body内にinertiaディレクティブを書くだけ。

AppServiceProvider.php
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Inertia\Inertia;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        $this->registerInertia();
    }

    public function registerInertia()
    {
        Inertia::version(function () {
            return md5_file(public_path('mix-manifest.json'));
            // mixでasset作る時のバージョニング設定
        });
    }

    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        //
    }
}
app.js
import Vue from 'vue'
import { InertiaApp } from '@inertiajs/inertia-vue'

Vue.use(InertiaApp)

let app = document.getElementById('app')

new Vue({
    render: h => h(InertiaApp, {
        props: {
            initialPage: JSON.parse(app.dataset.page),
            resolveComponent: name => 
            import(`@/Pages/${name}`).then(module => module.default),
            // Laravelで設定されたVueファイルの解決、後でLaravelのControllerでvueファイルをrenderメソッドに設定する
        },
    }),
}).$mount(app)
webpack.mix.js
const mix = require('laravel-mix')
const path = require('path')

mix.js('resources/js/app.js', 'public/js')
.sass('resources/sass/app.scss', 'public/css')
.webpackConfig({
    output: { chunkFilename: 'js/[name].js?id=[chunkhash]' },
        // assetのバージョニング設定
    resolve: {
        alias: {
            'vue$': 'vue/dist/vue.runtime.esm.js',
            '@': path.resolve('resources/js'),
        },
    },
})
.babelConfig({
    plugins: ['@babel/plugin-syntax-dynamic-import'], // code splittingにdynamic-importを使うらしい
});

mix.version()
.sourceMaps();

とりあえずのセットアップは、これだけ。

ただLaravelからVueに静的な値を渡すページを作成してみる

routes/web.php
Route::get('/')->uses('DashboardController');

いつも通りrouteの設定を書く

DashboardController.php
<?php

namespace App\Http\Controllers;

use Inertia\Inertia;

class DashboardController extends Controller
{
    public function __invoke()
    {
        return Inertia::render('Dashboard/Index', [
            'greeting' => 'Hi There!'
        ]);
    }
}

returnをInertia\Inertiaのrenderメソッドに置き換える。
このメソッドを表示するためのvueファイルを指定する、app.jsのimport(`@/Pages/${name}`)の行で解決する。この場合は、resources/js/Pages/Dashboard/Index.vueを参照している。
コンポーネントに渡す変数の定義はbladeに渡す時みたいにいつも通りにやる。

resources/js/Pages/Dashboard/Index.vue
<template>
    <div>
        <h1>Dashboard</h1>
        <p>{{ greeting }} Welcome to Inertia Sample</p>
    </div>
</template>

<script>
    import Layout from '@/Shared/Layout'
    export default {
        layout: Layout,
        props: {
            greeting: String,
        },
    }
</script>

LaravelのControllerで渡した変数は、コンポーネント側のpropsで受け取る。

データを参照するだけならこれだけで終わる、クライアント側にaxiosとかvueRouterとかがいらないのでめっちゃ楽

リンク

resources/js/Layouts/MainMenu.vue
<inertia-link href="/" method="get">
ダッシュボード
</inertia-link>

リンクは、inertia-linkというcomponentを使用する。
hrefにLaravelのrouteのurlを記述すればいい。

フォームを試す

routes/web.php
Route::get('users')->name('users')->uses('UsersController@index');
Route::get('users/create')->name('users.create')->uses('UsersController@create');
Route::post('users')->name('users.store')->uses('UsersController@store');
Route::get('users/{user}/edit')->name('users.edit')->uses('UsersController@edit');
Route::put('users/{user}')->name('users.update')->uses('UsersController@update');

ルーティングは普通に書く

UsersController.php
    public function store()
    {
        Request::validate([
            'name' => ['required', 'max:50'],
            'email' => ['required', 'max:50', 'email', Rule::unique('users')],
            'password' => ['nullable'],
        ]);

        User::create([
            'name' => Request::get('name'),
            'email' => Request::get('email'),
            'password' => Request::get('password'),
        ]);

        return Redirect::route('users')->with('success', 'User created.');
    }

storeメソッドとかはいつも通りに書く、Redirectもlaravelのroute渡せば、Inertiaがよしなにやってくれる。
Redirectのwithメソッドとか使ってフラッシュしたい場合,providerにも記述が必要(後で出てくる)。

resources/js/Pages/Users/Create.vue
<template>
    <div>
        <h1 class="mb-8 font-bold text-3xl">
            <inertia-link class="text-indigo-light hover:text-indigo-dark" href="/users">Users</inertia-link>
            <span class="text-indigo-light font-medium">/</span> Create
        </h1>
        <div class="bg-white rounded shadow overflow-hidden max-w-lg">
            <form @submit.prevent="submit">
                <div class="p-8 -mr-6 -mb-8 flex flex-wrap">
                    <text-input v-model="form.name" :errors="$page.errors.name" class="pr-6 pb-8 w-full lg:w-1/2" label="Name" />
                    <text-input v-model="form.email" :errors="$page.errors.email" class="pr-6 pb-8 w-full lg:w-1/2" label="Email" />
                    <text-input v-model="form.password" :errors="$page.errors.password" class="pr-6 pb-8 w-full lg:w-1/2" type="password" autocomplete="new-password" label="Password" />
                </div>
                <div class="px-8 py-4 bg-grey-lightest border-t border-grey-lighter flex justify-end items-center">
                    <loading-button :loading="sending" class="btn-indigo" type="submit">Create User</loading-button>
                </div>
            </form>
        </div>
    </div>
</template>

<script>
    import Layout from '@/Shared/Layout'
    import LoadingButton from '@/Shared/LoadingButton'
    import TextInput from '@/Shared/TextInput'

    export default {
        layout: Layout,
        components: {
            LoadingButton,
            TextInput,
        },
        remember: 'form',
        data() {
            return {
                sending: false,
                form: {
                    name: null,
                    email: null,
                    password: null,
                },
            }
        },
        methods: {
            submit() {
                this.sending = true
                const data = new FormData()
                data.append('name', this.form.name || '')
                data.append('email', this.form.email || '')
                data.append('password', this.form.password || '')
                this.$inertia.post('/users', data)
                .then(() => this.sending = false)
            },
        },
    }
</script>

axiosみたいな、\$inertiaが提供されてるので、初回のGET処理以外のリクエストは、これで投げる。
あとのform処理はいつも通り普通に書く。
\$page.errorsからサーバーサイドのバリデーションエラーが、取得できるが、providerにも記述が必要(後で出てくる)。

バリデーションエラー

Inertiaでのサーバー側のバリデーションエラー(422応答)の処理は、レスポンスでバリデーションエラーをキャッチしてからフォームの状態を更新する従来のajaxフォームとは少し異なる。

\$inertiaでRequestを投げてバリデーションエラーが出るとサーバ側でそのフォームページにリダイレクトするようになっていて、その際にセッションでエラーを返すようになっている。
従来のサーバーサイドバリデーション処理みたいな感じ。

コントローラごとにセッションをコンポーネントに渡せるけど、
すべてのページコンポーネントに対して自動的にこれを行う方がいいので、これを実現するため、Inertia共有機能を使用する。

AppServiceProvider.php
        Inertia::share([
            'errors' => function () {
                return Session::get('errors')
                    ? Session::get('errors')->getBag('default')->getMessages()
                    : (object) [];
            },
        ]);

Inertia::ShareでSessionのerrorを共有すると、どのコンポーネントからも$page.errorsから取得できるようにできる。

フラッシュ

フラッシュもバリデーションエラーと同じく一時的にSESSIONに格納されるので、
Innertia::Shareで共有する

AppServiceProvider.php
        Inertia::share([
            'flash' => function () {
                return [
                    'success' => Session::get('success'),
                    'error' => Session::get('error'),
                ];
            },
        ]);

これで、$page.flashでどのコンポーネントからも取得できる。
サンプルリポジトリでは、resources/js/Shared/FlashMessages.vueで使用している

フォームの状態のキャッシュ

Inertiaは、ローカルコンポーネントの状態の保存はできないが、
browser historyにキャッシュを保存できるみたい

resources/js/Pages/Users/Create.vue
// ~~~~~~~~~~
        remember: 'form',
        data() {
            return {
                sending: false,
                form: {
                    name: null,
                    email: null,
                    password: null,
                },
            }
        },
// ~~~~~~~~~~

remenberでキャッシュしたいstateを指定するとbrowser historyにキャッシュできる。
これをするとページ遷移してから、history backした場合フォームの状態が維持される。

認証を試す

JWTとか使わなくとも従来の方法で認証機能を作成できる。

routes/web.php
Route::get('login')->name('login')->uses('Auth\LoginController@showLoginForm')->middleware('guest');
Route::post('login')->name('login.attempt')->uses('Auth\LoginController@login')->middleware('guest');
Route::post('logout')->name('logout')->uses('Auth\LoginController@logout');

ルーティングはLaravelの標準機能を使用して普通に書く

Controllers/Auth/LoginController.php
<?php

namespace App\Http\Controllers\Auth;

use Inertia\Inertia;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;

class LoginController extends Controller
{
    use AuthenticatesUsers;

    protected $redirectTo = '/';

    public function showLoginForm()
    {
        return Inertia::render('Auth/Login');
    }
}

ログインページをInertiaでレンダリングする。
あとはコンポーネント側の各所でrouteのloginにpostするか、logoutにpostする。

AppServiceProvider.php
        Inertia::share([
            'auth' => function () {
                return [
                    'user' => Auth::user() ? [
                        'id' => Auth::user()->id,
                        'name' => Auth::user()->name,
                        'email' => Auth::user()->email,
                    ] : null,
                ];
            },
        ]);

必要なら、Inertia::shareでauthをコンポーネントで共有する。
これだけ、非常に簡単。

Inertiaでのグローバルストア

SPAではVueではvuex、Reactではreduxとか使用してコンポーネント共通の状態を管理する。
しかし、Inertiaでは使わない。(たぶん)
従来のサーバーサイド開発のようにセッションで管理して、Inertia::shareでコンポーネント間で共有すると思う。(たぶん)

まとめ

Inertiaは、従来のLaravelでのSPA開発より、bladeでの開発と同じ感覚で使用できるので、開発コストの低減が期待でき良さそうだ。
試してないけどPWAはいけるんかな?

47
49
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
47
49