Composition
Un composant reçoit du contenu comme il reçoit une propriété.
Propriétés
<!-- Carte.spd -->
<script lang="ts">
export let titre: string
export let sousTitre: string = '' // facultative : elle a une valeur par défaut
export let compte: number
</script>
<article>
<h2>{titre}</h2>
{#if sousTitre !== ''}<p>{sousTitre}</p>{/if}
<span>{compte}</span>
</article>
<Carte titre="Aujourd'hui" compte={taches.length} />
Annotez le type. Il devient l'interface Props que le compilateur exporte, et
c'est elle qui rend <Carte titre={42} /> refusé par speedster-check — sur la
ligne du parent, pas dans du code généré. Sans annotation ni valeur littérale, un
avertissement untyped-prop le rappelle.
Une propriété se transmet par accesseur : l'enfant lit à travers, donc jusqu'à l'état du parent. Elle reste réactive sans que l'enfant soit reconstruit quand la valeur change.
Étalement
<Carte {...donnees} />
Copié une fois : les valeurs ne resteront pas réactives. Le compilateur le
signale (static-spread). Préférez nommer les propriétés qui doivent bouger.
Contenu
Ce qu'on écrit entre les balises d'un composant devient une propriété nommée
children :
<!-- Boite.spd -->
<script lang="ts">
import type { Snippet } from '@ajustor/speedster-runtime'
export let titre: string
export let children: Snippet
</script>
<section>
<h2>{titre}</h2>
<div class="corps">{@render children()}</div>
</section>
<Boite titre="Composition">
<p>Ce paragraphe est écrit dans le parent…</p>
<button on:click={() => n++}>…et il en garde l'état : {n}</button>
</Boite>
Le contenu s'exécute dans la portée du parent : il voit ses états, ses fonctions, son contexte. L'enfant décide seulement où l'afficher.
Un composant qui ne déclare pas children mais en reçoit est une erreur de type,
signalée sur la ligne d'appel.
Fragments nommés
Un fragment nommé est du contenu paramétré, transmissible comme une valeur.
<!-- Liste.spd -->
<script lang="ts">
import type { Snippet } from '@ajustor/speedster-runtime'
export let items: Tache[]
export let ligne: Snippet<[Tache, number]>
export let vide: Snippet
</script>
<ul>
{#each items as item, i (item.id)}
{@render ligne(item, i)}
{:else}
{@render vide()}
{/each}
</ul>
<!-- Page.spd -->
{#snippet ligne(tache: Tache, index: number)}
<li class:faite={tache.faite}>
{index + 1}. {tache.titre}
<button on:click={() => basculer(tache.id)}>Basculer</button>
</li>
{/snippet}
{#snippet vide()}
<li>Rien à faire</li>
{/snippet}
<Liste items={taches} {ligne} {vide} />
Snippet<[Tache, number]> déclare les arguments : passer un string là où un
Tache est attendu est une erreur de compilation.
Déclarés là où ils servent
Un fragment peut aussi s'écrire dans les enfants du composant qui le reçoit. La forme au-dessus reste valable ; celle-ci se lit sans aller chercher le nom ailleurs dans le fichier, et dit à qui le fragment appartient.
<!-- Page.spd -->
<Liste items={taches}>
{#snippet ligne(tache: Tache, index: number)}
<li class:faite={tache.faite}>
{index + 1}. {tache.titre}
</li>
{/snippet}
{#snippet vide()}
<li>Rien à faire</li>
{/snippet}
</Liste>
C'est la même chose pour l'enfant : chaque fragment arrive sous son nom, comme une
propriété, et le typage est vérifié de la même façon. Ce qui reste entre les balises
en dehors d'un {#snippet} devient children, comme avant.
Un fragment déclaré ainsi n'est pas visible de ses frères, et ne peut pas être
affiché par un {@render} du parent : il appartient au composant dans la balise
duquel il est écrit. Pour le partager entre plusieurs endroits, déclarez-le au niveau
du composant et transmettez-le par son nom.
Les arguments restent vivants
Les arguments d'un fragment sont transmis comme des fonctions, et déballés à la
lecture. Vous écrivez {tache.titre} sans y penser, et la valeur reste réactive :
si la tâche change, la ligne se met à jour sans être reconstruite.
C'est aussi ce qui permet au même contrat de valoir côté serveur, où un fragment empile du texte au lieu d'insérer du DOM.
Liaison entre composants
bind: traverse la frontière d'un composant. Le parent transmet un accesseur en
lecture et en écriture :
<!-- Champ.spd -->
<script lang="ts">
export let valeur: string
export let etiquette: string
const vider = () => { valeur = '' } // réaffectée : c'est une liaison
</script>
<label>{etiquette}
<input bind:value={valeur} />
</label>
<button type="button" on:click={vider}>Vider</button>
<!-- Page.spd -->
<Champ bind:valeur={nom} etiquette="Nom" />
<p>Bonjour {nom}</p>
L'enfant ne déclare rien de particulier : il réaffecte sa propriété, et c'est tout. Ce qui se passe ensuite dépend du parent, et couvre les deux usages du même composant :
| Lecture | Écriture de l'enfant | |
|---|---|---|
bind:valeur={nom} |
va chercher chez le parent | y retourne |
valeur={nom} |
idem, tant que l'enfant n'écrit pas | reste locale ; une nouvelle valeur du parent reprend la main |
Autrement dit : un composant lié et le même composant non lié se comportent tous
deux correctement, sans être modifiés. La forme raccourcie bind:valeur fonctionne
quand le nom est le même des deux côtés.
La cible doit être un let du composant : bind:valeur={objet.champ} est refusé,
parce qu'écrire dans une propriété ne préviendrait pas le graphe réactif.
Ce qui ne s'applique pas à un composant
<Carte on:click={agir} /> <!-- refusé -->
<Carte use:infobulle /> <!-- refusé -->
<Carte transition:fondu /> <!-- refusé -->
Un composant n'est pas un élément du document : il peut n'avoir aucune racine, ou plusieurs. Pour un gestionnaire, transmettez-le en propriété :
<Carte auClic={agir} />
<script lang="ts">
export let auClic: () => void
</script>
<article on:click={auClic}>…</article>
C'est plus explicite, et cela laisse l'enfant décider quel élément porte le gestionnaire — ce qu'un transfert automatique ne saurait pas faire.
Organiser une application
Rien n'est imposé, mais deux habitudes se marient bien avec le modèle :
Les états partagés vivent dans des modules, pas dans un composant racine passé de propriété en propriété :
// magasin/taches.ts
import { signal, computed } from '@ajustor/speedster-runtime'
export const taches = signal<Tache[]>([])
export const restantes = computed(() => taches().filter((t) => !t.faite).length)
export const ajouter = (titre: string) => {
taches.set([...taches(), { id: Date.now(), titre, faite: false }])
}
<script lang="ts">
import { taches, restantes, ajouter } from './magasin/taches.js'
</script>
<p>{restantes()} restantes sur {taches().length}</p>
Le compilateur voit des appels de fonction : il pose un effet là où ils sont lus, et rien d'autre. C'est exactement ce qu'il ferait pour un état local.
Le contexte sert à ce qui dépend de l'endroit — un thème, une locale, une connexion — plutôt qu'à ce qui est global. Voir Réactivité.