Livewire a realtime vyhledání

V tomto článku si na jednoduchém příkladu ukážeme sílu Laravel Livewire. Náš projekt bude obsahovat realtime vyhledávání článků podle jejich názvu.

Než začneme, tak se pokusím vysvětlit co vlastně Livewire je a k čemu slouží. Livewire je fullstackový framework pro Laravel, který založil Američan Caleb Porzio. Jedná se o knihovnu, díky které je možné vytvářet moderní, reaktivní a dynamické aplikace a zároveň stále používáte šablony Blade a Laravel jako backend. Vue a React jsou extrémně silní hráči, nicméně pro fullstack vývojáře může být jejich komplexita a workflow velmi náročná. Livewire tak slouží k vytváření dynamických a reaktivních aplikací, aniž by jste museli napsat jediný řádek JavaScript kódu.

Příprava dat

Jako obvykle si prvně musíme připravit nějaké dummy data. Budu předpokládat, že již máte připravené připojení k databázi a vytváření seedu vám není cizí. Proto to vezmu trochu rychleji bez většího okomentování.

Úplně na začátku si vytvoříme nový projekt:

laravel new livewire-example

A v něm si vytvoříme naráz model, factory, seed a controller pomocí artisan flagu -a:

php artisan make:model Post -a
Model created successfully.
Factory created successfully.
Created Migration: 2020_10_16_140818_create_posts_table
Seeder created successfully.
Controller created successfully.

Upravíme si migraci s články:

    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->text('content');
            $table->string('author');
            $table->timestamps();
        });
    }

Připravíme si faktorku:

<?php

namespace Database\Factories;

use App\Models\Post;
use Illuminate\Database\Eloquent\Factories\Factory;

class PostFactory extends Factory
{
    /**
     * The name of the factory's corresponding model.
     *
     * @var string
     */
    protected $model = Post::class;

    /**
     * Define the model's default state.
     *
     * @return array
     */
    public function definition()
    {
        return [
            'title' => $this->faker->sentence,
            'content' => $this->faker->paragraph(),
            'author' => $this->faker->name
        ];
    }
}

V seedu si vytvoříme třeba 100 článků:

<?php

namespace Database\Seeders;

use App\Models\Post;
use Illuminate\Database\Seeder;

class PostSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        Post::factory(100)->create();
    }
}

A seeder zavoláme v hlavním seedu:

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     *
     * @return void
     */
    public function run()
    {
        $this->call(PostSeeder::class);
    }
}

A nakonec zavoláme migrace i se seedem. Tím máme data připravena.

php artisan migrate:fresh --seed
Dropped all tables successfully.
Migration table created successfully.
Migrating: 2014_10_12_000000_create_users_table
Migrated:  2014_10_12_000000_create_users_table (66.26ms)
Migrating: 2014_10_12_100000_create_password_resets_table
Migrated:  2014_10_12_100000_create_password_resets_table (60.74ms)
Migrating: 2019_08_19_000000_create_failed_jobs_table
Migrated:  2019_08_19_000000_create_failed_jobs_table (62.94ms)
Migrating: 2020_10_16_140818_create_posts_table
Migrated:  2020_10_16_140818_create_posts_table (17.37ms)
Seeding: Database\Seeders\PostSeeder
Seeded:  Database\Seeders\PostSeeder (135.00ms)
Database seeding completed successfully.

Serverové vyhledávání

Jako první si ukážeme, jak bychom to řešili klasicky s požadavkem na server a následném obnovení stránky s výsledky. Následně si to upravíme pro Livewire.

Na začátku si definujeme dvě routy, jedna bude výchozí a zobrazovat všechny články, druhá bude zobrazovat výsledky vyhledávání. Upravíme si tedy soubor routes/web.php:

<?php

use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;

Route::get('/', [PostController::class, 'index']);
Route::get('/hledej', [PostController::class, 'search'])->name('search');

Dříve jsme si již vytvořili controller PostController, který ale obsahuje všechny resource metody. Upravíme si tedy třídu aby obsahovala pouze dvě metody, které budeme potřebovat:

<?php

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;

class PostController extends Controller
{
    public function index(): View
    {
        return view('search', [
            'posts' => Post::all()
        ]);
    }

    public function search(Request $request): View
    {
        return view('search', [
            'posts' => Post::query()->where('title', 'like', '%' . $request->input('search') . '%')->get()
        ]);
    }
}

Obě metody vracejí Blade pohled s články. Vytvoříme si tedy novou šablonu resources/search.blade.php a v ní zobrazíme jednoduchý výpis článků:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>Laravel</title>

        <link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
    </head>
    <body>
        <div class="w-full max-w-screen-xl mx-auto px-6">
            <div class="flex justify-center p-4 px-3 py-10">
                <div class="w-full max-w-md">
                    <div class="bg-white shadow-md rounded-lg px-3 py-2 mb-4">
                        <div class="block text-gray-700 text-lg font-semibold py-2 px-2">
                            Seznam článků
                        </div>
                        <form method="get" action="{{ route('search') }}">
                            <div class="flex items-center bg-gray-200 rounded-md">
                                <div class="pl-2">
                                    <svg class="fill-current text-gray-500 w-6 h-6" xmlns="http://www.w3.org/2000/svg"
                                        viewBox="0 0 24 24">
                                        <path class="heroicon-ui"
                                            d="M16.32 14.9l5.39 5.4a1 1 0 0 1-1.42 1.4l-5.38-5.38a8 8 0 1 1 1.41-1.41zM10 16a6 6 0 1 0 0-12 6 6 0 0 0 0 12z"/>
                                    </svg>
                                </div>
                                <input
                                    class="w-full rounded-md bg-gray-200 text-gray-700 leading-tight focus:outline-none py-2 px-2"
                                    id="search" name="search" type="text" placeholder="Hledej..." value="{{ request()->input('search') }}">
                            </div>
                        </form>
                        <div class="overflow-x-scroll py-3 text-sm" style="height: 40rem">
                            @foreach($posts as $post)
                                <div class="flex justify-start cursor-pointer text-gray-700 hover:text-blue-400 hover:bg-blue-100 rounded-md px-2 py-2 my-2">
                                    <div class="w-4/6 font-medium px-2">{{ $post->title }}</div>
                                    <div class="w-2/6 text-sm font-normal text-gray-500 tracking-wide">{{ $post->author
                                }}</div>
                                </div>
                            @endforeach
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </body>
</html>

Pro zjednodušení jsem nepřidal žádné tlačítko, pro vyhledávání se tak bude používat enter (alespoň do té doby než přidáme Livewire).

Tím bychom měli serverové vyhledávání hotové. Není v tom žádná magie. Nyní přichází ta zajímavější část, a to, že si to upravíme pro Livewire.

Livewire realtime vyhledávání

Jako první je potřeba Livewire pomocí composeru nainstalovat:

composer require livewire/livewire

Livewire je založen na komponentách, kde každá komponenta má svůj vlastní životní cyklus a je nezávislá na ostatních komponentách. Proto si vytvoříme novou komponentu pro naše vyhledávání pomocí speciálního artisan příkazu, který Livewire používá:

php artisan make:livewire search
 COMPONENT CREATED  🤙

CLASS: app/Http/Livewire/Search.php
VIEW:  resources/views/livewire/search.blade.php

Artisan vytvořil dva soubory - třídu s komponentou a blade, který bude využívat. Jako první si upravíme komponentu:

<?php

namespace App\Http\Livewire;

use App\Models\Post;
use Illuminate\Database\Eloquent\Builder;
use Livewire\Component;

class Search extends Component
{
    public string $search = '';

    public function render()
    {
        return view('livewire.search', [
            'posts' => Post::query()->when($this->search, function (Builder $query) {
                $query->where('title', 'like', '%' . $this->search . '%');
            })->get(),
        ]);
    }
}

Livewire vychází z podobného schématu jako je Vue.js. Tedy definuje si určité modely, které si drží aktuální stav. Jako první model jsme si nadefinovali proměnnou search. Dále třída obsahuje metodu render(), která zodpovídá za vygenerování šablony. Celý proces je stejný jako bychom využívali klasicky jenom Laravel, změna je pouze v tom, že namísto requestu využíváme náš model (resp. proměnnou třídy). Tedy vrátíme šablonu a s ní data, které hledáme (pokud máme vůbec něco hledat).

Dále se vrhneme na samotnou šablonu v resources/livewire/search.blade. Zde zkopírujeme obsah z původní search.blade šablony a trochu si ji upravíme:

<div>
    <div class="w-full max-w-screen-xl mx-auto px-6">
        <div class="flex justify-center p-4 px-3 py-10">
            <div class="w-full max-w-md">
                <div class="bg-white shadow-md rounded-lg px-3 py-2 mb-4">
                    <div class="block text-gray-700 text-lg font-semibold py-2 px-2">
                        Seznam článků
                    </div>
                    <div class="flex items-center bg-gray-200 rounded-md">
                        <div class="pl-2">
                            <svg class="fill-current text-gray-500 w-6 h-6" xmlns="http://www.w3.org/2000/svg"
                                viewBox="0 0 24 24">
                                <path class="heroicon-ui"
                                    d="M16.32 14.9l5.39 5.4a1 1 0 0 1-1.42 1.4l-5.38-5.38a8 8 0 1 1 1.41-1.41zM10 16a6 6 0 1 0 0-12 6 6 0 0 0 0 12z"/>
                            </svg>
                        </div>
                        <input
                            class="w-full rounded-md bg-gray-200 text-gray-700 leading-tight focus:outline-none py-2 px-2"
                            wire:model="search" id="search" name="search" type="text" placeholder="Hledej...">
                    </div>
                    <div class="overflow-x-scroll py-3 text-sm" style="height: 40rem">
                        @foreach($posts as $post)
                            <div class="flex justify-start cursor-pointer text-gray-700
                            hover:text-blue-400 hover:bg-blue-100 rounded-md px-2 py-2 my-2">
                                <div class="w-4/6 font-medium px-2">{{ $post->title }}</div>
                                <div class="w-2/6 text-sm font-normal text-gray-500 tracking-wide">{{ $post->author
                                    }}</div>
                            </div>
                        @endforeach
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

Na první pohled se může zdát úplně stejná, nicméně je tam několik rozdílů:

  • Jako první si všimněte, že samotná šablona neobsahuje žádné tagy html, header, body atd. To proto, že za chvíli si upravíme původní search.blade.php, aby zavolala naší komponentu. Z toho důvodu není potřeba nic dalšího doplňovat, protože zde funguje klasická dědičnost jak jsme u Blade šablon zvyklí. Je potřeba si dát ale pozor, že i když zde dědičnost funguje, tak komponenta není schopna jednoduše převzít data z rodiče a museli by se v rodiči definovat. Více o tom ale zjistíte v oficiální dokumentaci Livewire.
  • Dále je potřeba mít na paměti, že každá Livewire komponenta musí mít pouze jeden hlavní kořen. Z toho důvodu jsem pro jistotu celý obsah ještě obalil do samostatného divu.
  • Protože už vyhledáváme realtime, tak nepotřebujeme žádný formulář
  • Všimněte si u vyhledávacího inputu nejdůležitějšího atributu wire:model="search". Právě tento atribut je odpovědný za to, že se v komponentně naváže na model $search.

Nakonec aby to celé fungovalo, tak je potřeba někde zavolat tuto komponentu. To provedeme v původní resources/search.blade.php komponentě, kterou si upravíme takto:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>Laravel</title>

        <link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
        @livewireStyles
    </head>
    <body>
        <livewire:search />
        @livewireScripts
    </body>
</html>

Aby mohl Livewire fungovat, je potřeba vždy přidat styly a javascripty, které bude Livewire používat. A to pomocí helperů @livewireStyles a @livewireScripts. A nakonec zavoláme Livewire komponentu search.

Jak to celé funguje

Prakticky to funguje velmi jednoduše. Cokoliv napíšeme do inputu, automaticky se pošle AJAX na server, konkretně do komponenty Search. Ta do modelu komponenty definovaného v šabloně (a poté v requestu) propíše obsah a vrátí zase odpověď z render() metody. Vlastně se tak pregenruje celá komponenta, ale pouze ona, zbytek stránek zůstane nedotčen.

Nakonec bych rád zmínil ještě jednu z mnoha věcí, které Laravel Livewire nabízí. Mějme příklad, kdy máme tisíce (či miliony) záznamů. V tom případě posílat požadavek na server při každém zadání znaku do vyhledávání nebude zrovna ideální. Možností je upravit input takto:

<input class="w-full rounded-md bg-gray-200 text-gray-700 leading-tight focus:outline-none py-2 px-2" wire:model.debounce.500ms="search" id="search" name="search" type="text" placeholder="Hledej...">

Díky metodě debounce je možné odpověď "zpomalit", konkrétně se požadavek nepošle hned, ale až 500ms po skončení psaní.

A nakonec zde je demo na Laravel Playground. Kód jsem trochu upravil, aby data bral z pole namísto z databáze.

Máte dotazy, něco vám není jasné nebo mám někde chybu? Napište mi do komentářů!