Hippo : des pages réactives en pur PHP
Des pages réactives en pur PHP, exécutées sur le serveur, c’est le concept d’Hippo. Aucun code JS à taper.
Introduction
J’ai toujours été jaloux des développeurs Elixir qui ont LiveView, et aussi un peu des devs JS.
Ils peuvent faire des pages qui se modifient en temps réel, sans provoquer de rechargement côté client, et sans beaucoup d’efforts.
En PHP, on n’a pas vraiment d’équivalent.
Oui, on a bien Laravel Livewire (que j’adore), mais il n’est disponible qu’avec Laravel.
Je n’ai pas tout le temps envie de lancer un projet Laravel.
On a aussi Symfony UX, mais je n’apprécie pas vraiment Symfony. Alors ce n’est pas une option pour moi.
Et puis, HTMX existe, mais il utilise des requêtes HTTP classiques avec tout l’overhead que cela implique.
Et ce n’est pas fun pour moi.
Non, je voulais un petit side-project sympathique, un proof-of-concept loin des frameworks, pour créer rapidement des sites réactifs en pur PHP, et surtout me faire plaisir. Je crois qu’on aime tous créer des frameworks. C’est un plaisir coupable.
Alors, du PHP, oui, mais du PHP inspiré par Elm et Lustre. Pourquoi ? Parce que ce sont des coups de cœur. Ils m’ont fait aimer le front-end.
Et puis j’adore l’approche par composants que React a initiée.
Oui ça fait un mélange qui peut faire peur, peur du gloubi-boulga.
Comment ça fonctionne ?
- Le navigateur initie une connexion WebSocket avec le serveur.
- Chaque action (de chaque composant) observée est envoyée au serveur (cliquer, changer la valeur d’un input, etc.) qui va re-rendre le composant.
- Le navigateur met à jour le nœud HTML le plus pertinent pour l’opération.
C’est aussi simple que ça. Un système de message -> update -> render
, en PHP.
Petit bonus
Comme tout fonctionne en WebSocket, on peut mettre à jour les composants sur tous les clients qui le partagent.
Ça veut dire qu’il est possible de faire un chat, la mise à jour en temps réel d’un score, etc.
Hippo est suffisamment rapide (~0,2ms par aller-retour de composant sur une Fly machine 1vCPU 256mo), et il gère assez facilement des centaines de clients, grâce à AMPHP. Ce choix est d’ailleurs discutable, mais je ne voulais pas utiliser Swoole/OpenSwoole cette fois-ci.
Par atavisme de développeur PHP, je voulais retrouver ce qu’on appelle aujourd’hui les server components tout en ayant une approche TEA (The Elm Architecture). Hippo le permet. Tout est exécuté sur le serveur, aucun élément du backend n’est accessible au client, excepté le rendu HTML du composant.
Mais objectivement, qu’est-ce que ça donne ?
Malheureusement, Hippo reste du PHP, donc on oublie la sécurité d’Elm ou de Gleam.
“No Runtime Exceptions” n’est pas le mot d’ordre ici.
Le plus gros problème à mon avis est l’absence de type-safety
des messages (actions).
Passer par une Enum
force une lourdeur déclarative supplémentaire et réduit la DX. Je passe donc par des string
, au risque de faire des erreurs.
L’absence de typage structurel en PHP n’aide pas dans la démarche.
Finalement, c’est surtout une belle expérience de conception d’API qui propose une bonne developer experience.
render()
doit-il renvoyer l’HTML pur du composant ? Une collection d’éléments HTML ? Une monade ?
Une centaine de changements plus tard, je ne crois pas qu’il y ait une meilleure solution parmi les bonnes solutions.
Pour le premier élement, les hippo sont synchronsés entre les clients, c’est ce que j’ai appelé un composant partagé
.
De même pour le petit compteur.
La recherche (non partagée) est produite seulement par ce code:
<?php
namespace Cedric\Hippo\Components;
use Cedric\Hippo\Internals\Context;
use Cedric\Hippo\Internals\HippoMessage;
use Cedric\Hippo\Support\Html\Div;
use Cedric\Hippo\Support\Html\H2;
use Cedric\Hippo\Support\Html\HtmlElement;
use Cedric\Hippo\Support\Html\Input;
use Cedric\Hippo\Support\Types\Collection;
class Select extends Component
{
/**
* @var Collection<string, string>
*/
public Collection $elements;
/**
* @var Collection<string, string>
*/
public Collection $baseElems;
public string $input;
/**
* @param array<string, string> $elems
*/
public function __construct(array $elems)
{
$this->elements = new Collection($elems);
$this->baseElems = new Collection($elems);
$this->input = "";
parent::__construct();
}
public function render(?Context &$context = null): HtmlElement
{
$gradientClass = $this->chooseRandomGradient();
return $this->createSection($gradientClass, $context);
}
private function chooseRandomGradient(): string
{
$gradients = [
'from-yellow-200 via-pink-300 to-red-200',
'from-blue-200 via-teal-300 to-green-200',
'from-purple-200 via-pink-300 to-red-200',
];
return $gradients[array_rand($gradients)];
}
private function createSection(string $gradientClass, Context $context): Div
{
return (new Div())
->setClasses("mb-8 mt-8 flex flex-col items-center rounded-xl shadow-lg bg-gradient-to-r $gradientClass p-6 sm:p-8 lg:p-12")
->hippoNode($this->getNodeId())
->addChild($this->getTitleSection())
->addChild($this->createInputContainer())
->addChild($this->getAvailableItems());
}
private function getTitleSection(): H2
{
return (new H2())
->setClasses('mt-4 mb-8 text-2xl sm:text-3xl font-bold text-gray-800 leading-tight text-center')
->setContent('Reactive text input');
}
private function createInputContainer(): Div
{
return (new Div())
->setClasses('w-full mb-4')
->addChild(
(new Input())
->setType('text')
->hippoInput('input')
->setAttribute("value", $this->input)
->setAttribute("placeholder", "Rechercher un animal.")
->setClasses('w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500')
);
}
protected function getAvailableItems(): HtmlElement
{
$e = $this->elements->map(
fn ($val, $key) => (new Div())
->setContent($val)
->setClasses('p-2 border-b last:border-b-0')
);
return (new Div())
->setClasses('w-full')
->addChildren($e);
}
public function update(HippoMessage $message, ?Context &$context = null): void
{
$message->whenAction('input', function () use ($message) {
$this->input = $message->getValue();
$this->elements = $this->baseElems
->filter(
fn ($val, $key) => str_contains($val, $this->input)
);
});
}
}
Evidemment, sans utiliser les classes qui génèrent du HTML, en optant pour du HTML brut, on pourra le faire en 3 fois moins de lignes.
En conclusion
Il faut aussi proposer des primitives pratiques, un DSL pour les éléments HTML (ce que j’ai toujours trouvé être la pire idée), une gestion du contexte de la requête.
L’avantage des side-projects, c’est la liberté de n’utiliser que peu de dépendances, et de s’amuser à penser depuis le début des briques élémentaires.
Je me suis même amusé à faire une page de présentation, comme si c’était un projet sérieux.
Programmer, c’est fun.