IntroducciónVisión general
NUCLO / 12

Lingua

Documentazione

Documentazione di Nuclo

Un riferimento pratico per installare Nuclo, costruire con aggiornamenti espliciti, applicare stili con CSS tipizzato e renderizzare HTML lato server.

Avvio rapidonpm create nuclo@latest
Avvio rapido
v0.3.29
Versione attuale
175
Tag builder
~18.4 KB
Core, gzip
Esplicito
Modello di runtime

Panoramica

#

Nuclo è una piccola libreria DOM per TypeScript. Costruisci la pagina con semplici funzioni come div() e button(), tieni lo stato in normali variabili e chiami update() quando la pagina deve cambiare.

Nuclo non ha virtual DOM, compilatore, proxy né signal.

main.ts
import 'nuclo'

let count = 0

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

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

Il modello mentale

  1. Costruisci la UI con i costruttori di tag.
  2. Passa una funzione per ogni valore che dipende dallo stato.
  3. Cambia lo stato con JavaScript puro.
  4. Chiama update(). Nuclo esegue di nuovo le funzioni e applica le modifiche al DOM.

Cambiare una variabile non tocca il DOM. La pagina cambia solo quando chiami update().

Avvio rapido

#

create-nuclo genera un progetto Vite e TypeScript pronto per Nuclo:

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

Ti chiede il nome del progetto. Per saltare la domanda, passa il nome e i flag. Con npm, metti -- prima dei flag:

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

Poi installa e avvia il server di sviluppo:

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

Opzioni

  • [project-name]: la cartella di destinazione. Il valore predefinito è nuclo-app.
  • -t, --template <name>: il template da copiare. Oggi esiste solo basic.
  • -y, --yes: non chiedere mai, usa i valori predefiniti.
  • -f, --force: scrivi in una cartella non vuota.
  • -h, --help: stampa l'aiuto.

Installazione e import

#

Per aggiungere Nuclo a un progetto esistente:

terminal
$ npm install nuclo

Oppure usa pnpm add nuclo, yarn add nuclo o bun add nuclo. Nuclo non ha dipendenze.

Il routing non fa parte del pacchetto principale. Viene distribuito separatamente come nuclo-router, che installi accanto a nuclo quando ti serve.

Globali

import 'nuclo' mette su globalThis:

  • 175 costruttori di tag: 112 per HTML (div, span, button, …) e 63 per SVG, con il suffisso Svg (svgSvg, pathSvg, …). Vedi Costruttori di tag.
  • 16 helper: update, scope, when, list, region, into, on, render, hydrate, forceUpdate, css, cx, createCss, variants, keyframes e globalStyle.

L'import deve essere eseguito prima di qualsiasi codice che chiama un costruttore. I moduli ES vengono eseguiti nell'ordine di import, quindi questo fallisce:

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

Metti import 'nuclo' per primo nel file di ingresso, oppure importalo in ogni file che usa i globali.

Import nominati

Gli helper sono anche export nominati. Sono le stesse funzioni dei globali. getCssText() e resetStyles() sono solo export nominati. I costruttori di tag sono solo globali, quindi import { div } from 'nuclo' non funziona.

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

Punti di ingresso

  • nuclo: registra i globali ed esporta i 16 helper più getCssText e resetStyles.
  • nuclo/ssr: renderToString, renderManyToString, renderToStringWithContainer e getCssText. Vedi Rendering lato server.
  • nuclo/polyfill: un piccolo DOM per Node e Bun. Vedi nuclo/polyfill.
  • nuclo/types: solo tipi. Vedi TypeScript.

TypeScript

#

Nuclo include i propri tipi. Ti servono due cose:

  • "moduleResolution": "bundler". node16 e nodenext non sono ancora supportati.
  • La lib DOM. TypeScript la include per impostazione predefinita quando non imposti lib.

Se un qualsiasi file del progetto importa 'nuclo', tutti i file vedono i tipi globali. Non ti serve altro. Se nessun file lo importa, per esempio in un tsconfig separato per i test, aggiungi i tipi tu stesso:

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

Oppure aggiungi /// <reference types="nuclo/types" /> a un file .d.ts.

Tipi utili

Questi tipi sono globali, quindi li usi senza import:

  • NodeModFn<Tag>: ciò che restituisce un costruttore di tag. Usalo come tipo di ritorno di un componente.
  • NodeModLike<Tag>: qualsiasi cosa un costruttore di tag accetti come argomento.
  • ExpandedElement<Tag>: ciò che restituiscono render() e hydrate().
  • ElementTagName: qualsiasi nome di tag HTML.
  • ExpandedElementAttributes<Tag>: l'oggetto degli attributi che un costruttore di tag accetta, inclusi gli handler on*, onMount e onDestroy. Usalo per i componenti che passano gli attributi al loro interno.
  • MountCallback e DestroyCallback: gli handler onMount e onDestroy.
  • WhenBuilder, ListModifier: ciò che restituiscono when() e list().
  • RegionOptions, RegionType: le opzioni che accetta region().
  • StyleResult, Style, ThemeConfig, CssInstance e gli altri tipi di styling. Puoi anche importarli: import type { StyleResult } from 'nuclo'.
badge.ts
function Badge(text: string): NodeModFn<'span'> {
  return span(text)
}

ExpandedElement<Tag> marca ogni membro del DOM come opzionale. Fai un cast quando ti serve il tipo completo dell'elemento: render(App, root) as HTMLDivElement.

Ogni tag HTML è una funzione globale con lo stesso nome: div, span, input, … L'unica eccezione è <var>, che è var_ perché var è una parola riservata. Ogni tag SVG ha il suffisso Svg: svgSvg, pathSvg, circleSvg, …

Chiamare un costruttore di tag non crea ancora un elemento. div('Hi') restituisce un builder: una funzione che crea l'elemento in seguito. Nuclo la chiama quando lo monti con render() o lo inserisci dentro un altro builder, when() o list(). Ogni uso crea un nuovo elemento.

card.ts
let name = 'Ada'

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

render(Card, document.body)
LiveUn builder accetta attributi, figli statici e testo dinamico. Rinomina cambia lo stato, poi chiama update().Apri ↗

Profile

Hello, Ada

Argomenti

Un builder accetta un numero qualsiasi di argomenti e li applica da sinistra a destra:

  • string, number, bigint: un nodo di testo.
  • () => value: testo che cambia a ogni update(). Vedi Valori dinamici.
  • null, undefined: ignorati.
  • true, false: resi come testo "true" o "false". Usa when() per le condizioni.
  • Un oggetto semplice: attributi. Vedi Attributi.
  • Un risultato di css(), cx() o variants(): aggiunge le sue classi.
  • on(...): un listener di eventi o un hook del ciclo di vita.
  • Un altro builder, when() o list(): un figlio.
  • Un Node del DOM: aggiunto così com'è.
  • (el, index) => …: un modificatore personalizzato. Vedi sotto.

Insidie

  • div(isAdmin && span('Admin')) rende il testo "false". Scrivi div(when(() => isAdmin, span('Admin'))).
  • Gli array non sono figli. Espandili: ul(...names.map((n) => li(n))). Per le liste che cambiano, usa list().
  • Una variabile locale con il nome di un tag nasconde il costruttore. Dentro function Row(label: string), label(...) non è chiamabile. Rinomina le variabili locali come label, title, data, p, a e i.
  • I browser espongono ogni elemento con un id come variabile globale. Se la pagina contiene <main id="main"> prima che Nuclo venga caricato, Nuclo lascia quel globale al suo posto, quindi main(...) lancia un TypeError. Usa altri id.

Modificatori personalizzati

Una funzione che dichiara almeno un parametro viene eseguita una volta, nell'ordine degli argomenti, mentre l'elemento viene costruito. Il primo argomento è l'elemento. Il secondo è un indice interno, quindi non farci affidamento. Se restituisce una stringa o un numero, quello diventa testo. Un Node viene aggiunto come figlio. Un oggetto viene applicato come attributi.

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

Ottenere l'elemento

render() restituisce l'elemento radice. Dentro un albero, usa on("mount", (el) => …) o un modificatore personalizzato. Puoi anche chiamare tu stesso la funzione builder per creare un elemento staccato:

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

Inseriscilo nel documento prima del prossimo update(). Un nodo che non è nel documento quando update() viene eseguito smette di aggiornarsi per sempre.

SVG

I costruttori SVG accettano gli stessi argomenti. Nuclo imposta ogni attributo SVG con setAttribute, quindi nomi come "stroke-width" funzionano così come sono scritti.

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

Tutti i costruttori

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

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

Un componente è una semplice funzione che restituisce un builder, come button(...). Non esiste un'API per i componenti. Nulla viene creato finché non lo renderizzi o lo inserisci in un albero.

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

render(() => div(Counter('A'), Counter('B')))
LiveUn componente è una semplice funzione. Ogni chiamata a Counter() conserva il proprio conteggio.Apri ↗

Lo stato dichiarato dentro la funzione appartiene a quell'istanza. Lo stato dichiarato fuori è condiviso da tutte le istanze. forceUpdate() chiama di nuovo i componenti, il che azzera lo stato dichiarato al loro interno.

I figli sono semplici argomenti:

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

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

Attributi

#

Passa un oggetto semplice per impostare gli attributi. Puoi passare più oggetti. Per la stessa chiave vince il valore successivo, tranne per className e style, che si uniscono. Un valore può essere statico, oppure una funzione senza parametri che viene eseguita di nuovo a ogni update().

example.ts
let busy = false

const save = button(
  { type: 'submit', disabled: () => busy, 'aria-busy': () => String(busy), 'data-id': 42 },
  'Save',
)
LiveI valori degli attributi possono essere funzioni. Il secondo pulsante inverte busy e chiama update(); disabled e aria-busy di Salva lo seguono.Apri ↗

Come viene impostato un valore

  • Se l'elemento HTML ha una proprietà con quel nome (value, checked, disabled, htmlFor, tabIndex, …), Nuclo imposta la proprietà. Altrimenti chiama setAttribute(key, String(value)), per esempio per "aria-*" e "data-*".
  • Sugli elementi SVG, Nuclo chiama sempre setAttribute.
  • null e undefined vengono ignorati. Un valore dinamico che restituisce null o undefined mantiene il valore precedente. Per azzerare un valore, restituisci "", oppure false per una proprietà booleana.
  • I booleani funzionano per le proprietà booleane del DOM, come disabled su un pulsante, checked, hidden e required. Ovunque altrove, true e false vengono scritti come stringhe "true" e "false".
  • Usa chiavi "data-*". dataset non è supportato.
  • innerHTML non viene escapato, e il rendering lato server non lo emette. Evitalo.

Classi

Usa className. Le stringhe di classi statiche, i risultati di css() e un className dinamico si uniscono tutti. Un className dinamico mantiene le classi statiche, e quando restituisce "" restano solo le classi statiche.

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

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

Non usare la chiave class sugli elementi HTML. Imposta l'attributo grezzo e sostituisce ogni classe applicata prima, incluse le classi di css().

Stili inline

style accetta un oggetto con chiavi in camelCase, oppure una funzione che ne restituisce uno.

example.ts
let progress = 30

const bar = div({ style: () => ({ width: `${progress}%`, backgroundColor: 'teal' }) })
  • Nuclo non aggiunge unità. Scrivi "12px", non 12. Le proprietà senza unità come opacity e zIndex accettano numeri.
  • "", null e undefined azzerano una proprietà.
  • Uno stile dinamico imposta solo le chiavi che restituisce. Una chiave che smette di restituire mantiene il valore precedente, quindi restituisci "" per azzerarla.
  • Le stringhe di stile ("color: red") e le proprietà personalizzate ("--gap") non sono supportate. Usa invece css(). Accetta le proprietà personalizzate tramite raw.
fnAPI pubblica
// DOM events such as "click". El and Ev are inferred from the tag and the event name. function on(type: DOMEventName, listener: (this: El, e: Ev) => unknown, options?: boolean | AddEventListenerOptions): NodeModFn // Custom events function on<K extends string, E extends Event = Event>(type: K, listener: (this: El, e: E) => unknown, options?: boolean | AddEventListenerOptions): NodeModFn // Lifecycle, see Lifecycle function on(type: "mount", listener: MountCallback): NodeModFn function on(type: "destroy", listener: DestroyCallback): NodeModFn

Ci sono due modi per ascoltare gli eventi: gli attributi on* e il modificatore on().

Attributi on*

In un oggetto di attributi, una funzione sotto una chiave come onClick (on seguito da una lettera maiuscola) è un gestore di eventi, non un valore dinamico.

search.ts
let query = ''

const search = input({
  placeholder: 'Search',
  value: () => query,
  onInput: (e) => { query = e.currentTarget.value; update() },
  onKeyDown: (e) => { if (e.key === 'Escape') { query = ''; update() } },
})
  • TypeScript deduce il tipo dell'evento dalla chiave e dall'elemento. e.currentTarget e this sono l'elemento.
  • Nuclo imposta la proprietà nativa, quindi onClick diventa el.onclick. C'è un solo handler per evento: un onClick successivo sostituisce quello precedente.
  • onDoubleClick e onDblClick indicano entrambi dblclick. Le chiavi in minuscolo come onclick sono errori di tipo.
  • Un nome che non è un evento noto ascolta la sua forma in minuscolo: onMyEvent ascolta "myevent".
  • Gli errori lanciati dall'handler non vengono catturati.
LiveonInput e onKeyDown sono attributi normali. Scrivi, poi premi Esc per cancellare.Apri ↗

Type something, then press Escape.

on()

on() restituisce un modificatore che chiama addEventListener sull'elemento.

panel.ts
const panel = div(
  on('scroll', onScroll, { passive: true }),
  on('click', logClick),
  on('click', closeMenu),
)
  • Ogni on() aggiunge un listener. Vengono eseguiti tutti, in ordine.
  • options va direttamente ad addEventListener: capture, once, passive, signal.
  • Passa on() direttamente a un builder e il tipo dell'elemento viene dedotto: input(on('input', (e) => e.currentTarget.value)).
  • Se un listener lancia un errore, Nuclo lo registra con console.error, e gli altri listener vengono comunque eseguiti.
  • Nuclo rimuove il listener quando rimuove l'elemento. Sul server, on() non fa nulla.
  • Puoi riutilizzare un singolo valore on() su molti elementi.

Per un evento personalizzato, passa il tipo dell'evento come generico:

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

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

Quale usare

Usa gli attributi on* per il caso comune. Usa on() quando ti servono più listener per un evento, opzioni del listener, oppure un nome di evento personalizzato come "my-event".

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

onMount viene eseguito una volta, dopo che l'elemento è stato inserito. onDestroy viene eseguito una volta, quando Nuclo rimuove l'elemento. Scrivili come attributi o con on(). Le due forme si comportano allo stesso modo, e l'elemento è tipizzato in base al suo tag.

timer.ts
let seconds = 0

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

const search = input(on('mount', (el) => el.focus()))
LiveonMount avvia un intervallo e restituisce la sua pulizia. Lo smontaggio esegue la pulizia, così il conteggio si ferma finché il timer non viene montato di nuovo.Apri ↗

Quando vengono eseguiti

  • onMount viene eseguito alla fine della chiamata a render(), hydrate(), update() o forceUpdate() che ha inserito l'elemento. Non viene mai eseguito mentre l'albero è in costruzione.
  • Se onMount restituisce una funzione, quella funzione viene eseguita alla distruzione, dopo le callback onDestroy dell'elemento.
  • onDestroy viene eseguito quando Nuclo rimuove l'elemento: una riga di list() viene rimossa, un ramo di when() cambia, un into() lascia la sua region, oppure forceUpdate() lo scarta. Viene eseguito subito prima della rimozione, quindi l'elemento è ancora nel documento.

Rimozione diretta dal DOM

Se rimuovi un elemento tu stesso, con node.remove() o innerHTML = '', i suoi onDestroy e le pulizie non vengono mai eseguiti, e i timer continuano a girare. Nuclo non ha unmount(). Per smontare un'app con pulizia, avvolgila in un when():

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

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

Ordine ed errori

  • Puoi registrare quanti hook vuoi, in entrambe le forme. Vengono eseguiti nell'ordine di registrazione.
  • onDestroy esegue sempre prima i figli, poi il genitore.
  • Gli elementi vengono montati nell'ordine del loro primo hook onMount, che segue l'ordine degli argomenti, e ogni elemento esegue tutti i suoi hook insieme. Passa l'hook di un genitore prima dei suoi figli se questi dipendono da esso.
  • Un hook che lancia un errore viene registrato con console.error. Gli altri hook vengono comunque eseguiti.
  • La callback deve restituire nulla oppure una funzione di pulizia, quindi non renderla async. Una Promise restituita non viene mai usata come pulizia. Avvia il lavoro asincrono al suo interno: { onMount: (el) => { void load(el) } }.
  • Gli hook non vengono mai eseguiti sul server. Sul client, vengono eseguiti dopo hydrate().
Nota: gli hook non sono effetti. Non vengono mai eseguiti di nuovo quando lo stato cambia. Ognuno viene eseguito al massimo una volta per elemento.

Aggiornamenti espliciti

#

Nuclo non aggiorna mai la pagina da solo. Cambia il tuo stato, poi chiama update().

counter.ts
let count = 0

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

render(Counter)
Livecount++ cambia solo la variabile. Il testo si allinea al prossimo update(), dal pulsante o da qualsiasi altra cosa in questa pagina.Apri ↗

Clicked 0 times

Cambia tutto lo stato che vuoi, poi chiama update() una sola volta:

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

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

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

Chiama update() dai gestori di eventi, dai timer, dalle callback di fetch o da onMount. Non chiamarlo dall'interno di un valore dinamico.

Valori dinamici

#

Una funzione senza parametri è un valore dinamico. Nuclo la esegue una volta quando costruisce l'elemento, e di nuovo a ogni update(). Usa i valori dinamici per i figli di testo, i valori degli attributi, style e className. Per i figli che compaiono, scompaiono o si ripetono, usa when() e list().

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

const Profile = () =>
  div(
    h1('Welcome'),              // static
    p(() => `Hello, ${name}`),  // dynamic text
    button({ disabled: () => busy }, 'Save'),
  )
LiveModifica il nome. Il testo statico è stato fissato quando l'elemento è stato costruito; quello dinamico gira di nuovo a ogni update(). forceUpdate() ricostruisce l'albero, così anche il testo statico si allinea.Apri ↗

static: Hello, Alice

dynamic: Hello, Alice

Nuclo controlla il numero di parametri (fn.length). Una funzione che dichiara un parametro è un modificatore personalizzato e viene eseguita una sola volta. (x = 0) => … e (...args) => … hanno length 0, quindi sono dinamiche.

Testo dinamico

  • Una string, number, bigint o boolean viene resa come String(value). () => ok && 'Saved' mostra "false", quindi scrivi () => (ok ? 'Saved' : '').
  • null e undefined vengono resi come testo vuoto.
  • Un risultato di css() o cx() alla prima esecuzione rende la funzione una classe dinamica, non testo. Vedi cx().
  • Un nodo, un builder, un array o un oggetto semplice non rende nulla, e non si aggiorna mai.

Attributi dinamici

  • Nuclo scrive un valore solo quando è diverso dall'ultimo valore che Nuclo ha scritto. Non lo confronta con il DOM reale, quindi se un utente digita in un input e la tua funzione restituisce lo stesso valore di prima, il testo digitato resta.
  • Un risultato null o undefined non scrive nulla, quindi il valore precedente resta. className è l'eccezione: qualsiasi risultato falsy rimuove le classi dinamiche e mantiene quelle statiche.
  • Una funzione style applica l'oggetto restituito a ogni aggiornamento.

Errori

Se un valore dinamico lancia un errore, Nuclo lo cattura e il resto dell'aggiornamento prosegue. Il testo diventa vuoto o mantiene il valore precedente, e un attributo mantiene il valore precedente. Entrambi si ripristinano al prossimo update() che va a buon fine. L'eccezione: una funzione di testo che lancia un errore alla prima esecuzione resta vuota per sempre.

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

Esegue un passaggio sincrono su ogni valore dinamico della pagina. Con degli id, aggiorna solo ciò che si trova dentro gli elementi marcati con quegli id di scope(). Ogni passaggio esegue questi passi in ordine:

  1. list(): rilegge gli elementi e aggiunge, sposta o rimuove righe.
  2. when(): ricontrolla le condizioni. Un ramo viene ricostruito solo quando diventa attivo un ramo diverso.
  3. Attributi dinamici, inclusi style e className.
  4. Testo dinamico.
  5. onMount per gli elementi inseriti dai passi 1 e 2.
  • Esegue tutto. Ogni valore dinamico viene eseguito a ogni chiamata, anche quando i suoi dati non sono cambiati. Viene saltata solo la scrittura sul DOM. Mantieni i valori dinamici veloci e privi di effetti collaterali.
  • Solo i nodi nel documento. Se un nodo non è nel documento quando update() viene eseguito, Nuclo smette di tracciarlo. Non si aggiorna mai più, anche dopo che lo inserisci. Questo vale anche per list() e when(). Renderizza in un contenitore che è nel documento.
  • I valori statici non cambiano mai. Le stringhe semplici e i valori degli attributi sono fissati quando l'elemento viene costruito. Per ricostruirli, usa forceUpdate().
  • Errori. Gli errori di testo e attributi vengono catturati, e il passaggio prosegue. Se una condizione di when() lancia un errore, l'errore viene registrato e quel when() smette di aggiornarsi. Se una funzione items o renderItem di list() lancia un errore, l'errore esce da update() e i passi rimanenti non vengono eseguiti.
  • Server. update() non fa nulla sul server.
LiveOgni update() esegue ogni valore dinamico, anche quando i suoi dati non sono cambiati. Anche le altre demo in questa pagina fanno salire il conteggio.Apri ↗

This dynamic value has run 1 times.

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

Assegna all'elemento a cui viene passato uno o più id di scope. update('id') aggiorna poi solo ciò che si trova dentro quell'elemento, inclusi gli attributi dell'elemento stesso.

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

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

render(App)

cartItems.push('apple')
user = 'Grace'
update('cart') // "Items: 1", the header still says "Hi, Ada"
update()       // everything, the header now says "Hi, Grace"
LiveEntrambi i pulsanti aggiungono un elemento e rinominano l'utente. update('cart') aggiorna solo il pannello nello scope.Apri ↗
Hi, Ada
Items: 0
  • Un elemento può avere più id: scope('cart', 'sidebar').
  • update('a', 'b') aggiorna tutto ciò che si trova dentro a o b. Se più elementi usano lo stesso id, si aggiornano tutti.
  • Con scope annidati, update('outer') include lo scope interno. update('inner') non tocca quello esterno.
  • Un id che nessun elemento nel documento possiede non aggiorna nulla e non genera errori, quindi controlla l'ortografia.
  • scope() non fa nulla sul server.
fnAPI pubblica
function when(condition: boolean | (() => boolean), ...content: NodeModLike[]): WhenBuilder interface WhenBuilder { when(condition: boolean | (() => boolean), ...content: NodeModLike[]): WhenBuilder else(...content: NodeModLike[]): WhenBuilder }

Renderizza contenuto in base a una condizione. Nuclo ricontrolla le condizioni a ogni update(). Vince la prima condizione vera. Se nessuna è vera, viene reso il contenuto di .else(). Senza .else(), non viene reso nulla.

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

const Status = () =>
  div(
    when(() => status === 'loading', p('Loading…'))
      .when(() => status === 'error', p('Something went wrong'))
      .else(p('Ready')),
  )
LiveVince la prima condizione vera; altrimenti renderizza .else(). Un nuovo ramo viene costruito da zero.Apri ↗

Loading…

Condizioni

  • Per lo stato, passa una funzione. Un booleano semplice viene letto una sola volta: when(open, …) non cambia mai.
  • In TypeScript la funzione deve restituire un boolean. Scrivi () => items.length > 0 oppure () => user != null.

Cosa si aggiorna

  • Finché lo stesso ramo resta attivo, i suoi elementi vengono mantenuti. I valori dinamici, le liste e i blocchi when() al suo interno continuano ad aggiornarsi.
  • Quando diventa attivo un ramo diverso, il vecchio contenuto viene rimosso e i suoi hook onDestroy vengono eseguiti. Il nuovo ramo viene costruito da zero e i suoi hook onMount vengono eseguiti. Lo stato nei vecchi elementi, come il testo di un input o il focus, va perso.

Contenuto dei rami

Metti dei figli in un ramo: testo, testo dinamico, builder, list() e when() annidati. Passa builder come p('Hi'), non nodi DOM: un nodo DOM smette di aggiornarsi dopo che il suo ramo viene nascosto una volta.

Gli oggetti di attributi e gli on() in un ramo si applicano all'elemento genitore, e restano dopo che il ramo viene nascosto. Per un attributo condizionale, usa un attributo dinamico: { className: () => (active ? 'active' : '') }.

Concatenamento

.when() e .else() restituiscono un nuovo builder e non modificano l'originale. Se chiami .else() due volte, vince l'ultima chiamata.

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

Renderizza una riga per elemento. A ogni update(), Nuclo chiama di nuovo items() e abbina i nuovi elementi alle righe esistenti per identità (===).

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

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

render(Fruits)

fruits.push('Cherry')
update()

Come vengono abbinate le righe

  • Un elemento ancora presente mantiene la sua riga. Nuclo sposta la riga se necessario, e non chiama di nuovo renderItem.
  • Un nuovo elemento riceve una nuova riga. Un elemento rimosso perde la sua riga, e i suoi hook onDestroy vengono eseguiti.
  • Gli elementi duplicati ricevono ciascuno la propria riga.
  • Puoi modificare l'array sul posto (push, splice, sort) oppure restituire un nuovo array.

Una riga mantenuta non viene mai renderizzata di nuovo, quindi usa valori dinamici per i campi che cambiano:

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

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

render(Todos)

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

Le copie sono nuovi elementi, quindi sostituire l'array con todos.map((t) => ({ ...t })) ricostruisce ogni riga.

LiveLe righe sono abbinate per identità. Spunta una riga, poi Inverti: lo stesso <li> si sposta, checkbox inclusa.Apri ↗

Cosa restituisce renderItem

  • Restituisci un solo elemento, come li(...), tr(...) o un builder SVG. Restituisci null o undefined per saltare un elemento. Stringhe, nodi di testo e frammenti non rendono nulla.
  • Non restituire direttamente when() o list(). Mettili dentro l'elemento della riga: li(todo.text, when(() => todo.done, ' ✓')).
  • Per nascondere elementi, filtra in items(), non in renderItem.
  • Mantieni renderItem privo di effetti collaterali. Nuclo può chiamarlo più di una volta per lo stesso elemento.

L'indice

index è la posizione dell'elemento quando la sua riga è stata creata. Non viene aggiornato quando le righe si spostano. Per la posizione corrente, usa un valore dinamico: li(() => `${names.indexOf(name) + 1}. ${name}`).

Iterabili

Funziona qualsiasi iterabile: array, Set, generatori e map.values(). Restituire ogni volta lo stesso array o Set va bene. Un iteratore, come un generatore o map.values(), si esaurisce dopo un passaggio, quindi crealo dentro items() a ogni chiamata. Iterare direttamente una Map crea ogni volta nuovi array [key, value], quindi ogni riga viene ricostruita. Usa invece map.values().

Posizionamento

Le righe compaiono dove list() si trova tra i suoi fratelli, tra due marcatori di commento. Gli altri figli del genitore non vengono toccati. list() funziona anche dentro i builder SVG.

region() e into()

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

region() marca un punto con nome nell'albero. into() lo riempie da qualsiasi altro punto, a qualsiasi profondità. Il layout non riceve il contenuto come prop, e il componente che costruisce il contenuto non sa dove finirà.

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

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

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

Come funziona

  • into() lascia solo un commento nel punto in cui è scritto. Il suo contenuto viene reso tra i marcatori della region.
  • L'ordine non conta. Un into() costruito prima della sua region aspetta e appare nel momento in cui la region viene costruita. Le viste appaiono nell'ordine in cui sono state scritte.
  • Il contenuto vive quanto la vista. Quando il when() che contiene un into() si chiude, o la sua riga di list() viene rimossa, il contenuto lascia la region. Riaprirlo aggiunge una copia, non due.
  • Quando una region scompare, per esempio perché il suo when() si chiude, le sue viste tornano in attesa. Una region costruita in seguito con lo stesso id le mostra.
  • Un into() il cui id non corrisponde a nessuna region non rende nulla e non genera errori, quindi controlla l'ortografia.
  • Gli id sono unici nell'albero. Una seconda region costruita con un id già attivo registra un avviso.
  • Le region funzionano con il rendering lato server. Con hydrate(), ogni vista rivendica l'HTML reso dal server invece di costruirlo di nuovo.

Opzioni

  • id: il nome che le chiamate a into() usano per trovare la region.
  • type: 'latest' (il predefinito) mostra solo la vista più recente. 'stack' mostra tutte le viste, in ordine di arrivo.
  • empty: reso mentre la region non contiene viste.

Region a pila

Con type: 'stack', una vista che si apre sopra un'altra non ricostruisce quella sottostante. Il suo DOM, il focus e lo stato del form restano. Usalo per un livello come un dialog sopra una pagina.

layers.ts
let open = false

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

Più region contemporaneamente

Passa un oggetto per riempire più di una region da un singolo into(). Un valore può essere un singolo figlio o un array di figli. Entrambe le forme si annidano: un into() dentro il contenuto di un'altra vista punta alla propria region.

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

Un dialog aperto dal profondo dell'albero

La region overlay si trova alla radice. Qualsiasi componente, a qualsiasi profondità, apre un dialog avvolgendo un into() in un when(). Chiudere il when() rimuove il dialog dalla region.

dialog.ts
let confirming = false

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

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

Toast dalle righe di una lista

Ogni riga di un list() è una vista. Una region a pila le mostra in ordine, e rimuovere un elemento dall'array rimuove il suo toast.

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

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

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

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

Un titolo di pagina impostato dalla pagina

Con il predefinito type: 'latest', vince la vista più recente. Il layout possiede l'intestazione, e la pagina aperta in quel momento le dà il nome. Una pagina senza into('title', …) lascia il testo di empty.

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

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

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

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

Valori dinamici dentro una vista

Il contenuto di un into() è normale contenuto Nuclo. Le funzioni al suo interno sono valori dipendenti dallo stato e si aggiornano a ogni update(), ovunque si trovi la region.

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

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

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

Una region che va e viene

L'into() della dashboard è scritto una sola volta. Mentre l'utente è disconnesso non esiste una region main, quindi la vista aspetta. L'accesso costruisce la region e la vista appare. La disconnessione rimuove la region e la vista torna in attesa.

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

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

Una vista con la propria region

Una vista può contenere una region(), e altre viste possono puntarvi. Qui la pagina dell'editor porta con sé una region tabs, e ogni tab la riempie. Quando l'editor si chiude, la sua region se ne va con lui.

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

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

Esempio dal vivo

region-app.ts
import 'nuclo'

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

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

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

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

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

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

export const RegionApp = () =>
  div(
    Layout(),
    when(() => page === 'home', Home()),
    when(() => page === 'article', Article()),
    when(() => dialog, Dialog()),
  )
LiveUn layout con una regione principale a strati e una regione laterale, riempite dall'esterno. Scrivi in Home, apri la finestra di dialogo e chiudila: il testo resta. Articolo riempie entrambe le regioni con un solo into().Apri ↗

No related links.

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

update() esegue solo i valori dinamici. I valori statici, come il testo in h1(labels[lang].title), sono fissati quando l'albero viene costruito. forceUpdate() chiama di nuovo i tuoi componenti e applica le modifiche al DOM reale per farlo corrispondere, valori statici inclusi. Usalo per cambiamenti rari e globali all'app, come il cambio di lingua. Usa update() per tutto il resto.

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

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

lang = 'pt'
forceUpdate() // same <h1> element, now "Olá"
Liveupdate() aggiorna solo la riga dinamica di lang. forceUpdate() chiama di nuovo App e patcha lo stesso <h1>. La forma esplicita ricostruisce solo questo albero, non l'intera pagina.Apri ↗

lang = 'en'

Hello

Static text, fixed on build.

Senza argomenti, forceUpdate() ricostruisce ogni radice renderizzata con render(App) o hydrate(App). Le radici renderizzate con render(App()) vengono saltate. Se un componente lancia un errore, l'errore viene registrato e quella radice mantiene il vecchio DOM. Anche un errore lanciato in seguito, mentre la nuova costruzione viene applicata, viene registrato, ma le parti già modificate restano modificate.

Cosa resta e cosa cambia

  • I figli vengono abbinati in ordine, per tag. Un elemento corrispondente viene riutilizzato, quindi il focus e i valori digitati negli input restano, e i suoi hook onMount non vengono eseguiti di nuovo. Dal momento in cui il tag di un figlio differisce, quel figlio e tutti i fratelli successivi vengono costruiti da zero.
  • Testo, attributi, classi, stili, listener, rami di when() e righe di list() seguono la nuova costruzione.
  • I nuovi elementi vengono creati, e i loro hook onMount vengono eseguiti. Gli elementi che la nuova costruzione non produce più vengono rimossi, e i loro hook onDestroy vengono eseguiti.
  • Anche le righe di list() vengono abbinate per posizione, non per elemento, quindi una riga riutilizzata può finire per mostrare un elemento diverso.
  • Gli attributi che la nuova costruzione non imposta più non vengono rimossi.
Attenzione: lo stato dichiarato dentro la funzione di un componente viene azzerato, perché la funzione viene eseguita di nuovo. Tieni lo stato che deve sopravvivere fuori dal componente.

Forma esplicita. forceUpdate(App(), parent) ricostruisce un solo albero in parent e restituisce la sua radice. Non registra la radice per le chiamate successive. Come hydrate(), si aspetta che la radice sia il primo nodo di parent. Se il primo nodo ha un tag diverso, viene sostituito.

Sul server, forceUpdate() non fa nulla.

render()

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

Costruisce il tuo albero e aggiunge il suo elemento radice a parent. parent ha come valore predefinito document.body. Restituisce l'elemento radice.

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

render(App, document.getElementById('app')!)
  • Puoi passare il componente (render(App)) o il builder che restituisce (render(App())). Il DOM è lo stesso. Solo render(App) permette a forceUpdate() di ricostruire la radice in seguito, quindi preferiscilo.
  • Aggiunge in coda. Non svuota parent, quindi chiamarlo due volte aggiunge due copie.
  • Ogni onMount nell'albero è stato eseguito quando render() ritorna.
  • Renderizza in un elemento che è nel documento. Vedi update().
  • La radice deve essere un elemento, come div(...), non when() o list().
  • index è interno. Non sceglie dove viene inserito l'elemento. Non passarlo.

Un componente passato come render(App) non deve avere parametri. Un parametro obbligatorio è un errore di tipo. Uno opzionale supera il controllo dei tipi, ma render() lancia un errore a runtime. Avvolgi un componente che accetta props:

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

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

hydrate()

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

Prende in carico l'HTML prodotto da renderToString(). Riutilizza gli elementi esistenti, collega i listener di eventi e connette i valori dinamici, when() e list(), così che update() funzioni in seguito. Poi esegue gli hook onMount. parent ha come valore predefinito document.body. Restituisce l'elemento radice.

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

hydrate(App, document.getElementById('root')!)
  • Usa lo stesso componente, e gli stessi dati, del server.
  • Idrata in un contenitore dedicato. La radice dell'app deve essere il suo primo nodo, anche se gli spazi bianchi vanno bene. Se un altro nodo viene prima, Nuclo sostituisce quel nodo e lascia lì la copia del server.
  • Come con render(), passa App, non App(), così che forceUpdate() possa ricostruire la radice in seguito. App non deve avere parametri. Con uno opzionale, hydrate() non collega nulla in silenzio. Avvolgi un componente che accetta props: hydrate(() => Page(props), root).
  • Se non c'è HTML dal server, usa render().

Quando server e client differiscono

Vince il client:

  • Un tag radice diverso: quel nodo viene sostituito con DOM nuovo. Un contenitore vuoto: viene aggiunto DOM nuovo.
  • Il testo diverso viene corretto. Elementi di list() e rami di when() diversi seguono il client.
  • I figli in eccesso del server vengono rimossi, e quelli mancanti vengono creati.
  • Gli attributi che il server ha scritto ma che il client non imposta vengono lasciati così come sono.

renderToString() scrive commenti HTML come marcatori, per esempio <!-- text-0 --> prima di ogni nodo di testo. hydrate() ne ha bisogno, quindi non rimuovere i commenti dall'HTML del server. Senza i marcatori, l'idratazione elimina le righe di list(), e list() e when() smettono di aggiornarsi.

Rendering lato server

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

Renderizza un componente in una stringa HTML sul server. Nel browser, chiama hydrate() con lo stesso componente per rendere quell'HTML interattivo. I runtime server non hanno document, quindi importa prima nuclo/polyfill.

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

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

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

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

Mantieni id="nuclo-styles" sul tag style. Il client riutilizza quell'elemento e aggiunge solo le nuove regole. Vedi getCssText().

Cosa viene eseguito sul server

  • Ogni chiamata a renderToString() costruisce l'albero ed esegue ogni valore dinamico una volta.
  • I gestori di eventi, onMount e onDestroy non vengono mai eseguiti. update(), scope() e forceUpdate() non fanno nulla.
  • Il rendering è sincrono. Carica prima i dati, poi renderizza.
  • Nulla resta registrato dopo un render, quindi un singolo processo può servire molte richieste. Passa i dati della richiesta come argomenti, per esempio renderToString(Page(user)). Le variabili a livello di modulo sono condivise da tutte le richieste.
  • Non chiamare render() sul server. Aggiunge contenuto al document.body condiviso.

renderToString()

Accetta un builder (App()), un componente (App) o un nodo DOM. null e undefined restituiscono "". La radice deve essere un elemento. Un componente passato come renderToString(Page) non deve avere parametri, quindi passa Page(user) per uno che accetta dati.

example.ts
renderToString(p('Hello'))
// '<p><!-- text-0 -->Hello</p>'
  • I valori di testo e attributi vengono escapati come HTML. Il testo dentro script() e style() viene scritto così com'è, con solo i tag di chiusura spezzati, quindi non metterci mai input dell'utente.
  • Non lancia errori. Se il componente, una funzione di list() o una condizione di when() lancia un errore, o il polyfill manca, registra l'errore e restituisce "". Controlla se il risultato è vuoto quando la richiesta deve fallire. Una funzione di testo o attributo che lancia un errore rende soltanto vuoto.

renderManyToString()

Equivale a inputs.map(renderToString).

renderToStringWithContainer()

Avvolge l'output in un elemento. containerTag ha come valore predefinito "div", e containerAttrs {}. I valori degli attributi vengono escapati. I nomi degli attributi vengono scritti come forniti, quindi usa nomi HTML come class. Non passare mai input dell'utente come tag.

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

nuclo/polyfill

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

Un piccolo DOM che permette a Nuclo di costruire alberi in Node e Bun. Importalo una volta, prima del primo render. La maggior parte delle app ha bisogno solo del semplice import.

  • Quando non c'è window, imposta globalThis.document, Node, Element e HTMLElement, ma solo quelli mancanti. Non crea window.
  • In un browser, o sotto jsdom, non fa nulla.
  • Event e CustomEvent sono i costruttori nativi del runtime. Node, Element e HTMLElement sono alias delle classi del polyfill.

È un DOM per costruire alberi, non un browser. Non ha innerHTML né getElementById, querySelector() restituisce null, e i listener di eventi vengono ignorati. Usa renderToString() per ottenere l'HTML.

css()

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

Trasforma un oggetto di stile tipizzato in classi CSS generate. Passa il risultato a qualsiasi costruttore di tag. Nel browser, le regole finiscono in un unico elemento <style id="nuclo-styles">. Sul server, le restituisce getCssText().

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

const Card = () => div(card, 'Simple card')
  • css() crea una classe per le dichiarazioni di base, più una classe per ogni chiave pseudo, selettore e blocco media. La card sopra ottiene due classi.
  • I nomi delle classi sono hash del contenuto. Stili identici condividono una classe, e server e client producono gli stessi nomi.
  • Definisci gli stili una sola volta, a livello di modulo. css() usa una cache per oggetto, quindi un oggetto nuovo a ogni chiamata viene rielaborato ogni volta.
  • Modificare un oggetto di stile dopo averlo passato a css() non ha effetto.
  • Ogni stile resta registrato per tutta la vita della pagina o del processo server. Per valori che cambiano per ogni elemento, come una larghezza che viene dai dati, usa l'attributo style.

Stili con nome

Passa prima un nome per ottenere un nome di classe leggibile:

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

pageTitle.className // "page-title page-title-<hash>"
  • La classe di base è esattamente il nome. Gli altri blocchi ottengono name-<hash>.
  • Il nome deve essere un nome di classe valido. Altrimenti Nuclo avvisa e ne genera uno.
  • I nomi sono globali. Se due stili diversi usano lo stesso nome, Nuclo avvisa.

Usare il risultato

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

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

String(card) e card.className restituiscono entrambi i nomi delle classi.

Oggetti di stile

#

L'oggetto che passi a css() usa le proprietà CSS comuni in camelCase, un insieme di abbreviazioni e chiavi annidate per pseudo stati, selettori e media query. TypeScript le completa tutte. Le proprietà fuori dall'insieme tipizzato, come borderTopLeftRadius, fill o paddingInline, vanno in raw (vedi sotto).

Valori

  • I numeri diventano px, tranne 0 e le proprietà senza unità: zIndex, opacity, fontWeight, lineHeight, flex, flexGrow, flexShrink, order, aspectRatio, gridColumn e gridRow. In raw, anche zoom, scale, column-count, orphans, widows, tab-size e animation-iteration-count sono senza unità.
  • Le stringhe sono usate così come sono scritte. null, undefined e false vengono saltati.
  • lineHeight (e leading) è senza unità, quindi leading: 24 significa 24 volte la dimensione del font. Scrivi "24px" per i pixel.
example.ts
css({ p: 16, m: 0, opacity: 0.5, leading: 1.5, z: 10, w: '50%' })
// padding:16px; margin:0; opacity:0.5; line-height:1.5; z-index:10; width:50%

Abbreviazioni

ChiaveCSS
p, pt, pr, pb, plpadding, e ciascun lato
px / pypadding sinistra e destra / sopra e sotto
m, mt, mr, mb, ml, mx, mylo stesso per margin
w, h, minW, maxW, minH, maxHwidth, height, e i rispettivi min e max
sizewidth e height
bgbackground
text / font / weightfont-size / font-family / font-weight
leading / trackingline-height / letter-spacing
align / items / justifytext-align / align-items / justify-content
z / rounded / shadow / selectz-index / border-radius / box-shadow / user-select

Queste chiavi accettano true:

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

Pseudo stati

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 e firstLetter. Si annidano: hover: { focus: {…} } produce :hover:focus.

Selettori e at-rule

Una chiave che inizia con & è un selettore, e ogni & diventa la classe. Una chiave che inizia con @ è una at-rule: @media, @container o @supports.

menu.ts
const menu = css({
  p: 8,
  '& > li': { py: 4 },
  '& > li:hover': { color: 'red' },
  '&:is(.dark *)': { color: 'white' },
  '@media (min-width: 768px)': { p: 16 },
})
  • La chiave deve iniziare con &. Una chiave come ".dark &" viene ignorata, quindi scrivi "&:is(.dark *)".
  • Due blocchi @media annidati si combinano con and. Una query che inizia con un tipo di media (print, only screen) viene messa per prima, perché la grammatica ammette un tipo solo all'inizio. Una coppia che non può essere appiattita — due tipi di media, o un not iniziale — produce un avviso e usa solo la query interna. Qualsiasi altra at-rule annidata sostituisce quella esterna.

raw

Usa raw per qualsiasi proprietà, con il nome scritto esattamente come in CSS. I numeri ricevono comunque px, quindi passa stringhe:

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

Qualsiasi altra chiave piatta viene convertita in kebab-case e scritta così com'è, quindi un errore di battitura come colr: 'red' diventa una dichiarazione che il browser scarta. Un oggetto annidato sotto una chiave che non è uno pseudo stato, uno screen, o una chiave & o @ viene ignorato. TypeScript segnala entrambi i casi.

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

Combina stili. Quando due input impostano la stessa proprietà, vince l'input successivo.

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

cx(base, blue)                  // padding: 8px; color: blue
cx(base, false, null, 'extra')  // base's classes plus "extra"
div(cx(base, blue), 'Blue text')
  • Gli input falsy vengono saltati, gli array appiattiti, e le stringhe divise e deduplicate.
  • Le classi generate per lo stesso blocco (base, stesso pseudo, stessa media query) si fondono in una nuova classe. Gli altri nomi di classe passano invariati.
  • Solo cx() conosce l'ordine degli argomenti. Con div(base, blue) vengono aggiunte entrambe le classi, e vince la regola creata per ultima. Metti gli stili che si sovrascrivono a vicenda in un'unica chiamata a cx().
  • Quando il blocco di uno stile con nome si fonde con il blocco di un altro input, la classe risultante è name-<hash>, non il nome nudo. I blocchi che non si fondono mantengono la loro classe, quindi cx(pageTitle, 'extra') ha ancora page-title.

Classi dinamiche

Passa una funzione che restituisce sempre cx(...):

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

let active = false

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

La funzione deve restituire un risultato di stile alla prima esecuzione. () => active && activeButton renderizza il testo "false" invece di una classe.

createCss()

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

Restituisce i propri css, cx, variants, keyframes e globalStyle, legati a un tema. Un tema dà un nome ai valori: con il tema qui sotto, borderColor: 'border' scrive #e5e7eb, e TypeScript completa i nomi. Ogni voce in screens diventa una chiave di stile per una media query, come md: {…}.

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

const panel = css({
  p: 12,
  rounded: 'card',
  border: '1px solid',
  borderColor: 'border',
  md: { p: 24 },
})
Chiave del temaUsata da
colorsbg, backgroundColor, color, borderColor e ciascun lato, outlineColor, caretColor, textDecorationColor
fontsfont, fontFamily
shadowsshadow, boxShadow
radiirounded, borderRadius
screensnuove chiavi di stile, come md: {…}
  • Un nome del tema viene risolto solo quando è l'intero valore. border: "1px solid primary" non viene risolto.
  • Un valore di screen senza @ diventa @media <valore>. Un valore che inizia con @ è usato così come è scritto, per esempio "@container (min-width: 400px)".
  • Gli screen esistono solo sulle istanze create con createCss(). Il css() globale non ne ha, ma accetta chiavi @media.
  • Tutte le istanze e gli helper globali condividono un unico foglio di stile. Le regole media degli screen vengono emesse nell'ordine in cui gli screen sono dichiarati, non nell'ordine in cui li usi, quindi elenca gli screen min-width dal più piccolo al più grande. Usa una sola istanza di createCss() per app, così quell'ordine è quello del tuo tema.
  • theme sul risultato è il tema che hai passato.

variants()

#
fnAPI pubblica
function variants(config: { base?: Style variants?: { [group: string]: { [value: string]: Style } } defaultVariants?: { [group: string]: value } compoundVariants?: ({ [group: string]: value } & { css: Style })[] }): (props?: { [group: string]: value }) => StyleResult

Costruisce una funzione che restituisce uno stile per un insieme di opzioni, come l'intento e la dimensione di un pulsante.

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

button(buttonClass(), 'Save')                                                // primary, sm
button(buttonClass({ intent: 'danger', size: 'lg', block: true }), 'Delete') // bold, full width
  • Gli stili si fondono in quest'ordine: base, il valore scelto di ogni gruppo, poi ogni compound corrispondente. Per ogni proprietà vince lo stile successivo.
  • Un'opzione mancante, undefined o null usa il default. Un gruppo senza default viene saltato.
  • Un gruppo con solo le chiavi true e false accetta un booleano.
  • Un compound si applica quando ogni gruppo che elenca corrisponde, default inclusi.
  • Ogni selezione viene costruita una volta e messa in cache.

keyframes() & globalStyle()

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

keyframes() registra un'animazione e restituisce il nome generato. Le chiavi dei frame sono from, to e percentuali, come "50%" o "0%, 100%". I frame accettano solo proprietà piatte. Frame identici restituiscono lo stesso nome.

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

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

globalStyle() aggiunge una regola per qualsiasi selettore:

reset.ts
globalStyle('*, *::before, *::after', { boxSizing: 'border-box' })
globalStyle('body', { m: 0, font: 'system-ui, sans-serif' })
  • Il selettore è usato esattamente come è scritto.
  • Funzionano solo le proprietà piatte. Chiavi pseudo, screen e chiavi @ vengono ignorate, quindi globalStyle() non può scrivere regole media.
  • La stessa regola viene aggiunta una sola volta. Uno stile diverso per lo stesso selettore aggiunge una seconda regola, e vince la successiva.

getCssText() & resetStyles()

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

getCssText() restituisce ogni regola che css(), cx(), variants(), keyframes() e globalStyle() hanno creato in questo processo: prima le regole di base, poi i blocchi @media, @container e @supports. Usala per il rendering lato server.

  • Chiamala dopo renderToString(). Le regole create durante il render sono incluse.
  • Copre l'intero processo, non una pagina o una richiesta, e cresce soltanto. È sicuro, perché i nomi delle classi sono hash del contenuto.
  • Mettila in <style id="nuclo-styles">. Il client riusa quell'elemento e salta le regole che già contiene. Con un altro id, o senza id, il client aggiunge un secondo foglio di stile con tutte le regole di nuovo.
  • Il testo non viene escapato, quindi non mettere mai input non fidato nei valori di stile.

resetStyles() è un helper per i test. Rimuove ogni regola e l'elemento #nuclo-styles. Gli stili creati prima mantengono i nomi delle classi ma perdono il loro CSS. Non chiamarla in un'app o su un server.

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

beforeEach(() => resetStyles())

nuclo-router

#
Pacchetto separato: nuclo-router non fa parte del pacchetto core di Nuclo. Installalo accanto a nuclo. È una release in anteprima, quindi la sua API può ancora cambiare.

Routing per Nuclo: rendering lato server, idratazione, rotte con code splitting e precaricamento in idle, costruito su list() e update(). Circa 4.9 KB gzip, senza dipendenze oltre a Nuclo stesso e senza side effect, quindi il tree-shaking lo elimina quando non è usato.

terminal
$ npm install nuclo nuclo-router

Oppure usa pnpm add, yarn add o bun add con gli stessi due nomi. Richiede nuclo 0.3 o successivo come peer dependency.

Il router decide cosa caricare. L'app dice dove con router.outlet(): una list() sullo stack dei livelli, una riga per livello, quindi una pagina ordinaria è una sola riga. Server e client costruiscono lo stesso albero, così hydrate() reclama i nodi del server invece di sostituirli. Tutto il resto è history, un listener click delegato e requestIdleCallback.

Un solo import

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

start() risolve i moduli della rotta corrispondente prima di restituire, quindi il primo albero è completo sia sul server sia sul client. Da lì in avanti il router legge attraverso la rotta che ha avviato: router.path, router.pending, router.go().

Un'app completa

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

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

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

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

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

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

Questa è tutta l'integrazione. I link funzionano perché un <a href> è semplicemente un <a href>. Un listener delegato su document intercetta i link di cui il router è responsabile e lascia ogni altro link al browser.

Sul server, start() restituisce una Route per richiesta e non tocca il router, quindi richieste concorrenti non possono vedere l'una la rotta dell'altra. route.run() rende quella Route quella letta dal router, e quindi dall'outlet, per la durata del render. Nel browser c'è una Route per caricamento di pagina, e start() la rende quella propria del router. Non serve alcun run().

Una pagina riceve il suo RouteContext e le sue PageProps, come (ctx, { layer, data, outlet, beforeLeave }), e può accettare meno argomenti. Restituisce qualsiasi cosa list() accetti. Per riempire anche un'altra parte del layout, restituisci un into() dall'interno della pagina, come div(article(…), into('sidebar', nav(…))). Quel contenuto se ne va insieme alla pagina. Vedi Pagine e regioni.

Pattern e rotte annidate

#
PatternCorrisponde aparams
/, /docs/introesattamente quel percorso{}
/blog/:slug/blog/hello{ slug: 'hello' }
/u/:id/edit/u/7/edit{ id: '7' }
/files/*rest/files/a/b.txt{ rest: 'a/b.txt' }
*tutto ciò che non corrisponde ad altro, la tua 404{ '*': '…' }

La precedenza è: statici, poi dinamici in ordine di dichiarazione, poi catch-all con il prefisso più lungo per primo. Quindi '*' corrisponde solo a ciò che nient'altro intercetta e può stare in qualsiasi punto della tabella. Slash finali, slash duplicati e percent-encoding vengono normalizzati, e i valori dei parametri arrivano decodificati. Il matching è un confronto per segmento per ogni pattern. Non vengono costruite né eseguite regex.

Rotte relative

Un pattern scritto con ./ è relativo al genitore che lo dichiara, e un href scritto con ./ è relativo alla rotta attiva. Insieme formano una sezione riutilizzabile che significa la stessa cosa sotto ogni genitore. Il valore di una tabella è un loader oppure un'altra tabella, le cui chiavi sono relative a essa:

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

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

Quelle due righe producono tutti e sei i pattern senza scrivere due volte alcun percorso. Entrambi i genitori ricevono gli stessi oggetti loader, quindi condividono una sola cache dei moduli e un solo import().

  • Dentro una tabella figlia, './x', '/x' e 'x' significano tutti la stessa cosa, '/' indica il percorso del genitore stesso, e '*rest' dà alla sezione il proprio catch-all. L'annidamento può essere profondo quanto vuoi.
  • Le chiavi di primo livello sono usate così come sono scritte, quindi '*' resta '*'.
  • Un figlio sotto un genitore *catch-all è un errore di createRouter(), perché un catch-all deve chiudere il suo pattern.

Pagine genitore e outlet()

Un genitore e i suoi figli sono una vera gerarchia. Il genitore renderizza, e il figlio renderizza al suo interno, nel punto in cui chiama outlet():

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

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

Navigare tra i figli non ricostruisce il genitore. Il suo nodo DOM è lo stesso nodo, quindi il focus, la posizione di scroll, l'input digitato a metà e qualsiasi stato nella sua closure sopravvivono tutti.

  • Una pagina senza rotta figlia non renderizza nulla dal suo outlet, quindi un layout può chiamarlo incondizionatamente. Un genitore che ha un figlio e non chiama mai outlet() lo inghiotte.
  • Non esistono rotte index. '/' è il layout stesso, quindi il percorso del genitore renderizza il layout con un outlet vuoto invece di una pagina index separata al suo interno. Metti il contenuto di atterraggio nel layout, oppure dagli un vero percorso figlio.

La stessa chiamata funziona poi da entrambi i genitori, e da una pagina che non sa in quale dei due si trova:

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

./x, ../x, . e .. si risolvono rispetto al percorso della rotta corrente, non alla "directory" dell'URL. Da /invoices/42, ./preview è /invoices/42/preview, dove un browser avrebbe detto /invoices/preview, e uno slash finale nell'URL non cambia nulla. ../ sale di un segmento e si ferma alla radice. Query e hash vengono mantenuti, base viene riaggiunto, e il percorso assoluto risolto è ciò che finisce nella history. È la stessa deviazione dalla risoluzione degli URL che React Router adotta per <Link to="./x">.

  • Solo ./ e ../ sono relativi alla rotta. Un semplice preview in un href è lasciato alle normali regole del browser.
  • Costruisci i link relativi con router.href('./preview') invece di scrivere href="./preview" a mano. href() risolve al momento del render, quindi il markup che viene inviato è assoluto e resta corretto per un crawler, per un click con il tasto centrale e con JavaScript disabilitato.

Cosa viene mantenuto, e cosa ricostruito

NodoAlla navigazione
un genitore il cui pattern e percorso non sono cambiatimantenuto: stesso nodo, e il suo load() non viene rieseguito
la pagina corrispondentemantenuta quando pattern e percorso non sono cambiati e non ha un load(), quindi un cambio di query o hash la conserva. Una rotta con un loader si aggiorna a ogni visita.
un genitore il cui percorso è cambiatoricostruito, insieme a tutto ciò che sta sotto di lui
un livello aperto con pushrenderizza la sola pagina corrispondente. I suoi genitori sono già montati nella riga sottostante.

Una pagina mantenuta conserva il ctx con cui è stata costruita. Questo è il significato di ctx ovunque: leggi router.* per i valori live. Lo stato guidato dalla query, come filtri, paginazione e tab, legge router.search dentro un valore dinamico e scrive con router.go('?page=2', { replace: true }). La pagina, il suo focus e il suo input digitato a metà restano dove sono.

Pagine e regioni

#

router.outlet() mette la pagina in un punto. Una pagina che ha bisogno anche di una sidebar, una toolbar o un'intestazione altrove nel layout la riempie con into(). Il layout segna il punto con region(), e la pagina restituisce un into() accanto al proprio contenuto. Il layout non riceve mai contenuto di pagina come prop, e la navigazione non lo ricostruisce mai.

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

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

Il contenuto della pagina è una riga della list() dell'outlet, e un into() vive quanto la riga che lo contiene. Quindi il contenuto della sidebar arriva con la pagina e se ne va con lei: navigare verso una pagina senza un into('sidebar', …) mostra di nuovo il contenuto empty della regione. La pagina può usare il suo ctx e i suoi data per entrambe le parti, perché entrambe sono costruite dalla stessa funzione.

Cosa succede alla navigazione

  • Una pagina mantenuta conserva le sue regioni. Un cambio di query o hash che mantiene la pagina non tocca nemmeno il contenuto della regione. Leggi router.search dentro un valore dinamico se la sidebar dipende da esso.
  • Una pagina ricostruita le riempie di nuovo. Quando la rotta cambia, la vecchia riga viene rimossa e le viste into() della nuova pagina prendono il suo posto. Non resta nulla indietro.
  • Un livello aperto con push aggiunge una vista. Il livello è una riga successiva dell'outlet, quindi il suo into() è una vista più recente della stessa regione. Con il default type: 'latest' copre la vista della pagina finché il livello non si chiude, e poi la vista della pagina riappare. Con type: 'stack' si vedono entrambe.
  • Anche una pagina genitore può riempire regioni. Le sue viste restano mentre i figli cambiano, perché il genitore non viene ricostruito.
  • Il rendering lato server funziona così com'è. Il server costruisce la pagina dentro route.run(), quindi le sue viste into() sono nell'HTML, e hydrate() le reclama.

Più regioni da una sola pagina

Passa un oggetto per riempire più regioni insieme. Il contenuto principale della pagina passa comunque dall'outlet, quindi restituiscilo accanto all'into():

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

Quale strumento per quale compito

  • outlet() è per una rotta figlia: contenuto deciso dall'URL, renderizzato dentro la sua pagina genitore. Vedi Rotte annidate.
  • into() è per la pagina corrispondente che si distribuisce nel layout. Il contenuto appartiene alla pagina e usa i suoi dati.
  • matches() è per il layout che decide dal solo URL, con la propria piccola tabella e senza alcuna pagina coinvolta. Vedi matches().

Tutti questi appartengono all'unica rotta corrispondente. Il router non guida due aree indipendenti da un solo URL, quindi una sidebar con le proprie rotte è una regione che una pagina riempie, non un secondo outlet. Una sidebar che vuole davvero un proprio router, caricamento e fallback ne tiene uno non avviato e risolve con match() — vedi Più router e caricamento combinato.

Loader di rotta e code splitting

#

Un modulo di pagina può esportare una funzione load. Viene eseguita prima che la pagina sia costruita, a ogni navigazione verso quella rotta, e ciò che restituisce viene passato alla pagina come data:

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

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

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

La tabella delle rotte non cambia. Il loader viaggia con la pagina. Riceve il RouteContext corrispondente, quindi params, search e path sono tutti disponibili. Può essere sincrono o asincrono, e viene eseguito anche sul server, quindi una pagina renderizzata lato server arriva con i suoi dati già nell'HTML invece di fare fetch dopo l'idratazione.

Il modulo è in cache. I dati no. Il chunk di una rotta viene scaricato una volta e riusato, e il suo loader viene rieseguito a ogni navigazione. Per questo /blog/a e /blog/b non possono essere confusi, e per questo un loader fallito può semplicemente essere ritentato.

SituazioneComportamento
mentre è in esecuzionerouter.pending è true e la pagina in cui ti trovavi resta visibile
lancia un'eccezione o rifiutafinisce in router.error. go() continua a non rifiutare mai, e la navigazione può essere ritentata.
durante start()il rifiuto esce da start(). Non c'è ancora una Route su cui metterlo.
un livello aperto con pushesegue il proprio loader, con i propri dati
il precaricatore in idlescalda solo i moduli. Non ha alcun contesto da passare, e scaricherebbe dati per una rotta che potresti non aprire mai.
rinavigare allo stesso URLil loader viene rieseguito e la riga ricostruita. Una rotta senza loader mantiene la sua riga.

Per rivalidare, naviga verso dove sei già senza aggiungere una voce alla history:

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

Avviare i dati insieme al chunk

Un load esportato dal modulo di pagina può partire solo quando quel modulo è arrivato, quindi una navigazione a freddo scarica il chunk, poi i dati. Per avviare entrambi insieme, dichiara il loader nella tabella con withLoad():

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

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

Sostituisce il load proprio del modulo e viene eseguito a ogni visita come quello. Un rifiuto mentre il chunk è ancora in caricamento finisce in router.error come qualsiasi altro.

Non esiste alcuna serializzazione dei dati del server nell'HTML, per scelta. Il load del client viene rieseguito all'idratazione. Se la cosa è importante, fai emettere al server route.data, per esempio come window.__DATA__, e fai in modo che il loader lo prenda per primo. route.data sono solo i dati della pagina corrispondente. Il risultato del load() di un genitore non ci sta.

Code splitting e precaricamento

Il valore di una rotta è sempre una funzione che restituisce il componente di pagina, quindi il componente viene raggiunto in modo lazy:

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

Solo le forme a modulo possono portare un export load. Scegliere un componente con nome tramite .then(m => m.BPage) consegna al router la sola funzione.

Ogni modulo viene importato una volta e messo in cache. Navigazioni concorrenti verso la stessa rotta condividono un solo import(). Una volta che l'utente ha lasciato la pagina in pace per idleTimeout (un secondo di default; nessun tocco, tasto, rotella o scroll), le rotte rimanenti vengono caricate in background, una per ogni intervallo idle (requestIdleCallback). Qualsiasi input, o una navigazione che l'utente sta aspettando, lo mette in pausa finché la pagina non torna idle.

Una rotta precaricata o eager non attiva mai router.pending e atterra prima del paint successivo: nessun frame di spinner, nessun layout shift. router.pending è true solo per un modulo o un loader che va atteso, e la pagina in uscita resta sullo schermo nel frattempo. Un chunk che non riesce a caricarsi emerge come router.error, mai come un rifiuto non gestito, e può semplicemente essere ritentato.

API del router

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

createRouter(table, options?)

table associa un pattern a un loader, o a una tabella annidata. Opzioni:

OpzioneDefault
base'/'Prefisso URL sotto cui è servita l'app, come '/docs'. Corrisponde solo al confine di un segmento: /docs/intro è dentro '/docs', /docsearch no. Gli URL fuori da esso non corrispondono a nulla, quindi start() rifiuta (la tua 404 lato server) e i link verso di essi sono lasciati al browser.
preloadtrueCarica i moduli delle rotte rimanenti in background quando la pagina è idle. false lo disattiva.
idleTimeout1000Millisecondi senza tocchi, tasti, rotella o scroll prima che il precaricamento inizi, o riprenda dopo che l'utente era attivo.
onNavigate(ctx, kind) => void, chiamata dopo ogni navigazione risolta nel browser, inclusa quella iniziale, una volta che il DOM si è aggiornato. kind è 'start', 'push', 'replace', 'pop' (Indietro, Avanti, close()) o 'layer'. L'hook per document.title, meta tag, focus, scroll e analytics. Mai chiamata durante la SSR.

Restituisce il router: start(), match(), outlet(), matches(), e ogni membro di Route tranne run(), letti attraverso la Route attiva. Questa è quella restituita dall'ultimo start() nel browser, o quella il cui run() è in esecuzione sul server. Leggere un membro senza nessuna delle due lancia un'eccezione. La tabella delle rotte, il matcher compilato e la cache dei moduli vivono sul router, quindi sono condivisi tra le richieste su un server.

router.start(url?)

Risolve url (default: location.href nel browser) nei suoi moduli di pagina e restituisce una Promise<Route>. Rifiuta quando nulla corrisponde e la tabella non ha una rotta '*'. Su un server, questa è la tua 404. Nel browser rende anche la Route quella attiva del router, aggancia i listener popstate e click delegato e pianifica il precaricamento. Un secondo start() ritira la Route precedente per te. Sul server non tocca nulla di condiviso, quindi renderizza dentro route.run().

router.match(url?)

Risolve un URL rispetto alla tabella senza caricare nulla: nessun import, nessuna history, nessun listener, nessuna Route. Restituisce un RouteContext, o null quando l'URL è fuori da base, non corrisponde a nessun pattern o, nel browser, è su un'altra origine. Solo URL assoluti, perché la risoluzione di ./ richiede una rotta attiva.

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

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

router.outlet()

Le pagine della rotta attiva come list(). Posizionalo una volta, dove vanno le pagine. Un livello aperto con push è una sua riga successiva. È vuoto prima che start() si risolva e dopo stop(). Sul server renderizza la Route il cui run() è in esecuzione.

router.matches(table)

Renderizza la vista di una seconda tabella a cui corrisponde il percorso della rotta attiva, come una list() che posizioni ovunque. Vedi matches().

Route

Ogni membro tranne run() è anche sul router, che è il punto da cui un'app lo legge.

Membro
path, pattern, params, search, hash, urlla corrispondenza attiva. search è un URLSearchParams. search, hash e url sono live.
pendinguna navigazione sta aspettando un modulo o un loader
errorl'ultimo caricamento fallito, azzerato dalla navigazione successiva
dataciò che il load() della pagina corrispondente ha restituito, perché un server lo serializzi. Quello di un genitore non ci sta.
depthquanti livelli ci sono nello stack (1 per una pagina ordinaria)
go(href, { replace })naviga, caricando il modulo se serve. Non rifiuta mai: un modulo che fallisce, rifiutando o lanciando un'eccezione, finisce in error.
push(href)apre una rotta come nuovo livello in cima, risolvendosi con ciò con cui si chiude. Accetta ./. Rifiuta se nulla corrisponde o il modulo fallisce.
href(path)prefissa un percorso con base, per a({ href }). Risolve ./ e ../ rispetto alla rotta attiva.
stop()stacca ogni listener, chiude i livelli aperti, svuota l'outlet. Idempotente. Una Route fermata risponde ancora con la sua ultima corrispondenza, e go() non fa nulla.
run(fn)chiama fn con questa Route come quella attiva del router e ne restituisce il risultato: route.run(() => renderToString(App)) sul server. Solo sincrono. La Route smette di essere attiva quando fn ritorna.

RouteContext

Il primo argomento di una pagina: path, pattern, params, search, hash, url. È il contesto con cui la pagina è stata costruita. Leggi router.* per i valori live. Differiscono solo quando query o hash sono cambiati senza una ricostruzione.

params è { [name]: string } di default. Una pagina può tipizzarlo dal proprio pattern con Params, che trasforma '/blog/:slug' in { slug: string } e '/files/*rest' in { rest: string }:

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

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

PageProps

Il secondo argomento di una pagina. Destruttura ciò di cui la pagina ha bisogno.

Prop
layerdove si trova la pagina nello stack. Vedi Layer sotto.
dataciò che il load() della rotta ha restituito, o undefined se non ne ha uno. Tipizzato da PageProps<TData>.
outletdove una pagina genitore renderizza la sua rotta figlia. Vedi Rotte annidate.
beforeLeaveregistra una guardia consultata prima che questa pagina venga scartata. Vedi Guardie di uscita.

Layer

Dove si trova una pagina nello stack, e la sua via d'uscita.

Membro
depth0 per la pagina di base, 1 e oltre per i livelli aperti con push
push(href)apre un altro livello dall'interno di una pagina, come Route.push()
close(result?)chiude questo livello e tutto ciò che sta sopra, risolvendo il push() che lo ha aperto. Senza argomento significa chiuso senza risultato. Non fa nulla a profondità 0.

DataLoader e withLoad()

DataLoader è (ctx: RouteContext) => TData | Promise<TData>, l'export opzionale load di un modulo di pagina. withLoad(loader, load) crea una rotta il cui load parte accanto all'import del suo chunk invece che dopo. Vedi Loader di rotta.

Tipi

Ogni tipo è un export con nome di nuclo-router, quindi una pagina, un loader o una guardia possono essere tipizzati senza importare da un percorso.

TipoCos'è
Params<'/blog/:slug'>l'oggetto params per un pattern. RouteParams è la forma non tipizzata, e Match è il risultato del matcher.
PageComponentuna pagina: (ctx, { layer, data, outlet, beforeLeave }) => …
RouteModuleun modulo di pagina: il componente stesso, o { default, load? } come restituito da import()
RouteLoader() => RouteModule | Promise<RouteModule>, che può portare un load da withLoad()
RouteTablela tabella che createRouter() accetta: pattern verso loader, o verso una tabella annidata
RouterOptions{ base?, preload?, idleTimeout?, onNavigate? }
NavigateOptions{ replace? }, il secondo argomento di go()
NavigationKind'start' | 'push' | 'replace' | 'pop' | 'layer', il secondo argomento di onNavigate
Outletil tipo di outlet() da PageProps
LeaveGuard(to) => boolean | void | Promise<…>, ciò che beforeLeave() accetta
MatchViewcos'è una vista di matches(): params in ingresso, contenuto o import() di una pagina in uscita

Livelli e guardie di uscita

#

push() apre una rotta sopra quella corrente invece di sostituirla, e si risolve con ciò con cui quel livello si chiude. Poiché le pagine sono un'unica list() sullo stack, aprire un livello è un append. La pagina sottostante non viene rirenderizzata, quindi il suo DOM, il suo focus e il suo form compilato a metà sono ancora tutti lì quando il livello si chiude.

Questo fa funzionare il flusso "mi serve un record che non esiste ancora" senza buttare via il form che l'utente stava compilando:

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

I livelli si annidano. Un dialogo che ha bisogno di un proprio dialogo chiama layer.push() e lo attende, senza alcun riferimento alla Route.

  • L'URL è il livello in cima. push() aggiunge una voce alla history, quindi un livello è linkabile e Indietro lo chiude, risolvendo il suo push() come chiuso senza risultato, esattamente come Annulla. Indietro e close() sono un unico percorso di codice: close() registra il suo risultato e torna indietro nella history, e la profondità in history.state è l'autorità. I browser conservano circa 50 voci per tab, quindi dopo una lunga camminata tra i livelli la voce sottostante potrebbe essere sparita: in quel caso close() chiude i livelli sul posto e riscrive la voce corrente con la pagina rimasta visibile.
  • Una navigazione ordinaria svuota lo stack. Un click su un link o go() non è un livello. Sostituisce tutto e chiude ogni livello aperto, così nessun chiamante resta in attesa.
  • Un caricamento a freddo è una pagina autonoma. L'URL indica il livello in cima ma non lo stack sotto di esso, quindi aprire direttamente l'URL di un livello lo renderizza a profondità 1 con layer.depth === 0 e un close() inerte. Una rotta aperta con push deve avere senso da sola. Diramati su layer.depth per offrire un link di uscita invece di un pulsante Annulla morto.
  • La promise non può sopravvivere a un ricaricamento, perché vive in una closure. Tratta il risultato come una scorciatoia attraverso un flusso che funziona anche per la strada lunga.
  • Nulla resta in attesa. stop() chiude ogni livello aperto, e un livello abbandonato da una navigazione si risolve con undefined.

Guardie di uscita

Una pagina che contiene qualcosa che vale la pena conservare, come un form compilato a metà, registra una guardia. Viene consultata prima che la pagina venga scartata, e restituire false (o una promise di esso) mantiene la pagina:

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

export default function Edit(_ctx: RouteContext, { beforeLeave }: PageProps) {
  let dirty = false
  beforeLeave(() => !dirty || confirm('Discard your changes?'))
  return form(input({ onInput: () => (dirty = true) }), …)
}
  • Solo quando la pagina se ne va. Una navigazione figlia sotto un layout, o un cambio di query o hash che mantiene la pagina, non la consulta. Una guardia vive e muore con la sua pagina, quindi non c'è nulla da deregistrare.
  • Ogni via d'uscita. Un link, go(), Indietro e Avanti, il close() di un livello, e go() verso un URL di cui il router non è responsabile (uscire dall'app, dove to è null). Un Indietro o Avanti rifiutato viene annullato, così la barra degli indirizzi resta sulla pagina. Chiudere la tab o ricaricare è compito di beforeunload.
  • to è il RouteContext verso cui la navigazione si dirige. Le guardie vengono eseguite dalla più interna e si fermano al primo rifiuto. Una che lancia un'eccezione mantiene la pagina. Anche close(result) passa dalla guardia, quindi azzera il tuo flag prima di chiudere con un risultato.
  • Il router può annullare solo le voci della history che ha scritto lui. Un Indietro o Avanti verso una voce che non ha scritto, come un link nativo #fragment o il pushState di un altro script, non viene sottoposto alla guardia.

matches() e sub-router

#

router.outlet() renderizza la pagina. router.matches(table) renderizza qualcos'altro che dipende da dove ti trovi, come una sidebar, un breadcrumb o una toolbar, dalla propria piccola tabella, senza caricare una rotta né aggiungere una pagina:

app.ts
div(
  aside(
    router.matches({
      '/':           () => nav('Home links'),
      '/blog/new':   () => nav('Drafting tips'),
      '/blog/:slug': ({ slug }) => nav(`More like ${slug}`),
      '*':           () => import('./Sidebar.ts'),   // code-split, rendered once loaded
    }),
  ),
  main(router.outlet()),
)
  • Stesso matching della tabella delle rotte. I pattern, la precedenza, il matching sul percorso grezzo e la rimozione di base sono quelli del router, quindi una vista e una pagina sono sempre d'accordo sull'URL. Legge il percorso del livello in cima, come router.path.
  • Una vista è (params, ctx) => content, dove params è tipizzato dal suo pattern e ctx è il RouteContext con il pattern e i params della vista. Può invece restituire l'import() di un modulo di pagina, che viene chiamato con ctx quando arriva.
  • Nulla corrisponde, nulla viene renderizzato. Aggiungi una vista '*' come fallback. È vuoto prima di start() e dopo stop(), come l'outlet.
  • Viene rirenderizzato solo quando la rotta cambia, quindi una vista mantiene il suo DOM attraverso qualsiasi cosa mantenga la pagina.
  • Una vista lazy non renderizza nulla finché il suo modulo non arriva. Un import() fallito viene registrato con console.error, non imposta router.error, e viene ritentato alla navigazione successiva. Un semplice update() non ritenta.
  • Sul server una vista lazy non invia nulla, perché il suo modulo arriva sul client. Usa una vista sincrona per tutto ciò che il primo HTML deve contenere.

Sub-router

Le rotte si annidano, quindi un sub-router di solito è semplicemente una tabella annidata. Un solo router: la pagina '/' della sezione è la sua cornice, e i suoi figli renderizzano nell'outlet() di quella pagina, così la cornice sopravvive alla navigazione dentro la sezione:

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

Oppure dai alla funzionalità un router tutto suo, creato ma mai avviato. Il genitore possiede una sola voce catch-all e il figlio risolve la coda rispetto ai propri pattern, params e fallback:

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

Non avviarlo mai è il punto. start() è ciò che aggancia i listener, e due router avviati entrano in conflitto. Il compromesso è che il figlio possiede la risoluzione, quindi i loader di rotta, che un router avviato esegue, spetta a te chiamarli. Questa forma inoltre ricostruisce la shell quando la coda cambia, perché il router vede una sola rotta e la shell risolve il resto da sé. Una tabella annidata è ciò che ti dà un genitore che il router tiene montato, quindi preferiscila a meno che la funzionalità non abbia davvero bisogno di un proprio router, base e fallback.

Più router e caricamento combinato

#

Un'app può avere tanti router quante sono le sue aree. La regola che mantiene tutto semplice: avviane esattamente uno per documento, e lascia ogni altro router non avviato, a risolvere URL con match(). Solo il router avviato possiede l'URL, la history e il listener dei click. Gli altri sono tabelle a cui fai domande — e ogni area può caricare con il proprio ritmo, con il proprio spinner.

Caricamento combinato: una catena di loader

Ogni pagina di una catena corrispondente può esportare load(). In una navigazione a freddo verso una rotta figlia, il loader del layout e quello della pagina vengono eseguiti in parallelo, e un unico router.pending copre l'intera catena: la pagina precedente resta sullo schermo finché la coppia non si risolve, così un albero caricato a metà non viene mai dipinto. Ogni componente riceve i propri data:

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

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

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

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

Entrambi i loader partono insieme, non uno dopo l'altro. router.pending è true mentre uno dei due è in attesa, e false il resto del tempo — una rotta precaricata o eager non lo attiva mai. Spostarsi tra le sezioni mantiene il layout, quindi viene rieseguito solo il loader della sezione; i dati del layout, il suo DOM e un input digitato a metà sopravvivono tutti. Rinavigare allo stesso URL riesegue il loader della foglia e ricostruisce la sua riga, esattamente come con una pagina singola.

Caricamento multiplo: un secondo router con il proprio spinner

Un'area indipendente — una sidebar, un tray, una live region — riceve un router tutto suo, creato ma mai avviato, e il proprio flag di caricamento. router.pending appartiene al router avviato e non dice nulla su quest'area, quindi le due possono caricare fianco a fianco e ciascuna può finire per prima:

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

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

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

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

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

Al secondo router si chiede, non si comanda: match() legge l'URL e restituisce un contesto, e nulla della sidebar entra mai nella tabella delle rotte principale. onNavigate viene eseguito dopo ogni navigazione risolta, inclusa quella iniziale, quindi è l'unico punto che tiene un'area indipendente al passo con l'URL.

  • pending appartiene al router avviato. Un router mai avviato non ha una Route, né pending né error. L'area traccia i propri flag, e un secondo spinner può stare accanto al primo. Nella demo la sidebar carica per 800ms mentre i loader della rotta girano per 900ms, quindi la pagina può finire prima della sidebar o viceversa.
  • match() non aggancia nulla. Un numero qualsiasi di router non avviati può condividere il documento; ciascuno ha la propria tabella, base e cache dei moduli, e nessuno di essi tocca la history. Costano una tabella e un matcher.
  • I suoi loader non vengono mai eseguiti. Il router avviato esegue il load() delle pagine a cui corrisponde. Un router mai avviato risolve soltanto, quindi se le sue viste hanno bisogno di dati, recuperali tu — il refreshSidebar() della demo. Se vuoi i loader, una tabella annidata o una regione riempita da una pagina lascia quel compito al router avviato.
  • Proteggiti dalle risposte stantie. Un fetch lento della sidebar non deve atterrare sulla sezione da cui ti sei allontanato. Incrementa un contatore di generazione a ogni refresh e scarta le risposte che non corrispondono più, come fa il router stesso quando ritira le navigazioni superate.
  • Nemmeno una vista lazy di matches() ha un pending. Non renderizza nulla finché il suo modulo non arriva. Tre sistemi di caricamento, tre orologi indipendenti: il pending della rotta, i tuoi flag, e una vista lazy che semplicemente non c'è ancora.

Valori calcolati

#

Nuclo non ha un'API per i valori calcolati. Usa una semplice funzione, e chiamala dai valori dinamici:

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

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

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

filter = 'an'
update()

Async e caricamento

#

Usa i normali async/await, e chiama update() ogni volta che lo stato cambia. Per un fetch, significa una volta prima dell'await e una volta dopo:

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

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

Buone pratiche

#
  • Raggruppa le modifiche. Cambia tutto lo stato che ti serve, poi chiama update() una volta sola.
  • Mantieni i valori dinamici veloci e puri. Vengono tutti eseguiti a ogni update().
  • Usa scope() per aggiornamenti piccoli e frequenti, come la digitazione o i timer.
  • Renderizza nel documento. I nodi che non sono nel documento quando update() viene eseguito smettono di aggiornarsi.
  • Usa when() e list() per i figli che cambiano, non una funzione che restituisce nodi.
  • Usa className, non class, e usa () => cx(...) o { className: () => … } per le classi dinamiche.
  • Definisci gli stili una sola volta, a livello di modulo.
  • Rendering lato server: renderizza prima, poi metti getCssText() in <style id="nuclo-styles">. Passa i dati della richiesta come argomenti, e mantieni i commenti HTML nell'output.

Indice delle API

#

Ogni funzione pubblica, e da dove proviene.

nuclo (globale ed export con nome)

nuclo (solo export con nome)

nuclo/ssr

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

nuclo/polyfill

nuclo-router (pacchetto separato)

Solo globali

  • 175 costruttori di tag: div, span, …, var_, e svgSvg, pathSvg, ….
  • Tipi: NodeModFn, NodeModLike, ExpandedElement e altri. I tipi di stile come StyleResult possono anche essere importati da 'nuclo'.

Chiavi speciali

Domande frequenti

#

Cambiare lo stato aggiorna la UI?

No. Il DOM resta com'è finché non chiami update().

Nuclo usa signal, proxy o un DOM virtuale?

No. Lo stato resta semplice JavaScript. Quando un albero viene renderizzato, Nuclo crea elementi e nodi di testo reali. update() esegue ogni valore dinamico registrato, o quelli dentro gli scope indicati, e aggiorna quegli stessi nodi dove un valore è cambiato. Non c'è alcun grafo delle dipendenze né alcun albero virtuale.

Perché la mia pagina mostra "false"?

I booleani vengono renderizzati come testo. div(ok && span('Done')) mostra "false". Usa when(() => ok, span('Done')) per i figli, e ok ? 'Done' : '' per il testo.

Perché la riga della mia lista non cambia quando modifico l'elemento?

Le righe degli elementi ancora presenti nella lista vengono mantenute, non renderizzate di nuovo. Usa valori dinamici come () => todo.text per i campi che cambiano. Vedi list().

Perché una parte della mia UI smette di aggiornarsi?

Di solito i suoi nodi non erano nel documento quando update() è stato eseguito. Nuclo smette di tracciare quei nodi, anche dopo che li hai collegati, quindi renderizza in un contenitore che è nel documento. Altre due cause: un when() la cui condizione lancia un'eccezione smette di aggiornarsi, e una funzione di testo che lancia un'eccezione alla prima esecuzione resta vuota. Vedi update().

Quando dovrei usare forceUpdate()?

Solo per cambiamenti rari a valori statici, come il cambio di lingua. Per tutto il resto, usa update(). Vedi forceUpdate().

Come metto una pagina in un layout senza passarla come prop?

Segna il punto con region({ id: 'main' }) e fai restituire alla pagina into('main', …). Vedi region() & into().

Come smonto un'app?

Avvolgila in when() e disattiva la condizione. Rimuovere il DOM da solo non esegue onDestroy. Vedi Ciclo di vita.

Nuclo include un router?

Non nel pacchetto core. Il routing è distribuito separatamente come nuclo-router, con rendering lato server, idratazione, rotte con code splitting e precaricamento in idle. Installalo con npm install nuclo nuclo-router. Vedi nuclo-router.

Nuclo ha gli effetti?

No. onMount e onDestroy vengono eseguiti una volta per elemento, legati all'inserimento e alla rimozione. Nulla viene rieseguito perché uno stato è cambiato.