Nuclo-Dokumentation
Eine praktische Referenz: Nuclo installieren, mit expliziten Updates bauen, mit typisiertem CSS stylen und HTML serverseitig rendern.
npm create nuclo@latestÜ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.
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
- Baue die UI mit Tag-Buildern.
- Übergib eine Funktion für jeden Wert, der vom State abhängt.
- Ändere den State mit normalem JavaScript.
- 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:
Es fragt nach einem Projektnamen. Um die Abfrage zu überspringen, übergib den Namen und die Flags. Bei npm setzt du -- vor die Flags:
$ npm create nuclo@latest my-app -- --template basic --yes
Dann installieren und den Dev-Server starten:
$ cd my-app $ npm install $ npm run dev
Optionen
[project-name]: der Zielordner. Der Standard istnuclo-app.-t, --template <name>: das zu kopierende Template. Derzeit existiert nurbasic.-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:
$ 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 einemSvg-Suffix (svgSvg,pathSvg, …). Siehe Tag-Builder. - 16 Helfer:
update,scope,when,list,region,into,on,render,hydrate,forceUpdate,css,cx,createCss,variants,keyframesundglobalStyle.
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:
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.
import { render, update, getCssText } from 'nuclo'
Entry Points
nuclo: registriert die Globals und exportiert die 16 Helfer sowiegetCssTextundresetStyles.nuclo/ssr:renderToString,renderManyToString,renderToStringWithContainerundgetCssText. 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".node16undnodenextwerden noch nicht unterstützt.- Die
DOM-Lib. TypeScript bindet sie standardmäßig ein, wenn dulibnicht 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:
{ "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>: wasrender()undhydrate()zurückgeben.ElementTagName: ein beliebiger HTML-Tag-Name.ExpandedElementAttributes<Tag>: das Attribut-Objekt, das ein Tag-Builder akzeptiert, einschließlichon*-Handlern,onMountundonDestroy. Verwende ihn für Komponenten, die Attribute durchreichen.MountCallbackundDestroyCallback:onMount- undonDestroy-Handler.WhenBuilder,ListModifier: waswhen()undlist()zurückgeben.RegionOptions,RegionType: die Optionen, dieregion()entgegennimmt.StyleResult,Style,ThemeConfig,CssInstanceund die übrigen Styling-Typen. Diese kannst du auch importieren:import type { StyleResult } from 'nuclo'.
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.
Tag-Builder
#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.
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)
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 beiupdate()ändert. Siehe Dynamische Werte.null,undefined: wird übersprungen.true,false: wird als Text "true" oder "false" gerendert. Verwendewhen()für Bedingungen.- Ein einfaches Objekt: Attribute. Siehe Attribute.
- Ein
css()-,cx()- odervariants()-Ergebnis: fügt seine Klassen hinzu. on(...): ein Event-Listener oder Lifecycle-Hook.- Ein anderer Builder,
when()oderlist(): 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". Schreibediv(when(() => isAdmin, span('Admin'))).- Arrays sind keine Kinder. Spreade sie:
ul(...names.map((n) => li(n))). Für Listen, die sich ändern, verwendelist(). - Eine lokale Variable mit einem Tag-Namen verdeckt den Builder. Innerhalb von
function Row(label: string)istlabel(...)nicht aufrufbar. Benenne lokale Variablen wielabel,title,data,p,aundium. - Browser stellen jedes Element mit einer
idals globale Variable bereit. Wenn die Seite<main id="main">enthält, bevor Nuclo lädt, lässt Nuclo dieses Global unangetastet, sodassmain(...)einenTypeErrorwirft. 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.
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:
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.
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
Komponenten
#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.
function Counter(label: string) { let count = 0 // one per call return button(() => `${label}: ${count}`, { onClick: () => { count++; update() }, }) } render(() => div(Counter('A'), Counter('B')))
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:
function Card(heading: string, ...children: NodeModLike<'div'>[]) { return div({ className: 'card' }, h2(heading), ...children) } render(() => Card('Hi', p('one'), p('two')))
Ü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.
let busy = false const save = button( { type: 'submit', disabled: () => busy, 'aria-busy': () => String(busy), 'data-id': 42 }, 'Save', )
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 essetAttribute(key, String(value))auf, zum Beispiel für"aria-*"und"data-*". - Bei SVG-Elementen ruft Nuclo immer
setAttributeauf. nullundundefinedwerden übersprungen. Ein dynamischer Wert, dernulloderundefinedzurückgibt, behält den alten Wert. Um einen Wert zu löschen, gib""zurück, oderfalsefür eine boolesche Property.- Booleans funktionieren für boolesche DOM-Properties wie
disabledan einem Button,checked,hiddenundrequired. Überall sonst werdentrueundfalseals die Strings "true" und "false" geschrieben. - Verwende
"data-*"-Schlüssel.datasetwird nicht unterstützt. innerHTMLwird 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.
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.
let progress = 30 const bar = div({ style: () => ({ width: `${progress}%`, backgroundColor: 'teal' }) })
- Nuclo ergänzt keine Einheiten. Schreibe
"12px", nicht12. Einheitenlose Properties wieopacityundzIndexakzeptieren Zahlen. "",nullundundefinedlö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 stattdessencss(). Es nimmt Custom Properties überrawentgegen.
Ereignisse
#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.
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.currentTargetundthissind das Element. - Nuclo setzt die native Property, sodass
onClickzuel.onclickwird. Es gibt einen Handler pro Event: ein späteresonClickersetzt ein früheres. onDoubleClickundonDblClickbedeuten beidedblclick. Kleingeschriebene Schlüssel wieonclicksind Typfehler.- Ein Name, der kein bekanntes Event ist, hört auf seine kleingeschriebene Form:
onMyEventhört auf"myevent". - Fehler, die der Handler wirft, werden nicht abgefangen.
Type something, then press Escape.
on()
on() gibt einen Modifier zurück, der addEventListener auf dem Element aufruft.
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. optionsgeht direkt anaddEventListener: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:
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.
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.
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()))
onMount 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
onMountläuft am Ende desrender()-,hydrate()-,update()- oderforceUpdate()-Aufrufs, der das Element eingefügt hat. Es läuft nie, während der Baum gebaut wird.- Gibt
onMounteine Funktion zurück, läuft diese beim Destroy, nach denonDestroy-Callbacks des Elements. onDestroyläuft, wenn Nuclo das Element entfernt: einelist()-Zeile wird entfernt, einwhen()-Zweig wechselt, eininto()verlässt seine Region, oderforceUpdate()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():
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.
onDestroylä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.errorprotokolliert. 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().
Nuclo aktualisiert die Seite nie von selbst. Ändere deinen State, dann rufe update() auf.
let count = 0 const Counter = () => button(() => `Clicked ${count} times`, { onClick: () => { count++; update() }, }) render(Counter)
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:
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.
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().
let name = 'Alice' let busy = false const Profile = () => div( h1('Welcome'), // static p(() => `Hello, ${name}`), // dynamic text button({ disabled: () => busy }, 'Save'), )
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' : ''). nullundundefinedwerden als leerer Text gerendert.- Ein
css()- odercx()-Ergebnis beim ersten Durchlauf macht die Funktion zu einer dynamischen Klasse, nicht zu Text. Siehecx(). - 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
nulloderundefinedschreibt nichts, der alte Wert bleibt also.classNameist 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.
update()
#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:
list(): liest die Items erneut und fügt Zeilen hinzu, verschiebt oder entfernt sie.when(): prüft die Bedingungen erneut. Ein Zweig wird nur neu gebaut, wenn ein anderer Zweig aktiv wird.- Dynamische Attribute, einschließlich
styleundclassName. - Dynamischer Text.
onMountfü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ürlist()undwhen(). 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 dieseswhen()hört auf, sich zu aktualisieren. Wirft eineitems- oderrenderItem-Funktion vonlist(), entkommt der Fehler ausupdate(), und die restlichen Schritte laufen nicht. - Server.
update()tut auf dem Server nichts.
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.
scope()
#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.
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"
- Ein Element kann mehrere ids haben:
scope('cart', 'sidebar'). update('a', 'b')aktualisiert alles innerhalb vonaoderb. 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.
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.
let status: 'loading' | 'error' | 'ready' = 'loading' const Status = () => div( when(() => status === 'loading', p('Loading…')) .when(() => status === 'error', p('Something went wrong')) .else(p('Ready')), )
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 > 0oder() => 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 seineonMount-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.
list()
#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.
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
renderItemnicht 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:
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.
Was renderItem zurückgibt
- Gib ein Element zurück, etwa
li(...),tr(...)oder einen SVG-Builder. Gibnulloderundefinedzurück, um ein Item zu überspringen. Strings, Textknoten und Fragmente rendern nichts. - Gib
when()oderlist()nicht direkt zurück. Setze sie in das Zeilenelement:li(todo.text, when(() => todo.done, ' ✓')). - Um Items auszublenden, filtere in
items(), nicht inrenderItem. - Halte
renderItemfrei 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() 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.
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 eininto()enthält, schließt, oder seinelist()-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 deninto()-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.
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.
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.
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.
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.
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.
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.
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.
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
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()), )
into().Öffnen ↗No related links.
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().
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á"
update() 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 undlist()-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 ihreonDestroy-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.
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()
#Baut deinen Baum und hängt sein Wurzelelement an parent an. parent ist standardmäßig document.body. Es gibt das Wurzelelement zurück.
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. Nurrender(App)erlaubtforceUpdate(), die Wurzel später neu zu bauen, bevorzuge es also. - Es hängt an. Es leert
parentnicht, ein zweifacher Aufruf fügt also zwei Kopien hinzu. - Jedes
onMountim Baum ist gelaufen, wennrender()zurückkehrt. - Rendere in ein Element, das im Dokument ist. Siehe
update(). - Die Wurzel muss ein Element sein, etwa
div(...), nichtwhen()oderlist(). indexist 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:
const Page = (props: { name: string }) => div(h1('Hi ', props.name)) render(() => Page({ name: 'Ana' }), root)
hydrate()
#Ü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.
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 duApp, nichtApp(), damitforceUpdate()die Wurzel später neu bauen kann.Appdarf keine Parameter haben. Mit einem optionalen Parameter hängthydrate()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 undwhen()-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
#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.
let count = 0 const heading = css({ color: 'red' }) export const App = () => main( h1(heading, () => `Count: ${count}`), button({ onClick: () => { count++; update() } }, '+1'), )
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>`
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,
onMountundonDestroylaufen nie.update(),scope()undforceUpdate()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 geteiltedocument.bodyan.
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.
renderToString(p('Hello')) // '<p><!-- text-0 -->Hello</p>'
- Text- und Attributwerte werden HTML-escaped. Text innerhalb von
script()undstyle()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 einewhen()-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.
renderToStringWithContainer(p('Hi'), 'section', { id: 'main', class: 'page' }) // '<section id="main" class="page"><p><!-- text-0 -->Hi</p></section>'
nuclo/polyfill
#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
windowgibt, setzt esglobalThis.document,Node,ElementundHTMLElement, aber nur die, die fehlen. Es erzeugt keinwindow. - In einem Browser oder unter jsdom tut es nichts.
EventundCustomEventsind die eigenen Konstruktoren der Laufzeit.Node,ElementundHTMLElementsind 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.
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.
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:
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
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ßer0und einheitenlose Eigenschaften:zIndex,opacity,fontWeight,lineHeight,flex,flexGrow,flexShrink,order,aspectRatio,gridColumnundgridRow. Inrawsind auchzoom,scale,column-count,orphans,widows,tab-sizeundanimation-iteration-counteinheitenlos. - Strings werden unverändert übernommen.
null,undefinedundfalsewerden übersprungen. lineHeight(undleading) ist einheitenlos,leading: 24bedeutet also das 24-Fache der Schriftgröße. Schreib"24px"für Pixel.
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
| Key | CSS |
|---|---|
p, pt, pr, pb, pl | padding und jede Seite |
px / py | padding links und rechts / oben und unten |
m, mt, mr, mb, ml, mx, my | dasselbe für margin |
w, h, minW, maxW, minH, maxH | width, height sowie deren min und max |
size | width und height |
bg | background |
text / font / weight | font-size / font-family / font-weight |
leading / tracking | line-height / letter-spacing |
align / items / justify | text-align / align-items / justify-content |
z / rounded / shadow / select | z-index / border-radius / box-shadow / user-select |
Diese Keys nehmen true:
| Key | CSS |
|---|---|
row | display: flex; flex-direction: row |
col | display: flex; flex-direction: column |
center | align-items: center; justify-content: center (setzt display nicht) |
truncate | overflow: 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.
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 mitandkombiniert. 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ührendesnot— 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:
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.
Kombiniert Styles. Setzen zwei Eingaben dieselbe Eigenschaft, gewinnt die spätere.
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. Beidiv(base, blue)werden beide Klassen hinzugefügt, und die zuletzt erzeugte Regel gewinnt. Pack Styles, die sich gegenseitig überschreiben, in einen einzigencx()-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 weiterhinpage-title.
Dynamische Klassen
Übergib eine Funktion, die immer cx(...) zurückgibt:
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.
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: {…}.
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-Key | Verwendet von |
|---|---|
colors | bg, backgroundColor, color, borderColor und jede Seite, outlineColor, caretColor, textDecorationColor |
fonts | font, fontFamily |
shadows | shadow, boxShadow |
radii | rounded, borderRadius |
screens | neue 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 globalecss()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 einecreateCss()-Instanz pro App, damit diese Reihenfolge die aus deinem Theme ist. themeauf dem Ergebnis ist das Theme, das du übergeben hast.
Baut eine Funktion, die für eine Menge von Optionen einen Style zurückgibt, etwa Intent und Größe eines Buttons.
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- odernull-Option verwendet den Default. Eine Gruppe ohne Default wird übersprungen. - Eine Gruppe mit nur
true- undfalse-Keys nimmt einen Boolean. - Ein Compound greift, wenn jede Gruppe, die er auflistet, passt, Defaults eingeschlossen.
- Jede Auswahl wird einmal gebaut und gecacht.
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.
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:
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()
#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.
import { resetStyles } from 'nuclo' beforeEach(() => resetStyles())
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.
$ 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
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
// 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'), )
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' : ''))) }
import 'nuclo' import { router, App } from './routes.ts' await router.start() hydrate(App, document.getElementById('app')!) // or render() without SSR
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.
| Pattern | Passt auf | params |
|---|---|---|
/, /docs/intro | genau 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:
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'), }
// 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 eincreateRouter()-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:
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:
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 nacktespreviewin einem href bleibt den gewöhnlichen Browserregeln überlassen. - Baue relative Links mit
router.href('./preview'), statthref="./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
| Knoten | Bei Navigation |
|---|---|
| ein Parent, dessen Pattern und Pfad unverändert sind | erhalten: derselbe Knoten, und sein load() läuft nicht erneut |
| die passende Seite | erhalten, 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 hat | neu gebaut, zusammen mit allem darunter |
| ein gepushter Layer | rendert 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.
export const App = () => div( aside( input({ placeholder: 'Filter' }), // keeps its text across navigations region({ id: 'sidebar', empty: p('Nothing to show.') }), ), main(router.outlet()), )
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.searchin 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 Defaulttype: 'latest'überdeckt sie die View der Seite, bis der Layer schließt, dann erscheint die View der Seite wieder. Mittype: '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(), ihreinto()-Views stehen also im HTML, undhydrate()ü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:
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. Siehematches().
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.
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:
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.
| Situation | Verhalten |
|---|---|
| während er läuft | router.pending ist true, und die Seite, auf der du warst, bleibt stehen |
| er wirft oder rejected | landet 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 Layer | führt seinen eigenen Loader aus, mit eigenen Daten |
| der Idle-Preloader | wä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 URL | der 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:
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():
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:
'/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
#createRouter(table, options?)
table bildet ein Pattern auf einen Loader oder auf eine verschachtelte Tabelle ab. Optionen:
| Option | Default | |
|---|---|---|
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. |
preload | true | Die Module der restlichen Routen im Hintergrund laden, sobald die Seite idle ist. false schaltet es ab. |
idleTimeout | 1000 | Millisekunden 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.
// 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, url | der aktive Match. search ist ein URLSearchParams. search, hash und url sind live. |
pending | eine Navigation wartet auf ein Modul oder einen Loader |
error | der letzte fehlgeschlagene Ladevorgang, von der nächsten Navigation gelöscht |
data | was das load() der passenden Seite zurückgegeben hat, damit ein Server es serialisieren kann. Das eines Parents ist nicht enthalten. |
depth | wie 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:
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 | |
|---|---|
layer | wo die Seite im Stack sitzt. Siehe Layer unten. |
data | was das load() der Route zurückgegeben hat, oder undefined ohne eines. Typisiert über PageProps<TData>. |
outlet | wo eine Parent-Seite ihre Kind-Route rendert. Siehe Verschachtelte Routen. |
beforeLeave | registriert 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 | |
|---|---|
depth | 0 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.
| Typ | Was es ist |
|---|---|
Params<'/blog/:slug'> | das params-Objekt für ein Pattern. RouteParams ist die untypisierte Form, und Match ist das Ergebnis des Matchers. |
PageComponent | eine Seite: (ctx, { layer, data, outlet, beforeLeave }) => … |
RouteModule | ein Seitenmodul: die Komponente selbst, oder { default, load? }, wie import() es liefert |
RouteLoader | () => RouteModule | Promise<RouteModule>, optional mit einem load aus withLoad() |
RouteTable | die 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 |
Outlet | der Typ von outlet() aus PageProps |
LeaveGuard | (to) => boolean | void | Promise<…>, was beforeLeave() nimmt |
MatchView | was eine matches()-View ist: params rein, Inhalt oder import() einer Seite raus |
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:
// the page underneath const created = await layer.push<Option>('/options/new') if (created) { options.push(created); selected = created.id; update() }
// 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 seinpush()als verworfen auf, genau wie Abbrechen. Zurück undclose()sind ein Codepfad:close()merkt sich sein Ergebnis und geht in der History zurück, und die Tiefe inhistory.stateist 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ßtclose()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 === 0und einem wirkungslosenclose(). Eine gepushte Route muss für sich allein Sinn ergeben. Verzweige auflayer.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 mitundefinedaufgelö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:
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, dasclose()eines Layers, undgo()zu einer URL, die dem Router nicht gehört (Verlassen der App, wobeitodannnullist). 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 vonbeforeunload. toist derRouteContext, 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 daspushStateeines 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:
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
basesind die des Routers, eine View und eine Seite sind sich über die URL also immer einig. Es liest den Pfad des obersten Layers, wierouter.path. - Eine View ist
(params, ctx) => content, wobeiparamsaus ihrem Pattern typisiert ist undctxderRouteContextmitpatternundparamsder View. Sie kann stattdessen dasimport()eines Seitenmoduls zurückgeben, das mitctxaufgerufen wird, sobald es ankommt. - Passt nichts, wird nichts gerendert. Füge eine
'*'-View als Fallback hinzu. Es ist leer vorstart()und nachstop(), 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 mitconsole.errorgeloggt, setztrouter.errornicht und wird bei der nächsten Navigation erneut versucht. Ein einfachesupdate()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:
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:
// parent table '/docs/*rest': () => import('./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.
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:
'/shop': { '/': () => import('./pages/Shop.ts'), // layout, has a load() './:section': () => import('./pages/Section.ts'), // page, has a load() },
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()), ) }
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:
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() }
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.
pendinggehört dem gestarteten Router. Ein nie gestarteter Router hat keine Route, keinpendingund keinerror. 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 — dasrefreshSidebar()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 keinpending. Sie rendert nichts, bis ihr Modul ankommt. Drei Ladesysteme, drei unabhängige Uhren: daspendingder Route, deine eigenen Flags und eine lazy View, die einfach noch nicht da ist.
Link-Handling
#Der delegierte Listener navigiert bei einem Linksklick auf ein <a href>, einschließlich eines SVG-<a> und Klicks auf alles, was darin verschachtelt ist, und hält sich bei allem anderen zurück: Klicks mit Modifier-Taste und Mittelklicks, ein target ungleich _self, download, rel="external", Cross-Origin-hrefs, reine Hash-Links auf der aktuellen Seite, Pfade außerhalb von base, Pfade, auf die keine Route passt (eine servergerenderte 404 bleibt also erreichbar), und jedes Event, auf dem ein App-Handler bereits preventDefault() aufgerufen hat. Nimm einen Link explizit mit data-nuclo-router="off" heraus.
Scrollen
Eine gepushte Navigation behält die aktuelle Scrollposition. Hat die neue URL einen #hash, der auf ein Element der Seite passt, wird dieses Element in den sichtbaren Bereich gescrollt. popstate überlässt das Scrollen dem Browser. Um neue Seiten oben zu landen, mach das in onNavigate, das sagt, wie die Navigation zustande kam:
let shown = '' createRouter(routes, { onNavigate: (ctx, kind) => { // Not on Back/Forward (the browser restores those), nor for a query change on the same page. if (kind === 'push' && ctx.path !== shown && !ctx.hash) window.scrollTo(0, 0) shown = ctx.path }, })
Hinweise
- Nichts wird festgehalten. Die Route hält die Seitenfunktion und ihren Kontext, nie DOM. Wegnavigieren gibt die vorherige Seite frei.
stop()gibt die Listener und jeden laufenden Ladevorgang frei und löschtpending, eine zurückgezogene Route lässt also nie einen Spinner hängen. Ein Modul, das nachstop()aufgelöst wird, wird stillschweigend verworfen. - Nebenläufigkeitssicher auf dem Server.
start()gibt Zustand pro Request zurück, undrun()begrenzt ihn auf einen synchronen Render, verschränkte Requests können also nicht die Route des anderen sehen. Nur der Modul-Cache wird geteilt. Der Scope endet, wenn die Funktion vonrun()zurückkehrt.renderToString()ist synchron, das reicht also. Ein asynchroner oder streamender Render müsste die Route überawaits hinweg mitführen, wasrun()nicht tut. - Scroll-Wiederherstellung bei
popstateist Sache des Browsers (scrollRestoration), nicht des Routers. - Starte einen Router pro Dokument. Jedes
start()fügt seinen eigenen delegiertenclick-Listener hinzu, und dasstart()eines zweiten Routers zieht den ersten nicht zurück. Nur ein zweitesstart()auf demselben Router tut das. Um eine URL gegen eine andere Tabelle aufzulösen, erzeuge einen Router und rufe nurmatch()auf. Es hängt nichts an. Ein unabhängiger Bereich kann so sein eigenes Laden neben dempendingder Route betreiben. Siehe Mehrere Router & kombiniertes Laden. - Absichtlich nicht enthalten: Hash- und Memory-History, da die Route im Pathname lebt und ein Server jede Route ausliefern muss; zwei unabhängig geroutete Bereiche aus einer URL, da eine Seite mit
into()zwar mehrere Regionen füllen kann, sie aber alle zu der einen passenden Route gehören — ein unabhängiger Bereich kann trotzdem seinen eigenen ungestarteten Router und sein eigenes Laden behalten, wie in Mehrere Router & kombiniertes Laden; und Hover-Preloading, das du bekommst, indem du den eigenen Loader der Route aus einemmouseenteraufrufst.
Beispiele
Zwei lauffähige Beispiele im Repository ergänzen sich, sodass jede Einstellung irgendwo beobachtbar ist:
examples/router: eine clientseitige Tour durch die ganze API. Jede Pattern-Form und Prioritätsregel, die Opt-outs des Link-Handlings, jedes Route-Member mit Live-Bedienelementen, ein Sub-Router, der Layer-Stack drei Ebenen tief, ein relativer Abschnitt unter zwei Parents und Routen-Loader mitpendingunderror. Setztpreload: false, damit diese Zustände sichtbar bleiben.examples/router-ssr:renderToString()pro Request, Hydration, die nachweislich die Knoten des Servers übernimmt, echte 404-Statuscodes, eine base ungleich Wurzel, Idle-Preloading, das du im Netzwerk-Panel beobachten kannst, und ein Loader, dessen Daten vom Server an den Client übergeben werden, damit die Hydration nicht erneut fetcht.
Nuclo hat keine Computed-API. Nimm eine einfache Funktion und ruf sie aus dynamischen Werten auf:
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()
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:
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()undlist()für Kinder, die sich ändern, nicht eine Funktion, die Knoten zurückgibt. - Nimm
className, nichtclass, 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)
update(...scopeIds): alle dynamischen Werte ausführen, oder die in den angegebenen Scopes.scope(...ids): einem Element Scope-IDs fürupdate(id)geben.when(condition, ...content): bedingter Inhalt, mit.when()und.else().list(items, renderItem): eine Zeile pro Element, zugeordnet über Identität.region(options)undinto(id, ...content): eine benannte Stelle im Baum, und Inhalt, der sie von überall füllt.on(type, listener, options?): ein Event-Listener, oderon('mount')/on('destroy').render(App, parent?): bauen und anhängen.hydrate(App, parent?): Server-HTML übernehmen.forceUpdate(): Komponenten erneut aufrufen und das DOM patchen.css(name?, style): generierte Klassen aus einem Style-Objekt.cx(...inputs): Styles kombinieren, später gewinnt.createCss(theme): themedcss,cx,variants,keyframesundglobalStyle.variants(config): Styles aus Optionen.keyframes(frames)undglobalStyle(selector, style).
nuclo (nur benannter Export)
nuclo/ssr
renderToString(input),renderManyToString(inputs),renderToStringWithContainer(input, tag?, attrs?)undgetCssText().
nuclo/polyfill
- Ein Side-Effect-Import, plus
document,Event,CustomEvent,Node,Element,HTMLElementund dieNuclo*-Klassen.
nuclo-router (separates Paket)
createRouter(table, options?): der Router, mitstart(),match(),outlet(),matches(),go(),push(),href()undstop().withLoad(loader, load): eine Route, deren Daten-Loader neben ihrem Chunk startet.- Typen:
RouteContext,PageProps,Params,DataLoader,RouteTableund mehr.
Nur global
- 175 Tag-Builder:
div,span, …,var_, sowiesvgSvg,pathSvg, …. - Typen:
NodeModFn,NodeModLike,ExpandedElementund mehr. Styling-Typen wieStyleResultlassen sich auch aus'nuclo'importieren.
Spezielle Keys
- Attribute:
className,style,on*-Handler,onMountundonDestroy. - Style-Objekte: Kurzformen, Pseudo-Zustände,
&-Selektoren,@-Regeln undraw.
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.