ВведениеОбзор
NUCLO / 12

Sprache

Dokumentation

Nuclo-Dokumentation

Eine praktische Referenz: Nuclo installieren, mit expliziten Updates bauen, mit typisiertem CSS stylen und HTML serverseitig rendern.

Schnellstartnpm create nuclo@latest
Schnellstart
v0.3.29
Aktuelle Version
175
Tag-Builder
~18.4 KB
Kern, gzipped
Explizit
Runtime-Modell

Überblick

#

Nuclo ist eine kleine DOM-Bibliothek für TypeScript. Du baust die Seite mit einfachen Funktionen wie div() und button(), hältst den State in gewöhnlichen Variablen und rufst update() auf, wenn sich die Seite ändern soll.

Nuclo hat kein virtuelles DOM, keinen Compiler, keine Proxies und keine Signals.

main.ts
import 'nuclo'

let count = 0

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

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

Das mentale Modell

  1. Baue die UI mit Tag-Buildern.
  2. Übergib eine Funktion für jeden Wert, der vom State abhängt.
  3. Ändere den State mit normalem JavaScript.
  4. Rufe update() auf. Nuclo führt die Funktionen erneut aus und patcht das DOM.

Das Ändern einer Variable berührt das DOM nicht. Die Seite ändert sich nur, wenn du update() aufrufst.

Schnellstart

#

create-nuclo erzeugt ein Vite- und TypeScript-Projekt, das für Nuclo bereit ist:

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

Es fragt nach einem Projektnamen. Um die Abfrage zu überspringen, übergib den Namen und die Flags. Bei npm setzt du -- vor die Flags:

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

Dann installieren und den Dev-Server starten:

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

Optionen

  • [project-name]: der Zielordner. Der Standard ist nuclo-app.
  • -t, --template <name>: das zu kopierende Template. Derzeit existiert nur basic.
  • -y, --yes: nie nachfragen, die Standardwerte verwenden.
  • -f, --force: in einen Ordner schreiben, der nicht leer ist.
  • -h, --help: die Hilfe ausgeben.

Installation und Importe

#

So fügst du Nuclo einem bestehenden Projekt hinzu:

terminal
$ npm install nuclo

Oder verwende pnpm add nuclo, yarn add nuclo oder bun add nuclo. Nuclo hat keine Abhängigkeiten.

Routing ist nicht Teil des Kernpakets. Es wird separat als nuclo-router ausgeliefert, das du neben nuclo installierst, wenn du es brauchst.

Globals

import 'nuclo' legt Folgendes auf globalThis ab:

  • 175 Tag-Builder: 112 für HTML (div, span, button, …) und 63 für SVG, mit einem Svg-Suffix (svgSvg, pathSvg, …). Siehe Tag-Builder.
  • 16 Helfer: update, scope, when, list, region, into, on, render, hydrate, forceUpdate, css, cx, createCss, variants, keyframes und globalStyle.

Der Import muss vor jedem Code laufen, der einen Builder aufruft. ES-Module werden in Import-Reihenfolge ausgeführt, deshalb schlägt das hier fehl:

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

Setze import 'nuclo' an den Anfang deiner Entry-Datei oder importiere es in jeder Datei, die die Globals verwendet.

Benannte Imports

Die Helfer sind auch als benannte Exports verfügbar. Es sind dieselben Funktionen wie die Globals. getCssText() und resetStyles() gibt es nur als benannte Exports. Tag-Builder gibt es nur als Globals, deshalb funktioniert import { div } from 'nuclo' nicht.

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

Entry Points

  • nuclo: registriert die Globals und exportiert die 16 Helfer sowie getCssText und resetStyles.
  • nuclo/ssr: renderToString, renderManyToString, renderToStringWithContainer und getCssText. Siehe Serverseitiges Rendering.
  • nuclo/polyfill: ein kleines DOM für Node und Bun. Siehe nuclo/polyfill.
  • nuclo/types: nur Typen. Siehe TypeScript.

TypeScript

#

Nuclo liefert seine eigenen Typen mit. Du brauchst zwei Dinge:

  • "moduleResolution": "bundler". node16 und nodenext werden noch nicht unterstützt.
  • Die DOM-Lib. TypeScript bindet sie standardmäßig ein, wenn du lib nicht setzt.

Wenn irgendeine Datei im Projekt 'nuclo' importiert, sieht jede Datei die globalen Typen. Mehr brauchst du nicht. Wenn keine Datei es importiert, zum Beispiel in einer separaten tsconfig für Tests, fügst du die Typen selbst hinzu:

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

Oder füge /// <reference types="nuclo/types" /> in eine .d.ts-Datei ein.

Nützliche Typen

Diese Typen sind global, du verwendest sie also ohne Import:

  • NodeModFn<Tag>: was ein Tag-Builder zurückgibt. Verwende ihn als Rückgabetyp einer Komponente.
  • NodeModLike<Tag>: alles, was ein Tag-Builder als Argument akzeptiert.
  • ExpandedElement<Tag>: was render() und hydrate() zurückgeben.
  • ElementTagName: ein beliebiger HTML-Tag-Name.
  • ExpandedElementAttributes<Tag>: das Attribut-Objekt, das ein Tag-Builder akzeptiert, einschließlich on*-Handlern, onMount und onDestroy. Verwende ihn für Komponenten, die Attribute durchreichen.
  • MountCallback und DestroyCallback: onMount- und onDestroy-Handler.
  • WhenBuilder, ListModifier: was when() und list() zurückgeben.
  • RegionOptions, RegionType: die Optionen, die region() entgegennimmt.
  • StyleResult, Style, ThemeConfig, CssInstance und die übrigen Styling-Typen. Diese kannst du auch importieren: import type { StyleResult } from 'nuclo'.
badge.ts
function Badge(text: string): NodeModFn<'span'> {
  return span(text)
}

ExpandedElement<Tag> markiert jedes DOM-Member als optional. Caste es, wenn du den vollständigen Elementtyp brauchst: render(App, root) as HTMLDivElement.

Jeder HTML-Tag ist eine globale Funktion mit demselben Namen: div, span, input, … Die einzige Ausnahme ist <var>, das var_ heißt, weil var ein reserviertes Wort ist. Jeder SVG-Tag hat ein Svg-Suffix: svgSvg, pathSvg, circleSvg, …

Der Aufruf eines Tag-Builders erzeugt noch kein Element. div('Hi') gibt einen Builder zurück: eine Funktion, die das Element später erzeugt. Nuclo ruft sie auf, wenn du den Builder mit render() mountest oder ihn in einen anderen Builder, in when() oder list() einsetzt. Jede Verwendung erzeugt ein neues Element.

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)
LiveEin Builder nimmt Attribute, statische Kinder und dynamischen Text. Umbenennen ändert den State und ruft dann update() auf.Öffnen ↗

Profile

Hello, Ada

Argumente

Ein Builder nimmt beliebig viele Argumente entgegen und wendet sie von links nach rechts an:

  • string, number, bigint: ein Textknoten.
  • () => value: Text, der sich bei update() ändert. Siehe Dynamische Werte.
  • null, undefined: wird übersprungen.
  • true, false: wird als Text "true" oder "false" gerendert. Verwende when() für Bedingungen.
  • Ein einfaches Objekt: Attribute. Siehe Attribute.
  • Ein css()-, cx()- oder variants()-Ergebnis: fügt seine Klassen hinzu.
  • on(...): ein Event-Listener oder Lifecycle-Hook.
  • Ein anderer Builder, when() oder list(): ein Kind.
  • Ein DOM-Node: wird unverändert angehängt.
  • (el, index) => …: ein eigener Modifier. Siehe unten.

Stolperfallen

  • div(isAdmin && span('Admin')) rendert den Text "false". Schreibe div(when(() => isAdmin, span('Admin'))).
  • Arrays sind keine Kinder. Spreade sie: ul(...names.map((n) => li(n))). Für Listen, die sich ändern, verwende list().
  • Eine lokale Variable mit einem Tag-Namen verdeckt den Builder. Innerhalb von function Row(label: string) ist label(...) nicht aufrufbar. Benenne lokale Variablen wie label, title, data, p, a und i um.
  • Browser stellen jedes Element mit einer id als globale Variable bereit. Wenn die Seite <main id="main"> enthält, bevor Nuclo lädt, lässt Nuclo dieses Global unangetastet, sodass main(...) einen TypeError wirft. Verwende andere ids.

Eigene Modifier

Eine Funktion, die mindestens einen Parameter deklariert, läuft einmal, in Argument-Reihenfolge, während das Element gebaut wird. Ihr erstes Argument ist das Element. Das zweite ist ein interner Index, verlass dich also nicht darauf. Gibt sie einen String oder eine Zahl zurück, wird daraus Text. Ein Node wird angehängt. Ein Objekt wird als Attribute angewendet.

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

Das Element erhalten

render() gibt das Wurzelelement zurück. Innerhalb eines Baums verwendest du on("mount", (el) => …) oder einen eigenen Modifier. Du kannst die Builder-Funktion auch selbst aufrufen, um ein losgelöstes Element zu erzeugen:

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

Hänge es vor dem nächsten update() ein. Ein Knoten, der beim Ausführen von update() nicht im Dokument ist, wird endgültig nicht mehr aktualisiert.

SVG

SVG-Builder nehmen dieselben Argumente entgegen. Nuclo setzt jedes SVG-Attribut mit setAttribute, sodass Namen wie "stroke-width" wie geschrieben funktionieren.

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' }),
  )

Alle Builder

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

Eine Komponente ist eine einfache Funktion, die einen Builder zurückgibt, etwa button(...). Es gibt keine Komponenten-API. Nichts wird erzeugt, bis du es renderst oder in einen Baum einsetzt.

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')))
LiveEine Komponente ist eine einfache Funktion. Jeder Counter()-Aufruf behält seinen eigenen Zähler.Öffnen ↗

State, der innerhalb der Funktion deklariert ist, gehört dieser Instanz. State, der außerhalb deklariert ist, wird von allen Instanzen geteilt. forceUpdate() ruft Komponenten erneut auf, was den innerhalb deklarierten State zurücksetzt.

Kinder sind einfach Argumente:

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

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

Attribute

#

Übergib ein einfaches Objekt, um Attribute zu setzen. Du kannst mehrere Objekte übergeben. Bei gleichem Schlüssel gewinnt der spätere Wert, außer bei className und style, die zusammengeführt werden. Ein Wert kann statisch sein oder eine Funktion ohne Parameter, die bei jedem update() erneut läuft.

example.ts
let busy = false

const save = button(
  { type: 'submit', disabled: () => busy, 'aria-busy': () => String(busy), 'data-id': 42 },
  'Save',
)
LiveAttributwerte können Funktionen sein. Der zweite Button kippt busy und ruft update() auf; disabled und aria-busy von Save folgen.Öffnen ↗

Wie ein Wert gesetzt wird

  • Hat das HTML-Element eine Property mit diesem Namen (value, checked, disabled, htmlFor, tabIndex, …), setzt Nuclo die Property. Andernfalls ruft es setAttribute(key, String(value)) auf, zum Beispiel für "aria-*" und "data-*".
  • Bei SVG-Elementen ruft Nuclo immer setAttribute auf.
  • null und undefined werden übersprungen. Ein dynamischer Wert, der null oder undefined zurückgibt, behält den alten Wert. Um einen Wert zu löschen, gib "" zurück, oder false für eine boolesche Property.
  • Booleans funktionieren für boolesche DOM-Properties wie disabled an einem Button, checked, hidden und required. Überall sonst werden true und false als die Strings "true" und "false" geschrieben.
  • Verwende "data-*"-Schlüssel. dataset wird nicht unterstützt.
  • innerHTML wird nicht escaped, und serverseitiges Rendering gibt es nicht aus. Vermeide es.

Klassen

Verwende className. Statische Klassen-Strings, css()-Ergebnisse und ein dynamisches className werden alle zusammengeführt. Ein dynamisches className behält die statischen Klassen, und wenn es "" zurückgibt, bleiben nur die statischen Klassen übrig.

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()

Verwende den Schlüssel class nicht bei HTML-Elementen. Er setzt das rohe Attribut und ersetzt jede zuvor angewendete Klasse, einschließlich der css()-Klassen.

Inline-Styles

style nimmt ein Objekt mit camelCase-Schlüsseln entgegen oder eine Funktion, die eines zurückgibt.

example.ts
let progress = 30

const bar = div({ style: () => ({ width: `${progress}%`, backgroundColor: 'teal' }) })
  • Nuclo ergänzt keine Einheiten. Schreibe "12px", nicht 12. Einheitenlose Properties wie opacity und zIndex akzeptieren Zahlen.
  • "", null und undefined löschen eine Property.
  • Ein dynamischer Style setzt nur die Schlüssel, die er zurückgibt. Ein Schlüssel, den er nicht mehr zurückgibt, behält seinen alten Wert, gib also "" zurück, um ihn zu löschen.
  • Style-Strings ("color: red") und Custom Properties ("--gap") werden nicht unterstützt. Verwende stattdessen css(). Es nimmt Custom Properties über raw entgegen.
fnÖffentliche API
// 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

Es gibt zwei Wege, auf Events zu hören: on*-Attribute und den on()-Modifier.

on*-Attribute

In einem Attribut-Objekt ist eine Funktion unter einem Schlüssel wie onClick (on gefolgt von einem Großbuchstaben) ein Event-Handler, kein dynamischer Wert.

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 leitet den Event-Typ aus dem Schlüssel und dem Element ab. e.currentTarget und this sind das Element.
  • Nuclo setzt die native Property, sodass onClick zu el.onclick wird. Es gibt einen Handler pro Event: ein späteres onClick ersetzt ein früheres.
  • onDoubleClick und onDblClick bedeuten beide dblclick. Kleingeschriebene Schlüssel wie onclick sind Typfehler.
  • Ein Name, der kein bekanntes Event ist, hört auf seine kleingeschriebene Form: onMyEvent hört auf "myevent".
  • Fehler, die der Handler wirft, werden nicht abgefangen.
LiveonInput und onKeyDown sind einfache Attribute. Tippe, dann drücke Escape zum Löschen.Öffnen ↗

Type something, then press Escape.

on()

on() gibt einen Modifier zurück, der addEventListener auf dem Element aufruft.

panel.ts
const panel = div(
  on('scroll', onScroll, { passive: true }),
  on('click', logClick),
  on('click', closeMenu),
)
  • Jedes on() fügt einen Listener hinzu. Alle laufen, in Reihenfolge.
  • options geht direkt an addEventListener: capture, once, passive, signal.
  • Übergib on() direkt an einen Builder, und der Elementtyp wird abgeleitet: input(on('input', (e) => e.currentTarget.value)).
  • Wirft ein Listener, protokolliert Nuclo den Fehler mit console.error, und die anderen Listener laufen trotzdem.
  • Nuclo entfernt den Listener, wenn es das Element entfernt. Auf dem Server tut on() nichts.
  • Du kannst einen on()-Wert auf vielen Elementen wiederverwenden.

Für ein eigenes Event übergibst du den Event-Typ als Generic:

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

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

Was wann verwenden

Verwende on*-Attribute für den Normalfall. Verwende on(), wenn du mehr als einen Listener für ein Event, Listener-Optionen oder einen eigenen Event-Namen wie "my-event" brauchst.

fnÖffentliche API
// 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 läuft einmal, nachdem das Element eingefügt wurde. onDestroy läuft einmal, wenn Nuclo das Element entfernt. Schreibe sie als Attribute oder mit on(). Beide Formen verhalten sich gleich, und das Element ist anhand seines Tags typisiert.

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()))
LiveonMount startet ein Intervall und gibt sein Cleanup zurück. Beim Unmount läuft das Cleanup, also pausiert der Zähler, bis der Timer wieder gemountet wird.Öffnen ↗

Wann sie laufen

  • onMount läuft am Ende des render()-, hydrate()-, update()- oder forceUpdate()-Aufrufs, der das Element eingefügt hat. Es läuft nie, während der Baum gebaut wird.
  • Gibt onMount eine Funktion zurück, läuft diese beim Destroy, nach den onDestroy-Callbacks des Elements.
  • onDestroy läuft, wenn Nuclo das Element entfernt: eine list()-Zeile wird entfernt, ein when()-Zweig wechselt, ein into() verlässt seine Region, oder forceUpdate() verwirft es. Es läuft unmittelbar vor dem Entfernen, das Element ist also noch im Dokument.

Direktes Entfernen aus dem DOM

Wenn du ein Element selbst entfernst, mit node.remove() oder innerHTML = '', laufen sein onDestroy und seine Cleanups nie, und Timer laufen weiter. Nuclo hat kein unmount(). Um eine App mit Cleanup abzubauen, wickle sie in when():

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

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

Reihenfolge und Fehler

  • Du kannst beliebig viele Hooks registrieren, in beiden Formen. Sie laufen in Registrierungsreihenfolge.
  • onDestroy läuft immer zuerst für die Kinder, dann für den Elternknoten.
  • Elemente mounten in der Reihenfolge ihres ersten onMount-Hooks, die der Argument-Reihenfolge folgt, und jedes Element führt alle seine Hooks zusammen aus. Übergib den Hook eines Elternknotens vor seinen Kindern, wenn diese davon abhängen.
  • Ein Hook, der wirft, wird mit console.error protokolliert. Die anderen Hooks laufen trotzdem.
  • Der Callback muss nichts oder eine Cleanup-Funktion zurückgeben, mache ihn also nicht async. Ein zurückgegebenes Promise wird nie als Cleanup verwendet. Starte asynchrone Arbeit darin: { onMount: (el) => { void load(el) } }.
  • Hooks laufen nie auf dem Server. Auf dem Client laufen sie nach hydrate().
Hinweis: Hooks sind keine Effects. Sie laufen nie erneut, wenn sich der State ändert. Jeder läuft höchstens einmal pro Element.

Explizite Updates

#

Nuclo aktualisiert die Seite nie von selbst. Ändere deinen State, dann rufe update() auf.

counter.ts
let count = 0

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

render(Counter)
Livecount++ ändert nur die Variable. Der Text holt beim nächsten update() auf, vom Button oder von irgendetwas anderem auf dieser Seite.Öffnen ↗

Clicked 0 times

Ändere so viel State, wie du willst, dann rufe update() einmal auf:

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

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

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

Rufe update() aus Event-Handlern, Timern, Fetch-Callbacks oder onMount auf. Rufe es nicht aus einem dynamischen Wert heraus auf.

Dynamische Werte

#

Eine Funktion ohne Parameter ist ein dynamischer Wert. Nuclo führt sie einmal aus, wenn es das Element baut, und erneut bei jedem update(). Verwende dynamische Werte für Text-Kinder, Attributwerte, style und className. Für Kinder, die erscheinen, verschwinden oder sich wiederholen, verwende when() und list().

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

const Profile = () =>
  div(
    h1('Welcome'),              // static
    p(() => `Hello, ${name}`),  // dynamic text
    button({ disabled: () => busy }, 'Save'),
  )
LiveÄndere den Namen. Der statische Text wurde festgelegt, als das Element gebaut wurde; der dynamische läuft bei jedem update() erneut. forceUpdate() baut den Baum neu, also holt auch der statische Text auf.Öffnen ↗

static: Hello, Alice

dynamic: Hello, Alice

Nuclo prüft die Parameteranzahl (fn.length). Eine Funktion, die einen Parameter deklariert, ist ein eigener Modifier und läuft nur einmal. (x = 0) => … und (...args) => … haben eine Länge von 0 und sind daher dynamisch.

Dynamischer Text

  • Ein String, eine Zahl, ein bigint oder ein Boolean wird als String(value) gerendert. () => ok && 'Saved' zeigt "false", schreibe also () => (ok ? 'Saved' : '').
  • null und undefined werden als leerer Text gerendert.
  • Ein css()- oder cx()-Ergebnis beim ersten Durchlauf macht die Funktion zu einer dynamischen Klasse, nicht zu Text. Siehe cx().
  • Ein Node, Builder, Array oder einfaches Objekt rendert nichts und wird nie aktualisiert.

Dynamische Attribute

  • Nuclo schreibt einen Wert nur, wenn er sich vom zuletzt von Nuclo geschriebenen Wert unterscheidet. Es vergleicht nicht mit dem Live-DOM. Wenn also ein Nutzer in ein Input tippt und deine Funktion denselben Wert wie zuvor zurückgibt, bleibt der getippte Text erhalten.
  • Ein Ergebnis von null oder undefined schreibt nichts, der alte Wert bleibt also. className ist die Ausnahme: jedes falsy Ergebnis entfernt die dynamischen Klassen und behält die statischen.
  • Eine style-Funktion wendet das zurückgegebene Objekt bei jedem Update an.

Fehler

Wirft ein dynamischer Wert, fängt Nuclo den Fehler ab, und der Rest des Updates läuft trotzdem. Text wird leer oder behält seinen alten Wert, und ein Attribut behält seinen alten Wert. Beide erholen sich beim nächsten erfolgreichen update(). Die Ausnahme: eine Textfunktion, die beim ersten Durchlauf wirft, bleibt endgültig leer.

fnÖffentliche API
function update(...scopeIds: string[]): void

Führt einen synchronen Durchlauf über jeden dynamischen Wert auf der Seite aus. Mit ids aktualisiert es nur, was sich innerhalb der mit diesen scope()-ids markierten Elemente befindet. Jeder Durchlauf führt diese Schritte in Reihenfolge aus:

  1. list(): liest die Items erneut und fügt Zeilen hinzu, verschiebt oder entfernt sie.
  2. when(): prüft die Bedingungen erneut. Ein Zweig wird nur neu gebaut, wenn ein anderer Zweig aktiv wird.
  3. Dynamische Attribute, einschließlich style und className.
  4. Dynamischer Text.
  5. onMount für die Elemente, die die Schritte 1 und 2 eingefügt haben.
  • Es führt alles aus. Jeder dynamische Wert läuft bei jedem Aufruf, auch wenn sich seine Daten nicht geändert haben. Nur der DOM-Schreibvorgang wird übersprungen. Halte dynamische Werte schnell und frei von Seiteneffekten.
  • Nur Knoten im Dokument. Ist ein Knoten beim Ausführen von update() nicht im Dokument, hört Nuclo auf, ihn zu verfolgen. Er wird nie wieder aktualisiert, auch nicht, nachdem du ihn eingehängt hast. Das gilt auch für list() und when(). Rendere in einen Container, der im Dokument ist.
  • Statische Werte ändern sich nie. Einfache Strings und Attributwerte werden beim Bauen des Elements festgelegt. Um sie neu zu bauen, verwende forceUpdate().
  • Fehler. Text- und Attributfehler werden abgefangen, und der Durchlauf geht weiter. Wirft eine when()-Bedingung, wird der Fehler protokolliert und dieses when() hört auf, sich zu aktualisieren. Wirft eine items- oder renderItem-Funktion von list(), entkommt der Fehler aus update(), und die restlichen Schritte laufen nicht.
  • Server. update() tut auf dem Server nichts.
LiveJedes update() führt jeden dynamischen Wert aus, auch wenn sich seine Daten nicht geändert haben. Andere Demos auf dieser Seite treiben den Zähler ebenfalls hoch.Öffnen ↗

This dynamic value has run 1 times.

fnÖffentliche API
function scope(...ids: string[]): NodeModFn

Gibt dem Element, an das es übergeben wird, eine oder mehrere Scope-ids. update('id') aktualisiert dann nur, was innerhalb dieses Elements liegt, einschließlich der Attribute des Elements selbst.

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"
LiveBeide Buttons fügen einen Eintrag hinzu und benennen den Benutzer um. update('cart') aktualisiert nur das Panel mit dem Scope.Öffnen ↗
Hi, Ada
Items: 0
  • Ein Element kann mehrere ids haben: scope('cart', 'sidebar').
  • update('a', 'b') aktualisiert alles innerhalb von a oder b. Verwenden mehrere Elemente dieselbe id, werden alle aktualisiert.
  • Bei verschachtelten Scopes schließt update('outer') den inneren Scope ein. update('inner') berührt den äußeren nicht.
  • Eine id, die kein Element im Dokument hat, aktualisiert nichts und löst keinen Fehler aus, prüfe also die Schreibweise.
  • scope() tut auf dem Server nichts.
fnÖffentliche API
function when(condition: boolean | (() => boolean), ...content: NodeModLike[]): WhenBuilder interface WhenBuilder { when(condition: boolean | (() => boolean), ...content: NodeModLike[]): WhenBuilder else(...content: NodeModLike[]): WhenBuilder }

Rendert Inhalt abhängig von einer Bedingung. Nuclo prüft die Bedingungen bei jedem update() erneut. Die erste wahre Bedingung gewinnt. Ist keine wahr, wird der .else()-Inhalt gerendert. Ohne .else() wird nichts gerendert.

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')),
  )
LiveDie erste wahre Bedingung gewinnt; sonst rendert .else(). Ein neuer Zweig wird von Grund auf gebaut.Öffnen ↗

Loading…

Bedingungen

  • Für State übergibst du eine Funktion. Ein einfacher Boolean wird einmal gelesen: when(open, …) ändert sich nie.
  • In TypeScript muss die Funktion einen Boolean zurückgeben. Schreibe () => items.length > 0 oder () => user != null.

Was aktualisiert wird

  • Solange derselbe Zweig aktiv bleibt, werden seine Elemente behalten. Dynamische Werte, Listen und when()-Blöcke darin werden weiter aktualisiert.
  • Wird ein anderer Zweig aktiv, wird der alte Inhalt entfernt und seine onDestroy-Hooks laufen. Der neue Zweig wird von Grund auf gebaut und seine onMount-Hooks laufen. State in den alten Elementen, etwa Input-Text oder Fokus, geht verloren.

Inhalt eines Zweigs

Setze Kinder in einen Zweig: Text, dynamischen Text, Builder, list() und verschachtelte when(). Übergib Builder wie p('Hi'), keine DOM-Knoten: ein DOM-Knoten hört auf, sich zu aktualisieren, nachdem sein Zweig einmal ausgeblendet wurde.

Attribut-Objekte und on() in einem Zweig gelten für das Elternelement und bleiben, nachdem der Zweig ausgeblendet wurde. Für ein bedingtes Attribut verwende ein dynamisches Attribut: { className: () => (active ? 'active' : '') }.

Verkettung

.when() und .else() geben einen neuen Builder zurück und ändern das Original nicht. Rufst du .else() zweimal auf, gewinnt der letzte Aufruf.

fnÖffentliche API
function list<T>( items: () => readonly T[] | Iterable<T>, renderItem: (item: T, index: number) => ListRenderResult, ): ListModifier

Rendert eine Zeile pro Item. Bei jedem update() ruft Nuclo items() erneut auf und ordnet die neuen Items den bestehenden Zeilen über Identität (===) zu.

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

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

render(Fruits)

fruits.push('Cherry')
update()

Wie Zeilen zugeordnet werden

  • Ein Item, das noch da ist, behält seine Zeile. Nuclo verschiebt die Zeile bei Bedarf und ruft renderItem nicht erneut auf.
  • Ein neues Item bekommt eine neue Zeile. Ein entferntes Item verliert seine Zeile, und seine onDestroy-Hooks laufen.
  • Doppelte Items bekommen jeweils ihre eigene Zeile.
  • Du kannst das Array in place ändern (push, splice, sort) oder ein neues Array zurückgeben.

Eine behaltene Zeile wird nie erneut gerendert, verwende also dynamische Werte für die Felder, die sich ändern:

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"

Kopien sind neue Items, deshalb baut das Ersetzen des Arrays durch todos.map((t) => ({ ...t })) jede Zeile neu.

LiveZeilen werden nach Identität zugeordnet. Hake eine Zeile ab, dann Umkehren: dasselbe <li> wandert, Checkbox und alles.Öffnen ↗

Was renderItem zurückgibt

  • Gib ein Element zurück, etwa li(...), tr(...) oder einen SVG-Builder. Gib null oder undefined zurück, um ein Item zu überspringen. Strings, Textknoten und Fragmente rendern nichts.
  • Gib when() oder list() nicht direkt zurück. Setze sie in das Zeilenelement: li(todo.text, when(() => todo.done, ' ✓')).
  • Um Items auszublenden, filtere in items(), nicht in renderItem.
  • Halte renderItem frei von Seiteneffekten. Nuclo kann es für dasselbe Item mehr als einmal aufrufen.

Der Index

index ist die Position des Items, als seine Zeile erzeugt wurde. Er wird nicht aktualisiert, wenn Zeilen verschoben werden. Für die aktuelle Position verwende einen dynamischen Wert: li(() => `${names.indexOf(name) + 1}. ${name}`).

Iterables

Jedes Iterable funktioniert: Arrays, Set, Generatoren und map.values(). Jedes Mal dasselbe Array oder Set zurückzugeben ist in Ordnung. Ein Iterator, etwa ein Generator oder map.values(), ist nach einem Durchlauf aufgebraucht, erzeuge ihn also bei jedem Aufruf innerhalb von items(). Eine Map direkt zu iterieren erzeugt jedes Mal neue [key, value]-Arrays, sodass jede Zeile neu gebaut wird. Verwende stattdessen map.values().

Platzierung

Zeilen erscheinen dort, wo list() unter seinen Geschwistern steht, zwischen zwei Kommentar-Markern. Andere Kinder des Elternelements werden nicht berührt. list() funktioniert auch innerhalb von SVG-Buildern.

region() & into()

#
fnÖffentliche API
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() markiert eine benannte Stelle im Baum. into() füllt sie von überall sonst, in beliebiger Tiefe. Das Layout bekommt den Inhalt nicht als Prop, und die Komponente, die den Inhalt baut, weiß nicht, wo er landet.

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()))

Wie es funktioniert

  • into() hinterlässt an seiner Stelle nur einen Kommentar. Sein Inhalt wird zwischen den Markern der Region gerendert.
  • Die Reihenfolge spielt keine Rolle. Ein into(), das vor seiner Region gebaut wird, wartet und erscheint in dem Moment, in dem die Region gebaut wird. Views erscheinen in der Reihenfolge, in der sie geschrieben wurden.
  • Der Inhalt lebt so lange wie die View. Wenn das when(), das ein into() enthält, schließt, oder seine list()-Zeile entfernt wird, verlässt der Inhalt die Region. Erneutes Öffnen fügt eine Kopie hinzu, nicht zwei.
  • Wenn eine Region verschwindet, zum Beispiel weil ihr when() schließt, warten ihre Views wieder. Eine später gebaute Region mit derselben id zeigt sie an.
  • Ein into(), dessen id zu keiner Region passt, rendert nichts und löst keinen Fehler aus, prüfe also die Schreibweise.
  • Ids sind im Baum eindeutig. Eine zweite Region, die unter einer aktiven id gebaut wird, protokolliert eine Warnung.
  • Regionen funktionieren mit serverseitigem Rendering. Bei hydrate() übernimmt jede View das vom Server gerenderte HTML, statt es erneut zu bauen.

Optionen

  • id: der Name, über den into()-Aufrufe die Region finden.
  • type: 'latest' (der Standard) zeigt nur die neueste View. 'stack' zeigt jede View, in Ankunftsreihenfolge.
  • empty: wird gerendert, solange die Region keine Views enthält.

Gestapelte Regionen

Mit type: 'stack' baut eine View, die sich über eine andere öffnet, die darunterliegende nicht neu. Deren DOM, Fokus und Formular-State bleiben erhalten. Verwende das für eine Ebene wie einen Dialog über einer Seite.

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'))),
  )

Mehrere Regionen auf einmal

Übergib ein Objekt, um mehr als eine Region aus einem einzigen into() zu füllen. Ein Wert kann ein Kind oder ein Array von Kindern sein. Beide Formen lassen sich verschachteln: ein into() innerhalb des Inhalts einer anderen View zielt auf seine eigene Region.

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

Ein Dialog, der tief im Baum geöffnet wird

Die Overlay-Region sitzt an der Wurzel. Jede Komponente, in beliebiger Tiefe, öffnet einen Dialog, indem sie ein into() in ein when() wickelt. Das Schließen des when() entfernt den Dialog aus der Region.

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() } }),
        ),
      ),
    ),
  )

Toasts aus Listenzeilen

Jede Zeile einer list() ist eine View. Eine gestapelte Region zeigt sie in Reihenfolge, und das Entfernen eines Items aus dem Array entfernt seinen 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) }))),
    ),
  )

Ein Seitentitel, den die Seite setzt

Mit dem Standard type: 'latest' gewinnt die neueste View. Das Layout besitzt die Überschrift, und die jeweils offene Seite benennt sie. Eine Seite ohne into('title', …) lässt den empty-Text stehen.

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(/* … */),
  )

Dynamische Werte innerhalb einer View

Der Inhalt eines into() ist gewöhnlicher Nuclo-Inhalt. Funktionen darin sind state-abhängige Werte und werden bei update() aktualisiert, wo immer die Region ist.

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,
      ),
    ),
  )

Eine Region, die kommt und geht

Das into() des Dashboards wird einmal geschrieben. Solange der Nutzer abgemeldet ist, gibt es keine main-Region, die View wartet also. Das Anmelden baut die Region, und die View erscheint. Das Abmelden entfernt die Region, und die View wartet wieder.

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

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

Eine View mit eigener Region

Eine View kann eine region() enthalten, und andere Views können darauf zielen. Hier bringt die Editor-Seite eine tabs-Region mit, und jeder Tab füllt sie. Wenn der Editor schließt, geht seine Region mit ihm.

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' }))),
  )

Live-Beispiel

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()),
  )
LiveEin Layout mit einer gestapelten Hauptregion und einer Seitenregion, von außen gefüllt. Tippe in Home, öffne den Dialog und schließe ihn: der Text bleibt. Article füllt beide Regionen mit einem into().Öffnen ↗

No related links.

fnÖffentliche API
function forceUpdate(): void function forceUpdate(nodeModFn: NodeModFn | (() => NodeModFn), parent?: Element): ExpandedElement

update() führt nur dynamische Werte aus. Statische Werte, etwa der Text in h1(labels[lang].title), werden beim Bauen des Baums festgelegt. forceUpdate() ruft deine Komponenten erneut auf und patcht das Live-DOM entsprechend, statische Werte eingeschlossen. Verwende es für seltene, app-weite Änderungen, etwa das Umschalten der Sprache. Für alles andere verwende update().

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á"
Liveupdate() aktualisiert nur die dynamische lang-Zeile. forceUpdate() ruft App erneut auf und patcht dasselbe <h1>. Die explizite Form baut nur diesen Baum neu, nicht die ganze Seite.Öffnen ↗

lang = 'en'

Hello

Static text, fixed on build.

Ohne Argumente baut forceUpdate() jede Wurzel neu, die mit render(App) oder hydrate(App) gerendert wurde. Mit render(App()) gerenderte Wurzeln werden übersprungen. Wirft eine Komponente, wird der Fehler protokolliert und diese Wurzel behält ihr altes DOM. Ein Fehler, der später beim Anwenden des neuen Builds geworfen wird, wird ebenfalls protokolliert, aber die bereits gepatchten Teile bleiben gepatcht.

Was bleibt und was sich ändert

  • Kinder werden in Reihenfolge nach Tag zugeordnet. Ein passendes Element wird wiederverwendet, sodass Fokus und getippte Input-Werte bleiben und seine onMount-Hooks nicht erneut laufen. Sobald sich der Tag eines Kindes unterscheidet, werden dieses Kind und jedes spätere Geschwister neu gebaut.
  • Text, Attribute, Klassen, Styles, Listener, when()-Zweige und list()-Zeilen folgen dem neuen Build.
  • Neue Elemente werden erzeugt, und ihre onMount-Hooks laufen. Elemente, die der neue Build nicht mehr erzeugt, werden entfernt, und ihre onDestroy-Hooks laufen.
  • list()-Zeilen werden ebenfalls nach Position zugeordnet, nicht nach Item, sodass ein wiederverwendetes Zeilenelement am Ende ein anderes Item zeigen kann.
  • Attribute, die der neue Build nicht mehr setzt, werden nicht entfernt.
Achtung: State, der innerhalb einer Komponentenfunktion deklariert ist, wird zurückgesetzt, weil die Funktion erneut läuft. Halte State, der überleben muss, außerhalb der Komponente.

Explizite Form. forceUpdate(App(), parent) baut einen Baum in parent neu und gibt seine Wurzel zurück. Es registriert die Wurzel nicht für spätere Aufrufe. Wie hydrate() erwartet es, dass die Wurzel der erste Knoten in parent ist. Hat der erste Knoten einen anderen Tag, wird er ersetzt.

Auf dem Server tut forceUpdate() nichts.

render()

#
fnÖffentliche API
function render(nodeModFn: NodeModFn | (() => NodeModFn), parent?: Element, index?: number): ExpandedElement

Baut deinen Baum und hängt sein Wurzelelement an parent an. parent ist standardmäßig document.body. Es gibt das Wurzelelement zurück.

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

render(App, document.getElementById('app')!)
  • Du kannst die Komponente (render(App)) oder den Builder übergeben, den sie zurückgibt (render(App())). Das DOM ist dasselbe. Nur render(App) erlaubt forceUpdate(), die Wurzel später neu zu bauen, bevorzuge es also.
  • Es hängt an. Es leert parent nicht, ein zweifacher Aufruf fügt also zwei Kopien hinzu.
  • Jedes onMount im Baum ist gelaufen, wenn render() zurückkehrt.
  • Rendere in ein Element, das im Dokument ist. Siehe update().
  • Die Wurzel muss ein Element sein, etwa div(...), nicht when() oder list().
  • index ist intern. Es bestimmt nicht, wo das Element eingefügt wird. Lass es weg.

Eine als render(App) übergebene Komponente darf keine Parameter haben. Ein erforderlicher Parameter ist ein Typfehler. Ein optionaler besteht die Typprüfung, aber render() wirft zur Laufzeit. Wickle eine Komponente, die Props nimmt, ein:

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

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

hydrate()

#
fnÖffentliche API
function hydrate(nodeModFn: NodeModFn | (() => NodeModFn), parent?: Element): ExpandedElement

Übernimmt HTML, das renderToString() erzeugt hat. Es verwendet die bestehenden Elemente wieder, hängt Event-Listener an und verbindet dynamische Werte, when() und list(), sodass update() danach funktioniert. Dann führt es die onMount-Hooks aus. parent ist standardmäßig document.body. Es gibt das Wurzelelement zurück.

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

hydrate(App, document.getElementById('root')!)
  • Verwende dieselbe Komponente und dieselben Daten wie der Server.
  • Hydriere in einen dedizierten Container. Die Wurzel der App muss sein erster Knoten sein, Whitespace ist aber in Ordnung. Kommt ein anderer Knoten zuerst, ersetzt Nuclo diesen Knoten und lässt die Server-Kopie zurück.
  • Wie bei render() übergibst du App, nicht App(), damit forceUpdate() die Wurzel später neu bauen kann. App darf keine Parameter haben. Mit einem optionalen Parameter hängt hydrate() stillschweigend nichts an. Wickle eine Komponente, die Props nimmt, ein: hydrate(() => Page(props), root).
  • Gibt es kein Server-HTML, verwende render().

Wenn Server und Client sich unterscheiden

Der Client gewinnt:

  • Ein anderer Wurzel-Tag: dieser Knoten wird durch frisches DOM ersetzt. Ein leerer Container: frisches DOM wird angehängt.
  • Unterschiedlicher Text wird gepatcht. Unterschiedliche list()-Items und when()-Zweige folgen dem Client.
  • Überzählige Server-Kinder werden entfernt, fehlende werden erzeugt.
  • Attribute, die der Server geschrieben hat, der Client aber nicht setzt, bleiben wie sie sind.

renderToString() schreibt HTML-Kommentare als Marker, etwa <!-- text-0 --> vor jedem Textknoten. hydrate() braucht sie, entferne also keine Kommentare aus dem Server-HTML. Ohne die Marker löscht die Hydration die list()-Zeilen, und list() und when() hören auf, sich zu aktualisieren.

Serverseitiges Rendering

#
fnÖffentliche API
// 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

Rendere eine Komponente auf dem Server zu einem HTML-String. Im Browser rufst du hydrate() mit derselben Komponente auf, um dieses HTML interaktiv zu machen. Server-Laufzeiten haben kein document, importiere also zuerst nuclo/polyfill.

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')!)

Behalte id="nuclo-styles" am Style-Tag. Der Client verwendet dieses Element wieder und fügt nur neue Regeln hinzu. Siehe getCssText().

Was auf dem Server läuft

  • Jeder renderToString()-Aufruf baut den Baum und führt jeden dynamischen Wert einmal aus.
  • Event-Handler, onMount und onDestroy laufen nie. update(), scope() und forceUpdate() tun nichts.
  • Das Rendering ist synchron. Lade zuerst deine Daten, dann rendere.
  • Nach einem Render bleibt nichts registriert, ein Prozess kann also viele Requests bedienen. Übergib Request-Daten als Argumente, etwa renderToString(Page(user)). Variablen auf Modulebene werden von allen Requests geteilt.
  • Rufe render() nicht auf dem Server auf. Es hängt an das geteilte document.body an.

renderToString()

Nimmt einen Builder (App()), eine Komponente (App) oder einen DOM-Knoten entgegen. null und undefined geben "" zurück. Die Wurzel muss ein Element sein. Eine als renderToString(Page) übergebene Komponente darf keine Parameter haben, übergib also Page(user) für eine, die Daten nimmt.

example.ts
renderToString(p('Hello'))
// '<p><!-- text-0 -->Hello</p>'
  • Text- und Attributwerte werden HTML-escaped. Text innerhalb von script() und style() wird unverändert geschrieben, nur schließende Tags werden aufgebrochen, setze dort also nie Nutzereingaben ein.
  • Es wirft nicht. Wirft die Komponente, eine list()-Funktion oder eine when()-Bedingung, oder fehlt der Polyfill, protokolliert es den Fehler und gibt "" zurück. Prüfe auf ein leeres Ergebnis, wenn der Request fehlschlagen soll. Eine Text- oder Attributfunktion, die wirft, rendert nur leer.

renderManyToString()

Dasselbe wie inputs.map(renderToString).

renderToStringWithContainer()

Wickelt die Ausgabe in ein Element. containerTag ist standardmäßig "div", containerAttrs standardmäßig {}. Attributwerte werden escaped. Attributnamen werden wie angegeben geschrieben, verwende also HTML-Namen wie class. Übergib nie Nutzereingaben als Tag.

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

Ein kleines DOM, damit Nuclo in Node und Bun Bäume bauen kann. Importiere es einmal, vor deinem ersten Render. Die meisten Apps brauchen nur den einfachen Import.

  • Wenn es kein window gibt, setzt es globalThis.document, Node, Element und HTMLElement, aber nur die, die fehlen. Es erzeugt kein window.
  • In einem Browser oder unter jsdom tut es nichts.
  • Event und CustomEvent sind die eigenen Konstruktoren der Laufzeit. Node, Element und HTMLElement sind Aliase der Polyfill-Klassen.

Es ist ein DOM zum Bauen von Bäumen, kein Browser. Es hat kein innerHTML und kein getElementById, querySelector() gibt null zurück, und Event-Listener werden ignoriert. Verwende renderToString(), um das HTML zu erhalten.

css()

#
fnÖffentliche API
function css(style: Style): StyleResult function css(name: string, style: Style): StyleResult type StyleResult = { className: string }

Wandelt ein typisiertes Style-Objekt in generierte CSS-Klassen um. Übergib das Ergebnis an einen beliebigen Tag-Builder. Im Browser landen die Regeln in einem einzigen <style id="nuclo-styles">-Element. Auf dem Server liefert getCssText() sie zurück.

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

const Card = () => div(card, 'Simple card')
  • css() erzeugt eine Klasse für die Basisdeklarationen plus eine Klasse für jeden Pseudo-Key, Selektor und Media-Block. Die Card oben bekommt zwei Klassen.
  • Klassennamen sind Hashes des Inhalts. Identische Styles teilen sich eine Klasse, und Server und Client erzeugen dieselben Namen.
  • Definiere Styles einmal, auf Modulebene. css() cacht pro Objekt, ein neues Objekt bei jedem Aufruf wird also jedes Mal neu verarbeitet.
  • Ein Style-Objekt nach der Übergabe an css() zu ändern, hat keine Wirkung.
  • Jeder Style bleibt für die Lebensdauer der Seite oder des Serverprozesses registriert. Für Werte, die sich pro Element ändern, etwa eine Breite aus Daten, nimm das style-Attribut.

Benannte Styles

Übergib zuerst einen Namen, um einen lesbaren Klassennamen zu erhalten:

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

pageTitle.className // "page-title page-title-<hash>"
  • Die Basisklasse ist genau der Name. Weitere Blöcke erhalten name-<hash>.
  • Der Name muss ein gültiger Klassenname sein. Andernfalls warnt Nuclo und generiert einen.
  • Namen sind global. Verwenden zwei unterschiedliche Styles denselben Namen, warnt Nuclo.

Das Ergebnis verwenden

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) und card.className liefern beide die Klassennamen.

Style-Objekte

#

Das Objekt, das du an css() übergibst, verwendet gängige CSS-Eigenschaften in camelCase, eine Reihe von Kurzformen und verschachtelte Keys für Pseudo-Zustände, Selektoren und Media Queries. TypeScript vervollständigt sie alle. Eigenschaften außerhalb der typisierten Menge, etwa borderTopLeftRadius, fill oder paddingInline, gehören in raw (siehe unten).

Werte

  • Zahlen werden zu px, außer 0 und einheitenlose Eigenschaften: zIndex, opacity, fontWeight, lineHeight, flex, flexGrow, flexShrink, order, aspectRatio, gridColumn und gridRow. In raw sind auch zoom, scale, column-count, orphans, widows, tab-size und animation-iteration-count einheitenlos.
  • Strings werden unverändert übernommen. null, undefined und false werden übersprungen.
  • lineHeight (und leading) ist einheitenlos, leading: 24 bedeutet also das 24-Fache der Schriftgröße. Schreib "24px" für Pixel.
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%

Kurzformen

KeyCSS
p, pt, pr, pb, plpadding und jede Seite
px / pypadding links und rechts / oben und unten
m, mt, mr, mb, ml, mx, mydasselbe für margin
w, h, minW, maxW, minH, maxHwidth, height sowie deren min und max
sizewidth und 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

Diese Keys nehmen true:

KeyCSS
rowdisplay: flex; flex-direction: row
coldisplay: flex; flex-direction: column
centeralign-items: center; justify-content: center (setzt display nicht)
truncateoverflow: hidden; text-overflow: ellipsis; white-space: nowrap

Pseudo-Zustände

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 und firstLetter. Sie lassen sich verschachteln: hover: { focus: {…} } ergibt :hover:focus.

Selektoren und At-Rules

Ein Key, der mit & beginnt, ist ein Selektor, und jedes & wird zur Klasse. Ein Key, der mit @ beginnt, ist eine At-Rule: @media, @container oder @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 },
})
  • Der Key muss mit & beginnen. Ein Key wie ".dark &" wird ignoriert, schreib also "&:is(.dark *)".
  • Zwei verschachtelte @media-Blöcke werden mit and kombiniert. Eine Query, die mit einem Medientyp beginnt (print, only screen), kommt an den Anfang, weil die Grammatik einen Typ nur dort erlaubt. Ein Paar, das sich nicht zusammenführen lässt — zwei Medientypen oder ein führendes not — erzeugt eine Warnung und verwendet nur die innere Query. Jede andere verschachtelte At-Rule ersetzt die äußere.

raw

Nimm raw für jede beliebige Eigenschaft, mit dem Namen genau wie in CSS geschrieben. Zahlen bekommen weiterhin px, übergib also Strings:

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

Jeder andere flache Key wird in kebab-case umgewandelt und unverändert ausgegeben, ein Tippfehler wie colr: 'red' wird also zu einer Deklaration, die der Browser verwirft. Ein verschachteltes Objekt unter einem Key, der kein Pseudo-Zustand, kein Screen und kein &- oder @-Key ist, wird ignoriert. TypeScript meldet beides.

fnÖffentliche API
function cx(...inputs: ClassInput[]): StyleResult type ClassInput = StyleResult | string | false | null | undefined | ClassInput[]

Kombiniert Styles. Setzen zwei Eingaben dieselbe Eigenschaft, gewinnt die spätere.

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')
  • Falsy-Eingaben werden übersprungen, Arrays werden abgeflacht, Strings werden aufgeteilt und dedupliziert.
  • Generierte Klassen für denselben Block (Basis, derselbe Pseudo-Zustand, dieselbe Media Query) werden zu einer neuen Klasse zusammengeführt. Andere Klassennamen werden unverändert durchgereicht.
  • Nur cx() kennt die Reihenfolge der Argumente. Bei div(base, blue) werden beide Klassen hinzugefügt, und die zuletzt erzeugte Regel gewinnt. Pack Styles, die sich gegenseitig überschreiben, in einen einzigen cx()-Aufruf.
  • Wenn der Block eines benannten Styles mit dem Block einer anderen Eingabe zusammengeführt wird, heißt die zusammengeführte Klasse name-<hash>, nicht der bloße Name. Blöcke, die nicht zusammengeführt werden, behalten ihre Klasse, cx(pageTitle, 'extra') enthält also weiterhin page-title.

Dynamische Klassen

Übergib eine Funktion, die immer cx(...) zurückgibt:

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() } },
  )

Die Funktion muss bei ihrem ersten Durchlauf ein Style-Ergebnis zurückgeben. () => active && activeButton rendert den Text "false" statt einer Klasse.

createCss()

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

Liefert eigene css, cx, variants, keyframes und globalStyle, gebunden an ein Theme. Ein Theme gibt Werten Namen: Mit dem Theme unten schreibt borderColor: 'border' den Wert #e5e7eb, und TypeScript vervollständigt die Namen. Jeder Eintrag in screens wird zu einem Style-Key für eine Media Query, etwa 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 },
})
Theme-KeyVerwendet von
colorsbg, backgroundColor, color, borderColor und jede Seite, outlineColor, caretColor, textDecorationColor
fontsfont, fontFamily
shadowsshadow, boxShadow
radiirounded, borderRadius
screensneue Style-Keys, etwa md: {…}
  • Ein Theme-Name wird nur aufgelöst, wenn er der gesamte Wert ist. border: "1px solid primary" wird nicht aufgelöst.
  • Ein Screen-Wert ohne @ wird zu @media <value>. Ein Wert, der mit @ beginnt, wird unverändert übernommen, zum Beispiel "@container (min-width: 400px)".
  • Screens existieren nur auf Instanzen aus createCss(). Das globale css() hat keine, akzeptiert aber @media-Keys.
  • Alle Instanzen und die globalen Helfer teilen sich ein Stylesheet. Screen-Media-Regeln werden in der Reihenfolge ausgegeben, in der die Screens deklariert sind, nicht in der Reihenfolge ihrer Verwendung, liste min-width-Screens also von klein nach groß. Verwende eine createCss()-Instanz pro App, damit diese Reihenfolge die aus deinem Theme ist.
  • theme auf dem Ergebnis ist das Theme, das du übergeben hast.

variants()

#
fnÖffentliche API
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

Baut eine Funktion, die für eine Menge von Optionen einen Style zurückgibt, etwa Intent und Größe eines Buttons.

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
  • Styles werden in dieser Reihenfolge zusammengeführt: base, der gewählte Wert jeder Gruppe, dann jeder passende Compound. Spätere Styles gewinnen pro Eigenschaft.
  • Eine fehlende, undefined- oder null-Option verwendet den Default. Eine Gruppe ohne Default wird übersprungen.
  • Eine Gruppe mit nur true- und false-Keys nimmt einen Boolean.
  • Ein Compound greift, wenn jede Gruppe, die er auflistet, passt, Defaults eingeschlossen.
  • Jede Auswahl wird einmal gebaut und gecacht.

keyframes() & globalStyle()

#
fnÖffentliche API
function keyframes(frames: KeyframeFrames): string function globalStyle(selector: string, style: FlatStyle): void

keyframes() registriert eine Animation und gibt ihren generierten Namen zurück. Frame-Keys sind from, to und Prozentwerte wie "50%" oder "0%, 100%". Frames nehmen nur flache Eigenschaften. Identische Frames liefern denselben Namen.

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

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

globalStyle() fügt eine Regel für einen beliebigen Selektor hinzu:

reset.ts
globalStyle('*, *::before, *::after', { boxSizing: 'border-box' })
globalStyle('body', { m: 0, font: 'system-ui, sans-serif' })
  • Der Selektor wird genau so verwendet, wie er geschrieben ist.
  • Nur flache Eigenschaften funktionieren. Pseudo-Keys, Screens und @-Keys werden ignoriert, globalStyle() kann also keine Media-Regeln schreiben.
  • Dieselbe Regel wird einmal hinzugefügt. Ein anderer Style für denselben Selektor fügt eine zweite Regel hinzu, und die spätere gewinnt.

getCssText() & resetStyles()

#
fnÖffentliche API
function getCssText(): string // from 'nuclo/ssr' or 'nuclo' function resetStyles(): void // from 'nuclo'

getCssText() liefert jede Regel, die css(), cx(), variants(), keyframes() und globalStyle() in diesem Prozess erzeugt haben: zuerst die Basisregeln, dann die @media-, @container- und @supports-Blöcke. Nimm es für serverseitiges Rendering.

  • Ruf es nach renderToString() auf. Regeln, die während des Renderns entstanden sind, sind enthalten.
  • Es umfasst den gesamten Prozess, nicht eine Seite oder einen Request, und es wächst nur. Das ist sicher, weil Klassennamen Inhalts-Hashes sind.
  • Pack es in <style id="nuclo-styles">. Der Client verwendet dieses Element wieder und überspringt die Regeln, die bereits darin stehen. Mit einer anderen id, oder ohne, fügt der Client ein zweites Stylesheet mit jeder Regel noch einmal hinzu.
  • Der Text wird nicht escaped, steck also niemals nicht vertrauenswürdige Eingaben in Style-Werte.

resetStyles() ist ein Test-Helfer. Es entfernt jede Regel und das #nuclo-styles-Element. Styles, die du zuvor erzeugt hast, behalten ihre Klassennamen, verlieren aber ihr CSS. Ruf es nicht in einer App oder auf einem Server auf.

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

beforeEach(() => resetStyles())

nuclo-router

#
Separates Paket: nuclo-router ist nicht Teil des Nuclo-Kernpakets. Installiere es neben nuclo. Es ist ein Preview-Release, die API kann sich also noch ändern.

Routing für Nuclo: serverseitiges Rendering, Hydration, code-gesplittete Routen und Idle-Preloading, gebaut auf list() und update(). Etwa 4.9 KB gzipped, ohne Abhängigkeiten außer Nuclo selbst und ohne Seiteneffekte, es wird also per Tree-Shaking entfernt, wenn es ungenutzt bleibt.

terminal
$ npm install nuclo nuclo-router

Oder nimm pnpm add, yarn add oder bun add mit denselben zwei Namen. Es braucht nuclo 0.3 oder neuer als Peer-Dependency.

Der Router entscheidet, was geladen wird. Die App sagt mit router.outlet(), wo: eine list() über den Layer-Stack, eine Zeile pro Layer, eine gewöhnliche Seite ist also eine einzige Zeile. Server und Client bauen denselben Baum, hydrate() übernimmt also die Knoten des Servers, statt sie zu ersetzen. Alles Weitere ist history, ein delegierter click-Listener und requestIdleCallback.

Ein 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() löst die Module der passenden Route auf, bevor es zurückkehrt, der erste Baum ist also auf Server und Client vollständig. Danach reicht der Router zur gestarteten Route durch: router.path, router.pending, router.go().

Eine ganze App

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))

Das ist die gesamte Integration. Links funktionieren, weil ein <a href> einfach ein <a href> ist. Ein delegierter Listener auf document fängt die Links ab, die dem Router gehören, und überlässt jeden anderen Link dem Browser.

Auf dem Server gibt start() pro Request eine Route zurück und lässt den Router in Ruhe, parallele Requests können also nicht die Route des anderen sehen. route.run() macht diese Route für die Dauer des Renderns zu derjenigen, die der Router, und damit das Outlet, liest. Im Browser gibt es eine Route pro Seitenaufruf, und start() macht sie zur eigenen des Routers. Kein run() nötig.

Eine Seite erhält ihren RouteContext und ihre PageProps als (ctx, { layer, data, outlet, beforeLeave }) und darf weniger Argumente annehmen. Sie gibt alles zurück, was list() akzeptiert. Um zusätzlich einen anderen Teil des Layouts zu füllen, gib aus der Seite heraus ein into() zurück, etwa div(article(…), into('sidebar', nav(…))). Dieser Inhalt verschwindet mit der Seite. Siehe Seiten & Regionen.

Patterns & verschachtelte Routen

#
PatternPasst aufparams
/, /docs/introgenau diesen Pfad{}
/blog/:slug/blog/hello{ slug: 'hello' }
/u/:id/edit/u/7/edit{ id: '7' }
/files/*rest/files/a/b.txt{ rest: 'a/b.txt' }
*alles, was sonst nicht passt, deine 404{ '*': '…' }

Die Priorität ist: statisch, dann dynamisch in Deklarationsreihenfolge, dann Catch-all mit dem längsten Präfix zuerst. '*' passt also nur auf das, was sonst nichts trifft, und kann an beliebiger Stelle in der Tabelle stehen. Abschließende Slashes, doppelte Slashes und Prozent-Kodierung werden normalisiert, Param-Werte kommen dekodiert an. Das Matching ist ein Segmentvergleich pro Pattern. Es werden keine Regexes gebaut oder ausgeführt.

Relative Routen

Ein Pattern mit ./ ist relativ zum Parent, der es deklariert, und ein href mit ./ ist relativ zur aktiven Route. Zusammen ergeben sie einen wiederverwendbaren Abschnitt, der unter jedem Parent dasselbe bedeutet. Der Wert in einer Tabelle ist entweder ein Loader oder eine weitere Tabelle, deren Keys relativ zu ihr sind:

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'),
})

Diese zwei Zeilen erzeugen alle sechs Patterns, ohne dass ein Pfad zweimal geschrieben wird. Beide Parents bekommen dieselben Loader-Objekte, sie teilen sich also einen Modul-Cache und ein import().

  • Innerhalb einer Kind-Tabelle bedeuten './x', '/x' und 'x' alle dasselbe, '/' steht für den Parent-Pfad selbst, und '*rest' gibt dem Abschnitt sein eigenes Catch-all. Die Verschachtelung geht beliebig tief.
  • Top-Level-Keys werden unverändert übernommen, '*' bleibt also '*'.
  • Ein Kind unter einem *catch-all-Parent ist ein createRouter()-Fehler, da ein Catch-all sein Pattern beenden muss.

Parent-Seiten und outlet()

Ein Parent und seine Kinder sind eine echte Hierarchie. Der Parent rendert, und sein Kind rendert in ihm, dort, wo er outlet() aufruft:

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()))
}

Die Navigation zwischen Kindern baut den Parent nicht neu. Sein DOM-Knoten bleibt derselbe Knoten, sein Fokus, seine Scrollposition, sein halb ausgefülltes Input und jeder Zustand in seiner Closure überleben also.

  • Eine Seite ohne Kind-Route rendert aus ihrem Outlet nichts, ein Layout kann es also bedingungslos aufrufen. Ein Parent, der ein Kind hat und outlet() nie aufruft, verschluckt es.
  • Es gibt keine Index-Routen. '/' ist das Layout selbst, der Parent-Pfad rendert also das Layout mit leerem Outlet statt einer separaten Index-Seite darin. Pack den Landing-Inhalt ins Layout oder gib ihm einen echten Kind-Pfad.

Derselbe Aufruf funktioniert dann von beiden Parents aus, und von einer Seite, die nicht weiß, in welchem sie steckt:

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, . und .. werden gegen den Pfad der aktuellen Route aufgelöst, nicht gegen das "Verzeichnis" der URL. Von /invoices/42 aus ist ./preview gleich /invoices/42/preview, wo ein Browser /invoices/preview gesagt hätte, und ein abschließender Slash in der URL ändert nichts. ../ geht ein Segment nach oben und stoppt an der Wurzel. Query und Hash werden mitgenommen, base wird wieder vorangestellt, und der aufgelöste absolute Pfad ist das, was in die History geht. Das ist dieselbe Abweichung von der URL-Auflösung, die React Router für <Link to="./x"> macht.

  • Nur ./ und ../ sind routen-relativ. Ein nacktes preview in einem href bleibt den gewöhnlichen Browserregeln überlassen.
  • Baue relative Links mit router.href('./preview'), statt href="./preview" von Hand zu schreiben. href() löst zur Renderzeit auf, das ausgelieferte Markup ist also absolut und bleibt korrekt für einen Crawler, für einen Mittelklick und bei deaktiviertem JavaScript.

Was erhalten bleibt, und was neu gebaut wird

KnotenBei Navigation
ein Parent, dessen Pattern und Pfad unverändert sinderhalten: derselbe Knoten, und sein load() läuft nicht erneut
die passende Seiteerhalten, wenn Pattern und Pfad unverändert sind und sie kein load() hat, eine Query- oder Hash-Änderung behält sie also. Eine Route mit Loader wird bei jedem Besuch aufgefrischt.
ein Parent, dessen Pfad sich geändert hatneu gebaut, zusammen mit allem darunter
ein gepushter Layerrendert die passende Seite allein. Seine Parents sind bereits in der Zeile darunter gemountet.

Eine erhaltene Seite behält den ctx, mit dem sie gebaut wurde. Das ist die Bedeutung von ctx überall: Lies router.* für Live-Werte. Query-getriebener Zustand wie Filter, Paginierung und Tabs liest router.search in einem dynamischen Wert und schreibt mit router.go('?page=2', { replace: true }). Die Seite, ihr Fokus und ihr halb ausgefülltes Input bleiben, wo sie sind.

Seiten & Regionen

#

router.outlet() platziert die Seite an einer Stelle. Eine Seite, die außerdem eine Sidebar, eine Toolbar oder eine Überschrift an anderer Stelle im Layout braucht, füllt sie mit into(). Das Layout markiert die Stelle mit region(), und die Seite gibt neben ihrem eigenen Inhalt ein into() zurück. Das Layout erhält nie Seiteninhalt als Prop, und die Navigation baut es nie neu.

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))))),
  )
}

Der Inhalt der Seite ist eine Zeile der list() des Outlets, und ein into() lebt so lange wie die Zeile, die es enthält. Der Sidebar-Inhalt kommt also mit der Seite und geht mit ihr: Die Navigation zu einer Seite ohne into('sidebar', …) zeigt wieder den empty-Inhalt der Region. Die Seite kann ihren ctx und ihre data für beide Teile nutzen, da beide von derselben Funktion gebaut werden.

Was bei der Navigation passiert

  • Eine erhaltene Seite behält ihre Regionen. Eine Query- oder Hash-Änderung, die die Seite behält, lässt auch den Regionsinhalt unangetastet. Lies router.search in einem dynamischen Wert, wenn die Sidebar davon abhängt.
  • Eine neu gebaute Seite füllt sie neu. Ändert sich die Route, wird die alte Zeile entfernt, und die into()-Views der neuen Seite übernehmen. Nichts bleibt zurück.
  • Ein gepushter Layer fügt eine View hinzu. Der Layer ist eine spätere Zeile des Outlets, sein into() ist also eine neuere View derselben Region. Mit dem Default type: 'latest' überdeckt sie die View der Seite, bis der Layer schließt, dann erscheint die View der Seite wieder. Mit type: 'stack' werden beide gezeigt.
  • Auch eine Parent-Seite kann Regionen füllen. Ihre Views bleiben, während ihre Kinder wechseln, weil der Parent nicht neu gebaut wird.
  • Serverseitiges Rendering funktioniert unverändert. Der Server baut die Seite innerhalb von route.run(), ihre into()-Views stehen also im HTML, und hydrate() übernimmt sie.

Mehrere Regionen aus einer Seite

Übergib ein Objekt, um mehrere Regionen auf einmal zu füllen. Der Hauptinhalt der Seite geht weiterhin durch das Outlet, gib ihn also neben dem into() zurück:

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')),
    }),
  )
}

Welches Werkzeug für welche Aufgabe

  • outlet() ist für eine Kind-Route: Inhalt, den die URL bestimmt, gerendert innerhalb seiner Parent-Seite. Siehe Verschachtelte Routen.
  • into() ist dafür, dass sich die passende Seite über das Layout verteilt. Der Inhalt gehört zur Seite und nutzt ihre Daten.
  • matches() ist dafür, dass das Layout allein anhand der URL entscheidet, mit seiner eigenen kleinen Tabelle und ohne Beteiligung einer Seite. Siehe matches().

All das gehört zu der einen passenden Route. Der Router steuert nicht zwei unabhängige Bereiche aus einer URL, eine Sidebar mit eigenen Routen ist also eine Region, die eine Seite füllt, kein zweites Outlet. Eine Sidebar, die wirklich einen eigenen Router, eigenes Laden und einen eigenen Fallback will, hält einen ungestarteten Router und löst mit match() auf — siehe Mehrere Router & kombiniertes Laden.

Routen-Loader & Code-Splitting

#

Ein Seitenmodul kann eine load-Funktion exportieren. Sie läuft, bevor die Seite gebaut wird, bei jeder Navigation zu dieser Route, und was sie zurückgibt, wird der Seite als data übergeben:

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))
}

Die Routentabelle bleibt unverändert. Der Loader reist mit der Seite. Er erhält den passenden RouteContext, params, search und path stehen also alle zur Verfügung. Er kann synchron oder asynchron sein, und er läuft auch auf dem Server, eine servergerenderte Seite kommt also mit ihren Daten bereits im HTML, statt nach der Hydration zu fetchen.

Das Modul wird gecacht. Die Daten nicht. Der Chunk einer Route wird einmal geladen und wiederverwendet, ihr Loader läuft bei jeder Navigation erneut. Deshalb können /blog/a und /blog/b nicht verwechselt werden, und deshalb lässt sich ein fehlgeschlagener Loader einfach erneut versuchen.

SituationVerhalten
während er läuftrouter.pending ist true, und die Seite, auf der du warst, bleibt stehen
er wirft oder rejectedlandet auf router.error. go() rejected weiterhin nie, und die Navigation kann wiederholt werden.
während start()die Rejection kommt aus start(). Es gibt noch keine Route, auf der sie landen könnte.
ein gepushter Layerführt seinen eigenen Loader aus, mit eigenen Daten
der Idle-Preloaderwärmt nur Module vor. Er hat keinen Kontext zum Übergeben und würde Daten für eine Route holen, die du vielleicht nie öffnest.
erneute Navigation zur selben URLder Loader läuft erneut, und die Zeile wird neu gebaut. Eine Route ohne Loader behält ihre Zeile.

Zum Revalidieren navigierst du dorthin, wo du schon bist, ohne einen History-Eintrag hinzuzufügen:

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

Daten zusammen mit dem Chunk starten

Ein vom Seitenmodul exportiertes load kann erst laufen, wenn dieses Modul angekommen ist, eine kalte Navigation holt also erst den Chunk, dann die Daten. Um beides gleichzeitig zu starten, deklariere den Loader in der Tabelle mit 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),
})

Es ersetzt das eigene load des Moduls und läuft wie dieses bei jedem Besuch. Eine Rejection, während der Chunk noch lädt, landet wie jede andere auf router.error.

Es gibt absichtlich keine Serialisierung von Serverdaten ins HTML. Das load des Clients läuft bei der Hydration erneut. Wenn das wichtig ist, lass den Server route.data ausgeben, zum Beispiel als window.__DATA__, und den Loader es zuerst nehmen. route.data sind nur die Daten der passenden Seite. Das Ergebnis des load() eines Parents ist nicht enthalten.

Code-Splitting und Preloading

Der Wert einer Route ist immer eine Funktion, die die Seitenkomponente zurückgibt, die Komponente wird also lazy erreicht:

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

Nur die modulförmigen Varianten können einen load-Export tragen. Eine benannte Komponente mit .then(m => m.BPage) auszuwählen, gibt dem Router nur die Funktion.

Jedes Modul wird einmal importiert und gecacht. Parallele Navigationen zur selben Route teilen sich ein import(). Sobald der User die Seite idleTimeout lang in Ruhe gelassen hat (standardmäßig eine Sekunde; keine Taps, Tasten, Mausrad oder Scrollen), werden die restlichen Routen im Hintergrund geladen, eine pro Idle-Slice (requestIdleCallback). Jede Eingabe, oder eine Navigation, auf die der User wartet, pausiert das, bis die Seite wieder idle ist.

Eine vorgeladene oder eager Route kippt router.pending nie und landet vor dem nächsten Paint: kein Spinner-Frame, kein Layout-Shift. router.pending ist nur true für ein Modul oder einen Loader, auf den gewartet werden muss, und die ausgehende Seite bleibt währenddessen auf dem Bildschirm. Ein Chunk, der nicht geladen werden kann, erscheint als router.error, nie als unbehandelte Rejection, und lässt sich einfach erneut versuchen.

Router-API

#
fnÖffentliche API
// 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 bildet ein Pattern auf einen Loader oder auf eine verschachtelte Tabelle ab. Optionen:

OptionDefault
base'/'URL-Präfix, unter dem die App ausgeliefert wird, etwa '/docs'. Es passt nur an einer Segmentgrenze: /docs/intro liegt innerhalb von '/docs', /docsearch nicht. URLs außerhalb passen auf nichts, start() rejected also (deine serverseitige 404), und Links dorthin bleiben dem Browser überlassen.
preloadtrueDie Module der restlichen Routen im Hintergrund laden, sobald die Seite idle ist. false schaltet es ab.
idleTimeout1000Millisekunden ohne Taps, Tasten, Mausrad oder Scrollen, bevor das Preloading startet oder nach User-Aktivität wieder aufgenommen wird.
onNavigate(ctx, kind) => void, aufgerufen nach jeder aufgelösten Navigation im Browser, die erste eingeschlossen, sobald das DOM nachgezogen hat. kind ist 'start', 'push', 'replace', 'pop' (Zurück, Vorwärts, close()) oder 'layer'. Der Hook für document.title, Meta-Tags, Fokus, Scrollen und Analytics. Wird während SSR nie aufgerufen.

Gibt den Router zurück: start(), match(), outlet(), matches() und jedes Route-Member außer run(), durchgereicht zur aktiven Route. Das ist im Browser die, die das letzte start() zurückgegeben hat, oder auf dem Server die, deren run() gerade ausgeführt wird. Ein Member ohne beides zu lesen, wirft. Die Routentabelle, der kompilierte Matcher und der Modul-Cache liegen auf dem Router und werden auf einem Server also über Requests hinweg geteilt.

router.start(url?)

Löst url (Default: location.href im Browser) zu ihren Seitenmodulen auf und gibt ein Promise<Route> zurück. Rejected, wenn nichts passt und die Tabelle keine '*'-Route hat. Auf einem Server ist das deine 404. Im Browser macht es die Route außerdem zur aktiven des Routers, hängt die popstate- und delegierten click-Listener an und plant das Preloading. Ein zweites start() zieht die vorherige Route für dich zurück. Auf dem Server berührt es nichts Geteiltes, rendere also innerhalb von route.run().

router.match(url?)

Löst eine URL gegen die Tabelle auf, ohne etwas zu laden: kein Import, keine History, keine Listener, keine Route. Gibt einen RouteContext zurück, oder null, wenn die URL außerhalb von base liegt, auf kein Pattern passt oder, im Browser, auf einem anderen Origin liegt. Nur absolute URLs, da die ./-Auflösung eine aktive Route braucht.

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()

Die Seiten der aktiven Route als list(). Platziere es einmal, dort, wo die Seiten hin sollen. Ein gepushter Layer ist eine spätere Zeile davon. Es ist leer, bevor start() aufgelöst ist, und nach stop(). Auf dem Server rendert es die Route, deren run() gerade ausgeführt wird.

router.matches(table)

Rendert die View einer zweiten Tabelle, auf die der Pfad der aktiven Route passt, als list(), die du beliebig platzierst. Siehe matches().

Route

Jedes Member außer run() ist auch auf dem Router, wo eine App es liest.

Member
path, pattern, params, search, hash, urlder aktive Match. search ist ein URLSearchParams. search, hash und url sind live.
pendingeine Navigation wartet auf ein Modul oder einen Loader
errorder letzte fehlgeschlagene Ladevorgang, von der nächsten Navigation gelöscht
datawas das load() der passenden Seite zurückgegeben hat, damit ein Server es serialisieren kann. Das eines Parents ist nicht enthalten.
depthwie viele Layer auf dem Stack liegen (1 für eine gewöhnliche Seite)
go(href, { replace })navigieren und bei Bedarf das Modul laden. Rejected nie: Ein Modul, das fehlschlägt, durch Rejection oder Throw, landet auf error.
push(href)eine Route als neuen Layer obenauf öffnen; wird mit dem aufgelöst, womit er schließt. Akzeptiert ./. Rejected bei fehlendem Match oder fehlgeschlagenem Modul.
href(path)einen Pfad mit base präfixen, für a({ href }). Löst ./ und ../ gegen die aktive Route auf.
stop()jeden Listener lösen, offene Layer verwerfen, das Outlet leeren. Idempotent. Eine gestoppte Route antwortet weiterhin mit ihrem letzten Match, und go() tut nichts.
run(fn)fn mit dieser Route als aktiver Route des Routers aufrufen und ihr Ergebnis zurückgeben: route.run(() => renderToString(App)) auf dem Server. Nur synchron. Die Route ist nicht mehr aktiv, sobald fn zurückkehrt.

RouteContext

Das erste Argument einer Seite: path, pattern, params, search, hash, url. Es ist der Kontext, mit dem die Seite gebaut wurde. Lies router.* für Live-Werte. Sie unterscheiden sich nur, wenn sich Query oder Hash ohne Neubau geändert haben.

params ist standardmäßig { [name]: string }. Eine Seite kann es mit Params aus ihrem eigenen Pattern typisieren, das '/blog/:slug' zu { slug: string } und '/files/*rest' zu { rest: string } macht:

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

Das zweite Argument einer Seite. Destrukturiere, was die Seite braucht.

Prop
layerwo die Seite im Stack sitzt. Siehe Layer unten.
datawas das load() der Route zurückgegeben hat, oder undefined ohne eines. Typisiert über PageProps<TData>.
outletwo eine Parent-Seite ihre Kind-Route rendert. Siehe Verschachtelte Routen.
beforeLeaveregistriert einen Guard, der gefragt wird, bevor diese Seite verworfen wird. Siehe Leave-Guards.

Layer

Wo eine Seite im Stack sitzt, und ihr Weg hinaus.

Member
depth0 für die Basisseite, 1 und höher für gepushte Layer
push(href)einen weiteren Layer aus einer Seite heraus öffnen, wie Route.push()
close(result?)diesen Layer und alles darüber schließen und das push() auflösen, das ihn geöffnet hat. Ohne Argument heißt verworfen. Ein No-op bei Tiefe 0.

DataLoader und withLoad()

DataLoader ist (ctx: RouteContext) => TData | Promise<TData>, der optionale load-Export eines Seitenmoduls. withLoad(loader, load) erzeugt eine Route, deren load neben dem Import ihres Chunks startet statt danach. Siehe Routen-Loader.

Typen

Jeder Typ ist ein benannter Export von nuclo-router, eine Seite, ein Loader oder ein Guard lässt sich also typisieren, ohne von einem Pfad zu importieren.

TypWas es ist
Params<'/blog/:slug'>das params-Objekt für ein Pattern. RouteParams ist die untypisierte Form, und Match ist das Ergebnis des Matchers.
PageComponenteine Seite: (ctx, { layer, data, outlet, beforeLeave }) => …
RouteModuleein Seitenmodul: die Komponente selbst, oder { default, load? }, wie import() es liefert
RouteLoader() => RouteModule | Promise<RouteModule>, optional mit einem load aus withLoad()
RouteTabledie Tabelle, die createRouter() nimmt: Pattern auf Loader oder auf eine verschachtelte Tabelle
RouterOptions{ base?, preload?, idleTimeout?, onNavigate? }
NavigateOptions{ replace? }, das zweite Argument von go()
NavigationKind'start' | 'push' | 'replace' | 'pop' | 'layer', das zweite Argument von onNavigate
Outletder Typ von outlet() aus PageProps
LeaveGuard(to) => boolean | void | Promise<…>, was beforeLeave() nimmt
MatchViewwas eine matches()-View ist: params rein, Inhalt oder import() einer Seite raus

Layer & Leave-Guards

#

push() öffnet eine Route über der aktuellen, statt sie zu ersetzen, und wird mit dem aufgelöst, womit dieser Layer schließt. Weil die Seiten eine einzige list() über den Stack sind, ist das Öffnen eines Layers ein Anhängen. Die Seite darunter wird nicht neu gerendert, ihr DOM, ihr Fokus und ihr halb ausgefülltes Formular sind also alle noch da, wenn der Layer schließt.

Damit funktioniert der Ablauf "Ich brauche einen Datensatz, der noch nicht existiert", ohne das Formular wegzuwerfen, das der User gerade ausfüllt:

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

Layer lassen sich verschachteln. Ein Dialog, der seinen eigenen Dialog braucht, ruft layer.push() auf und awaitet es, ohne Referenz auf die Route.

  • Die URL ist der oberste Layer. push() fügt einen History-Eintrag hinzu, ein Layer ist also verlinkbar, und Zurück schließt ihn und löst sein push() als verworfen auf, genau wie Abbrechen. Zurück und close() sind ein Codepfad: close() merkt sich sein Ergebnis und geht in der History zurück, und die Tiefe in history.state ist die Autorität. Browser behalten etwa 50 Einträge pro Tab, nach einem langen Weg durch Layer kann der Eintrag darunter also weg sein: Dann schließt close() die Layer an Ort und Stelle und überschreibt den aktuellen Eintrag mit der Seite, die sichtbar bleibt.
  • Eine gewöhnliche Navigation leert den Stack. Ein Link-Klick oder go() ist kein Layer. Es ersetzt alles und verwirft jeden offenen Layer, kein Aufrufer bleibt also wartend zurück.
  • Ein kalter Aufruf ist eine eigenständige Seite. Die URL benennt den obersten Layer, aber nicht den Stack darunter, das direkte Öffnen der URL eines Layers rendert ihn also bei Tiefe 1 mit layer.depth === 0 und einem wirkungslosen close(). Eine gepushte Route muss für sich allein Sinn ergeben. Verzweige auf layer.depth, um einen Link hinaus anzubieten statt eines toten Abbrechen-Buttons.
  • Das Promise kann einen Reload nicht überleben, weil es in einer Closure lebt. Behandle das Ergebnis als Abkürzung durch einen Ablauf, der auch auf dem langen Weg funktioniert.
  • Nichts bleibt wartend zurück. stop() verwirft jeden offenen Layer, und ein von einer Navigation aufgegebener Layer wird mit undefined aufgelöst.

Leave-Guards

Eine Seite, die etwas Bewahrenswertes hält, etwa ein halb ausgefülltes Formular, registriert einen Guard. Er wird gefragt, bevor die Seite verworfen wird, und die Rückgabe von false (oder einem Promise darauf) behält die Seite:

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) }), …)
}
  • Nur wenn die Seite geht. Eine Kind-Navigation unter einem Layout, oder eine Query- oder Hash-Änderung, die die Seite behält, fragt nicht. Ein Guard lebt und stirbt mit seiner Seite, es gibt also nichts abzumelden.
  • Jeder Weg hinaus. Ein Link, go(), Zurück und Vorwärts, das close() eines Layers, und go() zu einer URL, die dem Router nicht gehört (Verlassen der App, wobei to dann null ist). Ein abgelehntes Zurück oder Vorwärts wird rückgängig gemacht, die Adressleiste bleibt also auf der Seite. Das Schließen des Tabs oder ein Reload ist Sache von beforeunload.
  • to ist der RouteContext, zu dem die Navigation führt. Guards laufen von innen nach außen und stoppen bei der ersten Ablehnung. Einer, der wirft, behält die Seite. close(result) geht ebenfalls durch den Guard, setz dein Flag also zurück, bevor du mit einem Ergebnis schließt.
  • Der Router kann nur History-Einträge zurückgehen, die er selbst geschrieben hat. Ein Zurück oder Vorwärts auf einen, den er nicht geschrieben hat, etwa einen nativen #fragment-Link oder das pushState eines anderen Skripts, wird nicht gefragt.

matches() & Sub-Router

#

router.outlet() rendert die Seite. router.matches(table) rendert etwas anderes, das davon abhängt, wo du bist, etwa eine Sidebar, Breadcrumbs oder eine Toolbar, aus seiner eigenen kleinen Tabelle, ohne eine Route zu laden oder eine Seite hinzuzufügen:

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()),
)
  • Dasselbe Matching wie die Routentabelle. Patterns, Priorität, Raw-Path-Matching und das Entfernen von base sind die des Routers, eine View und eine Seite sind sich über die URL also immer einig. Es liest den Pfad des obersten Layers, wie router.path.
  • Eine View ist (params, ctx) => content, wobei params aus ihrem Pattern typisiert ist und ctx der RouteContext mit pattern und params der View. Sie kann stattdessen das import() eines Seitenmoduls zurückgeben, das mit ctx aufgerufen wird, sobald es ankommt.
  • Passt nichts, wird nichts gerendert. Füge eine '*'-View als Fallback hinzu. Es ist leer vor start() und nach stop(), wie das Outlet.
  • Es rendert nur neu, wenn sich die Route ändert, eine View behält ihr DOM also über alles hinweg, was die Seite behält.
  • Eine lazy View rendert nichts, bis ihr Modul ankommt. Ein fehlgeschlagenes import() wird mit console.error geloggt, setzt router.error nicht und wird bei der nächsten Navigation erneut versucht. Ein einfaches update() versucht es nicht erneut.
  • Auf dem Server liefert eine lazy View nichts, da ihr Modul erst auf dem Client ankommt. Nimm eine synchrone View für alles, was das erste HTML enthalten muss.

Sub-Router

Routen verschachteln sich, ein Sub-Router ist also meist einfach eine verschachtelte Tabelle. Ein Router: Die eigene '/'-Seite des Abschnitts ist sein Rahmen, und seine Kinder rendern im outlet() dieser Seite, der Rahmen überlebt also die Navigation innerhalb des Abschnitts:

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

Oder gib dem Feature einen eigenen Router, erzeugt, aber nie gestartet. Der Parent besitzt einen einzigen Catch-all-Eintrag, und das Kind löst den Rest gegen seine eigenen Patterns, Params und seinen Fallback auf:

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

Ihn nie zu starten, ist der Punkt. start() ist das, was die Listener anhängt, und zwei gestartete Router geraten in Konflikt. Der Preis ist, dass das Kind die Auflösung besitzt, Routen-Loader, die ein gestarteter Router ausführt, musst du also selbst aufrufen. Diese Form baut außerdem die Shell neu, wenn sich der Rest ändert, weil der Router eine Route sieht und die Shell den Rest selbst auflöst. Eine verschachtelte Tabelle ist das, was dir einen Parent verschafft, den der Router gemountet hält, bevorzuge sie also, sofern das Feature nicht wirklich einen eigenen Router, eine eigene base und einen eigenen Fallback braucht.

Mehrere Router & kombiniertes Laden

#

Eine App kann so viele Router haben, wie sie Bereiche hat. Die Regel, die das einfach hält: Starte genau einen pro Dokument, und lass jeden anderen Router ungestartet, der URLs mit match() auflöst. Nur der gestartete Router besitzt URL, History und den Click-Listener. Die übrigen sind Tabellen, die du befragst — und jeder Bereich kann nach eigener Uhr laden, mit eigenem Spinner.

Kombiniertes Laden: eine Kette von Loadern

Jede Seite auf einer passenden Kette darf load() exportieren. Bei einer kalten Navigation zu einer Kind-Route laufen der Loader des Layouts und der Loader der Seite parallel, und ein einziges router.pending deckt die ganze Kette ab: Die vorherige Seite bleibt auf dem Bildschirm, bis das Paar aufgelöst ist, ein halb geladener Baum wird also nie gezeichnet. Jede Komponente erhält ihre eigenen 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))))
}

Beide Loader starten zusammen, nicht nacheinander. router.pending ist true, solange auf einen von beiden gewartet wird, und sonst false — eine vorgeladene oder eager Route kippt es nie. Der Wechsel zwischen Sections behält das Layout, es läuft also nur der Loader der Section erneut; die Daten des Layouts, sein DOM und ein halb ausgefülltes Input überleben alle. Die erneute Navigation zur selben URL führt den Loader des Blatts erneut aus und baut dessen Zeile neu, genau wie bei einer einzelnen Seite.

Mehrfaches Laden: ein zweiter Router mit eigenem Spinner

Ein unabhängiger Bereich — eine Sidebar, ein Tray, eine Live-Region — bekommt einen eigenen Router, erzeugt, aber nie gestartet, und sein eigenes Lade-Flag. router.pending gehört dem gestarteten Router und sagt nichts über diesen Bereich aus, die beiden können also nebeneinander laden, und jeder kann zuerst fertig werden:

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
  },
})

Der zweite Router wird gefragt, nie angewiesen: match() liest die URL und gibt einen Kontext zurück, und nichts über die Sidebar gelangt je in die Haupt-Routentabelle. onNavigate läuft nach jeder aufgelösten Navigation, die erste eingeschlossen, und ist damit die eine Stelle, die einen unabhängigen Bereich mit der URL synchron hält.

  • pending gehört dem gestarteten Router. Ein nie gestarteter Router hat keine Route, kein pending und kein error. Der Bereich verfolgt seine eigenen Flags, und ein zweiter Spinner kann neben dem ersten sitzen. In der Demo lädt die Sidebar 800ms, während die Loader der Route 900ms laufen, die Seite kann also vor der Sidebar fertig sein oder umgekehrt.
  • match() hängt nichts an. Beliebig viele ungestartete Router können sich das Dokument teilen; jeder hat seine eigene Tabelle, base und seinen eigenen Modul-Cache, und keiner berührt die History. Sie kosten eine Tabelle und einen Matcher.
  • Seine Loader laufen nie. Der gestartete Router führt das load() der Seiten aus, auf die er passt. Ein nie gestarteter Router löst nur auf, wenn seine Views also Daten brauchen, hol sie selbst — das refreshSidebar() der Demo. Wenn du Loader willst, behält eine verschachtelte Tabelle oder eine Region, die eine Seite füllt, diese Aufgabe beim gestarteten Router.
  • Schütze dich vor veralteten Antworten. Ein langsamer Sidebar-Fetch darf nicht auf der Section landen, von der du weg navigiert bist. Erhöhe pro Refresh einen Generationszähler und verwirf Antworten, die nicht mehr passen, so wie der Router selbst überholte Navigationen zurückzieht.
  • Eine lazy matches()-View hat ebenfalls kein pending. Sie rendert nichts, bis ihr Modul ankommt. Drei Ladesysteme, drei unabhängige Uhren: das pending der Route, deine eigenen Flags und eine lazy View, die einfach noch nicht da ist.

Berechnete Werte

#

Nuclo hat keine Computed-API. Nimm eine einfache Funktion und ruf sie aus dynamischen Werten auf:

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()

Async & Laden

#

Nimm einfaches async/await und ruf jedes Mal update() auf, wenn sich der Zustand ändert. Bei einem Fetch heißt das: einmal vor dem await und einmal danach:

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()
}

Bewährte Vorgehensweisen

#
  • Änderungen bündeln. Ändere den gesamten nötigen Zustand, dann ruf update() einmal auf.
  • Dynamische Werte schnell und pur halten. Sie laufen alle bei jedem update().
  • Nimm scope() für kleine, häufige Updates, etwa Tippen oder Timer.
  • Ins Dokument rendern. Knoten, die beim update() nicht im Dokument sind, hören auf, sich zu aktualisieren.
  • Nimm when() und list() für Kinder, die sich ändern, nicht eine Funktion, die Knoten zurückgibt.
  • Nimm className, nicht class, und () => cx(...) oder { className: () => … } für dynamische Klassen.
  • Styles einmal definieren, auf Modulebene.
  • Serverseitiges Rendering: Erst rendern, dann getCssText() in <style id="nuclo-styles"> packen. Request-Daten als Argumente übergeben, und HTML-Kommentare in der Ausgabe behalten.

API-Index

#

Jede öffentliche Funktion, und woher sie kommt.

nuclo (global und benannter Export)

nuclo (nur benannter Export)

nuclo/ssr

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

nuclo/polyfill

  • Ein Side-Effect-Import, plus document, Event, CustomEvent, Node, Element, HTMLElement und die Nuclo*-Klassen.

nuclo-router (separates Paket)

Nur global

  • 175 Tag-Builder: div, span, …, var_, sowie svgSvg, pathSvg, ….
  • Typen: NodeModFn, NodeModLike, ExpandedElement und mehr. Styling-Typen wie StyleResult lassen sich auch aus 'nuclo' importieren.

Spezielle Keys

Häufig gestellte Fragen

#

Aktualisiert eine Zustandsänderung die UI?

Nein. Das DOM bleibt, wie es ist, bis du update() aufrufst.

Verwendet Nuclo Signals, Proxies oder ein virtuelles DOM?

Nein. Zustand bleibt einfaches JavaScript. Wenn ein Baum gerendert wird, erzeugt Nuclo echte Elemente und Textknoten. update() führt jeden registrierten dynamischen Wert aus, oder die innerhalb der angegebenen Scopes, und patcht genau diese Knoten, wo sich ein Wert geändert hat. Es gibt keinen Abhängigkeitsgraphen und keinen virtuellen Baum.

Warum zeigt meine Seite "false"?

Booleans werden als Text gerendert. div(ok && span('Done')) zeigt "false". Nimm when(() => ok, span('Done')) für Kinder und ok ? 'Done' : '' für Text.

Warum ändert sich meine Listenzeile nicht, wenn ich das Element bearbeite?

Zeilen für Elemente, die noch in der Liste sind, bleiben erhalten und werden nicht erneut gerendert. Nimm dynamische Werte wie () => todo.text für Felder, die sich ändern. Siehe list().

Warum hört ein Teil meiner UI auf, sich zu aktualisieren?

Meist waren seine Knoten nicht im Dokument, als update() lief. Nuclo hört auf, diese Knoten zu verfolgen, auch nachdem du sie angehängt hast, rendere also in einen Container, der im Dokument ist. Zwei weitere Ursachen: Ein when(), dessen Bedingung wirft, hört auf, sich zu aktualisieren, und eine Textfunktion, die bei ihrem ersten Durchlauf wirft, bleibt leer. Siehe update().

Wann sollte ich forceUpdate() verwenden?

Nur für seltene Änderungen an statischen Werten, etwa einen Sprachwechsel. Für alles andere nimm update(). Siehe forceUpdate().

Wie bringe ich eine Seite in ein Layout, ohne sie als Prop durchzureichen?

Markiere die Stelle mit region({ id: 'main' }) und lass die Seite into('main', …) zurückgeben. Siehe region() & into().

Wie unmounte ich eine App?

Wickle sie in when() und schalte die Bedingung ab. Das DOM selbst zu entfernen, führt onDestroy nicht aus. Siehe Lebenszyklus.

Enthält Nuclo einen Router?

Nicht im Kernpaket. Routing wird separat als nuclo-router ausgeliefert, mit serverseitigem Rendering, Hydration, code-gesplitteten Routen und Idle-Preloading. Installiere es mit npm install nuclo nuclo-router. Siehe nuclo-router.

Hat Nuclo Effects?

Nein. onMount und onDestroy laufen einmal pro Element, gebunden an Einfügen und Entfernen. Nichts läuft erneut, weil sich irgendein Zustand geändert hat.