مقدمةنظرة عامة
NUCLO / 12

Langue

Documentation

Documentation Nuclo

Une référence pratique pour installer Nuclo, construire avec des mises à jour explicites, styler avec du CSS typé et rendre du HTML côté serveur.

Démarrage rapidenpm create nuclo@latest
Démarrage rapide
v0.3.29
Version actuelle
175
Constructeurs de balises
~18.4 KB
Noyau, gzippé
Explicite
Modèle d'exécution

Vue d'ensemble

#

Nuclo est une petite bibliothèque DOM pour TypeScript. Vous construisez la page avec des fonctions ordinaires comme div() et button(), vous gardez l'état dans des variables classiques, et vous appelez update() quand la page doit changer.

Nuclo n'a ni DOM virtuel, ni compilateur, ni proxies, ni signaux.

main.ts
import 'nuclo'

let count = 0

const App = () =>
  div(
    h1(() => `Count: ${count}`),
    button('Add one', { onClick: () => { count++; update() } }),
  )

render(App, document.getElementById('app')!)

Le modèle mental

  1. Construisez l'interface avec des constructeurs de balises.
  2. Passez une fonction pour chaque valeur qui dépend de l'état.
  3. Modifiez l'état avec du JavaScript ordinaire.
  4. Appelez update(). Nuclo réexécute les fonctions et met à jour le DOM.

Modifier une variable ne touche pas au DOM. La page ne change que lorsque vous appelez update().

Démarrage rapide

#

create-nuclo génère un projet Vite et TypeScript prêt pour Nuclo :

$npm create nuclo@latest
$bun create nuclo
$pnpm create nuclo

Il demande un nom de projet. Pour éviter la question, passez le nom et les options. Avec npm, placez -- avant les options :

terminal
$ npm create nuclo@latest my-app -- --template basic --yes

Installez ensuite les dépendances et lancez le serveur de développement :

terminal
$ cd my-app
$ npm install
$ npm run dev

Options

  • [project-name] : le dossier cible. La valeur par défaut est nuclo-app.
  • -t, --template <name> : le modèle à copier. Seul basic existe pour le moment.
  • -y, --yes : ne jamais poser de question, utiliser les valeurs par défaut.
  • -f, --force : écrire dans un dossier non vide.
  • -h, --help : afficher l'aide.

Installation et imports

#

Pour ajouter Nuclo à un projet existant :

terminal
$ npm install nuclo

Ou utilisez pnpm add nuclo, yarn add nuclo ou bun add nuclo. Nuclo n'a aucune dépendance.

Le routage ne fait pas partie du paquet principal. Il est publié séparément sous le nom nuclo-router, que vous installez à côté de nuclo quand vous en avez besoin.

Globales

import 'nuclo' place ces éléments sur globalThis :

  • 175 constructeurs de balises : 112 pour HTML (div, span, button, …) et 63 pour SVG, avec un suffixe Svg (svgSvg, pathSvg, …). Voir Constructeurs de balises.
  • 16 fonctions utilitaires : update, scope, when, list, region, into, on, render, hydrate, forceUpdate, css, cx, createCss, variants, keyframes et globalStyle.

L'import doit s'exécuter avant tout code qui appelle un constructeur. Les modules ES s'exécutent dans l'ordre des imports, donc ceci échoue :

main.ts
import { App } from './app' // app.ts calls div() → ReferenceError: div is not defined
import 'nuclo'

Placez import 'nuclo' en premier dans votre fichier d'entrée, ou importez-le dans chaque fichier qui utilise les globales.

Imports nommés

Les fonctions utilitaires sont aussi des exports nommés. Ce sont les mêmes fonctions que les globales. getCssText() et resetStyles() sont uniquement des exports nommés. Les constructeurs de balises sont uniquement des globales, donc import { div } from 'nuclo' ne fonctionne pas.

main.ts
import { render, update, getCssText } from 'nuclo'

Points d'entrée

  • nuclo : enregistre les globales et exporte les 16 fonctions utilitaires plus getCssText et resetStyles.
  • nuclo/ssr : renderToString, renderManyToString, renderToStringWithContainer et getCssText. Voir Rendu côté serveur.
  • nuclo/polyfill : un petit DOM pour Node et Bun. Voir nuclo/polyfill.
  • nuclo/types : types uniquement. Voir TypeScript.

TypeScript

#

Nuclo fournit ses propres types. Il vous faut deux choses :

  • "moduleResolution": "bundler". node16 et nodenext ne sont pas encore pris en charge.
  • La lib DOM. TypeScript l'inclut par défaut quand vous ne définissez pas lib.

Si un fichier du projet importe 'nuclo', tous les fichiers voient les types globaux. Vous n'avez besoin de rien d'autre. Si aucun fichier ne l'importe, par exemple dans un tsconfig séparé pour les tests, ajoutez les types vous-même :

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "types": ["nuclo/types"]
  }
}

Ou ajoutez /// <reference types="nuclo/types" /> dans un fichier .d.ts.

Types utiles

Ces types sont globaux, vous les utilisez donc sans import :

  • NodeModFn<Tag> : ce que renvoie un constructeur de balise. Utilisez-le comme type de retour d'un composant.
  • NodeModLike<Tag> : tout ce qu'un constructeur de balise accepte comme argument.
  • ExpandedElement<Tag> : ce que renvoient render() et hydrate().
  • ElementTagName : n'importe quel nom de balise HTML.
  • ExpandedElementAttributes<Tag> : l'objet d'attributs qu'un constructeur de balise accepte, y compris les gestionnaires on*, onMount et onDestroy. Utilisez-le pour les composants qui transmettent des attributs.
  • MountCallback et DestroyCallback : les gestionnaires onMount et onDestroy.
  • WhenBuilder, ListModifier : ce que renvoient when() et list().
  • RegionOptions, RegionType : les options acceptées par region().
  • StyleResult, Style, ThemeConfig, CssInstance et les autres types de style. Vous pouvez aussi les importer : import type { StyleResult } from 'nuclo'.
badge.ts
function Badge(text: string): NodeModFn<'span'> {
  return span(text)
}

ExpandedElement<Tag> marque chaque membre du DOM comme optionnel. Faites un cast quand vous avez besoin du type complet de l'élément : render(App, root) as HTMLDivElement.

Chaque balise HTML est une fonction globale du même nom : div, span, input, … La seule exception est <var>, qui s'écrit var_ parce que var est un mot réservé. Chaque balise SVG porte un suffixe Svg : svgSvg, pathSvg, circleSvg, …

Appeler un constructeur de balise ne crée pas encore d'élément. div('Hi') renvoie un constructeur : une fonction qui créera l'élément plus tard. Nuclo l'appelle quand vous le montez avec render() ou le placez dans un autre constructeur, un when() ou un list(). Chaque utilisation crée un nouvel élément.

card.ts
let name = 'Ada'

const Card = () =>
  div(
    { id: 'card', className: 'card' },
    h2('Profile'),
    p(() => `Hello, ${name}`),
    button('Rename', { onClick: () => { name = 'Grace'; update() } }),
  )

render(Card, document.body)
En directUn constructeur prend des attributs, des enfants statiques et du texte dynamique. Renommer change l'état, puis appelle update().Ouvrir ↗

Profile

Hello, Ada

Arguments

Un constructeur accepte un nombre quelconque d'arguments et les applique de gauche à droite :

  • string, number, bigint : un nœud texte.
  • () => value : du texte qui change à chaque update(). Voir Valeurs dynamiques.
  • null, undefined : ignorés.
  • true, false : rendus comme le texte "true" ou "false". Utilisez when() pour les conditions.
  • Un objet simple : des attributs. Voir Attributs.
  • Un résultat de css(), cx() ou variants() : ajoute ses classes.
  • on(...) : un écouteur d'événement ou un hook de cycle de vie.
  • Un autre constructeur, un when() ou un list() : un enfant.
  • Un Node DOM : ajouté tel quel.
  • (el, index) => … : un modificateur personnalisé. Voir ci-dessous.

Pièges

  • div(isAdmin && span('Admin')) affiche le texte "false". Écrivez div(when(() => isAdmin, span('Admin'))).
  • Les tableaux ne sont pas des enfants. Décomposez-les : ul(...names.map((n) => li(n))). Pour les listes qui changent, utilisez list().
  • Une variable locale portant un nom de balise masque le constructeur. Dans function Row(label: string), label(...) n'est pas appelable. Renommez les variables locales telles que label, title, data, p, a et i.
  • Les navigateurs exposent chaque élément doté d'un id comme variable globale. Si la page contient <main id="main"> avant le chargement de Nuclo, Nuclo laisse cette globale en place, donc main(...) lève une TypeError. Utilisez d'autres ids.

Modificateurs personnalisés

Une fonction qui déclare au moins un paramètre s'exécute une fois, dans l'ordre des arguments, pendant la construction de l'élément. Son premier argument est l'élément. Le second est un index interne, ne vous y fiez pas. Si elle renvoie une chaîne ou un nombre, celui-ci devient du texte. Un Node est ajouté. Un objet est appliqué comme attributs.

example.ts
const field = input((el) => {
  el.autocomplete = 'off'
})

Récupérer l'élément

render() renvoie l'élément racine. Dans un arbre, utilisez on("mount", (el) => …) ou un modificateur personnalisé. Vous pouvez aussi appeler vous-même la fonction constructeur pour créer un élément détaché :

example.ts
const el = div('Hello')() as HTMLDivElement
document.body.append(el)

Attachez-le avant le prochain update(). Un nœud qui n'est pas dans le document quand update() s'exécute cesse définitivement d'être mis à jour.

SVG

Les constructeurs SVG acceptent les mêmes arguments. Nuclo définit chaque attribut SVG avec setAttribute, donc des noms comme "stroke-width" fonctionnent tels quels.

icon.ts
const Icon = () =>
  svgSvg(
    { viewBox: '0 0 24 24', width: 24, height: 24, className: 'icon' },
    circleSvg({ cx: 12, cy: 12, r: 10, fill: 'currentColor' }),
  )

Tous les constructeurs

HTML : a abbr address area article aside audio b base bdi bdo blockquote body br button canvas caption cite code col colgroup data datalist dd del details dfn dialog div dl dt em embed fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 head header hgroup hr html i iframe img input ins kbd label legend li link main map mark menu meta meter nav noscript object ol optgroup option output p picture pre progress q rp rt ruby s samp script search section select slot small source span strong style sub summary sup table tbody td template textarea tfoot th thead time title tr track u ul var_ video wbr

SVG : aSvg animateSvg animateMotionSvg animateTransformSvg circleSvg clipPathSvg defsSvg descSvg ellipseSvg feBlendSvg feColorMatrixSvg feComponentTransferSvg feCompositeSvg feConvolveMatrixSvg feDiffuseLightingSvg feDisplacementMapSvg feDistantLightSvg feDropShadowSvg feFloodSvg feFuncASvg feFuncBSvg feFuncGSvg feFuncRSvg feGaussianBlurSvg feImageSvg feMergeSvg feMergeNodeSvg feMorphologySvg feOffsetSvg fePointLightSvg feSpecularLightingSvg feSpotLightSvg feTileSvg feTurbulenceSvg filterSvg foreignObjectSvg gSvg imageSvg lineSvg linearGradientSvg markerSvg maskSvg metadataSvg mpathSvg pathSvg patternSvg polygonSvg polylineSvg radialGradientSvg rectSvg scriptSvg setSvg stopSvg styleSvg svgSvg switchSvg symbolSvg textSvg textPathSvg titleSvg tspanSvg useSvg viewSvg

Un composant est une simple fonction qui renvoie un constructeur, comme button(...). Il n'y a pas d'API de composant. Rien n'est créé tant que vous ne le rendez pas ou ne le placez pas dans un arbre.

counter.ts
function Counter(label: string) {
  let count = 0 // one per call
  return button(() => `${label}: ${count}`, {
    onClick: () => { count++; update() },
  })
}

render(() => div(Counter('A'), Counter('B')))
En directUn composant est une fonction ordinaire. Chaque appel à Counter() garde son propre compte.Ouvrir ↗

L'état déclaré à l'intérieur de la fonction appartient à cette instance. L'état déclaré à l'extérieur est partagé par toutes les instances. forceUpdate() appelle de nouveau les composants, ce qui réinitialise l'état déclaré à l'intérieur.

Les enfants sont simplement des arguments :

card.ts
function Card(heading: string, ...children: NodeModLike<'div'>[]) {
  return div({ className: 'card' }, h2(heading), ...children)
}

render(() => Card('Hi', p('one'), p('two')))

Attributs

#

Passez un objet simple pour définir des attributs. Vous pouvez passer plusieurs objets. Pour une même clé, la dernière valeur l'emporte, sauf pour className et style, qui fusionnent. Une valeur peut être statique, ou une fonction sans paramètre réexécutée à chaque update().

example.ts
let busy = false

const save = button(
  { type: 'submit', disabled: () => busy, 'aria-busy': () => String(busy), 'data-id': 42 },
  'Save',
)
En directLes valeurs d'attributs peuvent être des fonctions. Le second bouton bascule busy et appelle update() ; les disabled et aria-busy de Save suivent.Ouvrir ↗

Comment une valeur est appliquée

  • Si l'élément HTML possède une propriété de ce nom (value, checked, disabled, htmlFor, tabIndex, …), Nuclo définit la propriété. Sinon, il appelle setAttribute(key, String(value)), par exemple pour "aria-*" et "data-*".
  • Sur les éléments SVG, Nuclo appelle toujours setAttribute.
  • null et undefined sont ignorés. Une valeur dynamique qui renvoie null ou undefined conserve l'ancienne valeur. Pour effacer une valeur, renvoyez "", ou false pour une propriété booléenne.
  • Les booléens fonctionnent pour les propriétés DOM booléennes, telles que disabled sur un bouton, checked, hidden et required. Partout ailleurs, true et false sont écrits comme les chaînes "true" et "false".
  • Utilisez des clés "data-*". dataset n'est pas pris en charge.
  • innerHTML n'est pas échappé, et le rendu côté serveur ne le produit pas. Évitez-le.

Classes

Utilisez className. Les chaînes de classes statiques, les résultats de css() et un className dynamique fusionnent tous. Un className dynamique conserve les classes statiques, et quand il renvoie "" seules les classes statiques restent.

example.ts
let active = false
const base = css({ p: 8 })

const item = li(base, { className: 'item' }, { className: () => (active ? 'active' : '') }, 'Home')
// class="<base> item", then "<base> item active" after active = true; update()

N'utilisez pas la clé class sur les éléments HTML. Elle définit l'attribut brut et remplace toutes les classes appliquées avant elle, y compris les classes css().

Styles en ligne

style accepte un objet avec des clés en camelCase, ou une fonction qui en renvoie un.

example.ts
let progress = 30

const bar = div({ style: () => ({ width: `${progress}%`, backgroundColor: 'teal' }) })
  • Nuclo n'ajoute aucune unité. Écrivez "12px", pas 12. Les propriétés sans unité comme opacity et zIndex acceptent des nombres.
  • "", null et undefined effacent une propriété.
  • Un style dynamique ne définit que les clés qu'il renvoie. Une clé qu'il cesse de renvoyer conserve son ancienne valeur, donc renvoyez "" pour l'effacer.
  • Les chaînes de style ("color: red") et les propriétés personnalisées ("--gap") ne sont pas prises en charge. Utilisez css() à la place. Il accepte les propriétés personnalisées via raw.
fnAPI publique
// DOM events such as "click". El and Ev are inferred from the tag and the event name. function on(type: DOMEventName, listener: (this: El, e: Ev) => unknown, options?: boolean | AddEventListenerOptions): NodeModFn // Custom events function on<K extends string, E extends Event = Event>(type: K, listener: (this: El, e: E) => unknown, options?: boolean | AddEventListenerOptions): NodeModFn // Lifecycle, see Lifecycle function on(type: "mount", listener: MountCallback): NodeModFn function on(type: "destroy", listener: DestroyCallback): NodeModFn

Il existe deux façons d'écouter des événements : les attributs on* et le modificateur on().

Attributs on*

Dans un objet d'attributs, une fonction sous une clé telle que onClick (on suivi d'une majuscule) est un gestionnaire d'événement, pas une valeur dynamique.

search.ts
let query = ''

const search = input({
  placeholder: 'Search',
  value: () => query,
  onInput: (e) => { query = e.currentTarget.value; update() },
  onKeyDown: (e) => { if (e.key === 'Escape') { query = ''; update() } },
})
  • TypeScript déduit le type de l'événement à partir de la clé et de l'élément. e.currentTarget et this sont l'élément.
  • Nuclo définit la propriété native, donc onClick devient el.onclick. Il y a un gestionnaire par événement : un onClick ultérieur remplace le précédent.
  • onDoubleClick et onDblClick désignent tous deux dblclick. Les clés en minuscules comme onclick sont des erreurs de type.
  • Un nom qui n'est pas un événement connu écoute sa forme en minuscules : onMyEvent écoute "myevent".
  • Les erreurs levées par le gestionnaire ne sont pas interceptées.
En directonInput et onKeyDown sont des attributs ordinaires. Tapez, puis appuyez sur Échap pour effacer.Ouvrir ↗

Type something, then press Escape.

on()

on() renvoie un modificateur qui appelle addEventListener sur l'élément.

panel.ts
const panel = div(
  on('scroll', onScroll, { passive: true }),
  on('click', logClick),
  on('click', closeMenu),
)
  • Chaque on() ajoute un écouteur. Tous s'exécutent, dans l'ordre.
  • options est transmis directement à addEventListener : capture, once, passive, signal.
  • Passez on() directement à un constructeur et le type de l'élément est déduit : input(on('input', (e) => e.currentTarget.value)).
  • Si un écouteur lève une erreur, Nuclo la journalise avec console.error, et les autres écouteurs s'exécutent quand même.
  • Nuclo retire l'écouteur quand il retire l'élément. Côté serveur, on() ne fait rien.
  • Vous pouvez réutiliser une même valeur on() sur plusieurs éléments.

Pour un événement personnalisé, passez le type de l'événement en générique :

picker.ts
type PickEvent = CustomEvent<{ id: number }>

const picker = ul(on<'pick', PickEvent>('pick', (e) => choose(e.detail.id)))

Lequel utiliser

Utilisez les attributs on* dans le cas général. Utilisez on() quand vous avez besoin de plusieurs écouteurs pour un événement, d'options d'écouteur, ou d'un nom d'événement personnalisé tel que "my-event".

fnAPI publique
// As attributes { onMount?: (el: El) => void | (() => void), onDestroy?: (el: El) => void } // Or with on() function on(type: "mount", listener: (el: El) => void | (() => void)): NodeModFn function on(type: "destroy", listener: (el: El) => void): NodeModFn

onMount s'exécute une fois, après l'insertion de l'élément. onDestroy s'exécute une fois, quand Nuclo retire l'élément. Écrivez-les comme attributs ou avec on(). Les deux formes se comportent de la même façon, et l'élément est typé d'après sa balise.

timer.ts
let seconds = 0

const Timer = () =>
  span(
    {
      onMount: () => {
        const id = setInterval(() => { seconds++; update() }, 1000)
        return () => clearInterval(id) // runs when the span is removed
      },
    },
    () => `${seconds}s`,
  )

const search = input(on('mount', (el) => el.focus()))
En directonMount démarre un intervalle et retourne son nettoyage. Le démontage exécute le nettoyage, le compte se met donc en pause jusqu'à ce que le minuteur soit remonté.Ouvrir ↗

Quand ils s'exécutent

  • onMount s'exécute à la fin de l'appel render(), hydrate(), update() ou forceUpdate() qui a inséré l'élément. Il ne s'exécute jamais pendant la construction de l'arbre.
  • Si onMount renvoie une fonction, celle-ci s'exécute à la destruction, après les callbacks onDestroy de l'élément.
  • onDestroy s'exécute quand Nuclo retire l'élément : une ligne de list() est supprimée, une branche de when() change, un into() quitte sa région, ou forceUpdate() l'abandonne. Il s'exécute juste avant le retrait, l'élément est donc encore dans le document.

Retrait DOM manuel

Si vous retirez un élément vous-même, avec node.remove() ou innerHTML = '', ses onDestroy et nettoyages ne s'exécutent jamais, et les timers continuent de tourner. Nuclo n'a pas de unmount(). Pour démonter une application avec nettoyage, enveloppez-la dans un when() :

main.ts
let mounted = true
render(() => div(when(() => mounted, App())), root)

mounted = false
update() // App's onDestroy hooks and cleanups run

Ordre et erreurs

  • Vous pouvez enregistrer autant de hooks que vous voulez, sous l'une ou l'autre forme. Ils s'exécutent dans l'ordre d'enregistrement.
  • onDestroy exécute toujours les enfants d'abord, puis le parent.
  • Les éléments sont montés dans l'ordre de leur premier hook onMount, qui suit l'ordre des arguments, et chaque élément exécute tous ses hooks ensemble. Passez le hook d'un parent avant ses enfants s'ils en dépendent.
  • Un hook qui lève une erreur est journalisé avec console.error. Les autres hooks s'exécutent quand même.
  • Le callback doit ne rien renvoyer ou renvoyer une fonction de nettoyage, ne le rendez donc pas async. Une Promise renvoyée n'est jamais utilisée comme nettoyage. Démarrez le travail asynchrone à l'intérieur : { onMount: (el) => { void load(el) } }.
  • Les hooks ne s'exécutent jamais côté serveur. Côté client, ils s'exécutent après hydrate().
Note : les hooks ne sont pas des effets. Ils ne se réexécutent jamais quand l'état change. Chacun s'exécute au plus une fois par élément.

Nuclo ne met jamais la page à jour de lui-même. Modifiez votre état, puis appelez update().

counter.ts
let count = 0

const Counter = () =>
  button(() => `Clicked ${count} times`, {
    onClick: () => { count++; update() },
  })

render(Counter)
En directcount++ ne change que la variable. Le texte se rattrape au prochain update(), venant du bouton ou de n'importe quoi d'autre sur cette page.Ouvrir ↗

Clicked 0 times

Modifiez autant d'état que vous voulez, puis appelez update() une seule fois :

example.ts
let first = 'Ada'
let last = 'Lovelace'

render(() => p(() => `${first} ${last}`))

first = 'Grace'
last = 'Hopper'
update() // one pass, both changes

Appelez update() depuis des gestionnaires d'événements, des timers, des callbacks de fetch ou onMount. Ne l'appelez pas depuis une valeur dynamique.

Valeurs dynamiques

#

Une fonction sans paramètre est une valeur dynamique. Nuclo l'exécute une fois à la construction de l'élément, puis à chaque update(). Utilisez les valeurs dynamiques pour les enfants texte, les valeurs d'attributs, style et className. Pour les enfants qui apparaissent, disparaissent ou se répètent, utilisez when() et list().

example.ts
let name = 'Alice'
let busy = false

const Profile = () =>
  div(
    h1('Welcome'),              // static
    p(() => `Hello, ${name}`),  // dynamic text
    button({ disabled: () => busy }, 'Save'),
  )
En directModifiez le nom. Le texte statique a été fixé à la construction de l'élément ; le texte dynamique s'exécute à nouveau à chaque update(). forceUpdate() reconstruit l'arbre, le texte statique se rattrape donc aussi.Ouvrir ↗

static: Hello, Alice

dynamic: Hello, Alice

Nuclo vérifie le nombre de paramètres (fn.length). Une fonction qui déclare un paramètre est un modificateur personnalisé et ne s'exécute qu'une fois. (x = 0) => … et (...args) => … ont une longueur de 0, elles sont donc dynamiques.

Texte dynamique

  • Une chaîne, un nombre, un bigint ou un booléen est rendu comme String(value). () => ok && 'Saved' affiche "false", écrivez donc () => (ok ? 'Saved' : '').
  • null et undefined sont rendus comme un texte vide.
  • Un résultat de css() ou cx() à la première exécution fait de la fonction une classe dynamique, pas du texte. Voir cx().
  • Un nœud, un constructeur, un tableau ou un objet simple ne rend rien, et ne se met jamais à jour.

Attributs dynamiques

  • Nuclo n'écrit une valeur que si elle diffère de la dernière valeur qu'il a écrite. Il ne compare pas avec le DOM réel, donc si un utilisateur tape dans un champ et que votre fonction renvoie la même valeur qu'avant, le texte saisi reste.
  • Un résultat null ou undefined n'écrit rien, l'ancienne valeur reste donc. className est l'exception : tout résultat falsy retire les classes dynamiques et conserve les statiques.
  • Une fonction style applique l'objet renvoyé à chaque mise à jour.

Erreurs

Si une valeur dynamique lève une erreur, Nuclo l'intercepte et le reste de la mise à jour s'exécute quand même. Le texte devient vide ou conserve son ancienne valeur, et un attribut conserve son ancienne valeur. Les deux se rétablissent au prochain update() qui réussit. L'exception : une fonction texte qui lève une erreur à sa première exécution reste vide définitivement.

fnAPI publique
function update(...scopeIds: string[]): void

Exécute une passe synchrone sur chaque valeur dynamique de la page. Avec des ids, ne met à jour que ce qui se trouve dans les éléments marqués avec ces ids de scope(). Chaque passe exécute ces étapes dans l'ordre :

  1. list() : relit les éléments et ajoute, déplace ou supprime des lignes.
  2. when() : revérifie les conditions. Une branche n'est reconstruite que lorsqu'une autre branche devient active.
  3. Les attributs dynamiques, y compris style et className.
  4. Le texte dynamique.
  5. onMount pour les éléments insérés par les étapes 1 et 2.
  • Tout est exécuté. Chaque valeur dynamique s'exécute à chaque appel, même si ses données n'ont pas changé. Seule l'écriture dans le DOM est évitée. Gardez les valeurs dynamiques rapides et sans effets de bord.
  • Seulement les nœuds du document. Si un nœud n'est pas dans le document quand update() s'exécute, Nuclo cesse de le suivre. Il ne se met plus jamais à jour, même après que vous l'avez attaché. Cela vaut aussi pour list() et when(). Rendez dans un conteneur qui est dans le document.
  • Les valeurs statiques ne changent jamais. Les chaînes et valeurs d'attributs simples sont fixées à la construction de l'élément. Pour les reconstruire, utilisez forceUpdate().
  • Erreurs. Les erreurs de texte et d'attributs sont interceptées, et la passe continue. Si une condition de when() lève une erreur, elle est journalisée et ce when() cesse de se mettre à jour. Si une fonction items ou renderItem d'un list() lève une erreur, celle-ci sort de update() et les étapes restantes ne s'exécutent pas.
  • Serveur. update() ne fait rien côté serveur.
En directChaque update() exécute chaque valeur dynamique, même quand ses données n'ont pas changé. Les autres démos de cette page font aussi monter le compte.Ouvrir ↗

This dynamic value has run 1 times.

fnAPI publique
function scope(...ids: string[]): NodeModFn

Donne à l'élément auquel il est passé un ou plusieurs ids de portée. update('id') ne met alors à jour que ce qui se trouve dans cet élément, y compris ses propres attributs.

example.ts
let cartItems: string[] = []
let user = 'Ada'

const App = () =>
  div(
    header(() => `Hi, ${user}`),
    aside(scope('cart'), span(() => `Items: ${cartItems.length}`)),
  )

render(App)

cartItems.push('apple')
user = 'Grace'
update('cart') // "Items: 1", the header still says "Hi, Ada"
update()       // everything, the header now says "Hi, Grace"
En directLes deux boutons ajoutent un article et renomment l'utilisateur. update('cart') ne rafraîchit que le panneau du scope.Ouvrir ↗
Hi, Ada
Items: 0
  • Un élément peut avoir plusieurs ids : scope('cart', 'sidebar').
  • update('a', 'b') met à jour tout ce qui se trouve dans a ou b. Si plusieurs éléments utilisent le même id, tous sont mis à jour.
  • Avec des portées imbriquées, update('outer') inclut la portée interne. update('inner') ne touche pas à la portée externe.
  • Un id qu'aucun élément du document ne possède ne met rien à jour et ne lève aucune erreur, vérifiez donc l'orthographe.
  • scope() ne fait rien côté serveur.
fnAPI publique
function when(condition: boolean | (() => boolean), ...content: NodeModLike[]): WhenBuilder interface WhenBuilder { when(condition: boolean | (() => boolean), ...content: NodeModLike[]): WhenBuilder else(...content: NodeModLike[]): WhenBuilder }

Rend du contenu selon une condition. Nuclo revérifie les conditions à chaque update(). La première condition vraie l'emporte. Si aucune n'est vraie, le contenu de .else() est rendu. Sans .else(), rien n'est rendu.

status.ts
let status: 'loading' | 'error' | 'ready' = 'loading'

const Status = () =>
  div(
    when(() => status === 'loading', p('Loading…'))
      .when(() => status === 'error', p('Something went wrong'))
      .else(p('Ready')),
  )
En directLa première condition vraie l'emporte ; sinon .else() est rendu. Une nouvelle branche est construite de zéro.Ouvrir ↗

Loading…

Conditions

  • Pour de l'état, passez une fonction. Un booléen simple est lu une seule fois : when(open, …) ne change jamais.
  • En TypeScript, la fonction doit renvoyer un booléen. Écrivez () => items.length > 0 ou () => user != null.

Ce qui est mis à jour

  • Tant que la même branche reste active, ses éléments sont conservés. Les valeurs dynamiques, les listes et les blocs when() qu'elle contient continuent de se mettre à jour.
  • Quand une autre branche devient active, l'ancien contenu est retiré et ses hooks onDestroy s'exécutent. La nouvelle branche est construite de zéro et ses hooks onMount s'exécutent. L'état des anciens éléments, comme le texte d'un champ ou le focus, est perdu.

Contenu d'une branche

Placez des enfants dans une branche : du texte, du texte dynamique, des constructeurs, list() et des when() imbriqués. Passez des constructeurs comme p('Hi'), pas des nœuds DOM : un nœud DOM cesse de se mettre à jour dès que sa branche se cache une fois.

Les objets d'attributs et on() dans une branche s'appliquent à l'élément parent, et restent après que la branche se cache. Pour un attribut conditionnel, utilisez un attribut dynamique : { className: () => (active ? 'active' : '') }.

Chaînage

.when() et .else() renvoient un nouveau constructeur et ne modifient pas l'original. Si vous appelez .else() deux fois, le dernier appel l'emporte.

fnAPI publique
function list<T>( items: () => readonly T[] | Iterable<T>, renderItem: (item: T, index: number) => ListRenderResult, ): ListModifier

Rend une ligne par élément. À chaque update(), Nuclo rappelle items() et fait correspondre les nouveaux éléments aux lignes existantes par identité (===).

fruits.ts
let fruits = ['Apple', 'Banana']

const Fruits = () => ul(list(() => fruits, (fruit) => li(fruit)))

render(Fruits)

fruits.push('Cherry')
update()

Comment les lignes sont appariées

  • Un élément toujours présent conserve sa ligne. Nuclo déplace la ligne si nécessaire, et ne rappelle pas renderItem.
  • Un nouvel élément reçoit une nouvelle ligne. Un élément supprimé perd sa ligne, et ses hooks onDestroy s'exécutent.
  • Les éléments en double reçoivent chacun leur propre ligne.
  • Vous pouvez modifier le tableau en place (push, splice, sort) ou renvoyer un nouveau tableau.

Une ligne conservée n'est jamais rendue à nouveau, utilisez donc des valeurs dynamiques pour les champs qui changent :

todos.ts
const todos = [{ text: 'Write docs', done: false }]

const Todos = () =>
  ul(
    list(() => todos, (todo) =>
      li({ className: () => (todo.done ? 'done' : '') }, () => todo.text),
    ),
  )

render(Todos)

todos[0].done = true
update() // same <li>, its class is now "done"

Les copies sont de nouveaux éléments, donc remplacer le tableau par todos.map((t) => ({ ...t })) reconstruit toutes les lignes.

En directLes lignes sont appariées par identité. Cochez une ligne, puis Reverse : le même <li> se déplace, case à cocher comprise.Ouvrir ↗

Ce que renvoie renderItem

  • Renvoyez un seul élément, tel que li(...), tr(...) ou un constructeur SVG. Renvoyez null ou undefined pour ignorer un élément. Les chaînes, nœuds texte et fragments ne rendent rien.
  • Ne renvoyez pas when() ou list() directement. Placez-les dans l'élément de ligne : li(todo.text, when(() => todo.done, ' ✓')).
  • Pour masquer des éléments, filtrez dans items(), pas dans renderItem.
  • Gardez renderItem sans effets de bord. Nuclo peut l'appeler plusieurs fois pour le même élément.

L'index

index est la position de l'élément au moment où sa ligne a été créée. Il n'est pas mis à jour quand les lignes se déplacent. Pour la position courante, utilisez une valeur dynamique : li(() => `${names.indexOf(name) + 1}. ${name}`).

Itérables

Tout itérable fonctionne : tableaux, Set, générateurs et map.values(). Renvoyer le même tableau ou Set à chaque fois est correct. Un itérateur, comme un générateur ou map.values(), est épuisé après une passe, créez-le donc dans items() à chaque appel. Itérer directement une Map crée de nouveaux tableaux [key, value] à chaque fois, donc chaque ligne est reconstruite. Utilisez map.values() à la place.

Placement

Les lignes apparaissent là où list() se trouve parmi ses frères, entre deux marqueurs de commentaire. Les autres enfants du parent ne sont pas touchés. list() fonctionne aussi dans les constructeurs SVG.

region() et into()

#
fnAPI publique
function region(options: RegionOptions): NodeModFn function into(id: string, ...content: NodeModLike[]): NodeModFn function into(regions: Record<string, NodeModLike | NodeModLike[]>): NodeModFn interface RegionOptions { id: string type?: 'latest' | 'stack' // default: 'latest' empty?: NodeModLike }

region() marque un emplacement nommé dans l'arbre. into() le remplit depuis n'importe où ailleurs, à n'importe quelle profondeur. La mise en page ne reçoit pas le contenu en prop, et le composant qui construit le contenu ne sait pas où il atterrit.

app.ts
const Layout = () =>
  div(
    aside(nav(a({ href: '/' }, 'Home'))),
    main(region({ id: 'main', empty: p('Nothing open.') })),
  )

// Anywhere in the tree, before or after the region
const Article = () => into('main', article(h1('Hello')))

render(() => div(Layout(), Article()))

Fonctionnement

  • into() ne laisse qu'un commentaire à l'endroit où il est écrit. Son contenu est rendu entre les marqueurs de la région.
  • L'ordre n'a pas d'importance. Un into() construit avant sa région attend et s'affiche dès que la région est construite. Les vues s'affichent dans l'ordre où elles ont été écrites.
  • Le contenu vit aussi longtemps que la vue. Quand le when() qui contient un into() se ferme, ou que sa ligne de list() est supprimée, le contenu quitte la région. Le rouvrir ajoute une copie, pas deux.
  • Quand une région disparaît, par exemple parce que son when() se ferme, ses vues se remettent en attente. Une région construite plus tard avec le même id les affiche.
  • Un into() dont l'id ne correspond à aucune région ne rend rien et ne lève aucune erreur, vérifiez donc l'orthographe.
  • Les ids sont uniques dans l'arbre. Une seconde région construite sous un id actif journalise un avertissement.
  • Les régions fonctionnent avec le rendu côté serveur. Lors de hydrate(), chaque vue réclame le HTML rendu par le serveur au lieu de le reconstruire.

Options

  • id : le nom que les appels into() utilisent pour trouver la région.
  • type : 'latest' (par défaut) n'affiche que la vue la plus récente. 'stack' affiche toutes les vues, dans l'ordre d'arrivée.
  • empty : rendu tant que la région ne contient aucune vue.

Régions empilées

Avec type: 'stack', une vue qui s'ouvre par-dessus une autre ne reconstruit pas celle du dessous. Son DOM, son focus et l'état de ses formulaires restent. Utilisez cela pour une couche telle qu'une boîte de dialogue au-dessus d'une page.

layers.ts
let open = false

const App = () =>
  div(
    region({ id: 'main', type: 'stack' }),
    into('main', form(input({ placeholder: 'Keeps its text' }))),
    when(() => open, into('main', div('On top'))),
  )

Plusieurs régions à la fois

Passez un objet pour remplir plusieurs régions depuis un seul into(). Une valeur peut être un enfant ou un tableau d'enfants. Les deux formes s'imbriquent : un into() dans le contenu d'une autre vue cible sa propre région.

page.ts
const Page = () =>
  into({
    main: article(h1('Eager route')),
    sidebar: [h2('Related'), ul(li('One'), li('Two'))],
  })

Une boîte de dialogue ouverte depuis le fond de l'arbre

La région de superposition se trouve à la racine. N'importe quel composant, à n'importe quelle profondeur, ouvre une boîte de dialogue en enveloppant un into() dans un when(). Fermer le when() retire la boîte de dialogue de la région.

dialog.ts
let confirming = false

const App = () =>
  div(
    Page(),
    div({ className: 'overlay' }, region({ id: 'overlay' })),
  )

// Deep inside Page()
const DeleteButton = () =>
  div(
    button('Delete', { onClick: () => { confirming = true; update() } }),
    when(() => confirming,
      into('overlay',
        dialog({ open: true },
          p('Delete this item?'),
          button('Cancel', { onClick: () => { confirming = false; update() } }),
        ),
      ),
    ),
  )

Des toasts depuis les lignes d'une liste

Chaque ligne d'un list() est une vue. Une région empilée les affiche dans l'ordre, et retirer un élément du tableau retire son toast.

toasts.ts
const toasts: { text: string }[] = []

const notify = (text: string) => {
  toasts.push({ text })
  update()
}

const dismiss = (toast: { text: string }) => {
  toasts.splice(toasts.indexOf(toast), 1)
  update()
}

const App = () =>
  div(
    button('Save', { onClick: () => notify('Saved.') }),
    div({ className: 'toasts' }, region({ id: 'toasts', type: 'stack' })),
    list(() => toasts, (toast) =>
      into('toasts', div(toast.text, button('×', { onClick: () => dismiss(toast) }))),
    ),
  )

Un titre de page défini par la page

Avec le type: 'latest' par défaut, la vue la plus récente l'emporte. La mise en page possède le titre, et la page ouverte le nomme. Une page sans into('title', …) laisse le texte empty.

title.ts
let page: 'home' | 'settings' = 'home'

const Layout = () =>
  div(
    h1(region({ id: 'title', empty: 'My app' })),
    when(() => page === 'home', Home()).else(Settings()),
  )

const Home = () => p('Welcome.')

const Settings = () =>
  div(
    into('title', 'Settings'),
    form(/* … */),
  )

Valeurs dynamiques dans une vue

Le contenu d'un into() est du contenu Nuclo ordinaire. Les fonctions qu'il contient sont des valeurs dépendant de l'état et se rafraîchissent à chaque update(), où que soit la région.

status.ts
const selected = new Set<string>()

const Layout = () =>
  div(
    header(region({ id: 'status' })),
    Table(),
  )

const Table = () =>
  div(
    into('status', () => `${selected.size} selected`),
    list(() => rows, (row) =>
      label(
        input({ type: 'checkbox', onChange: (e) => {
          e.currentTarget.checked ? selected.add(row.id) : selected.delete(row.id)
          update()
        } }),
        row.name,
      ),
    ),
  )

Une région qui va et vient

Le into() du tableau de bord est écrit une seule fois. Tant que l'utilisateur est déconnecté, il n'y a pas de région main, la vue attend donc. La connexion construit la région et la vue s'affiche. La déconnexion retire la région et la vue se remet en attente.

auth.ts
let user: { name: string } | null = null

const App = () =>
  div(
    when(() => user !== null,
      main(region({ id: 'main' })),
    ).else(
      LoginForm(),
    ),
    into('main', Dashboard()),
  )

Une vue avec sa propre région

Une vue peut contenir un region(), et d'autres vues peuvent le cibler. Ici, la page éditeur apporte avec elle une région tabs, et chaque onglet la remplit. Quand l'éditeur se ferme, sa région disparaît avec lui.

editor.ts
let tab: 'code' | 'preview' = 'code'

const Editor = () =>
  into('main',
    div(
      nav(
        button('Code', { onClick: () => { tab = 'code'; update() } }),
        button('Preview', { onClick: () => { tab = 'preview'; update() } }),
      ),
      section(region({ id: 'tabs' })),
    ),
    when(() => tab === 'code', into('tabs', textarea()))
      .else(into('tabs', iframe({ src: '/preview' }))),
  )

Exemple interactif

region-app.ts
import 'nuclo'

let page: 'home' | 'article' | null = 'home'
let dialog = false

const go = (next: typeof page) => () => {
  page = next
  dialog = false
  update()
}

const Layout = () =>
  div(
    nav(
      button('Home', { onClick: go('home') }),
      button('Article', { onClick: go('article') }),
      button('Close page', { onClick: go(null) }),
    ),
    main(
      region({ id: 'main', type: 'stack', empty: p('Nothing open.') }),
    ),
    aside(region({ id: 'side', empty: p('No related links.') })),
  )

// Pages fill the layout's regions from outside it.
const Home = () =>
  into('main',
    input({ placeholder: 'Type here, then open the dialog' }),
    button('Open dialog', {
      onClick: () => { dialog = true; update() },
    }),
  )

const Article = () =>
  into({
    main: article(h2('Article'), p('One into() fills both regions.')),
    side: [h2('Related'), ul(li('One'), li('Two'))],
  })

// Stacks over Home, so Home keeps its DOM and the text you typed.
const Dialog = () =>
  into('main',
    div(
      p('On top of Home.'),
      button('Close', { onClick: () => { dialog = false; update() } }),
    ),
  )

export const RegionApp = () =>
  div(
    Layout(),
    when(() => page === 'home', Home()),
    when(() => page === 'article', Article()),
    when(() => dialog, Dialog()),
  )
En directUne mise en page avec une région principale empilée et une région latérale, remplies depuis l'extérieur. Tapez dans Home, ouvrez la boîte de dialogue et fermez-la : le texte reste. Article remplit les deux régions avec un seul into().Ouvrir ↗

No related links.

fnAPI publique
function forceUpdate(): void function forceUpdate(nodeModFn: NodeModFn | (() => NodeModFn), parent?: Element): ExpandedElement

update() n'exécute que les valeurs dynamiques. Les valeurs statiques, comme le texte dans h1(labels[lang].title), sont fixées à la construction de l'arbre. forceUpdate() rappelle vos composants et met à jour le DOM réel pour qu'il corresponde, valeurs statiques comprises. Utilisez-le pour des changements rares à l'échelle de l'application, comme le changement de langue. Utilisez update() pour tout le reste.

i18n.ts
const labels = { en: { title: 'Hello' }, pt: { title: 'Olá' } }
let lang: keyof typeof labels = 'en'

const App = () => div(h1(labels[lang].title))
render(App, root)

lang = 'pt'
forceUpdate() // same <h1> element, now "Olá"
En directupdate() ne rafraîchit que la ligne lang dynamique. forceUpdate() appelle App à nouveau et retouche le même <h1>. La forme explicite ne reconstruit que cet arbre, pas toute la page.Ouvrir ↗

lang = 'en'

Hello

Static text, fixed on build.

Sans argument, forceUpdate() reconstruit chaque racine rendue avec render(App) ou hydrate(App). Les racines rendues avec render(App()) sont ignorées. Si un composant lève une erreur, elle est journalisée et cette racine conserve son ancien DOM. Une erreur levée plus tard, pendant l'application de la nouvelle construction, est aussi journalisée, mais les parties déjà mises à jour le restent.

Ce qui reste et ce qui change

  • Les enfants sont appariés dans l'ordre, par balise. Un élément correspondant est réutilisé, donc le focus et les valeurs saisies restent, et ses hooks onMount ne se réexécutent pas. Dès que la balise d'un enfant diffère, cet enfant et tous les frères suivants sont reconstruits.
  • Le texte, les attributs, les classes, les styles, les écouteurs, les branches de when() et les lignes de list() suivent la nouvelle construction.
  • Les nouveaux éléments sont créés, et leurs hooks onMount s'exécutent. Les éléments que la nouvelle construction ne produit plus sont retirés, et leurs hooks onDestroy s'exécutent.
  • Les lignes de list() sont aussi appariées par position, pas par élément, donc un élément de ligne réutilisé peut finir par afficher un autre élément.
  • Les attributs que la nouvelle construction ne définit plus ne sont pas retirés.
Attention : l'état déclaré dans une fonction composant est réinitialisé, parce que la fonction s'exécute à nouveau. Gardez l'état qui doit survivre en dehors du composant.

Forme explicite. forceUpdate(App(), parent) reconstruit un seul arbre dans parent et renvoie sa racine. Il n'enregistre pas la racine pour les appels suivants. Comme hydrate(), il attend que la racine soit le premier nœud de parent. Si le premier nœud a une balise différente, il est remplacé.

Côté serveur, forceUpdate() ne fait rien.

render()

#
fnAPI publique
function render(nodeModFn: NodeModFn | (() => NodeModFn), parent?: Element, index?: number): ExpandedElement

Construit votre arbre et ajoute son élément racine à parent. parent vaut document.body par défaut. Renvoie l'élément racine.

main.ts
import 'nuclo'
import { App } from './app.ts'

render(App, document.getElementById('app')!)
  • Vous pouvez passer le composant (render(App)) ou le constructeur qu'il renvoie (render(App())). Le DOM est le même. Seul render(App) permet à forceUpdate() de reconstruire la racine plus tard, préférez-le donc.
  • Il ajoute. Il ne vide pas parent, donc l'appeler deux fois ajoute deux copies.
  • Tous les onMount de l'arbre ont été exécutés quand render() rend la main.
  • Rendez dans un élément qui est dans le document. Voir update().
  • La racine doit être un élément, comme div(...), pas un when() ou un list().
  • index est interne. Il ne choisit pas où l'élément est inséré. Omettez-le.

Un composant passé comme render(App) ne doit prendre aucun paramètre. Un paramètre obligatoire est une erreur de type. Un paramètre optionnel passe la vérification de types, mais render() lève une erreur à l'exécution. Enveloppez un composant qui prend des props :

main.ts
const Page = (props: { name: string }) => div(h1('Hi ', props.name))

render(() => Page({ name: 'Ana' }), root)

hydrate()

#
fnAPI publique
function hydrate(nodeModFn: NodeModFn | (() => NodeModFn), parent?: Element): ExpandedElement

Prend en charge le HTML produit par renderToString(). Réutilise les éléments existants, attache les écouteurs d'événements, et connecte les valeurs dynamiques, when() et list(), de sorte que update() fonctionne ensuite. Exécute ensuite les hooks onMount. parent vaut document.body par défaut. Renvoie l'élément racine.

client.ts
import 'nuclo'
import { App } from './app.ts'

hydrate(App, document.getElementById('root')!)
  • Utilisez le même composant, et les mêmes données, que le serveur.
  • Hydratez dans un conteneur dédié. La racine de l'application doit être son premier nœud, les espaces blancs étant tolérés. Si un autre nœud vient en premier, Nuclo remplace ce nœud et laisse la copie du serveur en place.
  • Comme avec render(), passez App, pas App(), pour que forceUpdate() puisse reconstruire la racine plus tard. App ne doit prendre aucun paramètre. Avec un paramètre optionnel, hydrate() n'attache rien, silencieusement. Enveloppez un composant qui prend des props : hydrate(() => Page(props), root).
  • S'il n'y a pas de HTML serveur, utilisez render().

Quand le serveur et le client diffèrent

Le client l'emporte :

  • Une balise racine différente : ce nœud est remplacé par un DOM neuf. Un conteneur vide : un DOM neuf est ajouté.
  • Un texte différent est corrigé. Les éléments de list() et les branches de when() différents suivent le client.
  • Les enfants en trop côté serveur sont retirés, et ceux qui manquent sont créés.
  • Les attributs écrits par le serveur mais non définis par le client sont laissés tels quels.

renderToString() écrit des commentaires HTML comme marqueurs, par exemple <!-- text-0 --> avant chaque nœud texte. hydrate() en a besoin, ne supprimez donc pas les commentaires du HTML serveur. Sans les marqueurs, l'hydratation supprime les lignes de list(), et list() et when() cessent de se mettre à jour.

Rendu côté serveur

#
fnAPI publique
// import { ... } from 'nuclo/ssr' function renderToString(input: RenderableInput): string function renderManyToString(inputs: RenderableInput[]): string[] function renderToStringWithContainer(input: RenderableInput, containerTag?: string, containerAttrs?: Record<string, string>): string function getCssText(): string type RenderableInput = NodeModFn | (() => NodeModFn) | Element | Node | null | undefined

Rendez un composant en chaîne HTML sur le serveur. Dans le navigateur, appelez hydrate() avec le même composant pour rendre ce HTML interactif. Les environnements serveur n'ont pas de document, importez donc nuclo/polyfill en premier.

app.ts
let count = 0
const heading = css({ color: 'red' })

export const App = () =>
  main(
    h1(heading, () => `Count: ${count}`),
    button({ onClick: () => { count++; update() } }, '+1'),
  )
server.ts
import 'nuclo/polyfill'
import 'nuclo'
import { renderToString, getCssText } from 'nuclo/ssr'
import { App } from './app.ts'

const html = renderToString(App)
const page = `<!doctype html>
<html>
  <head><style id="nuclo-styles">${getCssText()}</style></head>
  <body>
    <div id="root">${html}</div>
    <script type="module" src="/client.js"></script>
  </body>
</html>`
client.ts
import 'nuclo'
import { App } from './app.ts'

hydrate(App, document.getElementById('root')!)

Conservez id="nuclo-styles" sur la balise style. Le client réutilise cet élément et n'ajoute que les nouvelles règles. Voir getCssText().

Ce qui s'exécute côté serveur

  • Chaque appel à renderToString() construit l'arbre et exécute chaque valeur dynamique une fois.
  • Les gestionnaires d'événements, onMount et onDestroy ne s'exécutent jamais. update(), scope() et forceUpdate() ne font rien.
  • Le rendu est synchrone. Chargez vos données d'abord, puis rendez.
  • Rien ne reste enregistré après un rendu, un même processus peut donc servir de nombreuses requêtes. Passez les données de la requête en arguments, par exemple renderToString(Page(user)). Les variables au niveau du module sont partagées par toutes les requêtes.
  • N'appelez pas render() côté serveur. Il ajoute au document.body partagé.

renderToString()

Accepte un constructeur (App()), un composant (App) ou un nœud DOM. null et undefined renvoient "". La racine doit être un élément. Un composant passé comme renderToString(Page) ne doit prendre aucun paramètre, passez donc Page(user) pour un composant qui prend des données.

example.ts
renderToString(p('Hello'))
// '<p><!-- text-0 -->Hello</p>'
  • Le texte et les valeurs d'attributs sont échappés en HTML. Le texte dans script() et style() est écrit tel quel, seules les balises fermantes étant découpées, n'y placez donc jamais de saisie utilisateur.
  • Il ne lève pas d'erreur. Si le composant, une fonction de list() ou une condition de when() lève une erreur, ou si le polyfill manque, il journalise l'erreur et renvoie "". Vérifiez si le résultat est vide lorsque la requête doit échouer. Une fonction texte ou attribut qui lève une erreur ne rend qu'un contenu vide.

renderManyToString()

Équivalent à inputs.map(renderToString).

renderToStringWithContainer()

Enveloppe la sortie dans un élément. containerTag vaut "div" par défaut, et containerAttrs vaut {}. Les valeurs d'attributs sont échappées. Les noms d'attributs sont écrits tels quels, utilisez donc des noms HTML comme class. Ne passez jamais de saisie utilisateur comme balise.

example.ts
renderToStringWithContainer(p('Hi'), 'section', { id: 'main', class: 'page' })
// '<section id="main" class="page"><p><!-- text-0 -->Hi</p></section>'

nuclo/polyfill

#
import 'nuclo/polyfill' // Named exports: // document, Event, CustomEvent, Node, Element, HTMLElement, // NucloDocument, NucloElement, NucloText, NucloNode

Un petit DOM pour que Nuclo puisse construire des arbres dans Node et Bun. Importez-le une fois, avant votre premier rendu. La plupart des applications n'ont besoin que de l'import simple.

  • Quand il n'y a pas de window, il définit globalThis.document, Node, Element et HTMLElement, mais seulement ceux qui manquent. Il ne crée pas window.
  • Dans un navigateur, ou sous jsdom, il ne fait rien.
  • Event et CustomEvent sont les constructeurs propres à l'environnement. Node, Element et HTMLElement sont des alias des classes du polyfill.

C'est un DOM pour construire des arbres, pas un navigateur. Il n'a ni innerHTML ni getElementById, querySelector() renvoie null, et les écouteurs d'événements sont ignorés. Utilisez renderToString() pour obtenir le HTML.

css()

#
fnAPI publique
function css(style: Style): StyleResult function css(name: string, style: Style): StyleResult type StyleResult = { className: string }

Transforme un objet de style typé en classes CSS générées. Passez le résultat à n'importe quel constructeur de balise. Dans le navigateur, les règles vont dans un seul élément <style id="nuclo-styles">. Sur le serveur, getCssText() les renvoie.

card.ts
const card = css({
  p: 16,
  rounded: 8,
  bg: '#fff7ed',
  hover: { bg: '#ffedd5' },
})

const Card = () => div(card, 'Simple card')
  • css() crée une classe pour les déclarations de base, plus une classe pour chaque clé pseudo, sélecteur et bloc media. La carte ci-dessus reçoit deux classes.
  • Les noms de classe sont des hachages du contenu. Des styles identiques partagent une classe, et le serveur et le client produisent les mêmes noms.
  • Définissez les styles une seule fois, au niveau du module. css() met en cache par objet, donc un nouvel objet à chaque appel est retraité chaque fois.
  • Modifier un objet de style après l'avoir passé à css() n'a aucun effet.
  • Chaque style reste enregistré pendant toute la vie de la page ou du processus serveur. Pour des valeurs qui changent par élément, comme une largeur issue de données, utilisez l'attribut style.

Styles nommés

Passez un nom en premier pour obtenir un nom de classe lisible :

title.ts
const pageTitle = css('page-title', { text: 24, hover: { color: 'red' } })

pageTitle.className // "page-title page-title-<hash>"
  • La classe de base est exactement le nom. Les autres blocs reçoivent name-<hash>.
  • Le nom doit être un nom de classe valide. Sinon, Nuclo avertit et en génère un.
  • Les noms sont globaux. Si deux styles différents utilisent le même nom, Nuclo avertit.

Utiliser le résultat

example.ts
const card = css({ p: 8 })

div(card)                           // best
div(card, { className: 'featured' }) // className merges
div(card, { class: 'featured' })     // replaces: only "featured" is left

String(card) et card.className renvoient tous deux les noms de classe.

Objets de style

#

L'objet que vous passez à css() utilise les propriétés CSS courantes en camelCase, un ensemble de raccourcis, et des clés imbriquées pour les pseudo-états, les sélecteurs et les media queries. TypeScript les complète toutes. Les propriétés hors de l'ensemble typé, comme borderTopLeftRadius, fill ou paddingInline, vont dans raw (voir ci-dessous).

Valeurs

  • Les nombres deviennent des px, sauf 0 et les propriétés sans unité : zIndex, opacity, fontWeight, lineHeight, flex, flexGrow, flexShrink, order, aspectRatio, gridColumn et gridRow. Dans raw, zoom, scale, column-count, orphans, widows, tab-size et animation-iteration-count sont aussi sans unité.
  • Les chaînes sont utilisées telles quelles. null, undefined et false sont ignorés.
  • lineHeight (et leading) est sans unité, donc leading: 24 signifie 24 fois la taille de police. Écrivez "24px" pour des pixels.
example.ts
css({ p: 16, m: 0, opacity: 0.5, leading: 1.5, z: 10, w: '50%' })
// padding:16px; margin:0; opacity:0.5; line-height:1.5; z-index:10; width:50%

Raccourcis

CléCSS
p, pt, pr, pb, plpadding, et chaque côté
px / pypadding gauche et droit / haut et bas
m, mt, mr, mb, ml, mx, myla même chose pour margin
w, h, minW, maxW, minH, maxHwidth, height, et leurs min et max
sizewidth et height
bgbackground
text / font / weightfont-size / font-family / font-weight
leading / trackingline-height / letter-spacing
align / items / justifytext-align / align-items / justify-content
z / rounded / shadow / selectz-index / border-radius / box-shadow / user-select

Ces clés prennent true :

CléCSS
rowdisplay: flex; flex-direction: row
coldisplay: flex; flex-direction: column
centeralign-items: center; justify-content: center (ne définit pas display)
truncateoverflow: hidden; text-overflow: ellipsis; white-space: nowrap

Pseudo-états

hover, focus, focusVisible, focusWithin, active, visited, disabled, enabled, checked, required, invalid, valid, readOnly, first (:first-child), last (:last-child), only (:only-child), odd, even, empty, placeholderShown, placeholder, before, after, selection, marker, firstLine et firstLetter. Ils s'imbriquent : hover: { focus: {…} } donne :hover:focus.

Sélecteurs et at-rules

Une clé qui commence par & est un sélecteur, et chaque & devient la classe. Une clé qui commence par @ est une at-rule : @media, @container ou @supports.

menu.ts
const menu = css({
  p: 8,
  '& > li': { py: 4 },
  '& > li:hover': { color: 'red' },
  '&:is(.dark *)': { color: 'white' },
  '@media (min-width: 768px)': { p: 16 },
})
  • La clé doit commencer par &. Une clé comme ".dark &" est ignorée, écrivez donc "&:is(.dark *)".
  • Deux blocs @media imbriqués se combinent avec and. Une requête menée par un type de média (print, only screen) est placée en premier, car la grammaire n'autorise un type qu'au début. Une paire qui ne peut pas être aplatie — deux types de média, ou un not en tête — avertit et utilise la requête interne seule. Toute autre at-rule imbriquée remplace l'externe.

raw

Utilisez raw pour n'importe quelle propriété, avec le nom écrit exactement comme en CSS. Les nombres reçoivent toujours px, passez donc des chaînes :

example.ts
css({ raw: { '--gap': '8px', '-webkit-line-clamp': '2' } })

Toute autre clé plate est convertie en kebab-case et écrite telle quelle, donc une faute de frappe comme colr: 'red' devient une déclaration que le navigateur rejette. Un objet imbriqué sous une clé qui n'est ni un pseudo-état, ni un écran, ni une clé & ou @ est ignoré. TypeScript signale les deux cas.

fnAPI publique
function cx(...inputs: ClassInput[]): StyleResult type ClassInput = StyleResult | string | false | null | undefined | ClassInput[]

Combine des styles. Quand deux entrées définissent la même propriété, la dernière l'emporte.

example.ts
const base = css({ p: 8, color: 'red' })
const blue = css({ color: 'blue' })

cx(base, blue)                  // padding: 8px; color: blue
cx(base, false, null, 'extra')  // base's classes plus "extra"
div(cx(base, blue), 'Blue text')
  • Les entrées falsy sont ignorées, les tableaux sont aplatis, et les chaînes sont découpées et dédoublonnées.
  • Les classes générées pour le même bloc (base, même pseudo, même media query) fusionnent en une nouvelle classe. Les autres noms de classe passent inchangés.
  • Seul cx() connaît l'ordre des arguments. Avec div(base, blue), les deux classes sont ajoutées, et la règle créée en dernier l'emporte. Mettez les styles qui se surchargent dans un seul appel cx().
  • Quand le bloc d'un style nommé fusionne avec le bloc d'une autre entrée, la classe fusionnée est name-<hash>, pas le nom seul. Les blocs qui ne fusionnent pas gardent leur classe, donc cx(pageTitle, 'extra') contient toujours page-title.

Classes dynamiques

Passez une fonction qui renvoie toujours cx(...) :

toggle.ts
const baseButton = css({ px: 12, py: 8, border: '1px solid #ccc' })
const activeButton = css({ bg: '#ff3f00', color: 'white' })

let active = false

const Toggle = () =>
  button(
    () => cx(baseButton, active && activeButton),
    () => (active ? 'Active' : 'Inactive'),
    { onClick: () => { active = !active; update() } },
  )

La fonction doit renvoyer un résultat de style dès sa première exécution. () => active && activeButton affiche le texte "false" au lieu d'une classe.

createCss()

#
fnAPI publique
function createCss<const T extends ThemeConfig>(theme?: T): CssInstance<T> // CssInstance<T> = { css, cx, variants, keyframes, globalStyle, theme }

Renvoie ses propres css, cx, variants, keyframes et globalStyle, liés à un thème. Un thème donne des noms aux valeurs : avec le thème ci-dessous, borderColor: 'border' écrit #e5e7eb, et TypeScript complète les noms. Chaque entrée de screens devient une clé de style pour une media query, comme md: {…}.

theme.ts
export const { css, cx, variants } = createCss({
  colors: { primary: '#ff3f00', border: '#e5e7eb' },
  radii: { card: '12px' },
  screens: { md: '(min-width: 768px)' },
})

const panel = css({
  p: 12,
  rounded: 'card',
  border: '1px solid',
  borderColor: 'border',
  md: { p: 24 },
})
Clé du thèmeUtilisée par
colorsbg, backgroundColor, color, borderColor et chaque côté, outlineColor, caretColor, textDecorationColor
fontsfont, fontFamily
shadowsshadow, boxShadow
radiirounded, borderRadius
screensde nouvelles clés de style, comme md: {…}
  • Un nom de thème n'est résolu que lorsqu'il constitue toute la valeur. border: "1px solid primary" n'est pas résolu.
  • Une valeur d'écran sans @ devient @media <value>. Une valeur qui commence par @ est utilisée telle quelle, par exemple "@container (min-width: 400px)".
  • Les écrans n'existent que sur les instances créées avec createCss(). Le css() global n'en a pas, mais il accepte les clés @media.
  • Toutes les instances et les helpers globaux partagent une seule feuille de style. Les règles media des écrans sont émises dans l'ordre de déclaration des écrans, pas dans l'ordre où vous les utilisez, listez donc les écrans min-width du plus petit au plus grand. Utilisez une seule instance createCss() par application, pour que cet ordre soit celui de votre thème.
  • theme sur le résultat est le thème que vous avez passé.
fnAPI publique
function variants(config: { base?: Style variants?: { [group: string]: { [value: string]: Style } } defaultVariants?: { [group: string]: value } compoundVariants?: ({ [group: string]: value } & { css: Style })[] }): (props?: { [group: string]: value }) => StyleResult

Construit une fonction qui renvoie un style pour un ensemble d'options, comme l'intention et la taille d'un bouton.

button.ts
const buttonClass = variants({
  base: { px: 12, py: 8, rounded: 6 },
  variants: {
    intent: { primary: { bg: '#2563eb' }, danger: { bg: '#dc2626' } },
    size: { sm: { text: 12 }, lg: { text: 18 } },
    block: { true: { w: '100%' } },
  },
  defaultVariants: { intent: 'primary', size: 'sm' },
  compoundVariants: [{ intent: 'danger', size: 'lg', css: { weight: 700 } }],
})

button(buttonClass(), 'Save')                                                // primary, sm
button(buttonClass({ intent: 'danger', size: 'lg', block: true }), 'Delete') // bold, full width
  • Les styles fusionnent dans cet ordre : base, la valeur choisie de chaque groupe, puis chaque compound correspondant. Les styles suivants l'emportent propriété par propriété.
  • Une option absente, undefined ou null utilise la valeur par défaut. Un groupe sans valeur par défaut est ignoré.
  • Un groupe dont les seules clés sont true et false prend un booléen.
  • Un compound s'applique quand chaque groupe qu'il liste correspond, valeurs par défaut incluses.
  • Chaque sélection est construite une fois et mise en cache.

keyframes() & globalStyle()

#
fnAPI publique
function keyframes(frames: KeyframeFrames): string function globalStyle(selector: string, style: FlatStyle): void

keyframes() enregistre une animation et renvoie son nom généré. Les clés d'étapes sont from, to et des pourcentages, comme "50%" ou "0%, 100%". Les étapes n'acceptent que des propriétés plates. Des étapes identiques renvoient le même nom.

fade.ts
const fadeIn = keyframes({
  from: { opacity: 0 },
  to: { opacity: 1 },
})

const panel = css({ animation: `${fadeIn} 200ms ease-out` })

globalStyle() ajoute une règle pour n'importe quel sélecteur :

reset.ts
globalStyle('*, *::before, *::after', { boxSizing: 'border-box' })
globalStyle('body', { m: 0, font: 'system-ui, sans-serif' })
  • Le sélecteur est utilisé exactement tel qu'écrit.
  • Seules les propriétés plates fonctionnent. Les clés pseudo, les écrans et les clés @ sont ignorés, donc globalStyle() ne peut pas écrire de règles media.
  • La même règle n'est ajoutée qu'une fois. Un style différent pour le même sélecteur ajoute une seconde règle, et la dernière l'emporte.

getCssText() & resetStyles()

#
fnAPI publique
function getCssText(): string // from 'nuclo/ssr' or 'nuclo' function resetStyles(): void // from 'nuclo'

getCssText() renvoie toutes les règles que css(), cx(), variants(), keyframes() et globalStyle() ont créées dans ce processus : d'abord les règles de base, puis les blocs @media, @container et @supports. Utilisez-le pour le rendu côté serveur.

  • Appelez-le après renderToString(). Les règles créées pendant le rendu sont incluses.
  • Il couvre tout le processus, pas une page ou une requête, et il ne fait que grandir. C'est sans risque, car les noms de classe sont des hachages de contenu.
  • Placez-le dans <style id="nuclo-styles">. Le client réutilise cet élément et saute les règles qui s'y trouvent déjà. Avec un autre id, ou sans id, le client ajoute une seconde feuille de style avec toutes les règles à nouveau.
  • Le texte n'est pas échappé, ne mettez donc jamais d'entrée non fiable dans des valeurs de style.

resetStyles() est un utilitaire de test. Il supprime toutes les règles et l'élément #nuclo-styles. Les styles créés auparavant gardent leurs noms de classe mais perdent leur CSS. Ne l'appelez pas dans une application ni sur un serveur.

setup.test.ts
import { resetStyles } from 'nuclo'

beforeEach(() => resetStyles())
Paquet séparé : nuclo-router ne fait pas partie du paquet Nuclo de base. Installez-le à côté de nuclo. C'est une version preview, son API peut donc encore changer.

Le routage pour Nuclo : rendu côté serveur, hydratation, routes découpées en chunks et préchargement à l'inactivité, construit sur list() et update(). Environ 4.9 Ko gzippés, sans dépendance au-delà de Nuclo lui-même et sans effets de bord, donc il disparaît au tree-shaking quand il n'est pas utilisé.

terminal
$ npm install nuclo nuclo-router

Ou utilisez pnpm add, yarn add ou bun add avec les deux mêmes noms. Il requiert nuclo 0.3 ou plus récent comme peer dependency.

Le routeur décide quoi charger. L'application dit où avec router.outlet() : un seul list() sur la pile de couches, une ligne par couche, donc une page ordinaire est une seule ligne. Le serveur et le client construisent le même arbre, donc hydrate() réclame les nœuds du serveur au lieu de les remplacer. Tout le reste, c'est history, un seul écouteur click délégué et requestIdleCallback.

Un seul import

main.ts
export const router = createRouter(routes)   // define once, import anywhere
await router.start()                          // resolve the current URL
render(App, document.getElementById('app')!) // App places router.outlet() and reads router.path, router.go(), …

start() résout les modules de la route correspondante avant de rendre la main, donc le premier arbre est complet à la fois sur le serveur et sur le client. Ensuite, le routeur lit à travers la route qu'il a démarrée : router.path, router.pending, router.go().

Une application complète

routes.ts
// isomorphic: imported by the browser and the server
import { createRouter } from 'nuclo-router'

export const router = createRouter({
  '/':           () => import('./pages/Home.ts'),
  '/docs':       () => import('./pages/Docs.ts'),
  '/blog/:slug': () => import('./pages/Post.ts'),
  '*':           () => import('./pages/NotFound.ts'),
})

export const App = () =>
  div(
    header(a({ href: router.href('/') }, 'Home'), a({ href: router.href('/docs') }, 'Docs')),
    main(
      when(() => router.pending, Spinner()),
      when(() => router.error !== null, ErrorView()),
      router.outlet(),   // where the pages land
    ),
    footer('© nuclo'),
  )
pages/Post.ts
import type { RouteContext } from 'nuclo-router'
import { router } from '../routes.ts'

// A page is plain content
export default function Post(ctx: RouteContext) {
  // A query-only change keeps the page, so the query is read live.
  return article(h1(ctx.params.slug), p(() => (router.search.has('preview') ? 'draft' : '')))
}
main.ts
import 'nuclo'
import { router, App } from './routes.ts'

await router.start()
hydrate(App, document.getElementById('app')!)   // or render() without SSR
server.ts
import 'nuclo/polyfill'
import 'nuclo'
import { renderToString } from 'nuclo/ssr'
import { router, App } from './routes.ts'

// one Route per request
const route = await router.start(request.url)
const html = route.run(() => renderToString(App))

C'est toute l'intégration. Les liens fonctionnent parce qu'un <a href> n'est qu'un <a href>. Un seul écouteur délégué sur document intercepte les liens que le routeur possède et laisse tous les autres au navigateur.

Sur le serveur, start() renvoie une Route par requête et ne touche pas au routeur, donc des requêtes concurrentes ne peuvent pas voir la route l'une de l'autre. route.run() fait de cette Route celle que le routeur, et donc l'outlet, lit pendant toute la durée du rendu. Dans le navigateur, il y a une Route par chargement de page, et start() en fait celle du routeur. Aucun run() n'est nécessaire.

Une page reçoit son RouteContext et ses PageProps, sous la forme (ctx, { layer, data, outlet, beforeLeave }), et peut prendre moins d'arguments. Elle renvoie tout ce que list() accepte. Pour remplir aussi une autre partie de la mise en page, renvoyez un into() depuis la page, par exemple div(article(…), into('sidebar', nav(…))). Ce contenu part avec la page. Voir Pages & Régions.

MotifCorrespond àparams
/, /docs/introexactement ce chemin{}
/blog/:slug/blog/hello{ slug: 'hello' }
/u/:id/edit/u/7/edit{ id: '7' }
/files/*rest/files/a/b.txt{ rest: 'a/b.txt' }
*tout ce qui ne correspond à rien d'autre, votre 404{ '*': '…' }

La priorité va aux motifs statiques, puis aux dynamiques dans l'ordre de déclaration, puis aux catch-all avec le préfixe le plus long en premier. Ainsi '*' ne correspond qu'à ce que rien d'autre ne capture et peut se trouver n'importe où dans la table. Les barres obliques finales, les barres obliques dupliquées et l'encodage pourcent sont normalisés, et les valeurs des paramètres arrivent décodées. La correspondance est une comparaison segment par segment pour chaque motif. Aucune expression régulière n'est construite ni exécutée.

Routes relatives

Un motif écrit avec ./ est relatif au parent qui le déclare, et un href écrit avec ./ est relatif à la route active. Ensemble, ils forment une section réutilisable qui signifie la même chose sous chaque parent. La valeur d'une table est soit un chargeur, soit une autre table, dont les clés lui sont relatives :

records/routes.ts
import type { RouteTable } from 'nuclo-router'

// a section, with no idea where it lives
export const recordSection: RouteTable = {
  '/':             () => import('./Record.ts'),   // the parent path itself
  './preview':     () => import('./Preview.ts'),
  './preview/raw': () => import('./Raw.ts'),
}
routes.ts
// declare it wherever it belongs
createRouter({
  '/': () => import('./pages/Home.ts'),
  '/invoices/:id':  recordSection,
  '/customers/:id': recordSection,
  '*': () => import('./pages/NotFound.ts'),
})

Ces deux lignes produisent les six motifs sans écrire deux fois un chemin. Les deux parents reçoivent les mêmes objets chargeurs, donc ils partagent un seul cache de modules et un seul import().

  • Dans une table enfant, './x', '/x' et 'x' signifient tous la même chose, '/' désigne le chemin du parent lui-même, et '*rest' donne à la section son propre catch-all. L'imbrication va aussi profond que vous le souhaitez.
  • Les clés de premier niveau sont utilisées telles qu'écrites, donc '*' reste '*'.
  • Un enfant sous un parent *catch-all est une erreur de createRouter(), puisqu'un catch-all doit terminer son motif.

Pages parentes et outlet()

Un parent et ses enfants forment une vraie hiérarchie. Le parent est rendu, et son enfant est rendu à l'intérieur, à l'endroit où il appelle outlet() :

records/Record.ts
import type { PageProps, RouteContext } from 'nuclo-router'

// a parent page takes its outlet from (ctx, { outlet })
export default function Record(ctx: RouteContext, { outlet }: PageProps) {
  return div(Header(ctx), input({ id: 'notes' }), main(outlet()))
}

Naviguer entre les enfants ne reconstruit pas le parent. Son nœud DOM est le même nœud, donc son focus, sa position de défilement, son champ à moitié saisi et tout état dans sa closure survivent.

  • Une page sans route enfant ne rend rien depuis son outlet, donc une mise en page peut l'appeler sans condition. Un parent qui a un enfant et n'appelle jamais outlet() l'avale.
  • Il n'y a pas de routes index. '/' est la mise en page elle-même, donc le chemin du parent rend la mise en page avec un outlet vide plutôt qu'une page index séparée à l'intérieur. Placez le contenu d'accueil dans la mise en page, ou donnez-lui un vrai chemin enfant.

Le même appel fonctionne alors depuis l'un ou l'autre parent, et depuis une page qui ne sait pas dans lequel elle se trouve :

example.ts
router.href('./preview')      // '/invoices/42/preview' or '/customers/7/preview'
await router.go('./preview')
await layer.push('./preview') // the usual shape: a child layer

./x, ../x, . et .. se résolvent par rapport au chemin de la route courante, pas au "répertoire" de l'URL. Depuis /invoices/42, ./preview est /invoices/42/preview, là où un navigateur aurait dit /invoices/preview, et une barre oblique finale sur l'URL ne change rien. ../ remonte d'un segment et s'arrête à la racine. La query et le hash sont conservés, base est réajouté, et le chemin absolu résolu est ce qui entre dans l'historique. C'est la même déviation par rapport à la résolution d'URL que fait React Router pour <Link to="./x">.

  • Seuls ./ et ../ sont relatifs à la route. Un simple preview dans un href est laissé aux règles ordinaires du navigateur.
  • Construisez les liens relatifs avec router.href('./preview') plutôt qu'en écrivant href="./preview" à la main. href() résout au moment du rendu, donc le balisage livré est absolu et reste correct pour un robot d'indexation, pour un clic du milieu et avec JavaScript désactivé.

Ce qui est conservé, et ce qui est reconstruit

NœudÀ la navigation
un parent dont le motif et le chemin sont inchangésconservé : même nœud, et son load() n'est pas relancé
la page correspondanteconservée quand son motif et son chemin sont inchangés et qu'elle n'a pas de load(), donc un changement de query ou de hash la garde. Une route avec un chargeur se rafraîchit à chaque visite.
un parent dont le chemin a changéreconstruit, avec tout ce qui se trouve en dessous
une couche empiléerend la page correspondante seule. Ses parents sont déjà montés dans la ligne en dessous.

Une page conservée garde le ctx avec lequel elle a été construite. C'est le sens de ctx partout : lisez router.* pour les valeurs en direct. L'état piloté par la query, comme les filtres, la pagination et les onglets, lit router.search dans une valeur dynamique et écrit avec router.go('?page=2', { replace: true }). La page, son focus et son champ à moitié saisi restent en place.

Pages & Régions

#

router.outlet() place la page à un endroit. Une page qui a aussi besoin d'une barre latérale, d'une barre d'outils ou d'un titre ailleurs dans la mise en page le remplit avec into(). La mise en page marque l'emplacement avec region(), et la page renvoie un into() à côté de son propre contenu. La mise en page ne reçoit jamais le contenu de la page en prop, et la navigation ne la reconstruit jamais.

app.ts
export const App = () =>
  div(
    aside(
      input({ placeholder: 'Filter' }),   // keeps its text across navigations
      region({ id: 'sidebar', empty: p('Nothing to show.') }),
    ),
    main(router.outlet()),
  )
pages/Post.ts
import type { PageProps, RouteContext } from 'nuclo-router'

export default function Post(_ctx: RouteContext, { data: post }: PageProps<Post>) {
  return div(
    article(h1(post.title), p(post.body)),
    into('sidebar', nav(h2('In this post'), ul(list(() => post.headings, (h) => li(h))))),
  )
}

Le contenu de la page est une ligne du list() de l'outlet, et un into() vit aussi longtemps que la ligne qui le contient. Le contenu de la barre latérale arrive donc avec la page et part avec elle : naviguer vers une page sans into('sidebar', …) réaffiche le contenu empty de la région. La page peut utiliser son ctx et ses data pour les deux parties, puisque les deux sont construites par la même fonction.

Ce qui se passe à la navigation

  • Une page conservée garde ses régions. Un changement de query ou de hash qui garde la page ne touche pas non plus au contenu des régions. Lisez router.search dans une valeur dynamique si la barre latérale en dépend.
  • Une page reconstruite les remplit à nouveau. Quand la route change, l'ancienne ligne est retirée et les vues into() de la nouvelle page prennent le relais. Rien ne reste derrière.
  • Une couche empilée ajoute une vue. La couche est une ligne ultérieure de l'outlet, donc son into() est une vue plus récente de la même région. Avec le type: 'latest' par défaut, elle recouvre la vue de la page jusqu'à la fermeture de la couche, puis la vue de la page réapparaît. Avec type: 'stack', les deux s'affichent.
  • Une page parente peut aussi remplir des régions. Ses vues restent pendant que ses enfants changent, car le parent n'est pas reconstruit.
  • Le rendu côté serveur fonctionne tel quel. Le serveur construit la page dans route.run(), donc ses vues into() sont dans le HTML, et hydrate() les réclame.

Plusieurs régions depuis une page

Passez un objet pour remplir plusieurs régions à la fois. Le contenu principal de la page passe toujours par l'outlet, renvoyez-le donc à côté du into() :

pages/Report.ts
export default function Report(ctx: RouteContext) {
  return div(
    table(…),
    into({
      toolbar: [button('Export'), button('Print')],
      sidebar: nav(a({ href: router.href('./summary') }, 'Summary')),
    }),
  )
}

Quel outil pour quelle tâche

  • outlet() est pour une route enfant : du contenu décidé par l'URL, rendu dans sa page parente. Voir Routes imbriquées.
  • into() est pour la page correspondante qui se déploie dans la mise en page. Le contenu appartient à la page et utilise ses données.
  • matches() est pour la mise en page qui décide à partir de l'URL seule, avec sa propre petite table et sans page impliquée. Voir matches().

Tout cela appartient à l'unique route correspondante. Le routeur ne pilote pas deux zones indépendantes depuis une seule URL, donc une barre latérale avec ses propres routes est une région qu'une page remplit, pas un second outlet. Une barre latérale qui veut vraiment son propre routeur, son chargement et son fallback en garde un non démarré et résout avec match() — voir Routeurs multiples & Chargement combiné.

Chargeurs de route & Découpage du code

#

Un module de page peut exporter une fonction load. Elle s'exécute avant la construction de la page, à chaque navigation vers cette route, et ce qu'elle renvoie est transmis à la page en tant que data :

pages/Post.ts
import type { DataLoader, PageProps, RouteContext } from 'nuclo-router'

export const load: DataLoader<Post> = (ctx) => fetchPost(ctx.params.slug)

export default function Post(_ctx: RouteContext, { data: post }: PageProps<Post>) {
  return article(h1(post.title), p(post.body))
}

La table de routes est inchangée. Le chargeur voyage avec la page. Il reçoit le RouteContext correspondant, donc params, search et path sont tous disponibles. Il peut être synchrone ou asynchrone, et il s'exécute aussi sur le serveur, donc une page rendue côté serveur est livrée avec ses données déjà dans le HTML au lieu de les récupérer après l'hydratation.

Le module est mis en cache. Les données ne le sont pas. Le chunk d'une route est récupéré une fois et réutilisé, et son chargeur s'exécute à nouveau à chaque navigation. C'est pourquoi /blog/a et /blog/b ne peuvent pas être confondus, et pourquoi un chargeur en échec peut simplement être réessayé.

SituationComportement
pendant son exécutionrouter.pending est true et la page où vous étiez reste affichée
il lève une exception ou rejetteatterrit dans router.error. go() ne rejette toujours jamais, et la navigation peut être réessayée.
pendant start()le rejet sort de start(). Il n'y a pas encore de Route où le placer.
une couche empiléeexécute son propre chargeur, avec ses propres données
le préchargeur à l'inactiviténe préchauffe que les modules. Il n'a pas de contexte à transmettre, et récupérerait des données pour une route que vous n'ouvrirez peut-être jamais.
renaviguer vers la même URLle chargeur s'exécute à nouveau et la ligne est reconstruite. Une route sans chargeur garde sa ligne.

Pour revalider, naviguez là où vous êtes déjà sans ajouter d'entrée d'historique :

example.ts
await router.go(router.url, { replace: true })   // runs load() again

Démarrer les données avec le chunk

Un load exporté par le module de page ne peut s'exécuter qu'une fois ce module arrivé, donc une navigation à froid récupère le chunk, puis les données. Pour lancer les deux en même temps, déclarez le chargeur dans la table avec withLoad() :

routes.ts
import { createRouter, withLoad } from 'nuclo-router'
import { loadPost } from './data.ts'   // small, in the main bundle

createRouter({
  '/blog/:slug': withLoad(() => import('./pages/Post.ts'), loadPost),
})

Il remplace le load propre au module et s'exécute à chaque visite comme lui. Un rejet pendant que le chunk est encore en cours de chargement atterrit dans router.error comme n'importe quel autre.

Il n'y a pas de sérialisation des données serveur dans le HTML, par conception. Le load du client s'exécute à nouveau à l'hydratation. Si cela compte, faites émettre route.data par le serveur, par exemple sous la forme window.__DATA__, et faites-le lire en premier par le chargeur. route.data ne contient que les données de la page correspondante. Le résultat du load() d'un parent n'y figure pas.

Découpage du code et préchargement

La valeur d'une route est toujours une fonction qui renvoie le composant de page, donc le composant est atteint paresseusement :

routes.ts
'/a': () => import('./A.ts'),                      // default export (+ optional load)
'/b': () => import('./B.ts').then(m => m.BPage),   // named export
'/c': () => CPage,                                 // eager, not split
'/d': () => ({ default: DPage, load: loadD }),     // eager, module-shaped

Seules les formes en module peuvent porter un export load. Choisir un composant nommé avec .then(m => m.BPage) ne remet au routeur que la fonction.

Chaque module est importé une fois et mis en cache. Des navigations concurrentes vers la même route partagent un seul import(). Une fois que l'utilisateur a laissé la page tranquille pendant idleTimeout (une seconde par défaut ; ni tap, ni touche, ni molette, ni défilement), les routes restantes sont chargées en arrière-plan, une par tranche d'inactivité (requestIdleCallback). Toute saisie, ou une navigation que l'utilisateur attend, le met en pause jusqu'à ce que la page soit de nouveau inactive.

Une route préchargée ou eager ne bascule jamais router.pending et atterrit avant la prochaine peinture : pas d'image de spinner, pas de décalage de mise en page. router.pending n'est true que pour un module ou un chargeur qu'il faut attendre, et la page sortante reste à l'écran pendant ce temps. Un chunk qui échoue au chargement apparaît dans router.error, jamais comme un rejet non géré, et peut simplement être réessayé.

API du routeur

#
fnAPI publique
// import { ... } from 'nuclo-router' function createRouter(table: RouteTable, options?: RouterOptions): Router function withLoad(loader: RouteLoader, load: DataLoader): RouteLoader interface RouterOptions { base?: string; preload?: boolean; idleTimeout?: number; onNavigate?: (ctx: RouteContext, kind: NavigationKind) => void }

createRouter(table, options?)

table associe un motif à un chargeur, ou à une table imbriquée. Options :

OptionDéfaut
base'/'Préfixe d'URL sous lequel l'application est servie, comme '/docs'. Il ne correspond qu'à une frontière de segment : /docs/intro est dans '/docs', /docsearch ne l'est pas. Les URL en dehors ne correspondent à rien, donc start() rejette (votre 404 côté serveur) et les liens vers elles sont laissés au navigateur.
preloadtrueCharger les modules des routes restantes en arrière-plan une fois la page inactive. false le désactive.
idleTimeout1000Millisecondes sans tap, touche, molette ni défilement avant que le préchargement démarre, ou reprenne après une activité de l'utilisateur.
onNavigate(ctx, kind) => void, appelé après chaque navigation résolue dans le navigateur, y compris la première, une fois le DOM à jour. kind vaut 'start', 'push', 'replace', 'pop' (Précédent, Suivant, close()) ou 'layer'. Le point d'accroche pour document.title, les balises meta, le focus, le défilement et l'analytique. Jamais appelé pendant le SSR.

Renvoie le routeur : start(), match(), outlet(), matches(), et chaque membre de Route sauf run(), lus à travers la Route active. C'est celle que le dernier start() a renvoyée dans le navigateur, ou celle dont le run() s'exécute sur le serveur. Lire un membre sans l'une ni l'autre lève une exception. La table de routes, le matcher compilé et le cache de modules vivent sur le routeur, donc ils sont partagés entre les requêtes sur un serveur.

router.start(url?)

Résout url (par défaut : location.href dans le navigateur) vers ses modules de page et renvoie une Promise<Route>. Rejette quand rien ne correspond et que la table n'a pas de route '*'. Sur un serveur, c'est votre 404. Dans le navigateur, il fait aussi de la Route celle active du routeur, attache les écouteurs popstate et click délégué et planifie le préchargement. Un second start() retire la Route précédente pour vous. Sur le serveur, il ne touche à rien de partagé, rendez donc dans route.run().

router.match(url?)

Résout une URL contre la table sans rien charger : pas d'import, pas d'historique, pas d'écouteurs, pas de Route. Renvoie un RouteContext, ou null quand l'URL est en dehors de base, ne correspond à aucun motif ou, dans le navigateur, est sur une autre origine. URL absolues seulement, puisque la résolution de ./ nécessite une route active.

example.ts
// 404 before importing a single chunk
if (!router.match(request.url)) return new Response(null, { status: 404 })

// "is this link ours?" for a custom link component or a prefetch rule
const owned = router.match(href) !== null

router.outlet()

Les pages de la route active sous forme de list(). Placez-le une fois, là où vont les pages. Une couche empilée en est une ligne ultérieure. Il est vide avant que start() se résolve et après stop(). Sur le serveur, il rend la Route dont le run() s'exécute.

router.matches(table)

Rend la vue d'une seconde table à laquelle correspond le chemin de la route active, sous forme de list() que vous placez n'importe où. Voir matches().

Route

Chaque membre sauf run() est aussi sur le routeur, qui est là où une application le lit.

Membre
path, pattern, params, search, hash, urlla correspondance active. search est un URLSearchParams. search, hash et url sont en direct.
pendingune navigation attend un module ou un chargeur
errorle dernier chargement en échec, effacé par la navigation suivante
datace que le load() de la page correspondante a renvoyé, pour qu'un serveur le sérialise. Celui d'un parent n'y figure pas.
depthle nombre de couches sur la pile (1 pour une page ordinaire)
go(href, { replace })naviguer, en chargeant le module si nécessaire. Ne rejette jamais : un module qui échoue, en rejetant ou en levant une exception, atterrit dans error.
push(href)ouvrir une route comme nouvelle couche au-dessus, résolue avec ce avec quoi elle se ferme. Accepte ./. Rejette en l'absence de correspondance ou si le module échoue.
href(path)préfixer un chemin avec base, pour a({ href }). Résout ./ et ../ par rapport à la route active.
stop()détacher tous les écouteurs, fermer les couches ouvertes, vider l'outlet. Idempotent. Une Route arrêtée répond toujours avec sa dernière correspondance, et go() ne fait rien.
run(fn)appeler fn avec cette Route comme Route active du routeur et renvoyer son résultat : route.run(() => renderToString(App)) sur le serveur. Synchrone seulement. La Route cesse d'être active quand fn rend la main.

RouteContext

Le premier argument d'une page : path, pattern, params, search, hash, url. C'est le contexte avec lequel la page a été construite. Lisez router.* pour les valeurs en direct. Ils ne diffèrent que lorsque la query ou le hash a changé sans reconstruction.

params est { [name]: string } par défaut. Une page peut le typer à partir de son propre motif avec Params, qui transforme '/blog/:slug' en { slug: string } et '/files/*rest' en { rest: string } :

pages/Post.ts
import type { Params, RouteContext } from 'nuclo-router'

export default function Post(ctx: RouteContext<Params<'/blog/:slug'>>) {
  return article(h1(ctx.params.slug))   // slug: string, nothing else exists
}

PageProps

Le second argument d'une page. Déstructurez ce dont la page a besoin.

Prop
layeroù la page se situe dans la pile. Voir Layer ci-dessous.
datace que le load() de la route a renvoyé, ou undefined sans chargeur. Typé par PageProps<TData>.
outletoù une page parente rend sa route enfant. Voir Routes imbriquées.
beforeLeaveenregistre une garde consultée avant que cette page soit abandonnée. Voir Gardes de sortie.

Layer

Où une page se situe dans la pile, et sa porte de sortie.

Membre
depth0 pour la page de base, 1 et plus pour les couches empilées
push(href)ouvrir une autre couche depuis une page, comme Route.push()
close(result?)fermer cette couche et tout ce qui est au-dessus, en résolvant le push() qui l'a ouverte. Sans argument, elle est considérée comme annulée. Sans effet à la profondeur 0.

DataLoader et withLoad()

DataLoader est (ctx: RouteContext) => TData | Promise<TData>, l'export load optionnel d'un module de page. withLoad(loader, load) crée une route dont le load démarre en même temps que l'import de son chunk plutôt qu'après. Voir Chargeurs de route.

Types

Chaque type est un export nommé de nuclo-router, donc une page, un chargeur ou une garde peuvent être typés sans importer depuis un chemin.

TypeCe que c'est
Params<'/blog/:slug'>l'objet params pour un motif. RouteParams est la forme non typée, et Match est le résultat du matcher.
PageComponentune page : (ctx, { layer, data, outlet, beforeLeave }) => …
RouteModuleun module de page : le composant lui-même, ou { default, load? } tel que import() le produit
RouteLoader() => RouteModule | Promise<RouteModule>, portant éventuellement un load issu de withLoad()
RouteTablela table que createRouter() prend : motif vers chargeur, ou vers une table imbriquée
RouterOptions{ base?, preload?, idleTimeout?, onNavigate? }
NavigateOptions{ replace? }, le second argument de go()
NavigationKind'start' | 'push' | 'replace' | 'pop' | 'layer', le second argument de onNavigate
Outletle type de outlet() dans PageProps
LeaveGuard(to) => boolean | void | Promise<…>, ce que beforeLeave() prend
MatchViewce qu'est une vue de matches() : des params en entrée, du contenu ou l'import() d'une page en sortie

Couches & Gardes de sortie

#

push() ouvre une route au-dessus de la route courante au lieu de la remplacer, et se résout avec ce avec quoi cette couche se ferme. Comme les pages sont un seul list() sur la pile, ouvrir une couche est un ajout en fin de liste. La page en dessous n'est pas rendue à nouveau, donc son DOM, son focus et son formulaire à moitié rempli sont toujours là quand la couche se ferme.

Cela fait fonctionner le flux "j'ai besoin d'un enregistrement qui n'existe pas encore" sans jeter le formulaire que l'utilisateur remplissait :

pages/Edit.ts
// the page underneath
const created = await layer.push<Option>('/options/new')
if (created) { options.push(created); selected = created.id; update() }
pages/NewOption.ts
// the pushed page: (ctx, { layer })
layer.close(created)   // resolves the push() above with the new option
layer.close()          // dismissed: resolves with undefined

Les couches s'imbriquent. Une boîte de dialogue qui a besoin de sa propre boîte de dialogue appelle layer.push() et l'attend, sans référence à la Route.

  • L'URL est la couche du dessus. push() ajoute une entrée d'historique, donc une couche est liable et Précédent la ferme, en résolvant son push() comme annulé, exactement comme Annuler. Précédent et close() suivent un seul chemin de code : close() enregistre son résultat et recule dans l'historique, et la profondeur dans history.state fait autorité. Les navigateurs gardent environ 50 entrées par onglet, donc après un long parcours à travers des couches, l'entrée en dessous peut avoir disparu : close() ferme alors les couches sur place et réécrit l'entrée courante vers la page restée affichée.
  • Une navigation ordinaire vide la pile. Un clic sur un lien ou go() n'est pas une couche. Il remplace tout et annule toutes les couches ouvertes, donc aucun appelant ne reste en attente.
  • Un chargement à froid est une page autonome. L'URL nomme la couche du dessus mais pas la pile en dessous, donc ouvrir directement l'URL d'une couche la rend à la profondeur 1 avec layer.depth === 0 et un close() inerte. Une route empilée doit avoir un sens par elle-même. Testez layer.depth pour proposer un lien de sortie plutôt qu'un bouton Annuler mort.
  • La promesse ne peut pas survivre à un rechargement, car elle vit dans une closure. Traitez le résultat comme un raccourci à travers un flux qui fonctionne aussi par le chemin long.
  • Rien ne reste en attente. stop() annule toutes les couches ouvertes, et une couche abandonnée par une navigation se résout avec undefined.

Gardes de sortie

Une page qui détient quelque chose qui mérite d'être conservé, comme un formulaire à moitié rempli, enregistre une garde. Elle est consultée avant que la page soit abandonnée, et renvoyer false (ou une promesse de false) garde la page :

pages/Edit.ts
import type { PageProps, RouteContext } from 'nuclo-router'

export default function Edit(_ctx: RouteContext, { beforeLeave }: PageProps) {
  let dirty = false
  beforeLeave(() => !dirty || confirm('Discard your changes?'))
  return form(input({ onInput: () => (dirty = true) }), …)
}
  • Seulement quand la page part. Une navigation enfant sous une mise en page, ou un changement de query ou de hash qui garde la page, ne consulte pas la garde. Une garde vit et meurt avec sa page, il n'y a donc rien à désenregistrer.
  • Toutes les sorties. Un lien, go(), Précédent et Suivant, le close() d'une couche, et go() vers une URL que le routeur ne possède pas (quitter l'application, où to vaut null). Un Précédent ou Suivant refusé est annulé, donc la barre d'adresse reste sur la page. Fermer l'onglet ou recharger est le travail de beforeunload.
  • to est le RouteContext vers lequel la navigation se dirige. Les gardes s'exécutent de la plus interne à la plus externe et s'arrêtent au premier refus. Une garde qui lève une exception garde la page. close(result) passe aussi par la garde, remettez donc votre indicateur à zéro avant de fermer avec un résultat.
  • Le routeur ne peut reculer que sur les entrées d'historique qu'il a écrites. Un Précédent ou Suivant vers une entrée qu'il n'a pas écrite, comme un lien #fragment natif ou le pushState d'un autre script, n'est pas soumis à la garde.

matches() & Sous-routeurs

#

router.outlet() rend la page. router.matches(table) rend autre chose qui dépend de l'endroit où vous êtes, comme une barre latérale, un fil d'Ariane ou une barre d'outils, à partir de sa propre petite table, sans charger de route ni ajouter de page :

app.ts
div(
  aside(
    router.matches({
      '/':           () => nav('Home links'),
      '/blog/new':   () => nav('Drafting tips'),
      '/blog/:slug': ({ slug }) => nav(`More like ${slug}`),
      '*':           () => import('./Sidebar.ts'),   // code-split, rendered once loaded
    }),
  ),
  main(router.outlet()),
)
  • Même correspondance que la table de routes. Les motifs, la priorité, la correspondance sur le chemin brut et le retrait de base sont ceux du routeur, donc une vue et une page sont toujours d'accord sur l'URL. Elle lit le chemin de la couche du dessus, comme router.path.
  • Une vue est (params, ctx) => content, où params est typé à partir de son motif et ctx est le RouteContext avec le pattern et les params de la vue. Elle peut à la place renvoyer l'import() d'un module de page, qui est appelé avec ctx une fois arrivé.
  • Rien ne correspond, rien n'est rendu. Ajoutez une vue '*' comme fallback. Elle est vide avant start() et après stop(), comme l'outlet.
  • Elle n'est rendue à nouveau que lorsque la route change, donc une vue garde son DOM à travers tout ce qui garde la page.
  • Une vue paresseuse ne rend rien tant que son module n'est pas arrivé. Un import() en échec est journalisé avec console.error, ne définit pas router.error, et est réessayé à la navigation suivante. Un simple update() ne réessaie pas.
  • Sur le serveur, une vue paresseuse n'envoie rien, puisque son module arrive côté client. Utilisez une vue synchrone pour tout ce que le premier HTML doit contenir.

Sous-routeurs

Les routes s'imbriquent, donc un sous-routeur n'est en général qu'une table imbriquée. Un seul routeur : la page '/' propre à la section est son habillage, et ses enfants sont rendus dans le outlet() de cette page, donc l'habillage survit à la navigation à l'intérieur de la section :

routes.ts
createRouter({ '/': …, '/docs': docsRoutes, '*': … })

Ou donnez à la fonctionnalité un routeur à elle, créé mais jamais démarré. Le parent possède une seule entrée catch-all et l'enfant résout la fin du chemin contre ses propres motifs, params et fallback :

routes.ts
// parent table
'/docs/*rest': () => import('./docs/Shell.ts'),
docs/Shell.ts
const docs = createRouter(docsRoutes, { base: '/docs' })
const hit = docs.match()                       // { pattern: '/:topic', … }
const page = await docsRoutes[hit.pattern]()   // the app's own lookup

Ne jamais le démarrer est tout l'intérêt. C'est start() qui attache les écouteurs, et deux routeurs démarrés entrent en conflit. La contrepartie est que l'enfant possède la résolution, donc les chargeurs de route, qu'un routeur démarré exécute, sont à vous d'appeler. Cette forme reconstruit aussi l'habillage quand la fin du chemin change, car le routeur voit une seule route et l'habillage résout le reste lui-même. Une table imbriquée est ce qui vous donne un parent que le routeur garde monté, préférez-la donc à moins que la fonctionnalité ait vraiment besoin de son propre routeur, de sa base et de son fallback.

Routeurs multiples & Chargement combiné

#

Une application peut avoir autant de routeurs que de zones. La règle qui garde cela simple : démarrez-en exactement un par document, et laissez tous les autres routeurs non démarrés, en résolvant les URL avec match(). Seul le routeur démarré possède l'URL, l'historique et l'écouteur de clic. Les autres sont des tables que vous interrogez — et chaque zone peut charger à son propre rythme, avec son propre spinner.

Chargement combiné : une chaîne de chargeurs

Chaque page d'une chaîne correspondante peut exporter load(). Lors d'une navigation à froid vers une route enfant, le chargeur de la mise en page et celui de la page s'exécutent en parallèle, et un seul router.pending couvre toute la chaîne : la page précédente reste à l'écran jusqu'à ce que les deux se résolvent, donc un arbre à moitié chargé n'est jamais peint. Chaque composant reçoit ses propres data :

routes.ts
'/shop': {
  '/':          () => import('./pages/Shop.ts'),    // layout, has a load()
  './:section': () => import('./pages/Section.ts'), // page, has a load()
},
pages/Shop.ts
import type { DataLoader, PageProps, RouteContext } from 'nuclo-router'

export const load: DataLoader<ShopData> = async () => {
  await wait(500)                              // a slow API
  return { sections: ['toys', 'books', 'music'] }
}

// the layout gets its own data, and its outlet for the child page
export default function Shop(_ctx: RouteContext, { data, outlet }: PageProps<ShopData>) {
  return div(
    h1('Shop'),
    nav(...data.sections.map((s) => a({ href: router.href(`/shop/${s}`) }, s))),
    main(outlet()),
  )
}
pages/Section.ts
export const load: DataLoader<SectionData> = async (ctx) => {
  await wait(900)                              // slower than the layout's
  return { section: ctx.params.section, items: catalog[ctx.params.section] }
}

// the page gets its own data: the two loaders ran beside each other
export default function Section(_ctx: RouteContext, { data }: PageProps<SectionData>) {
  return article(h2(data.section), ul(...data.items.map((i) => li(i))))
}

Les deux chargeurs démarrent ensemble, pas l'un après l'autre. router.pending est true tant que l'un des deux est attendu, et false le reste du temps — une route préchargée ou eager ne le bascule jamais. Passer d'une section à l'autre garde la mise en page, donc seul le chargeur de la section s'exécute à nouveau ; les données de la mise en page, son DOM et un champ à moitié saisi survivent tous. Renaviguer vers la même URL relance le chargeur de la feuille et reconstruit sa ligne, exactement comme avec une page seule.

Chargement multiple : un second routeur avec son propre spinner

Une zone indépendante — une barre latérale, un tiroir, une région live — reçoit son propre routeur, créé mais jamais démarré, et son propre indicateur de chargement. router.pending appartient au routeur démarré et ne dit rien de cette zone, donc les deux peuvent charger côte à côte et l'un ou l'autre peut finir en premier :

sidebar.ts
import { createRouter } from 'nuclo-router'

// Created but never started: start() is what attaches the history and click
// listeners, and only the main router is started. match() attaches nothing.
const standIn = () => ({ default: () => div() })   // never rendered

const sidebarRouter = createRouter({
  '/shop':          standIn,
  '/shop/:section': standIn,
  '*':              standIn,
}, { base: '/docs' })              // the same base as the main router

export const sidebar = { loading: false, info: null as SidebarInfo | null }

export async function refreshSidebar(at: string) {
  const hit = sidebarRouter.match(at)          // the second router resolves
  if (!hit || (hit.pattern !== '/shop' && hit.pattern !== '/shop/:section')) {
    sidebar.info = null; update(); return
  }
  sidebar.loading = true                       // its own flag, its own spinner
  sidebar.info = null; update()
  const data = await fetchSidebar(hit.params.section)
  sidebar.loading = false; sidebar.info = data
  update()
}
routes.ts
createRouter(routes, {
  onNavigate: (ctx, kind) => {
    announce(kind)
    refreshSidebar(ctx.url)   // every navigation re-resolves the sidebar
  },
})

Le second routeur est interrogé, jamais commandé : match() lit l'URL et renvoie un contexte, et rien concernant la barre latérale n'entre jamais dans la table de routes principale. onNavigate s'exécute après chaque navigation résolue, la première incluse, c'est donc l'endroit unique qui garde une zone indépendante en phase avec l'URL.

  • pending appartient au routeur démarré. Un routeur jamais démarré n'a ni Route, ni pending, ni error. La zone suit ses propres indicateurs, et un second spinner peut se placer à côté du premier. Dans la démo, la barre latérale charge pendant 800 ms tandis que les chargeurs de la route tournent pendant 900 ms, donc la page peut finir avant la barre latérale ou l'inverse.
  • match() n'attache rien. N'importe quel nombre de routeurs non démarrés peuvent partager le document ; chacun a sa propre table, sa base et son cache de modules, et aucun ne touche à l'historique. Ils coûtent une table et un matcher.
  • Ses chargeurs ne s'exécutent jamais. Le routeur démarré exécute le load() des pages qu'il fait correspondre. Un routeur jamais démarré ne fait que résoudre, donc si ses vues ont besoin de données, récupérez-les vous-même — le refreshSidebar() de la démo. Si vous voulez des chargeurs, une table imbriquée ou une région qu'une page remplit laisse ce travail au routeur démarré.
  • Protégez-vous des réponses obsolètes. Une récupération lente de la barre latérale ne doit pas atterrir sur la section que vous avez quittée. Incrémentez un compteur de génération à chaque rafraîchissement et ignorez les réponses qui ne correspondent plus, comme le routeur lui-même retire les navigations dépassées.
  • Une vue matches() paresseuse n'a pas de pending non plus. Elle ne rend rien tant que son module n'est pas arrivé. Trois systèmes de chargement, trois horloges indépendantes : le pending de la route, vos propres indicateurs, et une vue paresseuse qui n'est simplement pas encore là.

Valeurs calculées

#

Nuclo n'a pas d'API de valeurs calculées. Utilisez une simple fonction, et appelez-la depuis les valeurs dynamiques :

example.ts
let items = ['apple', 'banana', 'cherry']
let filter = ''

const visible = () => items.filter((item) => item.includes(filter))

const Fruits = () =>
  div(
    p(() => `Showing ${visible().length} of ${items.length}`),
    ul(list(visible, (item) => li(item))),
  )

filter = 'an'
update()

Asynchrone & Chargement

#

Utilisez simplement async/await, et appelez update() chaque fois que l'état change. Pour un fetch, cela signifie une fois avant l'await et une fois après :

example.ts
let status: 'idle' | 'loading' | 'done' | 'error' = 'idle'
let result: unknown = null

async function loadData() {
  status = 'loading'
  update()
  try {
    result = await fetch('/api/data').then((r) => r.json())
    status = 'done'
  } catch {
    status = 'error'
  }
  update()
}

Bonnes pratiques

#
  • Regroupez les changements. Modifiez tout l'état nécessaire, puis appelez update() une seule fois.
  • Gardez les valeurs dynamiques rapides et pures. Elles s'exécutent toutes à chaque update().
  • Utilisez scope() pour les petites mises à jour fréquentes, comme la saisie ou les minuteurs.
  • Rendez dans le document. Les nœuds qui ne sont pas dans le document quand update() s'exécute cessent d'être mis à jour.
  • Utilisez when() et list() pour les enfants qui changent, pas une fonction qui renvoie des nœuds.
  • Utilisez className, pas class, et utilisez () => cx(...) ou { className: () => … } pour les classes dynamiques.
  • Définissez les styles une seule fois, au niveau du module.
  • Rendu côté serveur : rendez d'abord, puis placez getCssText() dans <style id="nuclo-styles">. Passez les données de la requête en arguments, et conservez les commentaires HTML dans la sortie.

Index de l'API

#

Chaque fonction publique, et d'où elle vient.

nuclo (global et export nommé)

nuclo (export nommé seulement)

nuclo/ssr

  • renderToString(input), renderManyToString(inputs), renderToStringWithContainer(input, tag?, attrs?) et getCssText().

nuclo/polyfill

nuclo-router (paquet séparé)

  • createRouter(table, options?) : le routeur, avec start(), match(), outlet(), matches(), go(), push(), href() et stop().
  • withLoad(loader, load) : une route dont le chargeur de données démarre en même temps que son chunk.
  • Types : RouteContext, PageProps, Params, DataLoader, RouteTable et plus.

Globaux seulement

  • 175 constructeurs de balises : div, span, …, var_, et svgSvg, pathSvg, ….
  • Types : NodeModFn, NodeModLike, ExpandedElement et plus. Les types de style comme StyleResult peuvent aussi être importés depuis 'nuclo'.

Clés spéciales

Questions fréquentes

#

Changer l'état met-il à jour l'interface ?

Non. Le DOM reste tel qu'il est jusqu'à ce que vous appeliez update().

Nuclo utilise-t-il des signaux, des proxies ou un DOM virtuel ?

Non. L'état reste du JavaScript ordinaire. Quand un arbre est rendu, Nuclo crée de vrais éléments et nœuds texte. update() exécute chaque valeur dynamique enregistrée, ou celles des scopes donnés, et corrige ces mêmes nœuds là où une valeur a changé. Il n'y a ni graphe de dépendances ni arbre virtuel.

Pourquoi ma page affiche-t-elle "false" ?

Les booléens sont rendus comme du texte. div(ok && span('Done')) affiche "false". Utilisez when(() => ok, span('Done')) pour les enfants, et ok ? 'Done' : '' pour le texte.

Pourquoi ma ligne de liste ne change-t-elle pas quand je modifie l'élément ?

Les lignes des éléments encore présents dans la liste sont conservées, pas rendues à nouveau. Utilisez des valeurs dynamiques comme () => todo.text pour les champs qui changent. Voir list().

Pourquoi une partie de mon interface cesse-t-elle de se mettre à jour ?

En général, ses nœuds n'étaient pas dans le document quand update() s'est exécuté. Nuclo cesse de suivre ces nœuds, même après que vous les avez attachés, rendez donc dans un conteneur qui est dans le document. Deux autres causes : un when() dont la condition lève une exception cesse de se mettre à jour, et une fonction texte qui lève une exception à sa première exécution reste vide. Voir update().

Quand utiliser forceUpdate() ?

Seulement pour des changements rares de valeurs statiques, comme changer de langue. Pour tout le reste, utilisez update(). Voir forceUpdate().

Comment placer une page dans une mise en page sans la passer en prop ?

Marquez l'emplacement avec region({ id: 'main' }) et faites renvoyer into('main', …) par la page. Voir region() & into().

Comment démonter une application ?

Enveloppez-la dans when() et passez la condition à faux. Retirer le DOM vous-même n'exécute pas onDestroy. Voir Cycle de vie.

Nuclo inclut-il un routeur ?

Pas dans le paquet de base. Le routage est livré séparément sous le nom nuclo-router, avec rendu côté serveur, hydratation, routes découpées en chunks et préchargement à l'inactivité. Installez-le avec npm install nuclo nuclo-router. Voir nuclo-router.

Nuclo a-t-il des effets ?

Non. onMount et onDestroy s'exécutent une fois par élément, liés à l'insertion et au retrait. Rien ne s'exécute à nouveau parce qu'un état a changé.