Documentação do Nuclo
Uma referência prática para instalar o Nuclo, construir com atualizações explícitas, estilizar com CSS tipado e renderizar HTML no servidor.
npm create nuclo@latestVisão geral
#Nuclo é uma pequena biblioteca DOM para TypeScript. Você constrói a página com funções simples como div() e button(), mantém o estado em variáveis comuns e chama update() quando a página deve mudar.
Nuclo não tem virtual DOM, compilador, proxies nem signals.
import 'nuclo' let count = 0 const App = () => div( h1(() => `Count: ${count}`), button('Add one', { onClick: () => { count++; update() } }), ) render(App, document.getElementById('app')!)
O modelo mental
- Construa a UI com construtores de tags.
- Passe uma função para cada valor que depende do estado.
- Altere o estado com JavaScript comum.
- Chame
update(). Nuclo executa as funções de novo e aplica as mudanças no DOM.
Alterar uma variável não toca o DOM. A página só muda quando você chama update().
Início rápido
#create-nuclo gera um projeto Vite e TypeScript pronto para o Nuclo:
Ele pede o nome do projeto. Para pular a pergunta, passe o nome e as flags. Com npm, coloque -- antes das flags:
$ npm create nuclo@latest my-app -- --template basic --yes
Depois instale e inicie o servidor de desenvolvimento:
$ cd my-app $ npm install $ npm run dev
Opções
[project-name]: a pasta de destino. O padrão énuclo-app.-t, --template <name>: o template a copiar. Hoje só existebasic.-y, --yes: nunca perguntar, usar os padrões.-f, --force: escrever em uma pasta que não está vazia.-h, --help: imprimir a ajuda.
Instalação e Imports
#Para adicionar o Nuclo a um projeto existente:
$ npm install nuclo
Ou use pnpm add nuclo, yarn add nuclo ou bun add nuclo. Nuclo não tem dependências.
Roteamento não faz parte do pacote principal. Ele é distribuído separadamente como nuclo-router, que você instala ao lado de nuclo quando precisar.
Globais
import 'nuclo' coloca o seguinte em globalThis:
- 175 construtores de tags: 112 para HTML (
div,span,button, …) e 63 para SVG, com o sufixoSvg(svgSvg,pathSvg, …). Veja Construtores de tags. - 16 helpers:
update,scope,when,list,region,into,on,render,hydrate,forceUpdate,css,cx,createCss,variants,keyframeseglobalStyle.
O import precisa rodar antes de qualquer código que chame um construtor. Módulos ES executam na ordem dos imports, então isto falha:
import { App } from './app' // app.ts calls div() → ReferenceError: div is not defined import 'nuclo'
Coloque import 'nuclo' primeiro no seu arquivo de entrada, ou importe-o em cada arquivo que usa os globais.
Imports nomeados
Os helpers também são exports nomeados. São as mesmas funções dos globais. getCssText() e resetStyles() existem apenas como exports nomeados. Construtores de tags existem apenas como globais, então import { div } from 'nuclo' não funciona.
import { render, update, getCssText } from 'nuclo'
Pontos de entrada
nuclo: registra os globais e exporta os 16 helpers maisgetCssTexteresetStyles.nuclo/ssr:renderToString,renderManyToString,renderToStringWithContaineregetCssText. Veja Renderização no servidor.nuclo/polyfill: um DOM pequeno para Node e Bun. Veja nuclo/polyfill.nuclo/types: apenas tipos. Veja TypeScript.
TypeScript
#Nuclo traz seus próprios tipos. Você precisa de duas coisas:
"moduleResolution": "bundler".node16enodenextainda não são suportados.- A lib
DOM. O TypeScript a inclui por padrão quando você não definelib.
Se qualquer arquivo do projeto importa 'nuclo', todos os arquivos veem os tipos globais. Você não precisa de mais nada. Se nenhum arquivo o importa, por exemplo em um tsconfig separado para testes, adicione os tipos você mesmo:
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "types": ["nuclo/types"] } }
Ou adicione /// <reference types="nuclo/types" /> a um arquivo .d.ts.
Tipos úteis
Estes tipos são globais, então você os usa sem import:
NodeModFn<Tag>: o que um construtor de tag retorna. Use como tipo de retorno de um componente.NodeModLike<Tag>: qualquer coisa que um construtor de tag aceita como argumento.ExpandedElement<Tag>: o querender()ehydrate()retornam.ElementTagName: qualquer nome de tag HTML.ExpandedElementAttributes<Tag>: o objeto de atributos que um construtor de tag aceita, incluindo handlerson*,onMounteonDestroy. Use em componentes que repassam atributos.MountCallbackeDestroyCallback: handlers deonMounteonDestroy.WhenBuilder,ListModifier: o quewhen()elist()retornam.RegionOptions,RegionType: as opções queregion()recebe.StyleResult,Style,ThemeConfig,CssInstancee os demais tipos de estilização. Estes também podem ser importados:import type { StyleResult } from 'nuclo'.
function Badge(text: string): NodeModFn<'span'> { return span(text) }
ExpandedElement<Tag> marca todos os membros do DOM como opcionais. Faça um cast quando precisar do tipo completo do elemento: render(App, root) as HTMLDivElement.
Construtores de tags
#Toda tag HTML é uma função global com o mesmo nome: div, span, input, … A única exceção é <var>, que é var_ porque var é uma palavra reservada. Toda tag SVG tem o sufixo Svg: svgSvg, pathSvg, circleSvg, …
Chamar um construtor de tag ainda não cria um elemento. div('Hi') retorna um builder: uma função que cria o elemento depois. Nuclo o chama quando você o monta com render() ou o coloca dentro de outro builder, when() ou list(). Cada uso cria um elemento novo.
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().Abrir ↗Profile
Hello, Ada
Argumentos
Um builder recebe qualquer número de argumentos e os aplica da esquerda para a direita:
- string, number, bigint: um nó de texto.
() => value: texto que muda emupdate(). Veja Valores dinâmicos.null,undefined: ignorados.true,false: renderizados como o texto "true" ou "false". Usewhen()para condições.- Um objeto simples: atributos. Veja Atributos.
- Um resultado de
css(),cx()ouvariants(): adiciona suas classes. on(...): um listener de evento ou hook de ciclo de vida.- Outro builder,
when()oulist(): um filho. - Um
Nodedo DOM: anexado como está. (el, index) => …: um modificador personalizado. Veja abaixo.
Armadilhas
div(isAdmin && span('Admin'))renderiza o texto "false". Escrevadiv(when(() => isAdmin, span('Admin'))).- Arrays não são filhos. Espalhe-os:
ul(...names.map((n) => li(n))). Para listas que mudam, uselist(). - Uma variável local com nome de tag esconde o construtor. Dentro de
function Row(label: string),label(...)não é chamável. Renomeie locais comolabel,title,data,p,aei. - Navegadores expõem todo elemento com
idcomo uma variável global. Se a página contém<main id="main">antes do Nuclo carregar, Nuclo deixa esse global no lugar, entãomain(...)lança umTypeError. Use outros ids.
Modificadores personalizados
Uma função que declara pelo menos um parâmetro roda uma vez, na ordem dos argumentos, enquanto o elemento é construído. Seu primeiro argumento é o elemento. O segundo é um índice interno, então não dependa dele. Se ela retorna uma string ou number, isso vira texto. Um Node é anexado. Um objeto é aplicado como atributos.
const field = input((el) => { el.autocomplete = 'off' })
Obtendo o elemento
render() retorna o elemento raiz. Dentro de uma árvore, use on("mount", (el) => …) ou um modificador personalizado. Você também pode chamar a função builder você mesmo para criar um elemento desanexado:
const el = div('Hello')() as HTMLDivElement document.body.append(el)
Anexe-o antes do próximo update(). Um nó que não está no documento quando update() roda para de atualizar para sempre.
SVG
Construtores SVG recebem os mesmos argumentos. Nuclo define todo atributo SVG com setAttribute, então nomes como "stroke-width" funcionam como escritos.
const Icon = () => svgSvg( { viewBox: '0 0 24 24', width: 24, height: 24, className: 'icon' }, circleSvg({ cx: 12, cy: 12, r: 10, fill: 'currentColor' }), )
Todos os construtores
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
Componentes
#Um componente é uma função simples que retorna um builder, como button(...). Não há API de componentes. Nada é criado até você renderizá-lo ou colocá-lo em uma árvore.
function Counter(label: string) { let count = 0 // one per call return button(() => `${label}: ${count}`, { onClick: () => { count++; update() }, }) } render(() => div(Counter('A'), Counter('B')))
Estado declarado dentro da função pertence àquela instância. Estado declarado fora é compartilhado por todas as instâncias. forceUpdate() chama os componentes de novo, o que reinicia o estado declarado dentro deles.
Filhos são apenas argumentos:
function Card(heading: string, ...children: NodeModLike<'div'>[]) { return div({ className: 'card' }, h2(heading), ...children) } render(() => Card('Hi', p('one'), p('two')))
Passe um objeto simples para definir atributos. Você pode passar vários objetos. Para a mesma chave, o último valor vence, exceto className e style, que são mesclados. Um valor pode ser estático, ou uma função sem parâmetros que roda de novo em cada update().
let busy = false const save = button( { type: 'submit', disabled: () => busy, 'aria-busy': () => String(busy), 'data-id': 42 }, 'Save', )
update(); o disabled e o aria-busy de Save acompanham.Abrir ↗Como um valor é definido
- Se o elemento HTML tem uma propriedade com aquele nome (
value,checked,disabled,htmlFor,tabIndex, …), Nuclo define a propriedade. Caso contrário, chamasetAttribute(key, String(value)), por exemplo para"aria-*"e"data-*". - Em elementos SVG, Nuclo sempre chama
setAttribute. nulleundefinedsão ignorados. Um valor dinâmico que retornanullouundefinedmantém o valor antigo. Para limpar um valor, retorne"", oufalsepara uma propriedade booleana.- Booleanos funcionam para propriedades booleanas do DOM, como
disabledem um botão,checked,hiddenerequired. Em qualquer outro lugar,trueefalsesão escritos como as strings "true" e "false". - Use chaves
"data-*".datasetnão é suportado. innerHTMLnão é escapado, e a renderização no servidor não o emite. Evite-o.
Classes
Use className. Strings de classe estáticas, resultados de css() e um className dinâmico são todos mesclados. Um className dinâmico mantém as classes estáticas, e quando retorna "" só as classes estáticas permanecem.
let active = false const base = css({ p: 8 }) const item = li(base, { className: 'item' }, { className: () => (active ? 'active' : '') }, 'Home') // class="<base> item", then "<base> item active" after active = true; update()
Não use a chave class em elementos HTML. Ela define o atributo bruto e substitui toda classe aplicada antes dela, incluindo classes de css().
Estilos inline
style recebe um objeto com chaves em camelCase, ou uma função que retorna um.
let progress = 30 const bar = div({ style: () => ({ width: `${progress}%`, backgroundColor: 'teal' }) })
- Nuclo não adiciona unidades. Escreva
"12px", não12. Propriedades sem unidade, comoopacityezIndex, aceitam números. "",nulleundefinedlimpam uma propriedade.- Um style dinâmico define apenas as chaves que retorna. Uma chave que ele deixa de retornar mantém o valor antigo, então retorne
""para limpá-la. - Strings de estilo (
"color: red") e propriedades personalizadas ("--gap") não são suportadas. Usecss()em vez disso. Ele aceita propriedades personalizadas viaraw.
Eventos
#Há duas formas de ouvir eventos: atributos on* e o modificador on().
Atributos on*
Em um objeto de atributos, uma função sob uma chave como onClick (on seguido de letra maiúscula) é um handler de evento, não um valor dinâmico.
let query = '' const search = input({ placeholder: 'Search', value: () => query, onInput: (e) => { query = e.currentTarget.value; update() }, onKeyDown: (e) => { if (e.key === 'Escape') { query = ''; update() } }, })
- O TypeScript infere o tipo do evento a partir da chave e do elemento.
e.currentTargetethissão o elemento. - Nuclo define a propriedade nativa, então
onClickvirael.onclick. Há um handler por evento: umonClickposterior substitui um anterior. onDoubleClickeonDblClicksignificam ambosdblclick. Chaves em minúsculas, comoonclick, são erros de tipo.- Um nome que não é um evento conhecido escuta sua forma em minúsculas:
onMyEventescuta"myevent". - Erros lançados pelo handler não são capturados.
Type something, then press Escape.
on()
on() retorna um modificador que chama addEventListener no elemento.
const panel = div( on('scroll', onScroll, { passive: true }), on('click', logClick), on('click', closeMenu), )
- Cada
on()adiciona um listener. Todos rodam, em ordem. optionsvai direto paraaddEventListener:capture,once,passive,signal.- Passe
on()diretamente a um builder e o tipo do elemento é inferido:input(on('input', (e) => e.currentTarget.value)). - Se um listener lança um erro, Nuclo o registra com
console.error, e os outros listeners ainda rodam. - Nuclo remove o listener quando remove o elemento. No servidor,
on()não faz nada. - Você pode reutilizar um mesmo valor de
on()em vários elementos.
Para um evento personalizado, passe o tipo do evento como genérico:
type PickEvent = CustomEvent<{ id: number }> const picker = ul(on<'pick', PickEvent>('pick', (e) => choose(e.detail.id)))
Qual usar
Use atributos on* no caso comum. Use on() quando precisar de mais de um listener para um evento, de opções de listener, ou de um nome de evento personalizado como "my-event".
Ciclo de vida
#onMount roda uma vez, depois que o elemento é inserido. onDestroy roda uma vez, quando Nuclo remove o elemento. Escreva-os como atributos ou com on(). As duas formas se comportam igual, e o elemento é tipado a partir da sua 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 inicia um intervalo e retorna sua limpeza. Desmontar executa a limpeza, então a contagem pausa até o timer montar de novo.Abrir ↗Quando eles rodam
onMountroda no fim da chamada derender(),hydrate(),update()ouforceUpdate()que inseriu o elemento. Nunca roda enquanto a árvore está sendo construída.- Se
onMountretorna uma função, essa função roda na destruição, depois dos callbacksonDestroydo elemento. onDestroyroda quando Nuclo remove o elemento: uma linha delist()é removida, um ramo dewhen()troca, uminto()sai de sua região, ouforceUpdate()o descarta. Roda logo antes da remoção, então o elemento ainda está no documento.
Remoção direta do DOM
Se você remove um elemento por conta própria, com node.remove() ou innerHTML = '', seu onDestroy e seus cleanups nunca rodam, e timers continuam rodando. Nuclo não tem unmount(). Para desmontar um app com limpeza, envolva-o em when():
let mounted = true render(() => div(when(() => mounted, App())), root) mounted = false update() // App's onDestroy hooks and cleanups run
Ordem e erros
- Você pode registrar quantos hooks quiser, em qualquer das formas. Eles rodam na ordem de registro.
onDestroysempre roda primeiro nos filhos, depois no pai.- Elementos montam na ordem do seu primeiro hook
onMount, que segue a ordem dos argumentos, e cada elemento roda todos os seus hooks juntos. Passe o hook do pai antes dos filhos se eles dependerem dele. - Um hook que lança um erro é registrado com
console.error. Os outros hooks ainda rodam. - O callback deve retornar nada ou uma função de cleanup, então não o torne
async. Uma Promise retornada nunca é usada como cleanup. Inicie trabalho assíncrono dentro dele:{ onMount: (el) => { void load(el) } }. - Hooks nunca rodam no servidor. No cliente, rodam depois de
hydrate().
Nuclo nunca atualiza a página por conta própria. Altere seu estado e então chame update().
let count = 0 const Counter = () => button(() => `Clicked ${count} times`, { onClick: () => { count++; update() }, }) render(Counter)
update(), vindo do botão ou de qualquer outra coisa nesta página.Abrir ↗Clicked 0 times
Altere quanto estado quiser e então chame update() uma vez:
let first = 'Ada' let last = 'Lovelace' render(() => p(() => `${first} ${last}`)) first = 'Grace' last = 'Hopper' update() // one pass, both changes
Chame update() a partir de handlers de eventos, timers, callbacks de fetch ou onMount. Não o chame de dentro de um valor dinâmico.
Uma função sem parâmetros é um valor dinâmico. Nuclo a executa uma vez ao construir o elemento, e de novo em cada update(). Use valores dinâmicos para filhos de texto, valores de atributos, style e className. Para filhos que aparecem, desaparecem ou se repetem, use 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() reconstrói a árvore, então o texto estático também se atualiza.Abrir ↗static: Hello, Alice
dynamic: Hello, Alice
Nuclo verifica a quantidade de parâmetros (fn.length). Uma função que declara um parâmetro é um modificador personalizado e roda apenas uma vez. (x = 0) => … e (...args) => … têm length 0, então são dinâmicas.
Texto dinâmico
- Uma string, number, bigint ou boolean renderiza como
String(value).() => ok && 'Saved'mostra "false", então escreva() => (ok ? 'Saved' : ''). nulleundefinedrenderizam como texto vazio.- Um resultado de
css()oucx()na primeira execução torna a função uma classe dinâmica, não texto. Vejacx(). - Um nó, builder, array ou objeto simples não renderiza nada, e nunca atualiza.
Atributos dinâmicos
- Nuclo escreve um valor apenas quando ele difere do último valor que Nuclo escreveu. Ele não compara com o DOM ao vivo, então se um usuário digita em um input e sua função retorna o mesmo valor de antes, o texto digitado permanece.
- Um resultado
nullouundefinednão escreve nada, então o valor antigo permanece.classNameé a exceção: qualquer resultado falsy remove as classes dinâmicas e mantém as estáticas. - Uma função de
styleaplica o objeto retornado em cada atualização.
Erros
Se um valor dinâmico lança um erro, Nuclo o captura e o resto da atualização ainda roda. O texto fica vazio ou mantém o valor antigo, e um atributo mantém o valor antigo. Ambos se recuperam no próximo update() bem-sucedido. A exceção: uma função de texto que lança um erro na primeira execução fica vazia para sempre.
update()
#Executa uma passagem síncrona sobre todos os valores dinâmicos da página. Com ids, atualiza apenas o que está dentro dos elementos marcados com esses ids de scope(). Cada passagem executa estes passos em ordem:
list(): lê os itens de novo e adiciona, move ou remove linhas.when(): verifica as condições de novo. Um ramo só é reconstruído quando um ramo diferente se torna ativo.- Atributos dinâmicos, incluindo
styleeclassName. - Texto dinâmico.
onMountpara os elementos que os passos 1 e 2 inseriram.
- Ele executa tudo. Todo valor dinâmico roda em cada chamada, mesmo quando seus dados não mudaram. Apenas a escrita no DOM é pulada. Mantenha valores dinâmicos rápidos e sem efeitos colaterais.
- Apenas nós no documento. Se um nó não está no documento quando
update()roda, Nuclo para de rastreá-lo. Ele nunca mais atualiza, mesmo depois que você o anexa. Isso vale paralist()ewhen()também. Renderize em um contêiner que está no documento. - Valores estáticos nunca mudam. Strings simples e valores de atributos são fixados quando o elemento é construído. Para reconstruí-los, use
forceUpdate(). - Erros. Erros de texto e atributos são capturados, e a passagem continua. Se uma condição de
when()lança um erro, o erro é registrado e aquelewhen()para de atualizar. Se uma funçãoitemsourenderItemdelist()lança um erro, o erro escapa deupdate()e os passos restantes não rodam. - Servidor.
update()não faz nada no servidor.
update() executa cada valor dinâmico, mesmo quando seus dados não mudaram. Outras demos nesta página também aumentam a contagem.Abrir ↗This dynamic value has run 1 times.
scope()
#Dá ao elemento que o recebe um ou mais ids de escopo. update('id') então atualiza apenas o que está dentro daquele elemento, incluindo os atributos do próprio elemento.
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"
- Um elemento pode ter vários ids:
scope('cart', 'sidebar'). update('a', 'b')atualiza tudo dentro deaoub. Se vários elementos usam o mesmo id, todos eles atualizam.- Com escopos aninhados,
update('outer')inclui o escopo interno.update('inner')não toca o externo. - Um id que nenhum elemento no documento possui não atualiza nada e não gera erro, então verifique a grafia.
scope()não faz nada no servidor.
Renderiza conteúdo com base em uma condição. Nuclo verifica as condições de novo em cada update(). A primeira condição verdadeira vence. Se nenhuma for verdadeira, o conteúdo de .else() é renderizado. Sem .else(), nada é renderizado.
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() renderiza. Um novo ramo é construído do zero.Abrir ↗Loading…
Condições
- Para estado, passe uma função. Um booleano simples é lido uma vez:
when(open, …)nunca muda. - Em TypeScript a função deve retornar um booleano. Escreva
() => items.length > 0ou() => user != null.
O que atualiza
- Enquanto o mesmo ramo permanece ativo, seus elementos são mantidos. Valores dinâmicos, listas e blocos
when()dentro dele continuam atualizando. - Quando um ramo diferente se torna ativo, o conteúdo antigo é removido e seus hooks
onDestroyrodam. O novo ramo é construído do zero e seus hooksonMountrodam. O estado dos elementos antigos, como texto de input ou foco, é perdido.
Conteúdo do ramo
Coloque filhos em um ramo: texto, texto dinâmico, builders, list() e when() aninhados. Passe builders como p('Hi'), não nós do DOM: um nó do DOM para de atualizar depois que seu ramo se esconde uma vez.
Objetos de atributos e on() em um ramo se aplicam ao elemento pai, e permanecem depois que o ramo se esconde. Para um atributo condicional, use um atributo dinâmico: { className: () => (active ? 'active' : '') }.
Encadeamento
.when() e .else() retornam um novo builder e não alteram o original. Se você chamar .else() duas vezes, a última chamada vence.
list()
#Renderiza uma linha por item. Em cada update(), Nuclo chama items() de novo e casa os novos itens com as linhas existentes por identidade (===).
let fruits = ['Apple', 'Banana'] const Fruits = () => ul(list(() => fruits, (fruit) => li(fruit))) render(Fruits) fruits.push('Cherry') update()
Como as linhas são casadas
- Um item que ainda está lá mantém sua linha. Nuclo move a linha se necessário, e não chama
renderItemde novo. - Um item novo recebe uma linha nova. Um item removido perde sua linha, e seus hooks
onDestroyrodam. - Itens duplicados recebem cada um a sua própria linha.
- Você pode alterar o array no lugar (
push,splice,sort) ou retornar um novo array.
Uma linha mantida nunca é renderizada de novo, então use valores dinâmicos para os campos que mudam:
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"
Cópias são itens novos, então substituir o array por todos.map((t) => ({ ...t })) reconstrói todas as linhas.
O que renderItem retorna
- Retorne um elemento, como
li(...),tr(...)ou um builder SVG. Retornenullouundefinedpara pular um item. Strings, nós de texto e fragments não renderizam nada. - Não retorne
when()oulist()diretamente. Coloque-os dentro do elemento da linha:li(todo.text, when(() => todo.done, ' ✓')). - Para esconder itens, filtre em
items(), não emrenderItem. - Mantenha
renderItemsem efeitos colaterais. Nuclo pode chamá-lo mais de uma vez para o mesmo item.
O índice
index é a posição do item quando sua linha foi criada. Ele não é atualizado quando as linhas se movem. Para a posição atual, use um valor dinâmico: li(() => `${names.indexOf(name) + 1}. ${name}`).
Iteráveis
Qualquer iterável funciona: arrays, Set, generators e map.values(). Retornar o mesmo array ou Set a cada vez é aceitável. Um iterator, como um generator ou map.values(), se esgota depois de uma passagem, então crie-o dentro de items() em cada chamada. Iterar um Map diretamente cria novos arrays [key, value] a cada vez, então todas as linhas são reconstruídas. Use map.values() em vez disso.
Posicionamento
As linhas aparecem onde list() está entre seus irmãos, entre dois marcadores de comentário. Os outros filhos do pai não são tocados. list() também funciona dentro de builders SVG.
region() marca um lugar nomeado na árvore. into() o preenche de qualquer outro lugar, em qualquer profundidade. O layout não recebe o conteúdo como prop, e o componente que constrói o conteúdo não sabe onde ele vai parar.
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()))
Como funciona
into()deixa apenas um comentário onde é escrito. Seu conteúdo é renderizado entre os marcadores da região.- A ordem não importa. Um
into()construído antes da sua região espera e aparece no momento em que a região é construída. As views aparecem na ordem em que foram escritas. - O conteúdo vive enquanto a view viver. Quando o
when()que contém uminto()fecha, ou sua linha delist()é removida, o conteúdo sai da região. Abri-lo de novo adiciona uma cópia, não duas. - Quando uma região desaparece, por exemplo quando seu
when()fecha, suas views voltam a esperar. Uma região construída depois com o mesmo id as mostra. - Um
into()cujo id não corresponde a nenhuma região não renderiza nada e não gera erro, então verifique a grafia. - Ids são únicos na árvore. Uma segunda região construída sob um id ativo registra um aviso.
- Regiões funcionam com renderização no servidor. Em
hydrate(), cada view reivindica o HTML que o servidor renderizou em vez de construí-lo de novo.
Opções
id: o nome que as chamadas deinto()usam para encontrar a região.type:'latest'(o padrão) mostra apenas a view mais recente.'stack'mostra todas as views, em ordem de chegada.empty: renderizado enquanto a região não contém views.
Regiões empilhadas
Com type: 'stack', uma view que abre sobre outra não reconstrói a que está embaixo. Seu DOM, foco e estado de formulário permanecem. Use isso para uma camada como um diálogo sobre uma página.
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'))), )
Várias regiões de uma vez
Passe um objeto para preencher mais de uma região a partir de um único into(). Um valor pode ser um filho ou um array de filhos. Ambas as formas aninham: um into() dentro do conteúdo de outra view mira sua própria região.
const Page = () => into({ main: article(h1('Eager route')), sidebar: [h2('Related'), ul(li('One'), li('Two'))], })
Um diálogo aberto do fundo da árvore
A região de overlay fica na raiz. Qualquer componente, em qualquer profundidade, abre um diálogo envolvendo um into() em um when(). Fechar o when() remove o diálogo da região.
let confirming = false const App = () => div( Page(), div({ className: 'overlay' }, region({ id: 'overlay' })), ) // Deep inside Page() const DeleteButton = () => div( button('Delete', { onClick: () => { confirming = true; update() } }), when(() => confirming, into('overlay', dialog({ open: true }, p('Delete this item?'), button('Cancel', { onClick: () => { confirming = false; update() } }), ), ), ), )
Toasts a partir de linhas de lista
Cada linha de um list() é uma view. Uma região empilhada as mostra em ordem, e remover um item do array remove seu 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) }))), ), )
Um título de página definido pela página
Com o padrão type: 'latest', a view mais recente vence. O layout é dono do título, e a página que estiver aberta o nomeia. Uma página sem into('title', …) deixa o texto de 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(/* … */), )
Valores dinâmicos dentro de uma view
O conteúdo de um into() é conteúdo Nuclo comum. Funções nele são valores dependentes de estado e se atualizam em update(), onde quer que a região esteja.
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, ), ), )
Uma região que vai e volta
O into() do dashboard é escrito uma vez. Enquanto o usuário está deslogado não há região main, então a view espera. Fazer login constrói a região e a view aparece. Fazer logout remove a região e a view volta a esperar.
let user: { name: string } | null = null const App = () => div( when(() => user !== null, main(region({ id: 'main' })), ).else( LoginForm(), ), into('main', Dashboard()), )
Uma view com sua própria região
Uma view pode conter um region(), e outras views podem mirá-lo. Aqui a página do editor traz consigo uma região tabs, e cada aba a preenche. Quando o editor fecha, sua região vai junto.
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' }))), )
Exemplo ao 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().Abrir ↗No related links.
update() só executa valores dinâmicos. Valores estáticos, como o texto em h1(labels[lang].title), são fixados quando a árvore é construída. forceUpdate() chama seus componentes de novo e ajusta o DOM ao vivo para corresponder, valores estáticos incluídos. Use-o para mudanças raras e globais no app, como trocar o idioma. Use update() para todo o 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() atualiza só a linha dinâmica de lang. forceUpdate() chama App de novo e aplica as mudanças no mesmo <h1>. A forma explícita reconstrói só esta árvore, não a página inteira.Abrir ↗lang = 'en'
Hello
Static text, fixed on build.
Sem argumentos, forceUpdate() reconstrói toda raiz renderizada com render(App) ou hydrate(App). Raízes renderizadas com render(App()) são puladas. Se um componente lança um erro, o erro é registrado e aquela raiz mantém seu DOM antigo. Um erro lançado depois, enquanto a nova construção é aplicada, também é registrado, mas as partes já ajustadas permanecem ajustadas.
O que permanece e o que muda
- Filhos são casados em ordem, por tag. Um elemento correspondente é reutilizado, então o foco e os valores digitados em inputs permanecem, e seus hooks
onMountnão rodam de novo. Assim que a tag de um filho difere, esse filho e todos os irmãos seguintes são construídos do zero. - Texto, atributos, classes, estilos, listeners, ramos de
when()e linhas delist()seguem a nova construção. - Novos elementos são criados, e seus hooks
onMountrodam. Elementos que a nova construção não produz mais são removidos, e seus hooksonDestroyrodam. - Linhas de
list()também são casadas por posição, não por item, então um elemento de linha reutilizado pode acabar mostrando um item diferente. - Atributos que a nova construção não define mais não são removidos.
Forma explícita. forceUpdate(App(), parent) reconstrói uma árvore em parent e retorna sua raiz. Ele não registra a raiz para chamadas futuras. Como hydrate(), espera que a raiz seja o primeiro nó em parent. Se o primeiro nó tem uma tag diferente, ele é substituído.
No servidor, forceUpdate() não faz nada.
render()
#Constrói sua árvore e anexa seu elemento raiz a parent. parent tem como padrão document.body. Retorna o elemento raiz.
import 'nuclo' import { App } from './app.ts' render(App, document.getElementById('app')!)
- Você pode passar o componente (
render(App)) ou o builder que ele retorna (render(App())). O DOM é o mesmo. Apenasrender(App)permite queforceUpdate()reconstrua a raiz depois, então prefira essa forma. - Ele anexa. Não limpa
parent, então chamá-lo duas vezes adiciona duas cópias. - Todo
onMountda árvore já rodou quandorender()retorna. - Renderize em um elemento que está no documento. Veja
update(). - A raiz deve ser um elemento, como
div(...), nãowhen()oulist(). indexé interno. Ele não escolhe onde o elemento é inserido. Deixe-o de fora.
Um componente passado como render(App) não deve ter parâmetros. Um parâmetro obrigatório é um erro de tipo. Um opcional passa na checagem de tipos, mas render() lança um erro em tempo de execução. Envolva um componente que recebe props:
const Page = (props: { name: string }) => div(h1('Hi ', props.name)) render(() => Page({ name: 'Ana' }), root)
hydrate()
#Assume o HTML produzido por renderToString(). Ele reutiliza os elementos existentes, anexa listeners de eventos e conecta valores dinâmicos, when() e list(), para que update() funcione depois. Então executa os hooks onMount. parent tem como padrão document.body. Retorna o elemento raiz.
import 'nuclo' import { App } from './app.ts' hydrate(App, document.getElementById('root')!)
- Use o mesmo componente, e os mesmos dados, que o servidor.
- Hidrate em um contêiner dedicado. A raiz do app deve ser seu primeiro nó, embora espaços em branco sejam aceitáveis. Se outro nó vem primeiro, Nuclo substitui esse nó e deixa a cópia do servidor para trás.
- Como em
render(), passeApp, nãoApp(), para queforceUpdate()possa reconstruir a raiz depois.Appnão deve ter parâmetros. Com um opcional,hydrate()silenciosamente não anexa nada. Envolva um componente que recebe props:hydrate(() => Page(props), root). - Se não há HTML do servidor, use
render().
Quando o servidor e o cliente diferem
O cliente vence:
- Uma tag de raiz diferente: esse nó é substituído por DOM novo. Um contêiner vazio: DOM novo é anexado.
- Texto diferente é corrigido. Itens de
list()e ramos dewhen()diferentes seguem o cliente. - Filhos extras do servidor são removidos, e os que faltam são criados.
- Atributos que o servidor escreveu mas o cliente não define são deixados como estão.
renderToString() escreve comentários HTML como marcadores, como <!-- text-0 --> antes de cada nó de texto. hydrate() precisa deles, então não remova os comentários do HTML do servidor. Sem os marcadores, a hidratação apaga as linhas de list(), e list() e when() param de atualizar.
Renderização no servidor
#Renderize um componente em uma string HTML no servidor. No navegador, chame hydrate() com o mesmo componente para tornar esse HTML interativo. Runtimes de servidor não têm document, então importe nuclo/polyfill primeiro.
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')!)
Mantenha id="nuclo-styles" na tag style. O cliente reutiliza esse elemento e adiciona apenas regras novas. Veja getCssText().
O que roda no servidor
- Cada chamada de
renderToString()constrói a árvore e executa cada valor dinâmico uma vez. - Handlers de eventos,
onMounteonDestroynunca rodam.update(),scope()eforceUpdate()não fazem nada. - A renderização é síncrona. Carregue seus dados primeiro, depois renderize.
- Nada permanece registrado depois de uma renderização, então um processo pode atender muitas requisições. Passe os dados da requisição como argumentos, como
renderToString(Page(user)). Variáveis no nível do módulo são compartilhadas por todas as requisições. - Não chame
render()no servidor. Ele anexa aodocument.bodycompartilhado.
renderToString()
Recebe um builder (App()), um componente (App) ou um nó do DOM. null e undefined retornam "". A raiz deve ser um elemento. Um componente passado como renderToString(Page) não deve ter parâmetros, então passe Page(user) para um que recebe dados.
renderToString(p('Hello')) // '<p><!-- text-0 -->Hello</p>'
- Texto e valores de atributos são escapados como HTML. Texto dentro de
script()estyle()é escrito como está, apenas com as tags de fechamento quebradas, então nunca coloque entrada do usuário ali. - Ele não lança erros. Se o componente, uma função de
list()ou uma condição dewhen()lança um erro, ou o polyfill está ausente, ele registra o erro e retorna"". Verifique se o resultado está vazio caso a requisição deva falhar. Uma função de texto ou atributo que lança um erro apenas renderiza vazio.
renderManyToString()
O mesmo que inputs.map(renderToString).
renderToStringWithContainer()
Envolve a saída em um elemento. containerTag tem como padrão "div", e containerAttrs, {}. Valores de atributos são escapados. Nomes de atributos são escritos como fornecidos, então use nomes HTML como class. Nunca passe entrada do usuário como a tag.
renderToStringWithContainer(p('Hi'), 'section', { id: 'main', class: 'page' }) // '<section id="main" class="page"><p><!-- text-0 -->Hi</p></section>'
nuclo/polyfill
#Um DOM pequeno para que o Nuclo possa construir árvores em Node e Bun. Importe-o uma vez, antes da sua primeira renderização. A maioria dos apps só precisa do import simples.
- Quando não há
window, ele defineglobalThis.document,Node,ElementeHTMLElement, mas apenas os que estão faltando. Ele não criawindow. - Em um navegador, ou sob jsdom, não faz nada.
EventeCustomEventsão os construtores do próprio runtime.Node,ElementeHTMLElementsão aliases das classes do polyfill.
É um DOM para construir árvores, não um navegador. Não tem innerHTML nem getElementById, querySelector() retorna null, e listeners de eventos são ignorados. Use renderToString() para obter o HTML.
Transforma um objeto de estilo tipado em classes CSS geradas. Passe o resultado a qualquer construtor de tag. No navegador, as regras vão para um único elemento <style id="nuclo-styles">. No servidor, getCssText() as retorna.
const card = css({ p: 16, rounded: 8, bg: '#fff7ed', hover: { bg: '#ffedd5' }, }) const Card = () => div(card, 'Simple card')
css()cria uma classe para as declarações base, mais uma classe para cada chave de pseudo-estado, seletor e bloco de media. O card acima recebe duas classes.- Os nomes de classe são hashes do conteúdo. Estilos idênticos compartilham uma classe, e o servidor e o cliente produzem os mesmos nomes.
- Defina os estilos uma vez, no nível do módulo.
css()faz cache por objeto, então um objeto novo a cada chamada é processado de novo toda vez. - Alterar um objeto de estilo depois de passá-lo a
css()não tem efeito. - Todo estilo permanece registrado pela vida da página ou do processo do servidor. Para valores que mudam por item, como uma largura vinda de dados, use o atributo
style.
Estilos nomeados
Passe um nome primeiro para obter um nome de classe legível:
const pageTitle = css('page-title', { text: 24, hover: { color: 'red' } }) pageTitle.className // "page-title page-title-<hash>"
- A classe base é exatamente o nome. Os outros blocos recebem
name-<hash>. - O nome precisa ser um nome de classe válido. Caso contrário, o Nuclo avisa e gera um.
- Os nomes são globais. Se dois estilos diferentes usam o mesmo nome, o Nuclo avisa.
Usando o resultado
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 retornam ambos os nomes de classe.
Objetos de Estilo
#O objeto que você passa a css() usa propriedades CSS comuns em camelCase, um conjunto de atalhos e chaves aninhadas para pseudo-estados, seletores e media queries. O TypeScript autocompleta todas elas. Propriedades fora do conjunto tipado, como borderTopLeftRadius, fill ou paddingInline, vão em raw (veja abaixo).
Valores
- Números viram
px, exceto0e propriedades sem unidade:zIndex,opacity,fontWeight,lineHeight,flex,flexGrow,flexShrink,order,aspectRatio,gridColumnegridRow. Emraw,zoom,scale,column-count,orphans,widows,tab-sizeeanimation-iteration-counttambém são sem unidade. - Strings são usadas como escritas.
null,undefinedefalsesão ignorados. lineHeight(eleading) é sem unidade, entãoleading: 24significa 24 vezes o tamanho da fonte. Escreva"24px"para pixels.
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%
Atalhos
| Chave | CSS |
|---|---|
p, pt, pr, pb, pl | padding, e cada lado |
px / py | padding esquerdo e direito / superior e inferior |
m, mt, mr, mb, ml, mx, my | o mesmo para margin |
w, h, minW, maxW, minH, maxH | width, height, e seus 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 |
Estas chaves recebem true:
| Chave | CSS |
|---|---|
row | display: flex; flex-direction: row |
col | display: flex; flex-direction: column |
center | align-items: center; justify-content: center (não define display) |
truncate | overflow: hidden; text-overflow: ellipsis; white-space: nowrap |
Pseudo-estados
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. Eles aninham: hover: { focus: {…} } gera :hover:focus.
Seletores e at-rules
Uma chave que começa com & é um seletor, e cada & vira a classe. Uma chave que começa com @ é uma at-rule: @media, @container ou @supports.
const menu = css({ p: 8, '& > li': { py: 4 }, '& > li:hover': { color: 'red' }, '&:is(.dark *)': { color: 'white' }, '@media (min-width: 768px)': { p: 16 }, })
- A chave precisa começar com
&. Uma chave como".dark &"é ignorada, então escreva"&:is(.dark *)". - Dois blocos
@mediaaninhados combinam comand. Uma query iniciada por um tipo de mídia (print,only screen) é colocada primeiro, porque a gramática só permite um tipo no início. Um par que não pode ser achatado — dois tipos de mídia, ou umnotinicial — avisa e usa apenas a query interna. Qualquer outra at-rule aninhada substitui a externa.
raw
Use raw para qualquer propriedade, com o nome escrito exatamente como no CSS. Números ainda recebem px, então passe strings:
css({ raw: { '--gap': '8px', '-webkit-line-clamp': '2' } })
Qualquer outra chave plana é convertida para kebab-case e escrita como está, então um erro de digitação como colr: 'red' vira uma declaração que o navegador descarta. Um objeto aninhado sob uma chave que não é um pseudo-estado, um screen, ou uma chave & ou @ é ignorado. O TypeScript reporta ambos.
Combina estilos. Quando duas entradas definem a mesma propriedade, a entrada posterior vence.
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')
- Entradas falsy são ignoradas, arrays são achatados, e strings são divididas e deduplicadas.
- Classes geradas para o mesmo bloco (base, o mesmo pseudo-estado, a mesma media query) se fundem em uma nova classe. Outros nomes de classe passam inalterados.
- Apenas
cx()conhece a ordem dos argumentos. Comdiv(base, blue), ambas as classes são adicionadas, e a regra criada por último vence. Coloque estilos que se sobrescrevem em uma única chamada decx(). - Quando o bloco de um estilo nomeado se funde com o bloco de outra entrada, a classe resultante é
name-<hash>, não o nome puro. Blocos que não se fundem mantêm sua classe, entãocx(pageTitle, 'extra')ainda tempage-title.
Classes dinâmicas
Passe uma função que sempre retorna 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() } }, )
A função precisa retornar um resultado de estilo na primeira execução. () => active && activeButton renderiza o texto "false" em vez de uma classe.
Retorna seus próprios css, cx, variants, keyframes e globalStyle, vinculados a um tema. Um tema dá nomes a valores: com o tema abaixo, borderColor: 'border' escreve #e5e7eb, e o TypeScript autocompleta os nomes. Cada entrada em screens vira uma chave de estilo para uma media query, como 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 }, })
| Chave do tema | Usada por |
|---|---|
colors | bg, backgroundColor, color, borderColor e cada lado, outlineColor, caretColor, textDecorationColor |
fonts | font, fontFamily |
shadows | shadow, boxShadow |
radii | rounded, borderRadius |
screens | novas chaves de estilo, como md: {…} |
- Um nome do tema só é resolvido quando é o valor inteiro.
border: "1px solid primary"não é resolvido. - Um valor de screen sem
@vira@media <valor>. Um valor que começa com@é usado como escrito, por exemplo"@container (min-width: 400px)". - Screens existem apenas em instâncias criadas com
createCss(). Ocss()global não tem nenhum, mas aceita chaves@media. - Todas as instâncias e os helpers globais compartilham uma única folha de estilos. As regras de media dos screens são emitidas na ordem em que os screens são declarados, não na ordem em que você os usa, então liste screens
min-widthdo menor para o maior. Use uma instância decreateCss()por app, para que essa ordem seja a do seu tema. themeno resultado é o tema que você passou.
Constrói uma função que retorna um estilo para um conjunto de opções, como a intenção e o tamanho de um botão.
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
- Os estilos se fundem nesta ordem:
base, o valor escolhido de cada grupo, depois cada compound que corresponder. Estilos posteriores vencem por propriedade. - Uma opção ausente,
undefinedounullusa o padrão. Um grupo sem padrão é ignorado. - Um grupo com apenas as chaves
trueefalserecebe um booleano. - Um compound se aplica quando todos os grupos que ele lista correspondem, incluindo os padrões.
- Cada seleção é construída uma vez e armazenada em cache.
keyframes() registra uma animação e retorna seu nome gerado. As chaves dos frames são from, to e porcentagens, como "50%" ou "0%, 100%". Frames aceitam apenas propriedades planas. Frames idênticos retornam o mesmo nome.
const fadeIn = keyframes({ from: { opacity: 0 }, to: { opacity: 1 }, }) const panel = css({ animation: `${fadeIn} 200ms ease-out` })
globalStyle() adiciona uma regra para qualquer seletor:
globalStyle('*, *::before, *::after', { boxSizing: 'border-box' }) globalStyle('body', { m: 0, font: 'system-ui, sans-serif' })
- O seletor é usado exatamente como escrito.
- Apenas propriedades planas funcionam. Chaves de pseudo-estado, screens e chaves
@são ignoradas, entãoglobalStyle()não pode escrever regras de media. - A mesma regra é adicionada uma vez. Um estilo diferente para o mesmo seletor adiciona uma segunda regra, e a posterior vence.
getCssText() & resetStyles()
#getCssText() retorna todas as regras que css(), cx(), variants(), keyframes() e globalStyle() criaram neste processo: regras base primeiro, depois os blocos @media, @container e @supports. Use-a para renderização no servidor.
- Chame-a depois de
renderToString(). Regras criadas durante a renderização são incluídas. - Ela cobre o processo inteiro, não uma página ou requisição, e só cresce. Isso é seguro, porque os nomes de classe são hashes do conteúdo.
- Coloque-a em
<style id="nuclo-styles">. O cliente reutiliza esse elemento e pula as regras que já estão nele. Com outro id, ou nenhum, o cliente adiciona uma segunda folha de estilos com todas as regras de novo. - O texto não é escapado, então nunca coloque entrada não confiável em valores de estilo.
resetStyles() é um helper de teste. Remove todas as regras e o elemento #nuclo-styles. Estilos criados antes mantêm seus nomes de classe mas perdem seu CSS. Não a chame em um app ou em um servidor.
import { resetStyles } from 'nuclo' beforeEach(() => resetStyles())
nuclo-router não faz parte do pacote principal do Nuclo. Instale-o junto com nuclo. É uma versão preview, então sua API ainda pode mudar.Roteamento para o Nuclo: renderização no servidor, hidratação, rotas com code splitting e pré-carregamento em idle, construído sobre list() e update(). Cerca de 4.9 KB gzipado, sem dependências além do próprio Nuclo e sem efeitos colaterais, então é removido pelo tree-shaking quando não usado.
$ npm install nuclo nuclo-router
Ou use pnpm add, yarn add ou bun add com os mesmos dois nomes. Requer nuclo 0.3 ou posterior como peer dependency.
O roteador decide o que carregar. O app diz onde com router.outlet(): um list() sobre a pilha de camadas, uma linha por camada, então uma página comum é uma única linha. O servidor e o cliente constroem a mesma árvore, então hydrate() reivindica os nós do servidor em vez de substituí-los. Todo o resto é history, um listener de click delegado e requestIdleCallback.
Um 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() resolve os módulos da rota correspondente antes de retornar, então a primeira árvore está completa tanto no servidor quanto no cliente. Depois disso o roteador lê diretamente a rota que iniciou: router.path, router.pending, router.go().
Um app completo
// 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))
Essa é toda a integração. Os links funcionam porque um <a href> é só um <a href>. Um listener delegado em document intercepta os links que o roteador possui e deixa todos os outros para o navegador.
No servidor, start() retorna um Route por requisição e não mexe no roteador, então requisições concorrentes não conseguem ver a rota uma da outra. route.run() torna esse Route aquele que o roteador, e portanto o outlet, lê durante a renderização. No navegador há um Route por carregamento de página, e start() o torna o do próprio roteador. Nenhum run() é necessário.
Uma página recebe seu RouteContext e suas PageProps, como (ctx, { layer, data, outlet, beforeLeave }), e pode receber menos argumentos. Ela retorna qualquer coisa que list() aceite. Para preencher também outra parte do layout, retorne um into() de dentro da página, como div(article(…), into('sidebar', nav(…))). Esse conteúdo sai junto com a página. Veja Páginas & Regiões.
| Padrão | Corresponde a | params |
|---|---|---|
/, /docs/intro | exatamente esse caminho | {} |
/blog/:slug | /blog/hello | { slug: 'hello' } |
/u/:id/edit | /u/7/edit | { id: '7' } |
/files/*rest | /files/a/b.txt | { rest: 'a/b.txt' } |
* | qualquer coisa sem correspondência, o seu 404 | { '*': '…' } |
A precedência é: estáticos, depois dinâmicos em ordem de declaração, depois catch-all com o prefixo mais longo primeiro. Então '*' só corresponde ao que nada mais corresponde e pode ficar em qualquer lugar da tabela. Barras finais, barras duplicadas e percent-encoding são normalizados, e os valores dos params chegam decodificados. A correspondência é uma comparação de segmentos por padrão. Nenhuma regex é construída ou executada.
Rotas relativas
Um padrão escrito com ./ é relativo ao pai que o declara, e um href escrito com ./ é relativo à rota ativa. Juntos eles formam uma seção reutilizável que significa a mesma coisa sob qualquer pai. O valor de uma tabela é um carregador ou outra tabela, cujas chaves são relativas a ela:
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'), })
Essas duas linhas produzem todos os seis padrões sem nenhum caminho escrito duas vezes. Ambos os pais recebem os mesmos objetos de carregador, então compartilham um único cache de módulos e um único import().
- Dentro de uma tabela filha,
'./x','/x'e'x'significam a mesma coisa,'/'significa o próprio caminho do pai, e'*rest'dá à seção seu próprio catch-all. O aninhamento vai tão fundo quanto você quiser. - Chaves de nível superior são usadas como escritas, então
'*'continua'*'. - Um filho sob um pai
*catch-allé um erro decreateRouter(), já que um catch-all precisa encerrar seu padrão.
Páginas pai e outlet()
Um pai e seus filhos formam uma hierarquia real. O pai renderiza, e seu filho renderiza dentro dele, onde quer que ele chame 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())) }
Navegar entre filhos não reconstrói o pai. Seu nó DOM é o mesmo nó, então seu foco, sua posição de scroll, seu input digitado pela metade e qualquer estado em sua closure sobrevivem.
- Uma página sem rota filha não renderiza nada do seu outlet, então um layout pode chamá-lo incondicionalmente. Um pai que tem um filho e nunca chama
outlet()o engole. - Não há rotas index.
'/'é o próprio layout, então o caminho do pai renderiza o layout com um outlet vazio em vez de uma página index separada dentro dele. Coloque o conteúdo inicial no layout, ou dê a ele um caminho filho real.
A mesma chamada então funciona a partir de qualquer dos pais, e a partir de uma página que não sabe em qual deles está:
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 .. são resolvidos contra o caminho da rota atual, não contra o "diretório" da URL. A partir de /invoices/42, ./preview é /invoices/42/preview, onde um navegador diria /invoices/preview, e uma barra final na URL não muda nada. ../ sobe um segmento e para na raiz. A query e o hash são preservados, base é adicionado de novo, e o caminho absoluto resolvido é o que entra no history. Este é o mesmo desvio da resolução de URL que o React Router faz para <Link to="./x">.
- Apenas
./e../são relativos à rota. Umpreviewpuro em um href fica sujeito às regras comuns do navegador. - Construa links relativos com
router.href('./preview')em vez de escreverhref="./preview"à mão.href()resolve no momento da renderização, então o markup entregue é absoluto e continua correto para um crawler, para um clique com o botão do meio e com JavaScript desabilitado.
O que é mantido, e o que é reconstruído
| Nó | Na navegação |
|---|---|
| um pai cujo padrão e caminho não mudaram | mantido: mesmo nó, e seu load() não é executado de novo |
| a página correspondente | mantida quando seu padrão e caminho não mudaram e ela não tem load(), então uma mudança de query ou hash a mantém. Uma rota com carregador é atualizada a cada visita. |
| um pai cujo caminho mudou | reconstruído, junto com tudo abaixo dele |
| uma camada empilhada | renderiza apenas a página correspondente. Seus pais já estão montados na linha abaixo dela. |
Uma página mantida mantém o ctx com que foi construída. Esse é o significado de ctx em todo lugar: leia router.* para valores ao vivo. Estado dirigido pela query, como filtros, paginação e abas, lê router.search dentro de um valor dinâmico e escreve com router.go('?page=2', { replace: true }). A página, seu foco e seu input digitado pela metade ficam no lugar.
Páginas & Regiões
#router.outlet() coloca a página em um lugar. Uma página que também precisa de uma sidebar, uma barra de ferramentas ou um título em outro ponto do layout o preenche com into(). O layout marca o lugar com region(), e a página retorna um into() ao lado do seu próprio conteúdo. O layout nunca recebe conteúdo de página como prop, e a navegação nunca o reconstrói.
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))))), ) }
O conteúdo da página é uma linha do list() do outlet, e um into() vive enquanto a linha que o contém viver. Então o conteúdo da sidebar chega com a página e sai com ela: navegar para uma página sem um into('sidebar', …) mostra o conteúdo empty da região de novo. A página pode usar seu ctx e seu data para ambas as partes, já que ambas são construídas pela mesma função.
O que acontece na navegação
- Uma página mantida mantém suas regiões. Uma mudança de query ou hash que mantém a página também não toca no conteúdo da região. Leia
router.searchdentro de um valor dinâmico se a sidebar depender dele. - Uma página reconstruída as preenche de novo. Quando a rota muda, a linha antiga é removida e as views
into()da nova página assumem. Nada fica para trás. - Uma camada empilhada adiciona uma view. A camada é uma linha posterior do outlet, então seu
into()é uma view mais nova da mesma região. Com o padrãotype: 'latest'ela cobre a view da página até a camada fechar, e então a view da página aparece de novo. Comtype: 'stack'ambas aparecem. - Uma página pai também pode preencher regiões. Suas views permanecem enquanto seus filhos mudam, porque o pai não é reconstruído.
- A renderização no servidor funciona como está. O servidor constrói a página dentro de
route.run(), então suas viewsinto()estão no HTML, ehydrate()as reivindica.
Várias regiões a partir de uma página
Passe um objeto para preencher mais de uma região de uma vez. O conteúdo principal da página continua passando pelo outlet, então retorne-o ao lado do into():
export default function Report(ctx: RouteContext) { return div( table(…), into({ toolbar: [button('Export'), button('Print')], sidebar: nav(a({ href: router.href('./summary') }, 'Summary')), }), ) }
Qual ferramenta para qual trabalho
outlet()é para uma rota filha: conteúdo que a URL decide, renderizado dentro de sua página pai. Veja Rotas Aninhadas.into()é para a página correspondente se espalhar pelo layout. O conteúdo pertence à página e usa seus dados.matches()é para o layout decidir apenas a partir da URL, com sua própria tabela pequena e sem página envolvida. Vejamatches().
Tudo isso pertence à única rota correspondente. O roteador não dirige duas áreas independentes a partir de uma URL, então uma sidebar com suas próprias rotas é uma região que uma página preenche, não um segundo outlet. Uma sidebar que realmente quer seu próprio roteador, carregamento e fallback mantém um não iniciado e resolve com match() — veja Múltiplos Roteadores & Carregamento Combinado.
Um módulo de página pode exportar uma função load. Ela roda antes da página ser construída, em toda navegação para essa rota, e o que ela retorna é entregue à página como 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)) }
A tabela de rotas não muda. O carregador viaja com a página. Ele recebe o RouteContext correspondente, então params, search e path estão todos disponíveis. Pode ser síncrono ou assíncrono, e roda também no servidor, então uma página renderizada no servidor é entregue com seus dados já no HTML em vez de buscá-los depois da hidratação.
O módulo é cacheado. Os dados não. O chunk de uma rota é buscado uma vez e reutilizado, e seu carregador roda de novo em toda navegação. É por isso que /blog/a e /blog/b não podem ser confundidos, e por que um carregador que falhou pode simplesmente ser tentado de novo.
| Situação | Comportamento |
|---|---|
| enquanto ele roda | router.pending é true e a página em que você estava continua na tela |
| ele lança ou rejeita | cai em router.error. go() continua nunca rejeitando, e a navegação pode ser tentada de novo. |
durante start() | a rejeição sai de start(). Ainda não há Route onde colocá-la. |
| uma camada empilhada | roda seu próprio carregador, com seus próprios dados |
| o pré-carregador em idle | aquece apenas módulos. Não tem contexto para passar, e estaria buscando dados para uma rota que você talvez nunca abra. |
| navegar de novo para a mesma URL | o carregador roda de novo e a linha é reconstruída. Uma rota sem carregador mantém sua linha. |
Para revalidar, navegue para onde você já está sem adicionar uma entrada no history:
await router.go(router.url, { replace: true }) // runs load() again
Iniciando os dados junto com o chunk
Um load exportado pelo módulo da página só pode rodar depois que esse módulo chegou, então uma navegação a frio busca o chunk e depois os dados. Para iniciar ambos de uma vez, declare o carregador na tabela com 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), })
Ele substitui o load do próprio módulo e roda a cada visita como ele. Uma rejeição enquanto o chunk ainda está carregando cai em router.error como qualquer outra.
Não há serialização dos dados do servidor no HTML, por design. O load do cliente roda de novo na hidratação. Se isso importar, faça o servidor emitir route.data, por exemplo como window.__DATA__, e o carregador usá-lo primeiro. route.data são apenas os dados da página correspondente. O resultado do load() de um pai não está nele.
Code splitting e pré-carregamento
O valor de uma rota é sempre uma função que retorna o componente da página, então o componente é alcançado de forma 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
Apenas as formas com formato de módulo podem carregar um export load. Escolher um componente nomeado com .then(m => m.BPage) entrega ao roteador apenas a função.
Cada módulo é importado uma vez e cacheado. Navegações concorrentes para a mesma rota compartilham um único import(). Depois que o usuário deixa a página quieta por idleTimeout (um segundo por padrão; sem toques, teclas, roda do mouse ou scroll), as rotas restantes são carregadas em segundo plano, uma por fatia de idle (requestIdleCallback). Qualquer entrada, ou uma navegação pela qual o usuário está esperando, pausa isso até a página ficar idle de novo.
Uma rota pré-carregada ou eager nunca liga router.pending e aparece antes do próximo paint: nenhum frame de spinner, nenhum layout shift. router.pending só é true para um módulo ou carregador que precisa ser aguardado, e a página de saída fica na tela enquanto isso. Um chunk que falha ao carregar aparece como router.error, nunca como uma rejeição não tratada, e pode simplesmente ser tentado de novo.
API do Roteador
#createRouter(table, options?)
table mapeia um padrão a um carregador, ou a uma tabela aninhada. Opções:
| Opção | Padrão | |
|---|---|---|
base | '/' | Prefixo de URL sob o qual o app é servido, como '/docs'. Só corresponde em um limite de segmento: /docs/intro está dentro de '/docs', /docsearch não. URLs fora dele não correspondem a nada, então start() rejeita (seu 404 do lado do servidor) e links para elas ficam para o navegador. |
preload | true | Carregar os módulos das rotas restantes em segundo plano quando a página ficar idle. false desliga. |
idleTimeout | 1000 | Milissegundos sem toques, teclas, roda do mouse ou scroll antes do pré-carregamento começar, ou retomar depois que o usuário esteve ocupado. |
onNavigate | (ctx, kind) => void, chamado depois de toda navegação resolvida no navegador, incluindo a inicial, quando o DOM já foi atualizado. kind é 'start', 'push', 'replace', 'pop' (Voltar, Avançar, close()) ou 'layer'. O gancho para document.title, meta tags, foco, scroll e analytics. Nunca chamado durante SSR. |
Retorna o roteador: start(), match(), outlet(), matches(), e todos os membros de Route exceto run(), lidos diretamente do Route ativo. Esse é aquele que o último start() retornou no navegador, ou aquele cujo run() está executando no servidor. Ler um membro sem nenhum dos dois lança um erro. A tabela de rotas, o matcher compilado e o cache de módulos vivem no roteador, então são compartilhados entre requisições em um servidor.
router.start(url?)
Resolve url (padrão: location.href no navegador) para seus módulos de página e retorna uma Promise<Route>. Rejeita quando nada corresponde e a tabela não tem rota '*'. Em um servidor, esse é o seu 404. No navegador também torna o Route o ativo do roteador, anexa os listeners de popstate e de click delegado e agenda o pré-carregamento. Um segundo start() aposenta o Route anterior para você. No servidor não toca em nada compartilhado, então renderize dentro de route.run().
router.match(url?)
Resolve uma URL contra a tabela sem carregar nada: sem import, sem history, sem listeners, sem Route. Retorna um RouteContext, ou null quando a URL está fora de base, não corresponde a nenhum padrão ou, no navegador, está em outra origem. Apenas URLs absolutas, já que a resolução de ./ precisa de uma rota ativa.
// 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()
As páginas da rota ativa como um list(). Coloque-o uma vez, onde as páginas devem ir. Uma camada empilhada é uma linha posterior dele. Está vazio antes de start() resolver e depois de stop(). No servidor renderiza o Route cujo run() está executando.
router.matches(table)
Renderiza a view de uma segunda tabela que o caminho da rota ativa corresponde, como um list() que você coloca em qualquer lugar. Veja matches().
Route
Todos os membros exceto run() também estão no roteador, que é onde um app os lê.
| Membro | |
|---|---|
path, pattern, params, search, hash, url | a correspondência ativa. search é um URLSearchParams. search, hash e url são ao vivo. |
pending | uma navegação está esperando um módulo ou um carregador |
error | o último carregamento que falhou, limpo pela próxima navegação |
data | o que o load() da página correspondente retornou, para um servidor serializar. O de um pai não está nele. |
depth | quantas camadas estão na pilha (1 para uma página comum) |
go(href, { replace }) | navegar, carregando o módulo se necessário. Nunca rejeita: um módulo que falha, rejeitando ou lançando, cai em error. |
push(href) | abrir uma rota como uma nova camada por cima, resolvendo com o que ela fechar. Aceita ./. Rejeita se não houver correspondência ou se o módulo falhar. |
href(path) | prefixar um caminho com base, para a({ href }). Resolve ./ e ../ contra a rota ativa. |
stop() | desanexar todos os listeners, descartar camadas abertas, esvaziar o outlet. Idempotente. Um Route parado ainda responde com sua última correspondência, e go() não faz nada. |
run(fn) | chamar fn com este Route como o ativo do roteador e retornar seu resultado: route.run(() => renderToString(App)) no servidor. Apenas síncrono. O Route deixa de ser ativo quando fn retorna. |
RouteContext
O primeiro argumento de uma página: path, pattern, params, search, hash, url. É o contexto com que a página foi construída. Leia router.* para valores ao vivo. Eles diferem apenas quando a query ou o hash mudou sem uma reconstrução.
params é { [name]: string } por padrão. Uma página pode tipá-lo a partir do seu próprio padrão com Params, que transforma '/blog/:slug' em { slug: string } e '/files/*rest' em { 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
O segundo argumento de uma página. Desestruture o que a página precisar.
| Prop | |
|---|---|
layer | onde a página está na pilha. Veja Layer abaixo. |
data | o que o load() da rota retornou, ou undefined sem um. Tipado por PageProps<TData>. |
outlet | onde uma página pai renderiza sua rota filha. Veja Rotas Aninhadas. |
beforeLeave | registra um guard consultado antes desta página ser descartada. Veja Guards de saída. |
Layer
Onde uma página está na pilha, e sua saída.
| Membro | |
|---|---|
depth | 0 para a página base, 1 em diante para camadas empilhadas |
push(href) | abrir outra camada de dentro de uma página, igual a Route.push() |
close(result?) | fechar esta camada e tudo acima dela, resolvendo o push() que a abriu. Sem argumento significa descartada. Sem efeito na profundidade 0. |
DataLoader e withLoad()
DataLoader é (ctx: RouteContext) => TData | Promise<TData>, o export opcional load de um módulo de página. withLoad(loader, load) cria uma rota cujo load começa junto com o import do seu chunk em vez de depois dele. Veja Carregadores de Rota.
Tipos
Todo tipo é um export nomeado de nuclo-router, então uma página, um carregador ou um guard pode ser tipado sem importar de um caminho.
| Tipo | O que é |
|---|---|
Params<'/blog/:slug'> | o objeto de params para um padrão. RouteParams é a forma não tipada, e Match é o resultado do matcher. |
PageComponent | uma página: (ctx, { layer, data, outlet, beforeLeave }) => … |
RouteModule | um módulo de página: o próprio componente, ou { default, load? } como import() produz |
RouteLoader | () => RouteModule | Promise<RouteModule>, opcionalmente carregando um load de withLoad() |
RouteTable | a tabela que createRouter() recebe: padrão para carregador, ou para uma tabela aninhada |
RouterOptions | { base?, preload?, idleTimeout?, onNavigate? } |
NavigateOptions | { replace? }, o segundo argumento de go() |
NavigationKind | 'start' | 'push' | 'replace' | 'pop' | 'layer', o segundo argumento de onNavigate |
Outlet | o tipo de outlet() de PageProps |
LeaveGuard | (to) => boolean | void | Promise<…>, o que beforeLeave() recebe |
MatchView | o que uma view de matches() é: params entram, conteúdo ou import() de uma página sai |
push() abre uma rota por cima da atual em vez de substituí-la, e resolve com o que essa camada fechar. Como as páginas são um único list() sobre a pilha, abrir uma camada é um append. A página abaixo não é renderizada de novo, então seu DOM, seu foco e seu formulário preenchido pela metade continuam lá quando a camada fecha.
Isso faz o fluxo "preciso de um registro que ainda não existe" funcionar sem jogar fora o formulário que o usuário estava preenchendo:
// 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
Camadas aninham. Um diálogo que precisa do seu próprio diálogo chama layer.push() e o aguarda, sem nenhuma referência ao Route.
- A URL é a camada do topo.
push()adiciona uma entrada no history, então uma camada é linkável e Voltar a fecha, resolvendo seupush()como descartado, exatamente como Cancelar. Voltar eclose()são um único caminho de código:close()registra seu resultado e volta no history, e a profundidade emhistory.stateé a autoridade. Navegadores mantêm cerca de 50 entradas por aba, então depois de um longo passeio por camadas a entrada abaixo pode ter sumido: entãoclose()fecha as camadas no lugar e reescreve a entrada atual para a página que ficou visível. - Uma navegação comum limpa a pilha. Um clique em link ou
go()não é uma camada. Substitui tudo e descarta toda camada aberta, então nenhum chamador fica aguardando. - Um carregamento a frio é uma página independente. A URL nomeia a camada do topo mas não a pilha abaixo dela, então abrir a URL de uma camada diretamente a renderiza na profundidade 1 com
layer.depth === 0e umclose()inerte. Uma rota empilhada precisa fazer sentido sozinha. Ramifique emlayer.depthpara oferecer um link de saída em vez de um botão Cancelar morto. - A promise não sobrevive a um reload, porque vive em uma closure. Trate o resultado como um atalho por um fluxo que também funciona pelo caminho longo.
- Nada fica aguardando.
stop()descarta toda camada aberta, e uma camada abandonada por uma navegação resolve comundefined.
Guards de saída
Uma página que guarda algo que vale manter, como um formulário preenchido pela metade, registra um guard. Ele é consultado antes da página ser descartada, e retornar false (ou uma promise dele) mantém a página:
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) }), …) }
- Só quando a página sai. Uma navegação filha sob um layout, ou uma mudança de query ou hash que mantém a página, não consulta. Um guard vive e morre com sua página, então não há nada para desregistrar.
- Todas as saídas. Um link,
go(), Voltar e Avançar, oclose()de uma camada, ego()para uma URL que o roteador não possui (saindo do app, ondetoénull). Um Voltar ou Avançar recusado é desfeito, então a barra de endereço fica na página. Fechar a aba ou recarregar é trabalho dobeforeunload. toé oRouteContextpara onde a navegação está indo. Guards rodam do mais interno primeiro e param na primeira recusa. Um que lança erro mantém a página.close(result)também passa pelo guard, então limpe sua flag antes de fechar com um resultado.- O roteador só consegue desfazer entradas de history que ele mesmo escreveu. Um Voltar ou Avançar para uma que ele não escreveu, como um link
#fragmentnativo ou opushStatede outro script, não é consultado.
matches() & Sub-roteadores
#router.outlet() renderiza a página. router.matches(table) renderiza outra coisa que depende de onde você está, como uma sidebar, breadcrumbs ou uma barra de ferramentas, a partir da sua própria tabela pequena, sem carregar uma rota ou adicionar uma página:
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()), )
- Mesma correspondência da tabela de rotas. Os padrões, a precedência, a correspondência de caminho bruto e a remoção de
basesão os do próprio roteador, então uma view e uma página sempre concordam sobre a URL. Ele lê o caminho da camada do topo, comorouter.path. - Uma view é
(params, ctx) => conteúdo, ondeparamsé tipado a partir do seu padrão ectxé oRouteContextcom opatterne osparamsda view. Ela pode, em vez disso, retornar oimport()de um módulo de página, que é chamado comctxquando chegar. - Nada corresponde, nada renderiza. Adicione uma view
'*'como fallback. Está vazio antes destart()e depois destop(), como o outlet. - Só renderiza de novo quando a rota muda, então uma view mantém seu DOM através de qualquer coisa que mantenha a página.
- Uma view lazy não renderiza nada até seu módulo chegar. Um
import()que falha é registrado comconsole.error, não definerouter.error, e é tentado de novo na próxima navegação. Umupdate()comum não tenta de novo. - No servidor uma view lazy não entrega nada, já que seu módulo chega no cliente. Use uma view síncrona para qualquer coisa que o primeiro HTML precise conter.
Sub-roteadores
Rotas aninham, então um sub-roteador normalmente é só uma tabela aninhada. Um roteador: a própria página '/' da seção é o seu chrome, e seus filhos renderizam no outlet() dessa página, então o chrome sobrevive à navegação dentro da seção:
createRouter({ '/': …, '/docs': docsRoutes, '*': … })
Ou dê à funcionalidade um roteador próprio, criado mas nunca iniciado. O pai possui uma única entrada catch-all e o filho resolve o restante contra seus próprios padrões, 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
Nunca iniciá-lo é o ponto. start() é o que anexa os listeners, e dois roteadores iniciados entram em conflito. A troca é que o filho é dono da resolução, então os carregadores de rota, que um roteador iniciado executa, ficam por sua conta chamar. Esse formato também reconstrói o shell quando o restante muda, porque o roteador vê uma rota e o shell resolve o resto sozinho. Uma tabela aninhada é o que lhe dá um pai que o roteador mantém montado, então prefira-a a menos que a funcionalidade realmente precise do seu próprio roteador, base e fallback.
Um app pode ter tantos roteadores quantas áreas tiver. A regra que mantém isso simples: inicie exatamente um por documento, e deixe todos os outros roteadores não iniciados, resolvendo URLs com match(). Apenas o roteador iniciado possui a URL, o history e o listener de clique. Os outros são tabelas às quais você faz perguntas — e cada área pode carregar no seu próprio ritmo, com seu próprio spinner.
Carregamento combinado: uma cadeia de carregadores
Toda página em uma cadeia correspondente pode exportar load(). Em uma navegação a frio para uma rota filha, o carregador do layout e o carregador da página rodam em paralelo, e um único router.pending cobre a cadeia inteira: a página anterior fica na tela até o par resolver, então uma árvore carregada pela metade nunca é pintada. Cada componente recebe seu próprio 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)))) }
Ambos os carregadores começam juntos, não um depois do outro. router.pending é true enquanto qualquer um dos dois é aguardado, e false no resto do tempo — uma rota pré-carregada ou eager nunca o liga. Mover-se entre seções mantém o layout, então apenas o carregador da seção roda de novo; os dados do layout, seu DOM e um input digitado pela metade sobrevivem. Navegar de novo para a mesma URL roda de novo o carregador da folha e reconstrói sua linha, exatamente como com uma única página.
Carregamento múltiplo: um segundo roteador com seu próprio spinner
Uma área independente — uma sidebar, uma bandeja, uma região ao vivo — recebe um roteador próprio, criado mas nunca iniciado, e sua própria flag de carregamento. router.pending pertence ao roteador iniciado e não diz nada sobre essa área, então as duas podem carregar lado a lado e qualquer uma pode terminar primeiro:
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 }, })
O segundo roteador é consultado, nunca comandado: match() lê a URL e retorna um contexto, e nada sobre a sidebar jamais entra na tabela de rotas principal. onNavigate roda depois de toda navegação resolvida, incluindo a inicial, então é o único lugar que mantém uma área independente em sincronia com a URL.
pendingpertence ao roteador iniciado. Um roteador nunca iniciado não tem Route, nempending, nemerror. A área acompanha suas próprias flags, e um segundo spinner pode ficar ao lado do primeiro. Na demo a sidebar carrega por 800ms enquanto os carregadores da rota rodam por 900ms, então a página pode terminar antes da sidebar ou o contrário.match()não anexa nada. Qualquer número de roteadores não iniciados pode compartilhar o documento; cada um tem sua própria tabela, base e cache de módulos, e nenhum deles toca no history. Custam uma tabela e um matcher.- Seus carregadores nunca rodam. O roteador iniciado executa o
load()das páginas que ele corresponde. Um roteador nunca iniciado apenas resolve, então se suas views precisam de dados, busque-os você mesmo — orefreshSidebar()da demo. Se você quer carregadores, uma tabela aninhada ou uma região que uma página preenche mantém esse trabalho com o roteador iniciado. - Proteja-se contra respostas obsoletas. Um fetch lento da sidebar não deve aterrissar na seção da qual você já saiu. Incremente um contador de geração por atualização e descarte respostas que não correspondem mais, do mesmo jeito que o próprio roteador aposenta navegações ultrapassadas.
- Uma view lazy de
matches()também não tempending. Ela não renderiza nada até seu módulo chegar. Três sistemas de carregamento, três relógios independentes: opendingda rota, suas próprias flags, e uma view lazy que simplesmente ainda não está lá.
Tratamento de Links
#O listener delegado navega em um clique esquerdo em um <a href>, incluindo um <a> SVG e cliques em qualquer coisa aninhada dentro de um, e sai do caminho para todo o resto: cliques com modificador e com o botão do meio, um target diferente de _self, download, rel="external", hrefs de outra origem, links somente de hash na página atual, caminhos fora de base, caminhos que nenhuma rota corresponde (então um 404 renderizado no servidor continua alcançável), e qualquer evento em que um handler do app já chamou preventDefault(). Exclua um link explicitamente com data-nuclo-router="off".
Scroll
Uma navegação por push preserva a posição de scroll atual. Quando a nova URL tem um #hash que corresponde a um elemento na página, esse elemento é rolado para a vista. popstate deixa o scroll para o navegador. Para novas páginas começarem no topo, faça isso em onNavigate, que diz como a navegação aconteceu:
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 }, })
Notas
- Nada é retido. O Route guarda a função da página e seu contexto, nunca DOM. Navegar para fora libera a página anterior.
stop()libera os listeners e qualquer carregamento em andamento, e limpapending, então um Route aposentado nunca deixa um spinner preso. Um módulo que resolve depois destop()é descartado silenciosamente. - Seguro para concorrência no servidor.
start()retorna estado por requisição erun()o restringe a uma renderização síncrona, então requisições intercaladas não conseguem ver a rota uma da outra. Apenas o cache de módulos é compartilhado. O escopo termina quando a função derun()retorna.renderToString()é síncrono, então isso basta. Uma renderização assíncrona ou em streaming precisaria do Route carregado através deawaits, o querun()não faz. - Restauração de scroll em
popstateé do navegador (scrollRestoration), não do roteador. - Inicie um roteador por documento. Cada
start()adiciona seu próprio listener declickdelegado, e ostart()de um segundo roteador não aposenta o primeiro. Apenas um segundostart()no mesmo roteador faz isso. Para resolver uma URL contra outra tabela, crie um roteador e chame apenasmatch(). Ele não anexa nada. Uma área independente pode executar seu próprio carregamento ao lado dopendingda rota dessa forma. Veja Múltiplos Roteadores & Carregamento Combinado. - Não incluído, por design: hash history e memory history, já que a rota vive no pathname e um servidor precisa servir toda rota; duas áreas roteadas independentemente dirigidas por uma URL, já que uma página pode preencher várias regiões com
into()mas todas pertencem à única rota correspondente — uma área independente ainda pode manter seu próprio roteador não iniciado e carregamento, como em Múltiplos Roteadores & Carregamento Combinado; e pré-carregamento no hover, que você obtém chamando o próprio carregador da rota a partir de ummouseenter.
Exemplos
Dois exemplos executáveis no repositório se complementam, então toda configuração é observável em algum lugar:
examples/router: um tour pelo lado do cliente por toda a API. Toda forma de padrão e regra de precedência, as exclusões do tratamento de links, todo membro de Route com controles ao vivo, um sub-roteador, a pilha de camadas com três níveis, uma seção relativa montada em dois pais, e carregadores de rota compendingeerror. Definepreload: falsepara que esses estados fiquem visíveis.examples/router-ssr:renderToString()por requisição, hidratação que comprovadamente reivindica os nós do servidor, códigos de status 404 reais, umbasefora da raiz, pré-carregamento em idle que você pode observar no painel de rede, e um carregador cujos dados são passados do servidor ao cliente para que a hidratação não busque de novo.
O Nuclo não tem uma API de valores computados. Use uma função comum, e chame-a a partir de valores dinâmicos:
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()
Use async/await comuns, e chame update() cada vez que o estado mudar. Para um fetch, isso significa uma vez antes do await e uma vez depois:
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() }
Boas Práticas
#- Agrupe as mudanças. Altere todo o estado que precisar, depois chame
update()uma vez. - Mantenha os valores dinâmicos rápidos e puros. Todos eles rodam em cada
update(). - Use
scope()para atualizações pequenas e frequentes, como digitação ou timers. - Renderize dentro do documento. Nós que não estão no documento quando
update()roda param de atualizar. - Use
when()elist()para filhos que mudam, não uma função que retorna nós. - Use
className, nãoclass, e use() => cx(...)ou{ className: () => … }para classes dinâmicas. - Defina os estilos uma vez, no nível do módulo.
- Renderização no servidor: renderize primeiro, depois coloque
getCssText()em<style id="nuclo-styles">. Passe os dados da requisição como argumentos, e mantenha os comentários HTML na saída.
Índice da API
#Toda função pública, e de onde ela vem.
nuclo (global e export nomeado)
update(...scopeIds): executa todos os valores dinâmicos, ou os dos escopos indicados.scope(...ids): dá a um elemento ids de escopo paraupdate(id).when(condition, ...content): conteúdo condicional, com.when()e.else().list(items, renderItem): uma linha por item, correspondidos por identidade.region(options)einto(id, ...content): um lugar nomeado na árvore, e conteúdo que o preenche de qualquer lugar.on(type, listener, options?): um listener de evento, ouon('mount')/on('destroy').render(App, parent?): constrói e anexa.hydrate(App, parent?): assume o HTML do servidor.forceUpdate(): chama os componentes de novo e aplica as mudanças no DOM.css(name?, style): classes geradas a partir de um objeto de estilo.cx(...inputs): combina estilos, o posterior vence.createCss(theme):css,cx,variants,keyframeseglobalStylecom tema.variants(config): estilos a partir de opções.keyframes(frames)eglobalStyle(selector, style).
nuclo (apenas export nomeado)
nuclo/ssr
renderToString(input),renderManyToString(inputs),renderToStringWithContainer(input, tag?, attrs?)egetCssText().
nuclo/polyfill
- Um import com efeito colateral, mais
document,Event,CustomEvent,Node,Element,HTMLElemente as classesNuclo*.
nuclo-router (pacote separado)
createRouter(table, options?): o roteador, comstart(),match(),outlet(),matches(),go(),push(),href()estop().withLoad(loader, load): uma rota cujo carregador de dados começa junto com seu chunk.- Tipos:
RouteContext,PageProps,Params,DataLoader,RouteTablee mais.
Apenas globais
- 175 construtores de tags:
div,span, …,var_, esvgSvg,pathSvg, …. - Tipos:
NodeModFn,NodeModLike,ExpandedElemente mais. Tipos de estilização comoStyleResulttambém podem ser importados de'nuclo'.
Chaves especiais
- Atributos:
className,style, handlerson*,onMounteonDestroy. - Objetos de estilo: atalhos, pseudo-estados, seletores
&, regras@eraw.
Perguntas Frequentes
#Alterar o estado atualiza a interface?
Não. O DOM fica como está até você chamar update().
O Nuclo usa signals, proxies ou um DOM virtual?
Não. O estado continua JavaScript puro. Quando uma árvore é renderizada, o Nuclo cria elementos e nós de texto reais. update() executa todo valor dinâmico registrado, ou os que estão dentro dos escopos indicados, e aplica as mudanças nesses mesmos nós onde um valor mudou. Não há grafo de dependências nem árvore virtual.
Por que minha página mostra "false"?
Booleanos são renderizados como texto. div(ok && span('Done')) mostra "false". Use when(() => ok, span('Done')) para filhos, e ok ? 'Done' : '' para texto.
Por que a linha da minha lista não muda quando eu edito o item?
Linhas de itens que ainda estão na lista são mantidas, não renderizadas de novo. Use valores dinâmicos como () => todo.text para campos que mudam. Veja list().
Por que parte da minha interface para de atualizar?
Normalmente seus nós não estavam no documento quando update() rodou. O Nuclo para de rastrear esses nós, mesmo depois que você os anexa, então renderize em um container que esteja no documento. Duas outras causas: um when() cuja condição lança erro para de atualizar, e uma função de texto que lança erro na primeira execução fica vazia. Veja update().
Quando devo usar forceUpdate()?
Apenas para mudanças raras em valores estáticos, como trocar o idioma. Para todo o resto, use update(). Veja forceUpdate().
Como coloco uma página em um layout sem passá-la como prop?
Marque o lugar com region({ id: 'main' }) e faça a página retornar into('main', …). Veja region() & into().
Como desmonto um app?
Envolva-o em when() e desligue a condição. Remover o DOM por conta própria não executa onDestroy. Veja Ciclo de Vida.
O Nuclo inclui um roteador?
Não no pacote principal. O roteamento é distribuído separadamente como nuclo-router, com renderização no servidor, hidratação, rotas com code splitting e pré-carregamento em idle. Instale-o com npm install nuclo nuclo-router. Veja nuclo-router.
O Nuclo tem effects?
Não. onMount e onDestroy rodam uma vez por elemento, atrelados à inserção e à remoção. Nada roda de novo porque algum estado mudou.