Laravel + HTMX = ❤️ 2x

このワクワクするチュートリアルシリーズの第2回では、LaravelとHTMXを使った現代のウェブアプリ開発の世界にさらに深く入っていきます。今回の主な焦点は、新しいチャープ(つぶやき)に対するリアルタイム更新を導入することでインデックスページのユーザー体験を豊かにすることです。まずは定期的に新しいチャープを取得するためのポーリング戦略を採用します。進展するにつれて、ポーリングからWebソケットを使用するよりダイナミックで効率的なアプローチに移行し、新しいチャープがリアルタイムで投稿された瞬間にユーザーに表示されるようにします。

まだの方は、このチュートリアルの第1部をチェックしてください。全体の実装はこのGitHubリポジトリで確認することができます。

リアルタイム更新:ポーリング版

シンプルなHTMX実装はスムーズに機能しており、ユーザーは楽しくチャープしています。しかし、プロダクトオーナーは理想的ではないユーザー体験を指摘しました:新しいチャープを見るためにユーザーは手動でページをリフレッシュする必要があります。このフィードバックに応えるために、定期的にサーバーから新しいチャープを取得するポーリングメカニズムをHTMXを使用して実装します。

まず、ポーリングリクエストを処理する新しいコントローラーアクションを作成しましょう:

// app/Http/Controllers/ChirpController.php

public function pool(Request $request): Response  
{  
    $validated = $request->validate([  
        'latest_from' => 'required|date',  
    ]);  

    $chirps = Chirp::with('user')  
        ->where('created_at', '>', $validated['latest_from'])  
        ->where('user_id', '!=', $request->user()->id)  
        ->latest()  
        ->get();  

    if($chirps->count() === 0) {  
        return \response()->noContent();  
    }  

    return \response()->view('chirps.pool', [  
        'chirps' => $chirps,  
    ]);  
}

このアクションでは以下のことを行っています:

  1. 入力データをバリデートしてlatest_fromが正しい日付フォーマットであることを確認します。
  2. データベースにクエリを実行してlatest_from日付より新しいチャープを取得し、現在のユーザーのものを除外します。
  3. 新しいチャープが見つからなかった場合、204 No Content応答を返します。それ以外の場合はchirps.poolテンプレートをレンダリングします。

次に、chirps.indexテンプレートを調整して、サーバーから新しいチャープをポーリングする新しいdivを含むようにしましょう:

<!-- resources/views/chirps/index.blade.php -->

<div x-data="{noscriptFix: true}" id="chirps" class="mt-6 bg-white shadow-sm rounded-lg divide-y">  
    <div hx-get="{{ route('chirps.pool', ['latest_from' => $chirps->first()->created_at->toISOString()]) }}"  
         hx-trigger="every 2s"  
         hx-swap="outerHTML"></div>

    @foreach ($chirps as $chirp)  
        <x-chirps.single :chirp="$chirp" />  
    @endforeach  

    @if($chirps->nextPageUrl())  
        <div  
            hx-get="{{ $chirps->nextPageUrl() }}"  
            hx-select="#chirps>div.chirp,#chirps>div.chirps-paginator"  
            hx-swap="outerHTML"  
            hx-trigger="intersect"  
            x-cloak  
            x-if="noscriptFix"  
            class="chirps-paginator"  
>  
            Loading more...  
        </div>  
    @endif

ポーリング要素を2回含めることのないよう、新しいチャープと.chirps-paginatordivだけを選択するようにページネータの動作も更新しました。リストの最上部にポーリングdivが1つだけ必要なのです。

加えて、新しいチャープとともに自己置換divを返し、ポーリングが更新されたlatest_from日付で継続されるような新しいテンプレートchirps.poolを作成する必要があります:

<!-- resources/views/chirps/pool.blade.php -->

<div hx-get="{{ route('chirps.pool', ['latest_from' => $chirps->first()->created_at->toISOString()]) }}"  
     hx-trigger="every 2s"  
     hx-swap="outerHTML"></div>  

@foreach($chirps as $chirp)  
    <x-chirps.single :chirp="$chirp" />  
@endforeach

重要な点として、HTMXにchirps.poolルートに対して2秒ごとにGETリクエストを送るよう指示します。新しいチャープが見つかった場合、HTMXはポーリングdivの全内容を新しいチャープと新しいポーリングdivで置き換え、ポーリングが更新されたlatest_from日付で継続されることを確実にします。

このメカニズムは、リアルタイム更新をページリフレッシュなしでユーザーに効率的に届け、プラットフォーム上でのユーザー体験を大きく向上させます。

リアルタイム更新:ウェブソケットの紹介

このようなシンプルな更新の場合、ポーリングだけでも十分かもしれません。さらに最適化するためにポーリング時間を5秒以上に増やすこともできます。しかし、新しいアップデートではウェブサイトが彼らのデータプランを消費し、バッテリーを消耗させていると嘆くユーザーがいたのでしょう。プロダクトオーナーがもっと最適な解決策を求めてきました。

Laravelでイベントをブロードキャストする

LaravelにはWebソケット経由でイベントをブロードキャストする簡単で素敵な方法があります。HTMXとの唯一の問題は、JSONではなくHTMLで通信しているため、[HATEOAS](https://htmx.org/essays/hateoas/)(Hypermedia as the Engine of Application State)のコンセプトから逸脱しています。

LaravelイベントとHTMX Webソケット拡張機能でこの問題を解決するために、バックエンドでイベントJSONのいくつかのフィールドにHTML文字列をレンダリングし、フロントエンドではhtmx:wsBeforeMessageイベントリスナーを登録してイベントを変換し、event.detail.messageがHTML文字列だけを含むようにします。

しかし、これは複雑です。もし好みがあるとすれば、シンプルで分かりやすいアプローチです。ですから、以下のようにします:

  1. Laravel ECHOを使ってウェブソケットブロードキャストイベントを受信します
  2. イベントを受け取ったら、カスタムブラウザイベントchirper:newChirpに変換します
  3. できればデバウンスされたイベントが発生したときにHTMXを使って新しいチャープを取得します

私はsoketiを使用しますが、何を使っても構いません。Laravelのセットアップ方法については、公式ドキュメントをフォローしてください。

chirps/poolエンドポイントとテンプレートを再利用しますが、テンプレートを少し変更する必要があります。

<!-- resources/views/chirps/pool.blade.php -->

<div  
    hx-trigger="chirper:chirpCreated throttle:500 from:body"  
    hx-get="{{ route('chirps.pool', ['latest_from' => $chirps->first()->created_at->toISOString()]) }}"  
    hx-swap="outerHTML"  
    ></div>  

@foreach($chirps as $chirp)  
    <x-chirps.single :chirp="$chirp" />  
@endforeach

基本的にはトリガーをevery 2sからchirper:chirpCreated throttle:500 from:bodyに変更しています。つまり、bodyでchirp-createdイベントがトリガーされるたびに500ms間デバウンスしてからデータを取得することを意味します。

chirps/index.blade.phpにも同じ変更を行うことを忘れないでください。

resources/js/app.jsには以下のような(Echoの設定に応じて)JSが必要です。

laravelEcho.channel('chirps')  
.listen('ChirpCreated', (event) => {  
    document.body.dispatchEvent(new CustomEvent('chirper:chirpCreated'));  
}  
);

このJSの断片はchirpsチャネルのChirpCreatedイベントをドキュメントのボディにCustomEventに変換します。

PHPのアプリの部分では、ChirpCreatedイベントがShouldBroadcastを実装し、ブロードキャストするチャネルはchirpsであることを明示する必要があります。

<?php  

...

class ChirpCreated implements ShouldBroadcast  
{  
    ...  

    /**  
     * Get the channels the event should broadcast on.     
     *     
     * @return array<int, Channel>  
     */    
     public function broadcastOn(): array  
    {  
        return [  
            new Channel('chirps'),  
        ];  
    }  
}

これで新しいチャープが作成されたときだけチャープを取得するようになります。プライベートチャネルにブロードキャストして他の人に対してのみtoOthersをブロードキャストするようなもっと高度な解決策を持つことができますが、このチュートリアルの範囲には問題ありません。

P.S. 私のテストサーバーにウェブソケットをデプロイするつもりはありません。soketiをデプロイしてnginxなどのセットアップを行うのは大変ですので、プールバージョンだけを紹介します。次のセクションからは、プールセクションの最後からスタートします。

スクリプティング

リアルタイム更新があると、ページがサーバーからの新しいチャープで自動的にリフレッシュされます。しかし、問題が発生しています。ユーザーが古いチャープを読むために下にスクロールしたときに、ページの上部に挿入された新しいチャープの存在に注意を払うことはありません。

解決策は、新しいチャープが挿入されたときに浮遊するボタンを導入することです。このボタンをクリックすると、ページがトップにスクロールされ、ユーザーは新しいチャープに気づかされます。

この機能を実装するためにはスクリプティングが不可欠です。HTMXはhtmx-on属性を提供しているので、フォームをリセットするような単純なタスクであれば十分ですが、私たちのシナリオでは、ボタンがクリックされたとき、または手動でトップにスクロールしたときにボタンを削除するために交差オブザーバが必要です。

ここでHyperscriptが登場します。Hyperscriptは、ブラウザでのイベントベースのスクリプティングをより人間的な読みやすいものにするよう設計されています。私たちはその構文に深く入り込むことはありませんが、彼らのウェブサイトで自由に探求することができます。

次に、新しいチャープに気づかせるための浮遊ボタン機能を実装するためのHyperscriptの統合プロセスを歩みます。

スクリプトをインストールするために、アプリレイアウトでHTMXスクリプトの後に別のスクリプトを追加します。

<!-- resources/views/layouts/app.blade.php -->

<script src="https://unpkg.com/htmx.org@1.9.6" integrity="sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni" crossorigin="anonymous"></script>  
<script src="https://unpkg.com/hyperscript.org@0.9.11"></script>

chirps poolテンプレートに新しいチャープのforeachの上に以下のHTMLを追加します。

<!-- resources/views/chirps/pool.blade.php -->

<div 
     class="border-transparent opacity-0"   
    _="on intersection(intersecting) if intersecting remove me else remove .opacity-0" >
    <div class="fixed top-4 left-0 right-0 flex justify-center">  
        <button            
            _="on click window.scrollTo({ top: 0, behavior: 'smooth' })"  
            type="button"  
            class="py-2 px-4 bg-indigo-600 hover:bg-indigo-800 rounded-full text-white shadow-lg flex justify-center<br><br>こちらの記事はdev.toの良い記事を日本人向けに翻訳しています。<br>[https://dev.to/turculaurentiu91/laravel-htmx-2x-4o48](https://dev.to/turculaurentiu91/laravel-htmx-2x-4o48)