Laravel で Svelte を使う 〜 よりシンプルを求めて2 〜

こんにちは❗フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、以前このブログでは以下のような記事をお届けしました。

📝 過去記事: Laravel + Livewire で CRUD を実装してみる 〜 よりシンプルを求めて 〜

理由としては、Vue.js23のメジャーアップデートで少し複雑になり、最大のメリット「シンプルなのに高機能」を失った感があったため、他のテクノロジーも調査してみたかったからです。(とはいえ、現在も私のメインはVue.jsを使ってますが)

そしてその流れで、今回はTwitterなどでも見かける「Svelte」を試してみることにしました。

Svelteとは、これまでのJavaScriptとは少し違ったアプローチで、フレームワークというよりは「コンパイラ」という位置づけです。

以下は、Svelte公式ページの説明を DeepL で翻訳したものです。(DeepLのクオリティ、すごいですね😳)

Svelte は、ユーザーインターフェイスを構築するための抜本的な新アプローチです。React や Vue のような従来のフレームワークがブラウザ上で作業の大部分を行うのに対し、Svelte はその作業をアプリのビルド時に発生するコンパイルステップにシフトさせます。

そこで❗

今回は、Laravel + Svelteの基本的な使い方をまとめてみることにしました。

ぜひ何かの参考になりましたら嬉しいです。😄✨

「いらないモノをあげた途端、
逆に目にはモノモライ…😣」

開発環境: Laravel 8.x

Laravel のインストールについて

今回はビルド環境もからんでくるため、LaravelSvelteを試す場合は新規インストールすることをおすすめします。

Laravel Mix 用の Svelte エクステンションをインストールする

Laravel MixにはSvelteの拡張が用意されています。
ということで、以下のコマンドでこの拡張をインストールしてください。

npm install wewowweb/laravel-mix-svelte

※ ちなみに、もしLaravelを新規インストールしている場合はnpm installも実行してください。

Laravel Mix の設定を変更する

次にLaravel Mix(ビルド環境)でSvelteが使えるようにします。

webpack.mix.js

const mix = require('laravel-mix');

require('laravel-mix-svelte');

mix.js('resources/js/app.js', 'public/js')
    .svelte();

Svelt のコンポーネントをつくる

ここからがSvelteの作業になります。

コンポーネント本体をつくる

まずはシンプルに「タイトルです」と表示されるだけのコンポーネントをつくってみます。

resources/js/svelte/components/App.svelte

<div>
    <h1>タイトルです</h1>
</div>

ビルドできるように登録する

次に、ファイルを作成しただけではコンポーネントが使えるようにはなりませんので、ビルドできるように登録をします。

resources/js/app.js

require('./bootstrap');

import App from './svelte/components/App.svelte';

new App({
    target: document.querySelector('#app')
});

ビルドする

では、この状態でビルドしてみましょう。
以下のコマンドを実行してください。

npm run dev

これで(エラーがなければ)/js/app.jsが作成されることになります。

コンポーネントを使う

では、ビルドされたコンポーネントを使ってみましょう。
まずは、ビューです。

resources/views/home.blade.php

<html>
    <body>
        <!-- 👇ここに挿入されます -->
        <div id="app"></div>
        <script src="{{ asset('js/app.js') }}"></script>
    </body>
</html>

そして、ルートへ登録します。

routes/web.php

Route::get('/', fn() => view('home'));

では、この状態でブラウザを確認してみましょう。

はい❗

うまく表示されました。
これが基本的な使い方です 👍

JavaScript & CSS をつかう

では、次にコンポーネントの中でJavaScriptCSSを使ってみましょう。といっても、これはSvelteの良い部分ですが、ほぼHTMLと同じように使うことができます。

resources/js/svelte/components/App.svelte

<script>

    let time = '';

    setInterval(() => {

        time = Date.now();

    }, 1000);

</script>

<style>

    #title {

        background-color: #ff0000;
        color: #ffffff;

    }

</style>

<div>
    <h1 id="title">タイトルです</h1>
    <div>{time}</div>
</div>

では、これをビルドしてみましょう。

はい❗
色がつき、時間が経つごとに内容が変更されるようになりました。

なお、{time}となっている部分がlet time;の中身をリアルタイムで表示してくれています。

CRUDを実装する

では続いて、Svelte + LaravelCRUD(追加・表示・変更・削除)を実装してみましょう。

DBまわりを準備する

まずモデル&マイグレーションですが、Livewireの記事と同じものを使います。詳しくは以下を参考にしてください。

📝 参考ページ: Laravel + Livewire で CRUD を実装してみる 〜 よりシンプルを求めて 〜:DBまわりを用意する

実際のテーブルはこうなります。

コントローラーをつくる

Livewireと違い、ajaxなどのメソッドが必要になるので、コントローラーを用意します。

以下のコマンドを実行してください。

php artisan make:controller ArticleController

するとファイルが作成されるので、中身を次のように変更してください。

app/Http/Controllers/ArticleController.php

<?php

namespace App\Http\Controllers;

use App\Models\Article;
use Illuminate\Http\Request;

class ArticleController extends Controller
{
    public function index()
    {
        return view('article.index');
    }

    public function list()
    {
        return Article::select('id', 'title', 'content')->paginate(10);
    }

    public function store(Request $request)
    {
        // バリデーションは省略してます

        $article = new Article();
        $article->fill($request->only(['title', 'content']));
        $result = $article->save();

        return ['result' => $result];
    }

    public function update(Article $article, Request $request)
    {
        // バリデーションは省略してます

        $article->fill($request->only(['title', 'content']));
        $result = $article->save();

        return ['result' => $result];
    }

    public function destroy(Article $article)
    {
        // バリデーションは省略してます

        $result = $article->delete();

        return [ 'result' => $result];
    }
}

ビューをつくる

先ほどのコントローラーの中でセットしたビューをつくります。

resources/views/article/index.blade.php

<html>
<head>
    <link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
</head>
<body>
<div id="app" class="p-5"></div>
<script src="{{ asset('js/app.js') }}"></script>
</body>
</html>

ルートをつくる

そして、ルートです。

routes/web.php

use App\Http\Controllers\ArticleController;

Route::get('article', [ArticleController::class, 'index']);
Route::get('article/list', [ArticleController::class, 'list']);
Route::post('article', [ArticleController::class, 'store']);
Route::put('article/{article}', [ArticleController::class, 'update']);
Route::delete('article/{article}', [ArticleController::class, 'destroy']);

Svelte の部分をつくる

では、メインのSvelte部分です。
今回作成するのは以下3ファイルです。

  • App.svelte: ページの基本になる部分。この中から以下2つのコンポーネントを呼び出します
  • ArticleList.svelte: 記事データの一覧を表示します。今回はシンプルなページネーションをつけます
  • ArticleInput.svelte: 登録・変更するための入力フォームです

では、1つずつご紹介します。

resources/js/svelte/components/App.svelte

<script>

    import ArticleList from "./article/ArticleList.svelte";
    import ArticleInput from "./article/ArticleInput.svelte";

    let params = {
        id: '',
        title: '',
        content: ''
    };
    let articleList; // コンポーネント内のメソッドを実行するために必要

    const onEdit = e => {

        params = e.detail.article;

    };

</script>

<div>
    <h1 class="text-3xl mb-4">
        Svelte のCRUDサンプル
    </h1>
    <div class="grid grid-cols-2 gap-7">
        <div>
            <ArticleList bind:this={articleList} on:article-edit={onEdit}></ArticleList>
        </div>
        <div>
            <ArticleInput params="{params}" on:article-saved={articleList.getArticles}></ArticleInput>
        </div>
    </div>
</div>

この中でArticleListArticleInputを呼び出しています。

また、重要なのがbind:this={articleList}の部分で、これはArticleList.svelte内にあるメソッドgetArticles()を呼び出すために必要になります。(Svelteが初見なのでもっとスマートな方法があるのかも・・・😅)

resources/js/svelte/components/article/ArticleList.svelte

<script>

    import { onMount, createEventDispatcher } from 'svelte';

    const dispatch = createEventDispatcher();
    let articles = [];
    let page = 1;
    let hasPrevPage = false;
    let hasNextPage = false;

    export const getArticles = () => {

        const url = `/article/list?page=${page}`;
        axios.get(url)
            .then(response => {

                articles = response.data.data;
                hasPrevPage = response.data.prev_page_url !== null;
                hasNextPage = response.data.next_page_url !== null;

            });

    };
    const onEdit = article => {

        dispatch('article-edit', { article: article });

    };
    const onDelete = article => {

        if(confirm('削除します。よろしいですか?')) {

            const url = `/article/${article.id}`;
            axios.delete(url)
                .then(response => {

                    if(response.data.result === true) {

                        getArticles();

                    }

                });

        }

    }
    const onMovePage = mode => {

        if(mode === 'next') {

            page++;

        } else if(mode === 'prev') {

            page--;

        }

        getArticles();

    }

    onMount(() => {

        getArticles();

    });

</script>

<div>
    <div class="pb-4 text-sm">
        (ArticleList)
    </div>
    <table class="w-full text-sm mb-5">
        <thead>
        <tr>
            <th class="border p-2">タイトル</th>
            <th class="border p-2">本文</th>
            <th class="border p-2">操作</th>
        </tr>
        </thead>
        <tbody>
        {#each articles as article}
        <tr>
            <td class="border px-2 py-1">{article.title}</td>
            <td class="border px-2 py-1">{article.content}</td>
            <td class="border px-2 py-1 text-right">
                <button
                    type="button"
                    class="bg-yellow-500 text-yellow-50 rounded p-2 text-xs" on:click={onEdit(article)}>
                    変更
                </button>
                <button
                    type="button"
                    class="bg-red-600 text-red-50 rounded p-2 text-xs" on:click={onDelete(article)}>
                    削除
                </button>
            </td>
        </tr>
        {/each}
        </tbody>
    </table>

    {#if hasPrevPage === true}
        <button
            type="button"
            class="bg-blue-500 text-blue-50 rounded p-2 text-xs" on:click={() => onMovePage('prev')}>
            前へ
        </button>
    {/if}
    {#if hasNextPage === true}
        <button
            type="button"
            class="bg-blue-500 text-blue-50 rounded p-2 text-xs" on:click={() => onMovePage('next')}>
            次へ
        </button>
    {/if}

</div>

この中で重要なのは、「独自イベント」を用意している部分です。
イベントを用意するには、以下のようにイベント機能を呼び出し、

const dispatch = createEventDispatcher();

そして、以下のように実行します。(article-editがイベント名、そして第2引数は送信するデータです)

dispatch('article-edit', { article: article });

すると、呼び出し元であるApp.svelteで以下のようにイベントを受け取ることできるようになります。

ArticleList bind:this={articleList} on:article-edit={onEdit}></ArticleList>

resources/js/svelte/components/article/ArticleInput.svelte

<script>

    import { createEventDispatcher } from 'svelte';

    const dispatch = createEventDispatcher();
    export let params = { // 親コンポーネントからアクセスできるように props 化
        id: '',
        title: '',
        content: '',
    };
    let resultMessage = '';
    let isModeCreate = false;
    let isModeEdit = false;

    // Vue で言うところの computed
    $: {
        isModeCreate = (params.id === '');
        isModeEdit = (params.id !== '');
    }

    const onSubmit = () => {

        if(confirm('送信します。よろしいですか?')) {

            let url = '';
            let additionalParams = {};

            if(isModeCreate) {

                url = '/article';

            } else if(isModeEdit) {

                url = `/article/${params.id}`;
                additionalParams = { _method: 'PUT' };

            }

            const data = Object.assign({}, params, additionalParams);

            axios.post(url, data)
                .then(response => {

                    if(response.data.result === true) {

                        dispatch('article-saved');

                        resultMessage = '保存が完了しました!';

                        setTimeout(() => { // 3 秒後にメッセージをクリア
                            resultMessage = '';
                        }, 3000);

                        params = {
                            id: '',
                            title: '',
                            content: '',
                        };

                    }

                });

        }

    }

</script>

<div>
    <div class="pb-4 text-sm">
        (ArticleInput)
    </div>
    <div>
        {#if resultMessage !== ''}
            <div class="text-green-700 p-3 bg-green-300 rounded mb-3">
                {resultMessage}
            </div>
        {/if}
    </div>
    <div class="mb-3">
        <label for="title">タイトル</label>
        <br>
        <input id="title" type="text" class="border w-full p-1" bind:value="{params.title}">
    </div>
    <div class="mb-4">
        <label for="content">本文</label>
        <br>
        <textarea id="content" rows="7" class="border w-full p-1" bind:value="{params.content}"></textarea>
    </div>
    {#if isModeCreate}
        <button type="submit" class="bg-purple-700 text-purple-50 p-2 rounded" on:click={onSubmit}>登録する</button>
    {:else if isModeEdit}
        <button type="submit" class="bg-blue-700 text-blue-50 p-2 rounded" on:click={onSubmit}>変更する</button>
    {/if}
</div>

この中で重要なのが、以下のparamsをセットしている部分です。

export let params = {
    id: '',
    title: '',
    content: '',
};

ここにはexportが使われていますが、これは親コンポーネントからアクセスするためです。

そのため、親コンポーネントであるApp.svelteでは、以下のようにparamsをセットしておけば、どこからでも子コンポーネントであるArticleInput内のparamsを変更できるようになります。

<ArticleInput params="{params}" on:article-saved={articleList.getArticles}></ArticleInput>

👇どこからでもparamsにアクセスできます

const onEdit = e => {

    params = e.detail.article; // 子コンポーネントの内容が変更になる

};

テストしてみる

では、実際にテストしてみましょう❗
まずはブラウザで「https://******/article」へアクセスします。

はい❗

ちょっと画像が小さいですが、ArticleListArticleInputを表示することができました。

では、ページネーションの「次へ」をクリックしてみましょう。

すると・・・・・・

2ページ目の内容が表示されました❗
では、次にフォームに以下のように入力し、新規登録してみましょう。

どうなったでしょうか・・・・・・??

はい❗
新しいデータが一覧に表示されました。

では、変更ボタンをクリックしてみます。

すると、空の入力フォームに登録内容がセットされました。

では、内容を以下のように変更して保存してみましょう。

どうなるでしょうか・・・・・・?

はい❗
内容が変更されました。

では、最後に削除です。
分かりやすいようにページを1ページまで戻して3行目のデータを削除してみましょう。

すると・・・・・・

はい❗
タイトル3」のデータが削除されました。

すべて成功です。😄✨

企業様へのご提案

今回ご紹介したようにSvelteLaravelと連携して利用することもできます。

もしLaravel + Svelteの開発をご希望でしたらぜひお問い合わせよりご連絡ください。

お待ちしております。m(_ _)m

ちなみに: 使ってみた感想

ちなみにSvelteを使ってみた感想をまとめてみました。
完全に「個人の感想です」というやつですが、もし良ければ参考にしてみてください。(今回初見なので、もっとスマートな書き方があるかもですが、その場合はぜひ教えてください。m(_ _)m)

いいなと思ったところ

  • 可読性がいい
  • $: で一気にリアクティブな変数をつくれるのは良い
  • Vue との設計思想が共通する部分があるので移行しやすい
  • ネイティブな JavaScript っぽく書けるので、よくあるフレームワークの「くせ」は少ない
  • Vue ではよく使う this が不要
  • コンパイルが想定してたよりも高速だった

気になったところ

  • Laravelとの連携がそこまでシンプルには感じなかった
  • Blade のように PHP のパラメータをシンプルに埋め込めない
  • イベントにパラメータをつける場合、「() => onTest(‘parameter’)」というように書かないといけない
  • ページネーションは自作しないといけない(Livewire だとそのまま使える)
  • うーん、でもやっぱり毎回ビルドするのは好きじゃない…
  • ページ移動部分は Svelte 側に書くか、Laravel 側の route を使うかの2択ですが、どちらにしても少し工夫が必要(個人的には SPA 的にはしたくないんですよね…)

おわりに

ということで、今回はLaravel + Svelteの使い方をまとめてみました。

個人的には、「そこまでシンプルではないかな😣」という結論になりましたので、今回の記事テーマとしては少し残念な結果になってしまいました。

ただ、Laravelとの連携がそれほどシンプルではないだけで、Svelte自体はシンプルにコードを書くことができると思います。(新しいアプローチも素晴らしいですよね👍)

なお、この辺りは個人の好みで違うと思うので、ぜひ皆さん自身で試してみてください。

ではでは〜❗


「久石譲さんがつくった
天外魔境Ⅱの曲、いいですね❗」

開発のご依頼お待ちしております 😊✨
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします!
このエントリーをはてなブックマークに追加       follow us in feedly  

開発効率を上げるための機材・まとめ