Documentazione di Nuclo
Un riferimento pratico per installare Nuclo, costruire con aggiornamenti espliciti, applicare stili con CSS tipizzato e renderizzare HTML lato server.
npm create nuclo@latestPanoramica
#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.
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
- Costruisci la UI con i costruttori di tag.
- Passa una funzione per ogni valore che dipende dallo stato.
- Cambia lo stato con JavaScript puro.
- 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:
Ti chiede il nome del progetto. Per saltare la domanda, passa il nome e i flag. Con npm, metti -- prima dei flag:
$ npm create nuclo@latest my-app -- --template basic --yes
Poi installa e avvia il server di sviluppo:
$ 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 solobasic.-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:
$ 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 suffissoSvg(svgSvg,pathSvg, …). Vedi Costruttori di tag. - 16 helper:
update,scope,when,list,region,into,on,render,hydrate,forceUpdate,css,cx,createCss,variants,keyframeseglobalStyle.
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:
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.
import { render, update, getCssText } from 'nuclo'
Punti di ingresso
nuclo: registra i globali ed esporta i 16 helper piùgetCssTexteresetStyles.nuclo/ssr:renderToString,renderManyToString,renderToStringWithContaineregetCssText. 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".node16enodenextnon sono ancora supportati.- La lib
DOM. TypeScript la include per impostazione predefinita quando non impostilib.
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:
{ "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 restituisconorender()ehydrate().ElementTagName: qualsiasi nome di tag HTML.ExpandedElementAttributes<Tag>: l'oggetto degli attributi che un costruttore di tag accetta, inclusi gli handleron*,onMounteonDestroy. Usalo per i componenti che passano gli attributi al loro interno.MountCallbackeDestroyCallback: gli handleronMounteonDestroy.WhenBuilder,ListModifier: ciò che restituisconowhen()elist().RegionOptions,RegionType: le opzioni che accettaregion().StyleResult,Style,ThemeConfig,CssInstancee gli altri tipi di styling. Puoi anche importarli:import type { StyleResult } from 'nuclo'.
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.
Costruttori di tag
#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.
let name = 'Ada' const Card = () => div( { id: 'card', className: 'card' }, h2('Profile'), p(() => `Hello, ${name}`), button('Rename', { onClick: () => { name = 'Grace'; update() } }), ) render(Card, document.body)
update().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 ogniupdate(). Vedi Valori dinamici.null,undefined: ignorati.true,false: resi come testo "true" o "false". Usawhen()per le condizioni.- Un oggetto semplice: attributi. Vedi Attributi.
- Un risultato di
css(),cx()ovariants(): aggiunge le sue classi. on(...): un listener di eventi o un hook del ciclo di vita.- Un altro builder,
when()olist(): un figlio. - Un
Nodedel DOM: aggiunto così com'è. (el, index) => …: un modificatore personalizzato. Vedi sotto.
Insidie
div(isAdmin && span('Admin'))rende il testo "false". Scrividiv(when(() => isAdmin, span('Admin'))).- Gli array non sono figli. Espandili:
ul(...names.map((n) => li(n))). Per le liste che cambiano, usalist(). - Una variabile locale con il nome di un tag nasconde il costruttore. Dentro
function Row(label: string),label(...)non è chiamabile. Rinomina le variabili locali comelabel,title,data,p,aei. - I browser espongono ogni elemento con un
idcome variabile globale. Se la pagina contiene<main id="main">prima che Nuclo venga caricato, Nuclo lascia quel globale al suo posto, quindimain(...)lancia unTypeError. 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.
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:
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.
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
Componenti
#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.
function Counter(label: string) { let count = 0 // one per call return button(() => `${label}: ${count}`, { onClick: () => { count++; update() }, }) } render(() => div(Counter('A'), Counter('B')))
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:
function Card(heading: string, ...children: NodeModLike<'div'>[]) { return div({ className: 'card' }, h2(heading), ...children) } render(() => Card('Hi', p('one'), p('two')))
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().
let busy = false const save = button( { type: 'submit', disabled: () => busy, 'aria-busy': () => String(busy), 'data-id': 42 }, 'Save', )
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 chiamasetAttribute(key, String(value)), per esempio per"aria-*"e"data-*". - Sugli elementi SVG, Nuclo chiama sempre
setAttribute. nulleundefinedvengono ignorati. Un valore dinamico che restituiscenulloundefinedmantiene il valore precedente. Per azzerare un valore, restituisci"", oppurefalseper una proprietà booleana.- I booleani funzionano per le proprietà booleane del DOM, come
disabledsu un pulsante,checked,hiddenerequired. Ovunque altrove,trueefalsevengono scritti come stringhe "true" e "false". - Usa chiavi
"data-*".datasetnon è supportato. innerHTMLnon 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.
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.
let progress = 30 const bar = div({ style: () => ({ width: `${progress}%`, backgroundColor: 'teal' }) })
- Nuclo non aggiunge unità. Scrivi
"12px", non12. Le proprietà senza unità comeopacityezIndexaccettano numeri. "",nulleundefinedazzerano 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 invececss(). Accetta le proprietà personalizzate tramiteraw.
Eventi
#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.
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.currentTargetethissono l'elemento. - Nuclo imposta la proprietà nativa, quindi
onClickdiventael.onclick. C'è un solo handler per evento: unonClicksuccessivo sostituisce quello precedente. onDoubleClickeonDblClickindicano entrambidblclick. Le chiavi in minuscolo comeonclicksono errori di tipo.- Un nome che non è un evento noto ascolta la sua forma in minuscolo:
onMyEventascolta"myevent". - Gli errori lanciati dall'handler non vengono catturati.
Type something, then press Escape.
on()
on() restituisce un modificatore che chiama addEventListener sull'elemento.
const panel = div( on('scroll', onScroll, { passive: true }), on('click', logClick), on('click', closeMenu), )
- Ogni
on()aggiunge un listener. Vengono eseguiti tutti, in ordine. optionsva direttamente adaddEventListener: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:
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".
Ciclo di vita
#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.
let seconds = 0 const Timer = () => span( { onMount: () => { const id = setInterval(() => { seconds++; update() }, 1000) return () => clearInterval(id) // runs when the span is removed }, }, () => `${seconds}s`, ) const search = input(on('mount', (el) => el.focus()))
onMount 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
onMountviene eseguito alla fine della chiamata arender(),hydrate(),update()oforceUpdate()che ha inserito l'elemento. Non viene mai eseguito mentre l'albero è in costruzione.- Se
onMountrestituisce una funzione, quella funzione viene eseguita alla distruzione, dopo le callbackonDestroydell'elemento. onDestroyviene eseguito quando Nuclo rimuove l'elemento: una riga dilist()viene rimossa, un ramo diwhen()cambia, uninto()lascia la sua region, oppureforceUpdate()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():
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.
onDestroyesegue 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().
Nuclo non aggiorna mai la pagina da solo. Cambia il tuo stato, poi chiama update().
let count = 0 const Counter = () => button(() => `Clicked ${count} times`, { onClick: () => { count++; update() }, }) render(Counter)
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:
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.
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().
let name = 'Alice' let busy = false const Profile = () => div( h1('Welcome'), // static p(() => `Hello, ${name}`), // dynamic text button({ disabled: () => busy }, 'Save'), )
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' : ''). nulleundefinedvengono resi come testo vuoto.- Un risultato di
css()ocx()alla prima esecuzione rende la funzione una classe dinamica, non testo. Vedicx(). - 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
nulloundefinednon scrive nulla, quindi il valore precedente resta.classNameè l'eccezione: qualsiasi risultato falsy rimuove le classi dinamiche e mantiene quelle statiche. - Una funzione
styleapplica 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.
update()
#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:
list(): rilegge gli elementi e aggiunge, sposta o rimuove righe.when(): ricontrolla le condizioni. Un ramo viene ricostruito solo quando diventa attivo un ramo diverso.- Attributi dinamici, inclusi
styleeclassName. - Testo dinamico.
onMountper 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 perlist()ewhen(). 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 quelwhen()smette di aggiornarsi. Se una funzioneitemsorenderItemdilist()lancia un errore, l'errore esce daupdate()e i passi rimanenti non vengono eseguiti. - Server.
update()non fa nulla sul server.
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.
scope()
#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.
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"
- Un elemento può avere più id:
scope('cart', 'sidebar'). update('a', 'b')aggiorna tutto ciò che si trova dentroaob. 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.
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.
let status: 'loading' | 'error' | 'ready' = 'loading' const Status = () => div( when(() => status === 'loading', p('Loading…')) .when(() => status === 'error', p('Something went wrong')) .else(p('Ready')), )
else(). 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 > 0oppure() => 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
onDestroyvengono eseguiti. Il nuovo ramo viene costruito da zero e i suoi hookonMountvengono 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.
list()
#Renderizza una riga per elemento. A ogni update(), Nuclo chiama di nuovo items() e abbina i nuovi elementi alle righe esistenti per identità (===).
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
onDestroyvengono 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:
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.
Cosa restituisce renderItem
- Restituisci un solo elemento, come
li(...),tr(...)o un builder SVG. Restituiscinulloundefinedper saltare un elemento. Stringhe, nodi di testo e frammenti non rendono nulla. - Non restituire direttamente
when()olist(). Mettili dentro l'elemento della riga:li(todo.text, when(() => todo.done, ' ✓')). - Per nascondere elementi, filtra in
items(), non inrenderItem. - Mantieni
renderItemprivo 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() 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à.
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 uninto()si chiude, o la sua riga dilist()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 ainto()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.
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.
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.
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.
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.
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.
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.
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.
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
import 'nuclo' let page: 'home' | 'article' | null = 'home' let dialog = false const go = (next: typeof page) => () => { page = next dialog = false update() } const Layout = () => div( nav( button('Home', { onClick: go('home') }), button('Article', { onClick: go('article') }), button('Close page', { onClick: go(null) }), ), main( region({ id: 'main', type: 'stack', empty: p('Nothing open.') }), ), aside(region({ id: 'side', empty: p('No related links.') })), ) // Pages fill the layout's regions from outside it. const Home = () => into('main', input({ placeholder: 'Type here, then open the dialog' }), button('Open dialog', { onClick: () => { dialog = true; update() }, }), ) const Article = () => into({ main: article(h2('Article'), p('One into() fills both regions.')), side: [h2('Related'), ul(li('One'), li('Two'))], }) // Stacks over Home, so Home keeps its DOM and the text you typed. const Dialog = () => into('main', div( p('On top of Home.'), button('Close', { onClick: () => { dialog = false; update() } }), ), ) export const RegionApp = () => div( Layout(), when(() => page === 'home', Home()), when(() => page === 'article', Article()), when(() => dialog, Dialog()), )
into().Apri ↗No related links.
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.
const labels = { en: { title: 'Hello' }, pt: { title: 'Olá' } } let lang: keyof typeof labels = 'en' const App = () => div(h1(labels[lang].title)) render(App, root) lang = 'pt' forceUpdate() // same <h1> element, now "Olá"
update() 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
onMountnon 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 dilist()seguono la nuova costruzione. - I nuovi elementi vengono creati, e i loro hook
onMountvengono eseguiti. Gli elementi che la nuova costruzione non produce più vengono rimossi, e i loro hookonDestroyvengono 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.
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()
#Costruisce il tuo albero e aggiunge il suo elemento radice a parent. parent ha come valore predefinito document.body. Restituisce l'elemento radice.
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. Solorender(App)permette aforceUpdate()di ricostruire la radice in seguito, quindi preferiscilo. - Aggiunge in coda. Non svuota
parent, quindi chiamarlo due volte aggiunge due copie. - Ogni
onMountnell'albero è stato eseguito quandorender()ritorna. - Renderizza in un elemento che è nel documento. Vedi
update(). - La radice deve essere un elemento, come
div(...), nonwhen()olist(). 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:
const Page = (props: { name: string }) => div(h1('Hi ', props.name)) render(() => Page({ name: 'Ana' }), root)
hydrate()
#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.
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(), passaApp, nonApp(), così cheforceUpdate()possa ricostruire la radice in seguito.Appnon 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 diwhen()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
#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.
let count = 0 const heading = css({ color: 'red' }) export const App = () => main( h1(heading, () => `Count: ${count}`), button({ onClick: () => { count++; update() } }, '+1'), )
import 'nuclo/polyfill' import 'nuclo' import { renderToString, getCssText } from 'nuclo/ssr' import { App } from './app.ts' const html = renderToString(App) const page = `<!doctype html> <html> <head><style id="nuclo-styles">${getCssText()}</style></head> <body> <div id="root">${html}</div> <script type="module" src="/client.js"></script> </body> </html>`
import 'nuclo' import { App } from './app.ts' hydrate(App, document.getElementById('root')!)
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,
onMounteonDestroynon vengono mai eseguiti.update(),scope()eforceUpdate()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 aldocument.bodycondiviso.
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.
renderToString(p('Hello')) // '<p><!-- text-0 -->Hello</p>'
- I valori di testo e attributi vengono escapati come HTML. Il testo dentro
script()estyle()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 diwhen()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.
renderToStringWithContainer(p('Hi'), 'section', { id: 'main', class: 'page' }) // '<section id="main" class="page"><p><!-- text-0 -->Hi</p></section>'
nuclo/polyfill
#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, impostaglobalThis.document,Node,ElementeHTMLElement, ma solo quelli mancanti. Non creawindow. - In un browser, o sotto jsdom, non fa nulla.
EventeCustomEventsono i costruttori nativi del runtime.Node,ElementeHTMLElementsono 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.
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().
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:
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
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, tranne0e le proprietà senza unità:zIndex,opacity,fontWeight,lineHeight,flex,flexGrow,flexShrink,order,aspectRatio,gridColumnegridRow. Inraw, anchezoom,scale,column-count,orphans,widows,tab-sizeeanimation-iteration-countsono senza unità. - Le stringhe sono usate così come sono scritte.
null,undefinedefalsevengono saltati. lineHeight(eleading) è senza unità, quindileading: 24significa 24 volte la dimensione del font. Scrivi"24px"per i pixel.
css({ p: 16, m: 0, opacity: 0.5, leading: 1.5, z: 10, w: '50%' }) // padding:16px; margin:0; opacity:0.5; line-height:1.5; z-index:10; width:50%
Abbreviazioni
| Chiave | CSS |
|---|---|
p, pt, pr, pb, pl | padding, e ciascun lato |
px / py | padding sinistra e destra / sopra e sotto |
m, mt, mr, mb, ml, mx, my | lo stesso per margin |
w, h, minW, maxW, minH, maxH | width, height, e i rispettivi min e max |
size | width e height |
bg | background |
text / font / weight | font-size / font-family / font-weight |
leading / tracking | line-height / letter-spacing |
align / items / justify | text-align / align-items / justify-content |
z / rounded / shadow / select | z-index / border-radius / box-shadow / user-select |
Queste chiavi accettano true:
| Chiave | CSS |
|---|---|
row | display: flex; flex-direction: row |
col | display: flex; flex-direction: column |
center | align-items: center; justify-content: center (non imposta display) |
truncate | overflow: 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.
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
@mediaannidati si combinano conand. 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 unnotiniziale — 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:
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.
Combina stili. Quando due input impostano la stessa proprietà, vince l'input successivo.
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. Condiv(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 acx(). - 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, quindicx(pageTitle, 'extra')ha ancorapage-title.
Classi dinamiche
Passa una funzione che restituisce sempre cx(...):
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.
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: {…}.
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 tema | Usata da |
|---|---|
colors | bg, backgroundColor, color, borderColor e ciascun lato, outlineColor, caretColor, textDecorationColor |
fonts | font, fontFamily |
shadows | shadow, boxShadow |
radii | rounded, borderRadius |
screens | nuove 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(). Ilcss()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-widthdal più piccolo al più grande. Usa una sola istanza dicreateCss()per app, così quell'ordine è quello del tuo tema. themesul risultato è il tema che hai passato.
Costruisce una funzione che restituisce uno stile per un insieme di opzioni, come l'intento e la dimensione di un pulsante.
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,
undefinedonullusa il default. Un gruppo senza default viene saltato. - Un gruppo con solo le chiavi
trueefalseaccetta 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() 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.
const fadeIn = keyframes({ from: { opacity: 0 }, to: { opacity: 1 }, }) const panel = css({ animation: `${fadeIn} 200ms ease-out` })
globalStyle() aggiunge una regola per qualsiasi selettore:
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, quindiglobalStyle()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()
#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.
import { resetStyles } from 'nuclo' beforeEach(() => resetStyles())
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.
$ 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
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
// isomorphic: imported by the browser and the server import { createRouter } from 'nuclo-router' export const router = createRouter({ '/': () => import('./pages/Home.ts'), '/docs': () => import('./pages/Docs.ts'), '/blog/:slug': () => import('./pages/Post.ts'), '*': () => import('./pages/NotFound.ts'), }) export const App = () => div( header(a({ href: router.href('/') }, 'Home'), a({ href: router.href('/docs') }, 'Docs')), main( when(() => router.pending, Spinner()), when(() => router.error !== null, ErrorView()), router.outlet(), // where the pages land ), footer('© nuclo'), )
import type { RouteContext } from 'nuclo-router' import { router } from '../routes.ts' // A page is plain content export default function Post(ctx: RouteContext) { // A query-only change keeps the page, so the query is read live. return article(h1(ctx.params.slug), p(() => (router.search.has('preview') ? 'draft' : ''))) }
import 'nuclo' import { router, App } from './routes.ts' await router.start() hydrate(App, document.getElementById('app')!) // or render() without SSR
import 'nuclo/polyfill' import 'nuclo' import { renderToString } from 'nuclo/ssr' import { router, App } from './routes.ts' // one Route per request const route = await router.start(request.url) const html = route.run(() => renderToString(App))
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 | Corrisponde a | params |
|---|---|---|
/, /docs/intro | esattamente 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:
import type { RouteTable } from 'nuclo-router' // a section, with no idea where it lives export const recordSection: RouteTable = { '/': () => import('./Record.ts'), // the parent path itself './preview': () => import('./Preview.ts'), './preview/raw': () => import('./Raw.ts'), }
// declare it wherever it belongs createRouter({ '/': () => import('./pages/Home.ts'), '/invoices/:id': recordSection, '/customers/:id': recordSection, '*': () => import('./pages/NotFound.ts'), })
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 dicreateRouter(), 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():
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:
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 semplicepreviewin un href è lasciato alle normali regole del browser. - Costruisci i link relativi con
router.href('./preview')invece di scriverehref="./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
| Nodo | Alla navigazione |
|---|---|
| un genitore il cui pattern e percorso non sono cambiati | mantenuto: stesso nodo, e il suo load() non viene rieseguito |
| la pagina corrispondente | mantenuta 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 è cambiato | ricostruito, insieme a tutto ciò che sta sotto di lui |
| un livello aperto con push | renderizza 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.
export const App = () => div( aside( input({ placeholder: 'Filter' }), // keeps its text across navigations region({ id: 'sidebar', empty: p('Nothing to show.') }), ), main(router.outlet()), )
import type { PageProps, RouteContext } from 'nuclo-router' export default function Post(_ctx: RouteContext, { data: post }: PageProps<Post>) { return div( article(h1(post.title), p(post.body)), into('sidebar', nav(h2('In this post'), ul(list(() => post.headings, (h) => li(h))))), ) }
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.searchdentro 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 defaulttype: 'latest'copre la vista della pagina finché il livello non si chiude, e poi la vista della pagina riappare. Contype: '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 visteinto()sono nell'HTML, ehydrate()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():
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. Vedimatches().
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.
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:
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.
| Situazione | Comportamento |
|---|---|
| mentre è in esecuzione | router.pending è true e la pagina in cui ti trovavi resta visibile |
| lancia un'eccezione o rifiuta | finisce 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 push | esegue il proprio loader, con i propri dati |
| il precaricatore in idle | scalda solo i moduli. Non ha alcun contesto da passare, e scaricherebbe dati per una rotta che potresti non aprire mai. |
| rinavigare allo stesso URL | il 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:
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():
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:
'/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
#createRouter(table, options?)
table associa un pattern a un loader, o a una tabella annidata. Opzioni:
| Opzione | Default | |
|---|---|---|
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. |
preload | true | Carica i moduli delle rotte rimanenti in background quando la pagina è idle. false lo disattiva. |
idleTimeout | 1000 | Millisecondi 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.
// 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, url | la corrispondenza attiva. search è un URLSearchParams. search, hash e url sono live. |
pending | una navigazione sta aspettando un modulo o un loader |
error | l'ultimo caricamento fallito, azzerato dalla navigazione successiva |
data | ciò che il load() della pagina corrispondente ha restituito, perché un server lo serializzi. Quello di un genitore non ci sta. |
depth | quanti 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 }:
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 | |
|---|---|
layer | dove si trova la pagina nello stack. Vedi Layer sotto. |
data | ciò che il load() della rotta ha restituito, o undefined se non ne ha uno. Tipizzato da PageProps<TData>. |
outlet | dove una pagina genitore renderizza la sua rotta figlia. Vedi Rotte annidate. |
beforeLeave | registra 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 | |
|---|---|
depth | 0 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.
| Tipo | Cos'è |
|---|---|
Params<'/blog/:slug'> | l'oggetto params per un pattern. RouteParams è la forma non tipizzata, e Match è il risultato del matcher. |
PageComponent | una pagina: (ctx, { layer, data, outlet, beforeLeave }) => … |
RouteModule | un modulo di pagina: il componente stesso, o { default, load? } come restituito da import() |
RouteLoader | () => RouteModule | Promise<RouteModule>, che può portare un load da withLoad() |
RouteTable | la 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 |
Outlet | il tipo di outlet() da PageProps |
LeaveGuard | (to) => boolean | void | Promise<…>, ciò che beforeLeave() accetta |
MatchView | cos'è una vista di matches(): params in ingresso, contenuto o import() di una pagina in 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:
// the page underneath const created = await layer.push<Option>('/options/new') if (created) { options.push(created); selected = created.id; update() }
// the pushed page: (ctx, { layer }) layer.close(created) // resolves the push() above with the new option layer.close() // dismissed: resolves with undefined
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 suopush()come chiuso senza risultato, esattamente come Annulla. Indietro eclose()sono un unico percorso di codice:close()registra il suo risultato e torna indietro nella history, e la profondità inhistory.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 casoclose()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 === 0e unclose()inerte. Una rotta aperta con push deve avere senso da sola. Diramati sulayer.depthper 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 conundefined.
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:
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, ilclose()di un livello, ego()verso un URL di cui il router non è responsabile (uscire dall'app, dovetoènull). Un Indietro o Avanti rifiutato viene annullato, così la barra degli indirizzi resta sulla pagina. Chiudere la tab o ricaricare è compito dibeforeunload. toè ilRouteContextverso 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. Ancheclose(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
#fragmento ilpushStatedi 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:
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
basesono quelli del router, quindi una vista e una pagina sono sempre d'accordo sull'URL. Legge il percorso del livello in cima, comerouter.path. - Una vista è
(params, ctx) => content, doveparamsè tipizzato dal suo pattern ectxè ilRouteContextcon ilpatterne iparamsdella vista. Può invece restituire l'import()di un modulo di pagina, che viene chiamato conctxquando arriva. - Nulla corrisponde, nulla viene renderizzato. Aggiungi una vista
'*'come fallback. È vuoto prima distart()e dopostop(), 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 conconsole.error, non impostarouter.error, e viene ritentato alla navigazione successiva. Un sempliceupdate()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:
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:
// parent table '/docs/*rest': () => import('./docs/Shell.ts'),
const docs = createRouter(docsRoutes, { base: '/docs' }) const hit = docs.match() // { pattern: '/:topic', … } const page = await docsRoutes[hit.pattern]() // the app's own lookup
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.
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:
'/shop': { '/': () => import('./pages/Shop.ts'), // layout, has a load() './:section': () => import('./pages/Section.ts'), // page, has a load() },
import type { DataLoader, PageProps, RouteContext } from 'nuclo-router' export const load: DataLoader<ShopData> = async () => { await wait(500) // a slow API return { sections: ['toys', 'books', 'music'] } } // the layout gets its own data, and its outlet for the child page export default function Shop(_ctx: RouteContext, { data, outlet }: PageProps<ShopData>) { return div( h1('Shop'), nav(...data.sections.map((s) => a({ href: router.href(`/shop/${s}`) }, s))), main(outlet()), ) }
export const load: DataLoader<SectionData> = async (ctx) => { await wait(900) // slower than the layout's return { section: ctx.params.section, items: catalog[ctx.params.section] } } // the page gets its own data: the two loaders ran beside each other export default function Section(_ctx: RouteContext, { data }: PageProps<SectionData>) { return article(h2(data.section), ul(...data.items.map((i) => li(i)))) }
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:
import { createRouter } from 'nuclo-router' // Created but never started: start() is what attaches the history and click // listeners, and only the main router is started. match() attaches nothing. const standIn = () => ({ default: () => div() }) // never rendered const sidebarRouter = createRouter({ '/shop': standIn, '/shop/:section': standIn, '*': standIn, }, { base: '/docs' }) // the same base as the main router export const sidebar = { loading: false, info: null as SidebarInfo | null } export async function refreshSidebar(at: string) { const hit = sidebarRouter.match(at) // the second router resolves if (!hit || (hit.pattern !== '/shop' && hit.pattern !== '/shop/:section')) { sidebar.info = null; update(); return } sidebar.loading = true // its own flag, its own spinner sidebar.info = null; update() const data = await fetchSidebar(hit.params.section) sidebar.loading = false; sidebar.info = data update() }
createRouter(routes, { onNavigate: (ctx, kind) => { announce(kind) refreshSidebar(ctx.url) // every navigation re-resolves the sidebar }, })
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.
pendingappartiene al router avviato. Un router mai avviato non ha una Route, népendingné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 — ilrefreshSidebar()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 unpending. Non renderizza nulla finché il suo modulo non arriva. Tre sistemi di caricamento, tre orologi indipendenti: ilpendingdella rotta, i tuoi flag, e una vista lazy che semplicemente non c'è ancora.
Gestione dei link
#Il listener delegato naviga un click sinistro su un <a href>, inclusi un <a> SVG e i click su qualsiasi cosa annidata al suo interno, e si fa da parte per tutto il resto: click con modificatori e con il tasto centrale, un target diverso da _self, download, rel="external", href cross-origin, link con solo hash sulla pagina corrente, percorsi fuori da base, percorsi a cui nessuna rotta corrisponde (così una 404 renderizzata lato server resta raggiungibile), e qualsiasi evento su cui un handler dell'app ha già chiamato preventDefault(). Escludi un link esplicitamente con data-nuclo-router="off".
Scroll
Una navigazione con push conserva la posizione di scroll corrente. Quando il nuovo URL ha un #hash che corrisponde a un elemento della pagina, quell'elemento viene portato in vista. popstate lascia lo scroll al browser. Per far atterrare le nuove pagine in cima, fallo in onNavigate, che dice come è avvenuta la navigazione:
let shown = '' createRouter(routes, { onNavigate: (ctx, kind) => { // Not on Back/Forward (the browser restores those), nor for a query change on the same page. if (kind === 'push' && ctx.path !== shown && !ctx.hash) window.scrollTo(0, 0) shown = ctx.path }, })
Note
- Nulla viene trattenuto. La Route contiene la funzione della pagina e il suo contesto, mai il DOM. Navigare altrove rilascia la pagina precedente.
stop()rilascia i listener e qualsiasi caricamento in corso, e azzerapending, così una Route ritirata non lascia mai uno spinner appeso. Un modulo che si risolve dopostop()viene scartato silenziosamente. - Sicuro con la concorrenza sul server.
start()restituisce uno stato per richiesta erun()lo delimita a un singolo render sincrono, quindi richieste interlacciate non possono vedere l'una la rotta dell'altra. Solo la cache dei moduli è condivisa. L'ambito termina quando la funzione dirun()ritorna.renderToString()è sincrono, quindi basta. Un render asincrono o in streaming richiederebbe di trasportare la Route attraverso gliawait, cosa cherun()non fa. - Il ripristino dello scroll su
popstateè del browser (scrollRestoration), non del router. - Avvia un solo router per documento. Ogni
start()aggiunge il proprio listenerclickdelegato, e lostart()di un secondo router non ritira il primo. Solo un secondostart()sullo stesso router lo fa. Per risolvere un URL rispetto a un'altra tabella, crea un router e chiama solomatch(). Non aggancia nulla. Un'area indipendente può così gestire il proprio caricamento accanto alpendingdella rotta. Vedi Più router e caricamento combinato. - Non incluso, per scelta: hash history e memory history, perché la rotta vive nel pathname e un server deve servire ogni rotta; due aree con routing indipendente guidate da un solo URL, perché una pagina può riempire più regioni con
into()ma appartengono tutte all'unica rotta corrispondente — un'area indipendente può comunque tenere il proprio router non avviato e il proprio caricamento, come in Più router e caricamento combinato; e il precaricamento all'hover, che ottieni chiamando il loader della rotta da unmouseenter.
Esempi
Due esempi eseguibili nel repository si completano a vicenda, così ogni impostazione è osservabile da qualche parte:
examples/router: un tour lato client dell'intera API. Ogni forma di pattern e regola di precedenza, le esclusioni della gestione dei link, ogni membro di Route con controlli live, un sub-router, lo stack dei livelli profondo tre, una sezione relativa montata su due genitori, e loader di rotta conpendingederror. Impostapreload: falsecosì quegli stati restano visibili.examples/router-ssr:renderToString()per richiesta, idratazione che reclama in modo dimostrabile i nodi del server, veri codici di stato 404, unbasenon radice, precaricamento in idle osservabile nel pannello di rete, e un loader i cui dati passano dal server al client così l'idratazione non rifà il fetch.
Nuclo non ha un'API per i valori calcolati. Usa una semplice funzione, e chiamala dai valori dinamici:
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()
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:
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()elist()per i figli che cambiano, non una funzione che restituisce nodi. - Usa
className, nonclass, 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)
update(...scopeIds): esegue tutti i valori dinamici, o quelli negli scope indicati.scope(...ids): assegna a un elemento gli id di scope perupdate(id).when(condition, ...content): contenuto condizionale, con.when()e.else().list(items, renderItem): una riga per elemento, abbinata per identità.region(options)einto(id, ...content): un punto con nome nell'albero, e contenuto che lo riempie da qualsiasi parte.on(type, listener, options?): un listener di eventi, oppureon('mount')/on('destroy').render(App, parent?): costruisce e aggiunge.hydrate(App, parent?): prende in carico l'HTML del server.forceUpdate(): richiama i componenti e aggiorna il DOM.css(name?, style): classi generate da un oggetto di stile.cx(...inputs): combina stili, vince il successivo.createCss(theme):css,cx,variants,keyframeseglobalStylecon tema.variants(config): stili da opzioni.keyframes(frames)eglobalStyle(selector, style).
nuclo (solo export con nome)
nuclo/ssr
renderToString(input),renderManyToString(inputs),renderToStringWithContainer(input, tag?, attrs?)egetCssText().
nuclo/polyfill
- Un import con side effect, più
document,Event,CustomEvent,Node,Element,HTMLElemente le classiNuclo*.
nuclo-router (pacchetto separato)
createRouter(table, options?): il router, constart(),match(),outlet(),matches(),go(),push(),href()estop().withLoad(loader, load): una rotta il cui loader di dati parte accanto al suo chunk.- Tipi:
RouteContext,PageProps,Params,DataLoader,RouteTablee altri.
Solo globali
- 175 costruttori di tag:
div,span, …,var_, esvgSvg,pathSvg, …. - Tipi:
NodeModFn,NodeModLike,ExpandedElemente altri. I tipi di stile comeStyleResultpossono anche essere importati da'nuclo'.
Chiavi speciali
- Attributi:
className,style, handleron*,onMounteonDestroy. - Oggetti di stile: abbreviazioni, pseudo stati, selettori
&, regole@eraw.
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.