ご質問・お見積り等お気軽にご相談ください
お問い合わせ

CakePHP ユーザーのための Laravel Livewire 超入門

CakePHP ユーザーのための Laravel Livewire 超入門

Livewire という超便利なプラグインを使おう

こんにちは、うのです。

以前の記事『何も知らない Cakeユーザー が Laravel8 入門を Jetstream で着地するまで』で、 Laravel8を「Jetstream」というパッケージと一緒にインストールするところまで扱いました。

本来、Jetstreamをインストールするときに、 Jetstreamと一緒に使用するプラグインとして

  • Livewireか
  • InertiaJSか

の選択があるのですが、 こっそりスルーして Livewire を選択していました。

 

▲ Livewire のロゴ。公式サイトは英語なので、翻訳しながら読むと吉。

 

「 Livewire 」は、Laravelで使えるプラグインの一つなのですが、 こっそり選んだにしてはとても便利だったので、皆さんにも紹介しようと思います。

 

この記事では、 「Livewireを理解するために押さえておくべき思想」 をまとめます。 CakePHPの代わりのLaravel、そのプラグインのLivewire……という認識だと分かりにくかったので。

細かい使い方なら調べればたくさん転がっているので、 ここではあまり教えてくれない、「思想」について語ります。

 

押さえておくべき思想は、

  • MVVM フレームワーク
  • コンポーネント&イベント駆動型プログラミング

です。

 

念のために言っておきますが、 ここで語るのは Livewire 超初心者が語る見解 ですので、 悪しからず。

MVVMフレームワーク

Laravelは Model, View, Controller の機能を持った、 いわゆる 「MVCフレームワーク」 として説明されることが多いです。 ですが、 Livewire を導入した Laravel プロジェクトはMVCというよりも 「MVVMフレームワーク」 としての特性を強く持つようになります。

 

MVVMとは、アプリケーションを

  • データの特性を定義する 「Model」、
  • 画面設計をする 「View」、
  • その画面が持つデータと、その処理を管理する 「View Model」

に分けて考えるプログラミングの手法です。(下図)

 

MVVM の View Model と MVC の Controller の違いは、

  • MVC の Controller は入出力のみで View とデータのやり取りをする、 View とは切り離されたもの
  • MVVM の View Model は、 View とデータをリアルタイムに共有しあう、 View と密接な関係にあるもの

という点にあります。

つまり、Livewire では、通信をするコードを書かずともサーバでデータを受け取ることができるので、 直感的にコードを書くことができるということです。

▲MVVCの概略図

コンポーネント&イベント駆動型プログラミング

MVCフレームワークでコーディングをするとき、基本的には 一つの Model に対して一つの Controller 、 Controller に用意された Action 一つごとに一つの View を用意していました。 例えば Post(投稿) Model があれば、

  • 投稿 PostController
    • 投稿のリスト PostController::list() と 一覧画面 Post/list.html
    • 投稿の作成 PostController::add() と 新規作成画面 Post/add.html

と言った具合です。(下図)

 

▲ MVCにおける、ControllerとViewの関係の例。

 

Livewire では、 View と View Model が密接な関係にあるので、 View Model も View 毎、正確に言えば 「機能」 毎に作成します。 この機能は「 コンポーネント 」という単位で呼ばれます。(下図)

 

これまでひとまとめになっていた機能をコンポーネントで区切ることで、 投稿の一覧画面に投稿の新規作成機能を同時に持たせたり、 検索窓を一緒に持たせたり、 ということがカンタンに出来ます。

 

当然、データはそれぞれのコンポーネント…… View と View Model の組で持つことになりますが、 コンポーネントを越えてやり取りする方法も用意されています。 それが「 イベント 」です。

 

例えば一つの画面に「投稿の一覧」と「投稿の作成」のコンポーネントを一緒に配置した場合、 投稿を作成した時に同時に一覧の更新もしたくなると思います。

 

イベントを利用した実装では、 投稿を作成する処理の最後で、同じページに存在する他のコンポーネントに対して 「【投稿作成イベント】を発行」します。 他のコンポーネントが【投稿作成イベント】を受けると、それぞれがあらかじめ決めた動作……例えば一覧コンポーネントなら、一覧の更新をする、 といった具合に動くのです。

 

▲MVVMにおける、ViewとViewModelの関係の例。

 

アプリケーション例

ここまで抽象的な話をしてきたので、 具体的にどのように作るのかを見ていきましょう。

ここでは

  • 投稿の一覧
  • 投稿作成

の機能だけがある、 いわゆる掲示板アプリのようなものを作りましょう。

 

モデル定義やマイグレーションなどは 多くの方が記事を書いてくださっているので、そちらに丸投げするとして。 ここでは「どんな構造でアプリを作るか」ということに注目したいと思います。

 

また、ここで扱う投稿(Post)のモデル構造は

  • User テーブルと 1:1 のリレーションを持つ
  • text型のカラム「body」を持つ

というシンプルなもので考えています。(下図)

▲ 作るアプリケーションの概要。View Model に実装する内容は図よりも絞って、シンプルにする。

 

基本のテンプレート

コンポーネントを配置する基本テンプレートを作成します。 このファイルにアクセスできるよう、 routes などで設定することになります。 デフォルトで作成される welcome.blade.php を置き換えると、 細かいことを考えずとも http://localhost でアクセスできます。

 

classが沢山ありますが、 jetstreamではデザインフレームワークとして Tailwind.css を使っているので、自然とこうなります。 見やすく組めるといいんですが、使い方は模索する必要がありますね。

 

注目してほしいのは、 <livewire:~~~ /> の部分です。 こう書くだけで、コンポーネントを配置できます。シンプル。

今回は2つのコンポーネントを使います。

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            POSTS
        </h2>
    </x-slot>

    <div class="p-4 grid grid-cols-3 gap-4">
        <div class="col-span-2">
            <div class="bg-white overflow-hidden shadow-xl border-b border-gray-200 sm:rounded-lg">
                <div class="p-6 sm:px-20 overflow-y-scroll">
                    <livewire:post-list />
                </div>
            </div>
        </div>
        <div class="">
            <div class="bg-white overflow-hidden shadow-xl border-b border-gray-200 sm:rounded-lg">
                <div class="p-6 sm:px-20">
                    <h3 class="card-header mb-2 text-lg font-semibold">投稿</h3>
                    <div class="card-body">
                        <livewire:post-edit />
                    </div>
                </div>
            </div>
        </div>
    </div>
</x-app-layout>

投稿作成

投稿の作成フォームを作成します。

投稿を作成した時にイベントを発行し、 他のコンポーネント(今回は一覧)に投稿の追加を通知します。

 

 

View 部分の作成

フォームの View 部分を作成します。 フォーム本体や部品のタグは Livewire 独自のものになっています。

特に送信ボタンのテキストは、 送信した後通信が完了するまで押せなくなり、 テキストが入れ替わるようになっています。

 

ローカルで動かしている限り、 ボタンを押してからテキストが変わるまでの間に少し間があるようなので 実用に耐えうるのかはまだよくわかりません……

<form wire:submit.prevent="store">
    @auth
        {{ csrf_field() }}

        <label for="body" class="block mb-2">
            <span>本文</span>
            <textarea wire:model="body" id="body" cols="30" rows="10" class="form-textarea block w-full"></textarea>
        </label>

        <button
            wire:loading.attr="disabled"
            wire:target="store"
            class="block p-4 text-center text-white bg-blue-300 hover:bg-blue-500 rounded"
        >
            <span wire:loading.remove wire:target="store">
                Save
            </span>
            <span wire:loading wire:target="store">
                Sending...
            </span>
        </button>
    @endauth
</form>

 

 

ViewModel 部分の作成

フォームからのリクエストを受信し、 実際に処理をする部分を作ります。 View 部分で store に対してリクエストを送信するようになっているので、 フォーム送信をすると View Model の store() が実行されます。

 

View 部分で wire:model="body" と書いたフォーム部品の中身が同期されるので、 View Model では $this->body で入力を読むことができます。

 

store() ではこれを利用して投稿の作成を行い、 その後イベント「postAdded」を発行します。

 

今回は、postAdded を発行すると一覧の更新をするようにしています。 一覧の更新は後述する投稿リストの部分で実装します。

<?php
/**
 * Livewire の自作コンポーネント 「PostEdit」のコントローラ的部分。
 * 認証を必要とするが、認証部分はroutesに丸投げしている
 */

namespace App\Http\Livewire;

use Livewire\Component;
use Illuminate\Support\Facades\Auth;
use App\Models\Post; // モデル

class PostEdit extends Component
{
    public $body;

    protected $rules = [
        'body' => 'max:30000',
    ];

    /**
     * 投稿を新規保存
     *
     * @return void
     */
    public function store()
    {
        $user = Auth::user();

        $post = new Post(['body' => $this->body,]);
        $post->user()->associate($user); //対多リレーションならsaveにuserの配列を入れればいいが、対1リレーションはassociate()らしい。
        $post->save();

        // イベントの発行
        $this->emit('postAdded');
    }

    public function render()
    {
        return view('livewire.post-edit');
    }
}

 

 

 

投稿のリスト

投稿のリストを表示・制御する、 PostList コンポーネントを作成します。

新規に投稿が作成されたことを感知すると、 最新の出品を取得・一覧の更新をするように組みます。

 

 

 

View 部分の作成

コンポーネントの View 部分を作成します。 シンプルな繰り返しで記述したいと思います。

ここで使用する変数 $posts は View Model に記述します。

@forelse ~ @empty ~ @endforelse は、

if (!empty($variable)) {
    foreach ($variable as $key => $value) {
        # forelse
    }
} else {
    # empty
}

の省略です。

 

また、Userモデルは Jetstream が生成してくれたものを使用していますが、デフォルトで画像を扱う機能が見当たりませんでした。なので、 https://picsum.photos/ を使ってランダムに画像が表示されるようにしています。 気分がいいからね。

<div>
    @forelse ($posts as $post)
        <!-- A message -->
        <div class="flex items-start mb-4 text-sm">
            <img src="https://picsum.photos/200?{{ $post->user->id }}" class="w-10 h-10 rounded mr-3">
            <div class="flex-1 overflow-hidden">
                <div>
                    <span class="font-bold">{{ $post->user->name }}</span>
                    <span class="text-grey text-xs">{{ $post->created_at }}</span>
                </div>
                <p class="text-black leading-normal">
                    {{-- モデルで、 $post->body をHTMLエスケープしたうえで改行を<br>タグに変えています --}}
                    {!! $post->parsed_body !!}
                </p>
            </div>
        </div>
        <!-- A message -->
    @empty
        <p>投稿が存在しません</p>
    @endforelse
</div>

 

 

 

View Model 部分の作成

 

一覧を制御する View Model 部分を作成します。 ここで定義した $posts は View と同期します。 イベントを受信するなどして、 View Model 内で内容が更新されれば、 View 側の内容も変わるということです。 ここでは「 postAdded イベント」を受信するように設定されています。

 

<?php
namespace App\Http\Livewire;

use Livewire\Component;
use App\Models\Post; // モデル

class PostList extends Component
{
    public $page_unit = 10;
    public $posts = null;
    public $oldest = '';
    public $order = 'created_at';

    // イベントリスナー。他のコンポーネントがイベントを発行すると指定のメソッドを実行する
    // 参考 -> https://laravel-livewire.com/docs/2.x/events
    protected $listeners = [
        // 受信イベント => 発火メソッド
        'postAdded' => 'getNext',
    ];

    /**
     * コンストラクタ
     *
     * @return void
     */
    public function mount()
    {
        $this->getNext();
    }

    /**
     * 投稿を新しいものから page_unit 件取得して $posts に追加
     * 
     * @return void
     */
    public function getNext()
    {
        $query = Post::has('user');
        if(!empty($this->oldest)) $query = $query->where($this->order, '>', $this->oldest);

        $posts = $query->latest($this->order)
            ->take($this->page_unit)
            ->get()
        ;

        if (!empty($this->posts)) $this->posts = $posts->merge($this->posts);
        else $this->posts = $posts;

        $this->refleshLimit();
    }

    public function refleshLimit()
    {
        $this->oldest = $this->posts[count($this->posts) - 1]->{$this->order};
    }

    public function render()
    {
        return view('livewire.post-list');
    }
}