speedster-js Guide

Composants

Un composant tient dans un fichier .spd : un script typé, du balisage, un style.

<script lang="ts">
  interface Tache { id: number; titre: string; faite: boolean }

  export let titre: string = 'Mes tâches'
  let taches: Tache[] = []
  let filtre = ''

  const visibles = taches.filter((tache) => tache.titre.includes(filtre))
  const restantes = visibles.filter((tache) => !tache.faite).length

  const retirer = (id: number) => {
    taches = taches.filter((tache) => tache.id !== id)
  }
</script>

<section aria-labelledby="titre">
  <h2 id="titre">{titre} — {restantes} restantes</h2>

  <label for="filtre">Filtrer</label>
  <input id="filtre" bind:value={filtre} />

  {#if visibles.length === 0}
    <p role="status">Aucune tâche</p>
  {:else}
    <ul>
      {#each visibles as tache (tache.id)}
        <li class:faite={tache.faite}>
          <span>{tache.titre}</span>
          <button on:click={() => retirer(tache.id)} aria-label="Retirer">&times;</button>
        </li>
      {/each}
    </ul>
  {/if}
</section>

<style>
  .faite { text-decoration: line-through }
</style>

Les trois blocs sont facultatifs. lang="ts" est le défaut : un composant est typé sauf mention contraire.


Le script

<script lang="ts">…</script>          <!-- exécuté à chaque montage -->
<script module lang="ts">…</script>   <!-- exécuté une seule fois -->

Un nom du script de module est visible depuis l'instance, jamais l'inverse. Utilisez-le pour ce qui doit être partagé entre toutes les instances — un compteur d'identifiants, une table constante.

Les imports remontent d'office au niveau du module, ainsi que les déclarations de type : interface Tache écrite dans le script d'instance est visible depuis l'interface Props générée.

Interpolation

<p>Bonjour {prenom} !</p>
<p>{compteur * 2}</p>
<p>{items.map((item) => item.titre).join(', ')}</p>

Toute expression TypeScript convient. Le texte statique et les interpolations voisines sont repliés en un seul nœud : Cliqué {n} fois n'occupe pas trois nœuds mais un, mis à jour d'un seul écrit.

Le texte est échappé. Pour insérer du balisage, voir {@html} plus bas.

Attributs

Forme Sens
nom="texte" Littéral. Part dans le gabarit, ne coûte rien à l'exécution.
nom={expression} Calculé.
nom="a{expression}b" Composé.
nom Booléen présent.
{nom} Raccourci de nom={nom}.
{...objet} Étalement.

Un attribut entièrement statique est écrit dans le gabarit, jamais posé par du code. Un attribut calculé n'est réécrit que lorsque sa valeur change réellement.

aria-*, contenteditable, draggable et spellcheck sont énumérés : aria-expanded={false} produit aria-expanded="false", et non un attribut absent. L'absence et le « faux » ne disent pas la même chose à une technologie d'assistance.

Directives

<button on:click={agir}>Agir</button>
<form on:submit|preventDefault={envoyer}>…</form>
<div class:actif={ouvert} class:vide={items.length === 0}>…</div>
<div style:color={couleur} style:--marge="1rem">…</div>
<div use:infobulle={'Fermer'}>…</div>

Événements

Le gestionnaire est typé d'après le nom de l'événement : on:click attend un MouseEvent, on:keydown un KeyboardEvent. Un gestionnaire qui ne déclare aucun paramètre est accepté, comme n'importe quelle fonction qui en déclare moins.

Modificateurs disponibles, combinables avec | :

Modificateur Effet
preventDefault event.preventDefault() avant le gestionnaire
stopPropagation event.stopPropagation()
stopImmediatePropagation event.stopImmediatePropagation()
self N'agit que si l'événement vient de l'élément lui-même
once Le gestionnaire ne s'exécute qu'une fois
capture Écoute en phase de capture
passive Déclare l'écouteur passif : il ne bloquera pas le défilement

capture, once et passive décrivent l'écoute elle-même : ils sont transmis au navigateur, et écartent donc la délégation pour cet écouteur-là.

Les événements qui remontent l'arbre sont délégués : un seul écouteur par type sur la racine de l'application, quel que soit le nombre d'éléments. Une liste de mille lignes avec un on:click chacune installe un écouteur, pas mille.

class: et style:

class:actif={condition} ajoute ou retire une classe sans toucher aux autres. Elle se compose avec un class="…" écrit à côté.

style: accepte les propriétés personnalisées : style:--marge="1rem".

use: — actions

Une action est une fonction ordinaire qui reçoit un nœud. Voir Mouvement.

bind: — liaisons

Voir Formulaires.

transition:, in:, out:

Voir Mouvement.


Blocs

Conditionnel

{#if condition}
  …
{:else if autre}
  …
{:else}
  …
{/if}

La condition passe par une valeur dérivée : une dépendance qui change sans inverser le booléen ne reconstruit rien. Un compteur qui passe de 2 à 4 ne touche pas à un bloc gouverné par compteur % 2 === 0.

Liste

{#each items as item}…{/each}
{#each items as item, i}…{/each}
{#each items as item, i (item.id)}…{/each}
{#each items as { id, titre } (id)}…{/each}

{#each items as item (item.id)}
  <li>{item.titre}</li>
{:else}
  <li>Aucun élément</li>
{/each}

Donnez une clé. Sans elle, la position tient lieu d'identité : réordonner la liste réécrit tout le contenu. Avec, chaque élément garde ses nœuds, son état et sa portée — un réordonnancement déplace les nœuds au lieu de les recréer, ce qui préserve le focus, la sélection de texte, la lecture d'un média et l'état d'un champ non contrôlé.

Le nombre de déplacements est minimisé par la plus longue sous-suite croissante des positions conservées : seuls les éléments qui doivent bouger bougent.

Une clé dupliquée est signalée en développement.

Identité

{#key formulaire.id}
  <Formulaire donnees={formulaire} />
{/key}

Reconstruit le contenu dès que la clé change. Utile quand l'identité compte plus que la valeur : réinitialiser un formulaire au changement d'enregistrement.

Fragments nommés

{#snippet ligne(tache: Tache, index: number)}
  <li>{index + 1}. {tache.titre}</li>
{/snippet}

<ul>
  {#each taches as tache, i}{@render ligne(tache, i)}{/each}
</ul>

Un fragment nommé est transmissible comme une valeur — c'est ce qui permet à un composant de recevoir du contenu paramétré. Voir Composition.

Annotez les paramètres : c'est ce qui rend les appels vérifiables.


Étiquettes

{@const}

{#each taches as tache}
  {@const restant = tache.total - tache.fait}
  <li>{tache.titre} — {restant} restants</li>
{/each}

Une valeur locale au bloc, calculée une fois par élément. Elle devient automatiquement une dérivée mémoïsée si elle lit un état réactif.

{@render}

{@render children()}
{@render ligne(tache, i)}

{@html}

<div>{@html contenuRiche}</div>

C'est une injection. Le contenu s'exécute avec les droits de la page : il doit venir d'une source de confiance, ou avoir été assaini avant. Le runtime n'embarque pas d'assainisseur — ce serait plusieurs kilo-octets imposés à toutes les applications, et un faux sentiment de sécurité pour les autres.

Deux détails utiles : le texte est comparé au précédent, donc réévaluer sans changement ne reconstruit rien ; et l'espace de noms suit l'élément englobant, donc un <path> inséré dans un <svg> est bien un élément SVG.


Le style

<style>
  .faite { text-decoration: line-through }
</style>

Le bloc ne s'applique qu'à ce composant. Deux composants qui écrivent .faite ne parlent pas de la même chose, et rien ne les fait se marcher dessus :

<!-- ce qui est rendu -->
<li class="faite spd-1a2b3c">…</li>

<!-- ce qui est installé -->
<style data-spd="spd-1a2b3c">.faite.spd-1a2b3c { text-decoration: line-through }</style>

Une classe unique par bloc de style est posée sur chaque élément que le composant écrit, et chacun de ses sélecteurs l'exige. La classe est un condensé du nom du composant et du contenu de son style : elle est donc stable d'un build à l'autre et d'une machine à l'autre, ce qui laisse les caches tranquilles.

Le choix n'est pas anodin : porter la contrainte dans le sélecteur plutôt que dans le nom de classe est ce qui permet à class={…} de continuer à fonctionner. Renommer .faite en .faite-1a2b3c — la voie des modules CSS — obligerait à réécrire aussi les classes calculées, dont la valeur n'existe qu'à l'exécution.

Où va le CSS

Dans un fichier, par défaut. Le module du composant importe sa feuille, et l'empaqueteur les rassemble en un .css minifié que la page lie :

dist/main.js     le code
dist/main.css    le style de tous les composants qu'il atteint

Trois conséquences, et c'est pour elles que c'est le défaut. Le style est peint avant que le premier script ne soit évalué, donc pas de page nue le temps du téléchargement. Il est mis en cache comme une image, indépendamment du code : une correction de JavaScript ne fait pas retélécharger le CSS. Et il ne paie plus le prix d'une chaîne échappée dans du JavaScript — mesuré sur le site du projet, 1,1 ko compressés de moins pour le même style.

C'est le <link> qui est à ne pas oublier : le nom du fichier porte une empreinte, donc les gabarits de speedster init le posent depuis la sortie du build. Une page qui ne le déclare pas est servie sans style, sans qu'aucune erreur soit émise — le build s'arrête donc si l'empaqueteur ne produit aucune feuille.

// build.ts — le plugin n'a rien à régler, c'est déjà le défaut
plugins: [speedster()]

// … et pour remettre le CSS dans le module :
plugins: [speedster({ css: 'inject' })]

inject reste utile là où personne ne peut servir un fichier de plus — un composant distribué en bibliothèque, un fragment posé dans une page qu'on ne contrôle pas — et c'est ce que fait l'exécution directe d'un .spd, où il n'y a pas d'empaqueteur. Le style est alors installé une fois par identifiant, quel que soit le nombre de montages, et jamais en double avec le rendu serveur — qui pose les mêmes feuilles dans le <head>, marquées data-spd, que le navigateur relève avant d'en ajouter.

Ce qui n'est pas encore là. L'empaqueteur émet bien une feuille par morceau, mais recopie toutes les règles dans celle de l'entrée : une page chargée à la demande n'apporte donc pas encore sa feuille à elle. Le build lie l'entrée et retire les doublons — vérifié sur les classes de portée, pas supposé.

Ce qui traverse la portée

Écriture Effet
:global(.theme-sombre) la règle sort de la portée, telle quelle
.carte :global(.venu-dailleurs) la fin du sélecteur sort, le début reste porté
@keyframes tourne renommée tourne-spd-1a2b3c, usages compris
body, html, :root restent globaux, avec un avertissement

Les trois derniers désignent un élément unique, qui ne porte aucune classe de composant : les porter empêcherait la règle de s'appliquer. Ils restent donc globaux, et le compilateur le signale — écrivez :global(body) pour le dire explicitement et faire taire l'avertissement.

Deux limites à connaître. Le contenu d'un {@html} n'est pas écrit par le compilateur : ses éléments ne portent pas la classe, et un style de composant ne l'atteint donc pas sans :global(). Et un sélecteur qui ne correspond à rien n'est pas signalé : la portée est posée sans vérifier que la règle sert.


Ce qui est refusé, et le dit

Le compilateur préfère refuser clairement plutôt que produire du code qui ne fait pas ce qu'on croit.

Code Refus
readonly-assignment Affecter une valeur dérivée
short-circuit-assignment n ??= x sur un état : la réécriture changerait la sémantique
unsupported-bind Liaison inconnue, ou vers autre chose qu'un let du composant
unsupported-directive Directive autre que bind: sur un composant
invalid-render {@render} sans appel
unknown-modifier Modificateur d'événement inconnu
mutation-without-reassignment taches.push(x) sans réaffectation (avertissement)
unknown-reference Un nom ni déclaré, ni importé (avertissement)
static-spread Étalement sur un composant : copié une fois (avertissement)
untyped-prop Propriété sans type déductible (avertissement)
css-global-selector Sélecteur qui ne peut pas porter la portée : body, html, :root (avertissement)

Chaque diagnostic est localisé au caractère et affiché avec son extrait de source.