Signaux
Les signaux correspondent au temps d'exécution de réactivité à granularité fine de bext. Ils se trouvent aux côtés des îles "use client" basées sur React comme deuxième modèle d'hydratation : pas de React dans le bundle, pas de DOM virtuel, pas de nouveau rendu du balisage initial. Le serveur émet du HTML avec des commentaires de marqueurs appariés autour des expressions réactives ; le client parcourt les marqueurs, attache des abonnements aux nœuds DOM existants.
Un compteur de signaux fournit environ 2 à 3 Ko de temps d'exécution d'hydratation plus le code du composant, contre environ 45 Ko minimum de React. Cliquez → écriture du signal → uniquement le texte lié ou les mises à jour d'attributs ; rien d'autre ne bouge.
Quand utiliser les signaux par rapport aux îles React#
"use client" (React) |
"use signals" (bext) |
|
|---|---|---|
| Taille du paquet | ~ 45 Ko minimum ( react + react -dom) | ~ 3 Ko d'exécution + composant |
| Modèle d'hydratation | refaire le rendu avec hydrateRoot ; réconcilier avec le serveur DOM |
marqueurs de marche, attachez des sous-marins au DOM existant |
| Modèle mental | les composants renvoient VDOM ; différences de cadre | les fonctions créent des signaux ; lit, suivez-les |
| Écosystème | écosystème complet React (hooks, libs, devtools) | primitives internes au framework uniquement |
| Idéal pour | SPA riches en fonctionnalités, bibliothèques react, intégration devtool | petits widgets interactifs, rendus sensibles aux performances |
Les signaux brillent sur les compteurs, les bascules, les listes simples, tout ce où la charge utile React est excessive et où la stabilité du DOM est importante (mise au point, défilement, animations, iframes tiers).
Démarrage rapide#
src/components/Counter.tsx:
"use signals";
/** @jsxImportSource @bext-stack/framework/signals */
import { signal, computed } from "@bext-stack/framework/signals";
export default function Counter(props: { initial?: number }) {
const count = signal(props.initial ?? 0);
const doubled = computed(() => count.value * 2);
return (
<div>
<p>Count: {count.value} (×2 = {doubled.value})</p>
<button onClick={() => { count.value++; }}>+1</button>
<button onClick={() => { count.value--; }}>-1</button>
</div>
);
}
Les deux lignes du prologue constituent l’intégralité de l’opt-in :
- Directive
"use signals"— L'étape de découverte de bext reprend cela et achemine le composant via le pipeline de création de signaux plutôt que React. @jsxImportSource @bext-stack/framework/signalspragma — tsc-rs résout les appels JSX contre l'adaptateur de signaux, qui émet des marqueurs de reprise et des index par gestionnaire au lieu des éléments React.
Montez-le sur une page PRISM :
import { signalsIsland } from "@bext-stack/framework/signals";
import Counter from "../../components/Counter";
export default function Page() {
return (
<section>
<h1>Hello</h1>
{signalsIsland("Counter", Counter, { initial: 7 })}
</section>
);
}
C'est ça. Le serveur restitue le compteur au format HTML avec des marqueurs ; le pipeline de construction émet un bundle par île ; le script de chargement au niveau de la page le récupère par data-runtime="signals" et attache la réactivité.
Primitives#
import { signal, computed, effect, batch, untracked } from "@bext-stack/framework/signals";
signal(initial)#
Cellule inscriptible. Lecture de .value dans un effet/abonnement calculé ; l'écriture informe les abonnés.
const count = signal(0);
count.value; // 0 — subscribes if called inside effect/computed
count.value = 1; // notifies subscribers
count.peek(); // 1 — read without subscribing
set est court-circuité par Object.is : écrire la même valeur est un non-op.
computed(fn)#
Signal dérivé paresseux et mis en cache. Réexécute uniquement lorsqu'une entrée a changé ET que quelque chose lit son .value.
const a = signal(2);
const b = signal(3);
const sum = computed(() => a.value + b.value);
sum.value; // 5 — subscribes, fn runs once
a.value = 10;
sum.value; // 13 — fn runs again on first read
Mémorisation des calculs : la lecture répétée de .value entre les modifications d'entrée renvoie le résultat mis en cache sans réexécuter la fonction.
effect(fn)#
Exécutez fn et réexécutez-le chaque fois qu'un signal lu change. Renvoie une fonction de suppression.
const c = signal(0);
const stop = effect(() => {
console.log("count is", c.value);
});
c.value = 1; // logs "count is 1"
stop(); // detach
c.value = 2; // no log
Si fn renvoie une fonction, il s'agit d'un nettoyage qui s'exécute avant la prochaine réexécution et lors de la suppression. Utile pour addEventListener /
setInterval / abonnements.
batch(fn)#
Diffère toutes les mises à jour d’effet jusqu’au retour de fn. Chaque effet affecté s'exécute au maximum une fois par lot.
batch(() => {
a.value = 10;
b.value = 20;
}); // effects that depend on a OR b run once, after the batch
untracked(fn)#
Lisez à l'intérieur du corps sans vous abonner.
effect(() => {
const c = a.value; // subscribed
const b = untracked(() => b.value); // NOT subscribed
});
Abonnements élagage de branches#
Les lectures conditionnelles sont automatiquement élaguées. Si un effet indique a.value lors d'une exécution et b.value lors de la suivante, il reste abonné uniquement à ce qu'il a réellement lu lors de l'exécution la plus récente : pas de fuite, pas de surchauffe.
const cond = signal(true);
effect(() => {
if (cond.value) console.log(a.value); // sub: cond, a
else console.log(b.value); // sub: cond, b — `a` dropped
});
Adaptateur JSX#
L'adaptateur JSX de signaux est par fichier via le pragma @jsxImportSource. À l'intérieur d'un composant de signaux :
- Texte réactif :
{count}fonctionne (l'adaptateur détecte le signal valeurs),{count.value}fonctionne également (la passe de compilation l'enveloppe automatiquement - voir ci-dessous),{() => count.value * 2}fonctionne toujours. - Attributs réactifs :
<div className={cls}>oùclsest un signal. Idem avec les remerciements :<div className={() => active.value ? "on" : "off"}>. - Gestionnaires d'événements :
onClick={() => count.value++}etc. Gestionnaires sont indexés et émis en HTML sous le nomdata-bs-onclick="N"; l'hydrateur liehandlers[N]après avoir parcouru le DOM.
La passe de compilation auto-wrap#
Le pipeline de compilation de bext -core inclut une passe Rust qui parcourt les conteneurs d'expression JSX dans les fichiers "use signals" et encapsule toute expression contenant un <sig>.value lu dans (() => EXPR). Donc ceci :
<p>Count: {count.value > 0 ? "yes" : "no"}</p>
devient (avant que tsc-rs ne le voie) :
<p>Count: {(() => count.value > 0 ? "yes" : "no")}</p>
Le bruit sourd s'exécute à l'intérieur de l'effet de l'hydrateur, de sorte que le conditionnel est réévalué à chaque fois que count change.
La passe est conservatrice :
- Seuls les fichiers commençant par
"use signals"sont concernés. - Seuls les corps du conteneur d'expression JSX sont encapsulés (pas les fonctions bodys — les gestionnaires d’événements restent non réactifs).
- Seules les expressions contenant des lectures
<knownSig>.valuesont encapsulées ;<p>{1 + 2}</p>et<p>{user.name}</p>(lorsqueusern'est pas un signal) passent inchangés. - Les affectations (
count.value = 5) sur le LHS ne sont jamais encapsulées.
Désactivez par environnement avec BEXT_PRISM_COMPILE=0.
<List> — tableaux réactifs#
import { signal, List } from "@bext-stack/framework/signals";
const items = signal(["a", "b", "c"]);
<List
each={items}
render={(item) => <li>{item}</li>}
/>;
Le serveur émet des commentaires de marqueurs de liste appariés autour des éléments rendus. Lors du changement de signal, l'hydrateur intègre de nouveaux éléments dans la gamme DOM.
Rapprochement à clé#
Ajoutez key pour la préservation de l'identité lors des réorganisations, des insertions et des suppressions : les éléments conservent leur DOM (donc le focus, le défilement, la valeur <input> et les animations survivent tous) :
<List
each={todos}
key={(t) => t.id}
render={(t) => <li><input value={t.text} /></li>}
/>
Lorsque todos change, l'hydrateur calcule la sous-séquence croissante la plus longue de nouvelles positions dans l'ancienne liste. Les éléments du LIS conservent leur DOM intact (zéro appel insertBefore) ; seuls les articles en rupture de stock sont déplacés. Les insertions restituent le nouveau DOM dans de nouveaux contextes par élément ; les suppressions suppriment les abonnements et suppriment les nœuds.
Les clés sont nettoyées à [A-Za-z0-9_:.] pour la sécurité des commentaires HTML ; -,
/ et les autres caractères deviennent _. Choisissez les clés en tenant compte de cela – généralement un identifiant numérique ou un uuid convient.
Modèle de mémoire#
Les éléments d'une liste sans clé s'exécutent dans le contexte de rendu externe. Les éléments d'une liste à clé ont chacun leur propre sous-contexte par élément, de sorte que les ID de gestionnaire s'étendent par élément - pas de collisions entre les éléments. Cascade de broyeurs : lorsque l'îlot extérieur est supprimé, les abonnements de chaque élément de la liste imbriquée l'accompagnent.
Le <List> imbriqué est pris en charge : tous les travaux sont saisis, non saisis et saisis sans saisie. Le parcours de pièce jointe par élément revient dans les liaisons de liste au sein de la plage DOM de l'élément.
Modèle d'hydratation#
Le rendu du serveur émet du HTML avec trois types de marqueurs :
| Marqueur | But |
|---|---|
<!--bsN-->...<!--/bsN--> |
texte réactif — s'ouvre autour de la valeur initiale |
<!--bsListN-->...<!--/bsListN--> |
plage de liste - encapsule tous les éléments |
<!--bsI{listId}:{key}-->...<!--/bsI{listId}:{key}--> |
plage par élément (listes à clé uniquement) |
<el data-bs-attrN="<name>" <name>="initial"> |
attribut réactif |
<el data-bs-on<event>="N"> |
index du gestionnaire d'événements |
Après hydratation, chaque attribut data-bs-* est supprimé et chaque commentaire de marqueur est consommé. Le DOM finit par être propre.
Client hydrateSignalsIsland(root, Component, props) :
- Réexécute
Component(props)pour reconstruire le graphique réactif et collecter les gestionnaires + les liaisons. Les identifiants sont attribués dans le même ordre que celui vu par le serveur, ils s'alignent donc. - Pour chaque élément
data-bs-on<event>="N"→ ajoute le capturé gestionnaire. - Pour chaque élément
data-bs-attrN→ installe un effet qui met à jour l'attribut lorsque son signal lié change. - Pour chaque paire
<!--bsN-->...<!--/bsN-->→ échange dans un nouveau NœudTextlié via effet au signal. - Pour chaque liaison de liste → installe le système avec ou sans clé réconciliateur.
Le composant exécute une fois par montage, et non par rendu. Les mises à jour ultérieures du DOM passent par des effets individuels et non par une réexécution du corps du composant.
Construire un pipeline#
L'étape de découverte "use signals" est câblée à trois endroits :
framework/src/build.ts(ensemble de récupération __de production).framework/src/serve.ts(serveur dev Bun — parité avec build).crates/bext-server/src/ssr_pipeline/prism.rs( PRISM -rouille native répartiteur utilisé par le serveur bext).
Tous les trois découvrent des îlots de signaux, génèrent des entrées client par îlot et injectent un script de chargement compatible avec l'exécution dans les pages contenant des éléments <bext-island data-runtime="signals">.
Téléchargements en continu (multipart)#
La pile de signaux est composée avec le actions du serveur de bext, y compris le chemin de streaming multipart : les téléchargements de fichiers au-dessus du seuil de spool (~ 1 Mo par défaut) écrivent dans un fichier temporaire /tmp/bext-stream-* et l'analyseur de l'action parcourt les morceaux via le pont __bextReadChunk V8 natif. Consultez le guide des actions du serveur pour plus de détails.
Comparaison avec Marko / Solid / Qwik#
L’espace de réactivité à grain fin est en bonne compagnie. Le temps d'exécution des signaux de bext est le plus proche de Solid dans l'esprit (signaux + DOM réactif, pas de VDOM) et de Marko dans le mécanisme (marqueurs de balisage au moment de la compilation, hydratation paresseuse). Différences à connaître :
- versus Solid : bext ne livre pas de
createSignalde style solide API hook - les primitives sont des fonctions directes. Le modèle d'hydratation est la reprise à partir du serveur-DOM (pas de compilation de modèles côté client), ce qui réduit le temps d'exécution. Aides au contrôle de fluxShow,SwitchetMatchsont fournis (voir la référence API) ; les conditions peuvent également être écrites en tant qu'enfants d'expression JSX. - contre Marko : Marko est un compilateur descendant complet ; bext la passe de compilation est additive — votre code est toujours TSX, l'auto-wrap est la seule transformation obligatoire. Compromis : le compilateur de Marko propose un partitionnement statique beaucoup plus agressif.
- versus Qwik : bext ne charge pas les gestionnaires paresseusement - ils sont dans le
paquet par île, évalué avec impatience sur l'hydrate. La reprise consiste à ne pas restituer le balisage, et non à différer l'envoi du code. Pour le code paresseux, utilisez le chemin d'accès de l'îlot React avec
next/dynamicou votre propreimport().
Référence API#
Module : @bext-stack/framework/signals.
function signal<T>(initial: T): Signal<T>;
function computed<T>(fn: () => T): Signal<T>;
function effect(fn: () => void | Cleanup): () => void;
function batch<T>(fn: () => T): T;
function untracked<T>(fn: () => T): T;
function isSignal(v: unknown): v is Signal<unknown>;
interface Signal<T> {
value: T; // get + set
peek(): T; // read without subscribing
}
function signalsIsland(
name: string,
Component: (props: any) => string,
props?: any,
): string;
function List<T>(props: {
each: Signal<T[]> | (() => T[]);
render?: (item: T, i: number) => string;
children?: (item: T, i: number) => string;
key?: (item: T, i: number) => string | number;
}): string;
Module : @bext-stack/framework/signals/hydrate (client uniquement).
function hydrateSignalsIsland(
root: HTMLElement,
Component: (props: any) => string,
props?: any,
options?: { strict?: boolean },
): { dispose: () => void };
Statut#
- ✅ Rendu du serveur avec marqueurs + index par gestionnaire.
- ✅ Hydrateur avec texte, attribut, événement, liste (avec + sans clé), liste imbriquée et disposer en cascade.
- ✅ Rapprochement basé sur LIS pour les listes saisies.
- ✅ Compile pass enveloppe automatiquement
<sig>.valuedans les conteneurs JSX. - ✅ Construisez/servez/Les pipelines natifs PRISM découvrent tous
"use signals". - ✅ L'itinéraire de démonstration /signaux dans
prism-demoexerce le chemin complet. - ✅ Assistants de contrôle de flux
Show/Switch/Match(ou utilisez JSX expressions conditionnelles). - 🚧 Pas de
Resourcede style solide pour les données asynchrones (utilisez le bextSuspenseau niveau de la page à la place).
La passe de compilation d'auto-wrap ne se déclenche que pour les fichiers dont la première directive est "use signals". Un fichier manquant cette directive ne verra pas ses lectures signal.value enveloppées comme des thunks - la réactivité s'interrompt silencieusement sans erreur. Mettez la directive à la ligne 1.
En rapport#
- Architecture des îles — comment
signalsIsland()s'intègre dans le modèle de page PRISM - Passe de compilation PRISM - passe Rust compagnon qui encapsule automatiquement les lectures
.value - Cadre PRISM — la structure du serveur signale que les composants s'exécutent à l'intérieur
- Exécution JSX — l'adaptateur JSX de base, l'adaptateur de signaux s'étend
- Actions du serveur — associer l'hydratation des signaux à des mutations côté serveur