NUCLO / 12

Язык

Документация

Документация Nuclo

Практический справочник по установке Nuclo, созданию UI с явными обновлениями, типизированным стилям CSS и серверному рендерингу HTML.

Быстрый стартnpm create nuclo@latest
Быстрый старт
v0.3.30
Текущий выпуск
175
Функции построения элементов
~18.4 KB
Ядро, gzip
Явная
Модель выполнения

Обзор

#

Nuclo — небольшая библиотека DOM для TypeScript. Вы создаете страницу с помощью простых функций, таких как div() и button(), сохраняете состояние в обычных переменных и вызываете update(), когда страница должна измениться.

Nuclo не имеет виртуального DOM, компилятора, прокси и сигналов.

main.ts
import 'nuclo'

let count = 0

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

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

Ментальная модель

  1. Создайте UI с помощью функций построения элементов.
  2. Передайте функцию для каждого значения, которое зависит от состояния.
  3. Измените состояние с помощью простого JavaScript.
  4. Вызовите update(). Nuclo снова запускает функции и обновляет DOM.

Изменение переменной не затрагивает DOM. Страница меняется только при вызове update().

Быстрый старт

#

create-nuclo создает заготовку проекта на Vite и TypeScript, готовую для Nuclo:

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

Запрашивает имя проекта. Чтобы пропустить запрос, передайте имя и флаги. Для npm поставьте -- перед флагами:

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

Затем установите и запустите сервер разработки:

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

Опции

  • [project-name]: целевая папка. По умолчанию — nuclo-app.
  • -t, --template <name>: шаблон для копирования. Пока доступен только basic.
  • -y, --yes: не задавать вопросов, использовать значения по умолчанию.
  • -f, --force: запись в папку, которая не пуста.
  • -h, --help: вывести справку.

Установка и импорты

#

Чтобы добавить Nuclo в существующий проект:

terminal
$ npm install nuclo

Или используйте pnpm add nuclo, yarn add nuclo или bun add nuclo. Nuclo не имеет зависимостей.

Маршрутизация не является частью основного пакета. Она поставляется отдельно в пакете nuclo-router, который вы устанавливаете рядом с nuclo, когда он вам нужен.

Глобальные функции

import 'nuclo' помещает их на globalThis:

  • 175 функций построения элементов: 112 для HTML (div, span, button, …) и 63 для SVG, с суффиксом Svg (svgSvg, pathSvg, …). См. Функции построения элементов.
  • 16 вспомогательных функций: update, scope, when, list, region, into, on, render, hydrate, forceUpdate, css, cx, createCss, variants, keyframes и globalStyle.

Импорт должен выполняться перед любым кодом, вызывающим построитель. Модули ES запускаются в порядке импорта, поэтому это не удается:

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

Поместите import 'nuclo' первым в файле точки входа или импортируйте его в каждый файл, использующий глобальные переменные.

Именованный импорт

Вспомогательные функции также доступны как именованные экспорты. Это те же функции, что и глобальные переменные. getCssText() и resetStyles() являются только именованными экспортами. Функции построения элементов являются только глобальными, поэтому import { div } from 'nuclo' не работает.

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

Точки входа

  • nuclo: регистрирует глобальные переменные и экспортирует 16 вспомогательных функций, а также getCssText и resetStyles.
  • nuclo/ssr: renderToString, renderManyToString, renderToStringWithContainer и getCssText. См. Серверный рендеринг.
  • nuclo/polyfill: небольшой DOM для Node и Bun. См. nuclo/polyfill.
  • nuclo/types: только типы. См. TypeScript.

TypeScript

#

Nuclo поставляет свои собственные типы. Вам нужны две вещи:

  • "moduleResolution": "bundler". node16 и nodenext пока не поддерживаются.
  • DOM lib. TypeScript включает его по умолчанию, если вы не установили lib.

Если какой-либо файл в проекте импортирует 'nuclo', каждый файл видит глобальные типы. Вам больше ничего не нужно. Если ни один файл его не импортирует, например в отдельный tsconfig для тестов, добавьте типы самостоятельно:

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

Или добавьте /// <reference types="nuclo/types" /> в файл .d.ts.

Полезные типы

Эти типы являются глобальными, поэтому их можно использовать без импорта:

  • NodeModFn<Tag>: что возвращает функция построения элементов. Используйте его как возвращаемый тип компонента.
  • NodeModLike<Tag>: все, что функция построения элементов принимает в качестве аргумента.
  • ExpandedElement<Tag>: что возвращают render() и hydrate().
  • ElementTagName: любое имя тега HTML.
  • ExpandedElementAttributes<Tag>: объект атрибутов, который принимает функция построения элементов, включая обработчики on*, onMount и onDestroy. Используйте его для компонентов, которые передают атрибуты.
  • MountCallback и DestroyCallback: обработчики onMount и onDestroy.
  • WhenBuilder, ListModifier: что возвращают when() и list().
  • RegionOptions, RegionType: параметры, которые принимает region().
  • StyleResult, Style, ThemeConfig, CssInstance и другие типы стилей. Вы также можете импортировать их: import type { StyleResult } from 'nuclo'.
badge.ts
function Badge(text: string): NodeModFn<'span'> {
  return span(text)
}

ExpandedElement<Tag> делает все члены DOM необязательными. Когда нужен полный тип элемента, используйте приведение типа: render(App, root) as HTMLDivElement.

Каждый тег HTML представляет собой глобальную функцию с одинаковым именем: div, span, input,… Единственным исключением является <var>, который называется var_, поскольку var — зарезервированное слово. Каждый тег SVG имеет суффикс Svg: svgSvg, pathSvg, circleSvg,…

Вызов функции построения элемента еще не создает элемент. div('Hi') возвращает функцию построения элемента (builder): функцию, которая создает элемент позже. Nuclo вызывает ее при монтировании с помощью render() или помещаете в другую функцию построения элемента, when() или list(). Каждое использование создает новый элемент.

card.ts
let name = 'Ada'

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

render(Card, document.body)
ДемонстрацияПостроитель принимает атрибуты, статические дочерние узлы и динамический текст. Rename меняет состояние, затем вызывает update().Открыть ↗

Profile

Hello, Ada

Аргументы

Функция построения элемента принимает любое количество аргументов и применяет их слева направо:

  • строка, число, bigint: текстовый узел.
  • () => value: текст, который меняется на update(). См. Динамические значения.
  • null, undefined: пропущено.
  • true, false: отображается как текст «true» или «false». Используйте when() для условий.
  • Обычный объект: атрибуты. См. Атрибуты.
  • Результат css(), cx() или variants(): добавляет свои классы.
  • on(...): обработчик событий или хук жизненного цикла.
  • Другая функция построения элемента, when() или list(): дочерний узел.
  • Узел DOM Node: добавляется как есть.
  • (el, index) => …: пользовательский модификатор. См. ниже.

Типичные ошибки

  • div(isAdmin && span('Admin')) отображает текст «false». Напишите div(when(() => isAdmin, span('Admin'))).
  • Массив нельзя передать как дочерний узел. Используйте spread: ul(...names.map((n) => li(n))). Для списков, которые изменяются, используйте list().
  • Локальная переменная с именем тега скрывает построитель. Внутри function Row(label: string) label(...) не может быть вызван. Переименуйте локальные объекты, такие как label, title, data, p, a и i.
  • Браузеры предоставляют каждый элемент, имеющий id, как глобальную переменную. Если страница содержит <main id="main"> до загрузки Nuclo, Nuclo оставляет этот глобальный объект на месте, поэтому main(...) выдает TypeError. Используйте другие идентификаторы.

Пользовательские модификаторы

Функция, объявляющая хотя бы один параметр, выполняется один раз в порядке аргументов во время построения элемента. Ее первый аргумент — элемент. Второй — внутренний индекс, поэтому не полагайтесь на него. Если она возвращает строку или число, оно становится текстом. Добавляется Node. Объект применяется как атрибуты.

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

Получение элемента

render() возвращает корневой элемент. Внутри дерева используйте on("mount", (el) => …) или собственный модификатор. Вы также можете самостоятельно вызвать функцию построения элемента, чтобы создать отдельный элемент:

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

Прикрепите его перед следующим update(). Узел, которого нет в документе при запуске update(), перестает обновляться навсегда.

SVG

Функции SVG принимают те же аргументы. Nuclo задает каждый атрибут SVG через setAttribute, поэтому такие имена, как "stroke-width", работают так, как написано.

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

Все функции построения элементов

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

Компонент — это простая функция, возвращающая построитель, например button(...). Отдельного API компонентов нет. Ничто не создается до тех пор, пока вы не отрендерите его или не поместите в дерево.

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

render(() => div(Counter('A'), Counter('B')))
ДемонстрацияКомпонент — обычная функция. Каждый вызов Counter() хранит собственный счётчик.Открыть ↗

Состояние, объявленное внутри функции, принадлежит этому экземпляру. Состояние, объявленное снаружи, используется всеми экземплярами. forceUpdate() снова вызывает компоненты, что сбрасывает состояние, объявленное внутри них.

Дочерние узлы — обычные аргументы:

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

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

Атрибуты

#

Передайте простой объект для установки атрибутов. Вы можете передать несколько объектов. Для того же ключа побеждает более позднее значение, за исключением className и style, которые объединяются. Значение может быть статическим или функцией без параметров, которая запускается снова при каждом update().

example.ts
let busy = false

const save = button(
  { type: 'submit', disabled: () => busy, 'aria-busy': () => String(busy), 'data-id': 42 },
  'Save',
)
ДемонстрацияЗначения атрибутов могут быть функциями. Вторая кнопка переключает busy и вызывает update(); атрибуты disabled и aria-busy кнопки Save следуют за этим значением.Открыть ↗

Как устанавливается значение

  • Если элемент HTML имеет свойство с таким именем (value, checked, disabled, htmlFor, tabIndex, …), Nuclo устанавливает это свойство. В противном случае он вызывает setAttribute(key, String(value)), например для "aria-*" и "data-*".
  • В элементах SVG Nuclo всегда вызывает setAttribute.
  • null и undefined пропускаются. Динамическое значение, возвращающее null или undefined, сохраняет старое значение. Чтобы очистить значение, верните "" или false для логического свойства.
  • Логические значения работают с логическими свойствами DOM, такими как disabled на кнопке, checked, hidden и required. В других местах true и false записываются как строки «true» и «false».
  • Используйте ключи "data-*". dataset не поддерживается.
  • innerHTML не экранирован, и серверный рендеринг не выводит его. Избегайте этого.

Классы

Используйте className. Строки статического класса, результаты css() и динамический className объединяются. Динамический className сохраняет статические классы, а когда он возвращает "", остаются только статические классы.

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

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

Не используйте ключ class для элементов HTML. Он устанавливает необработанный атрибут и заменяет каждый примененный до него класс, включая классы css().

Встроенные стили

style принимает объект с ключами camelCase или функцию, которая его возвращает.

example.ts
let progress = 30

const bar = div({ style: () => ({ width: `${progress}%`, backgroundColor: 'teal' }) })
  • Nuclo не добавляет единицы измерения. Пишите "12px", а не 12. Свойства без единиц измерения, такие как opacity и zIndex, принимают числа.
  • "", null и undefined очищают свойство.
  • Динамический стиль устанавливает только те ключи, которые он возвращает. Ключ, который он перестает возвращать, сохраняет прежнее значение, поэтому верните "", чтобы очистить его.
  • Строки стиля ("color: red") и пользовательские свойства ("--gap") не поддерживаются. Вместо этого используйте css(). Он принимает пользовательские свойства через raw.
fnПубличный API
// DOM events such as "click". El and Ev are inferred from the tag and the event name. function on(type: DOMEventName, listener: (this: El, e: Ev) => unknown, options?: boolean | AddEventListenerOptions): NodeModFn // Custom events function on<K extends string, E extends Event = Event>(type: K, listener: (this: El, e: E) => unknown, options?: boolean | AddEventListenerOptions): NodeModFn // Lifecycle, see Lifecycle function on(type: "mount", listener: MountCallback): NodeModFn function on(type: "destroy", listener: DestroyCallback): NodeModFn

Существует два способа прослушивания событий: атрибуты on* и модификатор on().

on* атрибуты

В объекте атрибутов функция под ключом, например onClick (on, за которым следует заглавная буква), является обработчиком событий, а не динамическим значением.

search.ts
let query = ''

const search = input({
  placeholder: 'Search',
  value: () => query,
  onInput: (e) => { query = e.currentTarget.value; update() },
  onKeyDown: (e) => { if (e.key === 'Escape') { query = ''; update() } },
})
  • TypeScript определяет тип события на основе ключа и элемента. e.currentTarget и this — это элемент.
  • Nuclo устанавливает собственное свойство, поэтому onClick становится el.onclick. Для каждого события существует один обработчик: более поздний onClick заменяет более ранний.
  • onDoubleClick и onDblClick означают dblclick. Ключи нижнего регистра, такие как onclick, являются ошибками типа.
  • Имя, которое не является известным событием, прослушивает его строчную форму: onMyEvent прослушивает "myevent".
  • Ошибки, выдаваемые обработчиком, не перехватываются.
ДемонстрацияonInput и onKeyDown — обычные атрибуты. Введите текст, затем нажмите Escape, чтобы очистить поле.Открыть ↗

Type something, then press Escape.

on()

on() возвращает модификатор, который вызывает addEventListener для элемента.

panel.ts
const panel = div(
  on('scroll', onScroll, { passive: true }),
  on('click', logClick),
  on('click', closeMenu),
)
  • Каждый on() добавляет обработчик. Все обработчики выполняются по порядку.
  • options переходит прямо к addEventListener: capture, once, passive, signal.
  • Передайте on() непосредственно функции построения элемента, чтобы TypeScript вывел тип элемента: input(on('input', (e) => e.currentTarget.value)).
  • Если обработчик выдает ошибку, Nuclo регистрирует ошибку с помощью console.error, а другие обработчики продолжают работать.
  • Nuclo удаляет обработчик при удалении элемента. На сервере on() ничего не делает.
  • Одно значение on() можно повторно использовать во многих элементах.

Для пользовательского события передайте тип события как параметр типа:

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

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

Какой использовать

В обычном случае используйте атрибуты on*. Используйте on(), если вам нужно несколько обработчиков для события, параметров прослушивателя или пользовательского имени события, например "my-event".

fnПубличный API
// As attributes { onMount?: (el: El) => void | (() => void), onDestroy?: (el: El) => void } // Or with on() function on(type: "mount", listener: (el: El) => void | (() => void)): NodeModFn function on(type: "destroy", listener: (el: El) => void): NodeModFn

onMount вызывается один раз после вставки элемента. onDestroy вызывается один раз, когда Nuclo удаляет элемент. Задавайте их как атрибуты или через on(). Обе формы работают одинаково, а тип элемента выводится из тега.

timer.ts
let seconds = 0

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

const search = input(on('mount', (el) => el.focus()))
ДемонстрацияonMount запускает интервал и возвращает функцию очистки. При размонтировании она выполняется, поэтому счётчик останавливается до следующего монтирования таймера.Открыть ↗

Когда вызываются хуки

  • onMount выполняется в конце вызова render(), hydrate(), update() или forceUpdate(), который вставил элемент. Он никогда не запускается, пока строится дерево.
  • Если onMount возвращает функцию, она вызывается при удалении элемента после его обработчиков onDestroy.
  • onDestroy запускается, когда Nuclo удаляет элемент: строка list() удаляется, ветвь when() переключается, into() покидает свой регион или forceUpdate() удаляет его. Он запускается непосредственно перед удалением, поэтому элемент все еще находится в документе.

Удаление DOM вручную

Если вы удаляете элемент самостоятельно с помощью node.remove() или innerHTML = '', его onDestroy и очистка никогда не запускаются, а таймеры продолжают работать. Nuclo не имеет unmount(). Чтобы удалить приложение с помощью очистки, оберните его в when():

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

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

Порядок и ошибки

  • Вы можете зарегистрировать любое количество хуков в любой форме. Они выполняются в порядке регистрации.
  • Обработчики onDestroy сначала вызываются у дочерних элементов, затем у родительского.
  • Хуки монтирования элементов выполняются в порядке регистрации первого onMount, который определяется порядком аргументов. Все хуки одного элемента выполняются вместе. Если дочерние элементы зависят от родительского хука, передайте его раньше дочерних узлов.
  • Если хук выбрасывает исключение, ошибка записывается через console.error. Остальные хуки продолжают выполняться.
  • Обработчик должен возвращать функцию очистки или ничего, поэтому не делайте его async. Возвращенный промис не используется для очистки. Запускайте асинхронную работу внутри обработчика: { onMount: (el) => { void load(el) } }.
  • Хуки никогда не запускаются на сервере. На клиенте они вызываются после hydrate().
Примечание: Хуки не являются эффектами. Они никогда не запускаются снова, когда состояние меняется. Каждый из них запускается не более одного раза для каждого элемента.

Nuclo никогда не обновляет страницу самостоятельно. Измените состояние, затем вызовите update().

counter.ts
let count = 0

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

render(Counter)
Демонстрацияcount++ меняет только переменную. Текст обновится при следующем update(), вызванном этой кнопкой или другим действием на странице.Открыть ↗

Clicked 0 times

Внесите в состояние сколько угодно изменений, затем один раз вызовите update():

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

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

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

Вызовите update() из обработчиков событий, таймеров, обработчиков завершения загрузки данных или onMount. Не вызывайте его изнутри динамического значения.

Динамические значения

#

Функция без параметров задает динамическое значение. Nuclo вызывает ее один раз при построении элемента и еще раз при каждом update(). Используйте динамические значения для текстовых дочерних узлов, значений атрибутов, style и className. Для дочерних элементов, которые появляются, исчезают или повторяются, используйте when() и list().

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

const Profile = () =>
  div(
    h1('Welcome'),              // static
    p(() => `Hello, ${name}`),  // dynamic text
    button({ disabled: () => busy }, 'Save'),
  )
ДемонстрацияИзмените имя. Статический текст зафиксирован при создании элемента; динамический вычисляется при каждом update(). forceUpdate() заново строит дерево, поэтому обновляется и статический текст.Открыть ↗

static: Hello, Alice

dynamic: Hello, Alice

Nuclo проверяет количество параметров (fn.length). Функция, объявляющая параметр, является пользовательским модификатором и выполняется только один раз. (x = 0) => … и (...args) => … имеют длину 0, поэтому они динамические.

Динамический текст

  • Строка, число, bigint или логическое значение отображается как String(value). () => ok && 'Saved' показывает «false», поэтому напишите () => (ok ? 'Saved' : '').
  • null и undefined отображаются как пустой текст.
  • Результат css() или cx() при первом запуске делает функцию динамическим классом, а не текстовым. См. cx().
  • Узел, построитель, массив или простой объект ничего не визуализирует и никогда не обновляет.

Динамические атрибуты

  • Nuclo записывает значение только в том случае, если оно отличается от последнего записанного Nuclo значения. Он не сравнивается с действующим DOM, поэтому, если пользователь вводит текст в поле, а ваша функция возвращает то же значение, что и раньше, введенный им текст остается.
  • Результат null или undefined ничего не записывает, поэтому старое значение остается. className является исключением: любой результат, приводимый к false удаляет динамические классы и сохраняет статические.
  • Функция style применяет возвращаемый объект при каждом обновлении.

Ошибки

Если функция динамического значения выбрасывает исключение, Nuclo улавливает ошибку, и остальная часть обновления продолжает выполняться. Текст становится пустым или сохраняет прежнее значение, а атрибут сохраняет прежнее значение. Оба восстанавливаются при следующем успешном update(). Исключение: текстовая функция, которая выбрасывает исключение при первом запуске, навсегда остается пустой.

fnПубличный API
function update(...scopeIds: string[]): void

Выполняет один синхронный проход для каждого динамического значения на странице. При использовании идентификаторов обновляется только то, что находится внутри элементов, отмеченных этими идентификаторами scope(). Каждый проход выполняет эти шаги по порядку:

  1. list(): снова считывает элементы и добавляет, перемещает или удаляет строки.
  2. when(): снова проверяет условия. Ветка перестраивается только тогда, когда другая ветка становится активной.
  3. Динамические атрибуты, включая style и className.
  4. Динамический текст.
  5. onMount для элементов, вставленных на шагах 1 и 2.
  • Он запускает все. Каждое динамическое значение выполняется при каждом вызове, даже если его данные не изменились. Пропускается только запись DOM. Функции динамических значений должны работать быстро и не иметь побочных эффектов.
  • Только узлы в документе. Если во время запуска update() узла нет в документе, Nuclo прекращает его отслеживание. Он больше никогда не обновляется, даже после того, как вы его прикрепите. Это относится и к list() и when(). Выполните рендеринг в контейнер, который находится в документе.
  • Статические значения никогда не меняются. Простые строки и значения атрибутов фиксируются при построении элемента. Чтобы их пересобрать, используйте forceUpdate().
  • Ошибки. Ошибки текста и атрибутов перехватываются, и проход продолжается. Если условие when() выбрасывает исключение, ошибка регистрируется и when() прекращает обновление. Если функция list() items или renderItem выдает ошибку, ошибка переходит в update(), а остальные шаги не выполняются.
  • На сервере. update() ничего не делает на сервере.
ДемонстрацияКаждый update() вычисляет все динамические значения, даже если их данные не изменились. Другие демонстрации на странице тоже увеличивают счётчик.Открыть ↗

This dynamic value has run 1 times.

fnПубличный API
function scope(...ids: string[]): NodeModFn

Предоставляет элементу, которому он передается, один или несколько идентификаторов области. update('id') затем обновляет только то, что находится внутри этого элемента, включая собственные атрибуты элемента.

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

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

render(App)

cartItems.push('apple')
user = 'Grace'
update('cart') // "Items: 1", the header still says "Hi, Ada"
update()       // everything, the header now says "Hi, Grace"
ДемонстрацияОбе кнопки добавляют элемент и переименовывают пользователя. update('cart') обновляет только панель в указанной области.Открыть ↗
Hi, Ada
Items: 0
  • Элемент может иметь несколько идентификаторов: scope('cart', 'sidebar').
  • update('a', 'b') обновляет все внутри a или b. Если несколько элементов используют один и тот же идентификатор, все они обновляются.
  • При использовании вложенных областей update('outer') включает внутреннюю область. update('inner') не касается внешнего.
  • Идентификатор, которого нет ни у одного элемента в документе, ничего не обновляет и не вызывает ошибок, поэтому проверьте написание.
  • scope() ничего не делает на сервере.
fnПубличный API
function when(condition: boolean | (() => boolean), ...content: NodeModLike[]): WhenBuilder interface WhenBuilder { when(condition: boolean | (() => boolean), ...content: NodeModLike[]): WhenBuilder else(...content: NodeModLike[]): WhenBuilder }

Отрисовывает содержимое на основе условия. Nuclo снова проверяет условия на каждом update(). Первое истинное условие побеждает. Если ни одно из них не соответствует действительности, отображается содержимое .else(). Без .else() ничего не рендерится.

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

const Status = () =>
  div(
    when(() => status === 'loading', p('Loading…'))
      .when(() => status === 'error', p('Something went wrong'))
      .else(p('Ready')),
  )
ДемонстрацияВыбирается первое истинное условие; иначе выводится .else(). Новая ветвь создаётся с нуля.Открыть ↗

Loading…

Условия

  • Для состояния передайте функцию. Простое логическое значение считывается один раз: when(open, …) никогда не изменяется.
  • В TypeScript функция должна возвращать логическое значение. Напишите () => items.length > 0 или () => user != null.

Что обновляется

  • Пока одна и та же ветка остается активной, ее элементы сохраняются. Динамические значения, списки и блоки when() внутри него постоянно обновляются.
  • Когда другая ветка становится активной, старое содержимое удаляется и запускаются ее хуки onDestroy. Новая ветка создается с нуля, и ее хуки onMount работают. Состояние старых элементов, таких как вводимый текст или фокус, теряется.

Содержимое ветки

Поместите дочерние элементы в ветку: текст, динамический текст, функции построения элементов, list() и вложенный when(). Передайте функции построения элементов, такие как p('Hi'), а не узлы DOM: узел DOM перестает обновляться после того, как его ветвь скрывается один раз.

Объекты атрибутов и on() в ветке применяются к родительскому элементу и остаются после скрытия ветки. В качестве условного атрибута используйте динамический атрибут: { className: () => (active ? 'active' : '') }.

Цепочка

.when() и .else() возвращают новую сборку и не меняют оригинал. Если вы вызовете .else() дважды, победит последний вызов.

fnПубличный API
function list<T>( items: () => readonly T[] | Iterable<T>, renderItem: (item: T, index: number) => ListRenderResult, ): ListModifier

Отрисовывает одну строку для каждого элемента. В каждом update() Nuclo снова вызывает items() и сопоставляет новые элементы с существующими строками по идентичности (===).

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

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

render(Fruits)

fruits.push('Cherry')
update()

Как сопоставляются строки

  • Элемент, который все еще существует, сохраняет свою строку. Nuclo перемещает строку, если это необходимо, и больше не вызывает renderItem.
  • Новый элемент получает новую строку. Удаленный элемент теряет свою строку, и его хуки onDestroy выполняются.
  • Каждому повторяющемуся элементу присваивается отдельная строка.
  • Вы можете изменить массив на месте (push, splice, sort) или вернуть новый массив.

Сохраненная строка никогда не строится заново, поэтому используйте динамические значения для изменяющихся полей:

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

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

render(Todos)

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

Копии — это новые элементы, поэтому замена массива на todos.map((t) => ({ ...t })) перестраивает каждую строку.

ДемонстрацияСтроки сопоставляются по идентичности объектов. Отметьте строку и нажмите Reverse: тот же <li> переместится вместе с флажком.Открыть ↗

Что возвращает renderItem

  • Возвращает один элемент, например li(...), tr(...) или построитель SVG. Верните null или undefined, чтобы пропустить элемент. Строки, текстовые узлы и фрагменты ничего не отображают.
  • Не возвращайте when() или list() напрямую. Поместите их внутри элемента строки: li(todo.text, when(() => todo.done, ' ✓')).
  • Чтобы скрыть элементы, фильтруйте по items(), а не по renderItem.
  • Берегите renderItem от побочных эффектов. Nuclo может вызывать его несколько раз для одного и того же элемента.

Индекс

index — это позиция элемента на момент создания его строки. Он не обновляется при перемещении строк. Для текущей позиции используйте динамическое значение: li(() => `${names.indexOf(name) + 1}. ${name}`).

Итерируемые объекты

Подойдет любой итерируемый объект: массивы, Set, генераторы и map.values(). Возврат одного и того же массива или Set каждый раз — это нормально. Итератор, такой как генератор или map.values(), используется за один проход, поэтому создавайте его внутри items() при каждом вызове. Итерация Map каждый раз напрямую создает новые массивы [key, value], поэтому каждая строка перестраивается. Вместо этого используйте map.values().

Размещение

Строки появляются там, где list() находится среди своих одноуровневых элементов, между двумя маркерами комментариев. Другие дочерние узлы родителя не затрагиваются. list() также работает внутри функций построения SVG-элементов.

fnПубличный API
function region(options: RegionOptions): NodeModFn function into(id: string, ...content: NodeModLike[]): NodeModFn function into(regions: Record<string, NodeModLike | NodeModLike[]>): NodeModFn interface RegionOptions { id: string type?: 'latest' | 'stack' // default: 'latest' empty?: NodeModLike }

region() отмечает именованное место в дереве. into() заполняет его откуда угодно, на любой глубине. Макет не получает контент в качестве параметра, а компонент, создающий контент, не знает, где он находится.

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

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

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

Как это работает

  • into() оставляет только комментарий там, где это написано. Его содержимое отображается между маркерами региона.
  • Порядок не имеет значения. into(), созданный до того, как его регион, ждет и отображается сразу после создания региона. Представления отображаются в том порядке, в котором они были написаны.
  • Содержимое существует столько же, сколько его представление. Когда when(), содержащий into(), закрывается или его строка list() удаляется, содержимое покидает регион. При повторном открытии добавляется одна копия, а не две.
  • Когда регион исчезает, например закрывается when(), его представления снова ждут. Их показывает регион, созданный позже с тем же идентификатором.
  • into(), идентификатор которого не соответствует ни одному региону, ничего не отображает и не выдает ошибок, поэтому проверьте правильность написания.
  • Идентификаторы уникальны в дереве. Второй регион, созданный с уже используемым идентификатором, регистрирует предупреждение.
  • Регионы поддерживают серверный рендеринг. В hydrate() каждое представление использует HTML, созданный сервером, вместо того, чтобы создавать его заново.

Опции

  • id: имя, которое вызывает into(), используется для поиска региона.
  • type: 'latest' (по умолчанию) показывает только самое новое представление. 'stack' показывает все представления в порядке поступления.
  • empty: отображается, когда в регионе нет представлений.

Регионы со стеком представлений

При использовании type: 'stack' вид, открывающийся поверх другого, не перестраивает вид, расположенный под ним. Его DOM, фокус и состояние формы сохраняются. Используйте это для такого слоя, как диалог на странице.

layers.ts
let open = false

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

Сразу несколько регионов

Передайте объект для заполнения более чем одной области из одного into(). Значением может быть один дочерний элемент или массив дочерних элементов. Любая форма вложена: into() внутри содержимого другого представления нацелен на свой собственный регион.

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

Диалоговое окно, открытое из глубины дерева.

Область наложения находится в корне. Любой компонент на любой глубине открывает диалоговое окно, заключая into() в when(). Закрытие when() удаляет диалоговое окно из региона.

dialog.ts
let confirming = false

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

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

Тосты из строк списка

Каждая строка list() представляет собой представление. В регионе со стеком представлений они отображаются по порядку, а удаление элемента из массива удаляет его всплывающее уведомление.

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

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

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

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

Заголовок страницы, заданный страницей.

При использовании type: 'latest' по умолчанию побеждает новейшее представление. Макету принадлежит заголовок, и какая бы страница ни была открыта, он получает имя. На странице без into('title', …) остается текст empty.

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

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

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

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

Динамические значения внутри представления

Содержимое into() является обычным содержимым Nuclo. Функции в нем представляют собой значения, зависящие от состояния, и обновляются на update(), где бы ни находился регион.

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

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

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

Регион, который приходит и уходит

into() панели управления записывается один раз. Пока пользователь вышел из системы, региона main нет, поэтому представление ожидает. При входе в систему создается регион, и отображается представление. При выходе из системы регион удаляется, и представление снова ожидает.

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

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

Вид со своим регионом

Представление может содержать region(), и другие представления могут нацеливаться на него. Здесь страница редактора содержит регион tabs, и каждая вкладка заполняет его. Когда редактор закрывается, его регион закрывается вместе с ним.

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

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

Живой пример

region-app.ts
import 'nuclo'

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

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

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

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

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

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

export const RegionApp = () =>
  div(
    Layout(),
    when(() => page === 'home', Home()),
    when(() => page === 'article', Article()),
    when(() => dialog, Dialog()),
  )
ДемонстрацияМакет с основным регионом со стеком представлений и боковым регионом заполняется снаружи. Введите текст в Home, откройте и закройте диалог: текст сохранится. Article заполняет оба региона одним into().Открыть ↗

No related links.

fnПубличный API
function forceUpdate(): void function forceUpdate(nodeModFn: NodeModFn | (() => NodeModFn), parent?: Element): ExpandedElement

update() обрабатывает только динамические значения. Статические значения, такие как текст в h1(labels[lang].title), фиксируются при построении дерева. forceUpdate() снова вызывает ваши компоненты и обновляет существующий DOM в соответствии с новым деревом, включая статические значения. Используйте его для редких изменений всего приложения, таких как переключение языка. Для всего остального используйте update().

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

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

lang = 'pt'
forceUpdate() // same <h1> element, now "Olá"
Демонстрацияupdate() обновляет только динамическую строку lang. forceUpdate() повторно вызывает App и изменяет тот же <h1>. Явная форма перестраивает только это дерево, а не всю страницу.Открыть ↗

lang = 'en'

Hello

Static text, fixed on build.

Без аргументов forceUpdate() перестраивает каждый корень, отображаемый с помощью render(App) или hydrate(App). Корни, визуализированные с помощью render(App()), пропускаются. Если компонент выдает ошибку, ошибка регистрируется, и этот корень сохраняет свой старый DOM. Ошибка, возникающая позже, при применении новой сборки, также регистрируется, но уже примененные изменения сохраняются.

Что остается, а что меняется

  • Дочерние элементы сопоставляются по порядку, по тегам. Соответствующий элемент используется повторно, поэтому фокус и введенные значения полей остаются, а его хуки onMount больше не запускаются. Как только тег одного дочернего элемента отличается, этот дочерний элемент и все последующие узлы того же уровня создаются заново.
  • Текст, атрибуты, классы, стили, обработчики, ветки when() и строки list() соответствуют новой сборке.
  • Создаются новые элементы и запускаются их хуки onMount. Элементы, которые новая сборка больше не создает, удаляются, и их хуки onDestroy запускаются.
  • list() также сопоставляются по позиции, а не по идентичности элемента, поэтому повторно используемый элемент строки может в конечном итоге отображать другой элемент.
  • Атрибуты, которые новое дерево больше не задает, не удаляются.
Осторожно: состояние , объявленное внутри функции компонента, сбрасывается, поскольку функция запускается снова. Храните состояние, которое должно сохраниться, вне компонента.

Явная форма. forceUpdate(App(), parent) перестраивает одно дерево в parent и возвращает его корень. Он не регистрирует корень для последующих вызовов. Как и hydrate(), он ожидает, что корнем будет первый узел в parent. Если первый узел имеет другой тег, он заменяется.

На сервере forceUpdate() ничего не делает.

render()

#
fnПубличный API
function render(nodeModFn: NodeModFn | (() => NodeModFn), parent?: Element, index?: number): ExpandedElement

Строит дерево и добавляет его корневой элемент в parent. parent по умолчанию равен document.body. Он возвращает корневой элемент.

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

render(App, document.getElementById('app')!)
  • Вы можете передать компонент (render(App)) или возвращаемый им построитель (render(App())). DOM будет одинаковым. Только render(App) позволяет forceUpdate() позже пересобрать корень, поэтому предпочтите его.
  • Добавляет. Он не очищает parent, поэтому его двойной вызов добавляет две копии.
  • К моменту возврата из render() все хуки onMount в дереве уже выполнены.
  • Выполняйте рендеринг в элемент, находящийся в документе. См. update().
  • Корнем должен быть элемент, например div(...), а не when() или list().
  • index является внутренним. Он не выбирает, куда вставлять элемент. Не передавайте его.

Компонент, передаваемый как render(App), не должен принимать никаких параметров. Обязательный параметр является ошибкой типа. Необязательный параметр проходит проверку типов, но render() выдает ошибку во время выполнения. Оберните компонент, который принимает параметр:

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

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

hydrate()

#
fnПубличный API
function hydrate(nodeModFn: NodeModFn | (() => NodeModFn), parent?: Element): ExpandedElement

Подключает интерактивность к HTML, созданному renderToString(). Он повторно использует существующие элементы, подключает обработчики событий и регистрирует динамические значения, блоки when() и списки list(), чтобы последующие вызовы update() работали. Затем он запускает хуки onMount. parent по умолчанию равен document.body. Он возвращает корневой элемент.

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

hydrate(App, document.getElementById('root')!)
  • Используйте тот же компонент и те же данные, что и сервер.
  • Гидратируйте в специальный контейнер. Корнем приложения должен быть его первый узел, хотя пробелы допустимы. Если первым появляется другой узел, Nuclo заменяет этот узел и оставляет копию сервера.
  • Как и в случае с render(), передайте App, а не App(), чтобы forceUpdate() мог позже пересобрать корень. App не должен принимать никаких параметров. При наличии необязательного параметра hydrate() ничего не подключает и не сообщает об ошибке. Оберните компонент, который принимает параметры: hydrate(() => Page(props), root).
  • Если серверного HTML нет, используйте render().

Когда сервер и клиент различаются

Клиент выигрывает:

  • Другой корневой тег: этот узел заменяется новым DOM. Пустой контейнер: добавлен свежий DOM.
  • Исправлен другой текст. Различные элементы list() и ветки when() следуют за клиентом.
  • Лишние дочерние узлы сервера удаляются и создаются недостающие.
  • Атрибуты, которые записал сервер, но не установил клиент, остаются без изменений.

renderToString() добавляет перед каждым текстовым узлом HTML-комментарий-маркер, например <!-- text-0 -->. Они нужны hydrate(), поэтому сохраняйте комментарии в серверном HTML. Без маркеров гидратация удаляет строки list(), а list() и when() перестают обновляться.

Серверный рендеринг

#
fnПубличный API
// import { ... } from 'nuclo/ssr' function renderToString(input: RenderableInput): string function renderManyToString(inputs: RenderableInput[]): string[] function renderToStringWithContainer(input: RenderableInput, containerTag?: string, containerAttrs?: Record<string, string>): string function getCssText(): string type RenderableInput = NodeModFn | (() => NodeModFn) | Element | Node | null | undefined

Преобразуйте компонент в строку HTML на сервере. В браузере вызовите hydrate() с тем же компонентом, чтобы сделать HTML интерактивным. Среды выполнения сервера не имеют document, поэтому сначала импортируйте nuclo/polyfill.

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

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

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

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

Оставьте id="nuclo-styles" в теге стиля. Клиент повторно использует этот элемент и добавляет только новые правила. См. getCssText().

Что работает на сервере

  • Каждый вызов renderToString() строит дерево и запускает каждое динамическое значение один раз.
  • Обработчики событий и хуки onMount и onDestroy никогда не запускаются. update(), scope() и forceUpdate() ничего не делают.
  • Рендеринг синхронный. Сначала загрузите данные, затем выполните рендеринг.
  • После рендеринга ничего не остается зарегистрированным, поэтому один процесс может обслуживать множество запросов. Передавайте данные запроса в качестве аргументов, например renderToString(Page(user)). Переменные уровня модуля используются всеми запросами.
  • Не вызывайте render() на сервере. Он добавляется к общему document.body.

renderToString()

Принимает построитель (App()), компонент (App) или узел DOM. null и undefined возвращают "". Корень должен быть элементом. Компонент, передаваемый как renderToString(Page), не должен принимать никаких параметров, поэтому передайте Page(user) для компонента, который принимает данные.

example.ts
renderToString(p('Hello'))
// '<p><!-- text-0 -->Hello</p>'
  • Значения текста и атрибутов экранируются HTML. Текст внутри script() и style() записывается как есть, с разбитыми только закрывающими тегами, поэтому никогда не помещайте туда пользовательский ввод.
  • Не выбрасывает исключений. Если компонент, функция list() или условие when() выдает ошибку или полифилл отсутствует, он регистрирует ошибку и возвращает "". Проверяйте пустой результат, если ошибка рендеринга должна приводить к ошибке запроса. Если исключение выбрасывает только функция текста или атрибута, соответствующее значение выводится пустым.

renderManyToString()

То же, что inputs.map(renderToString).

renderToStringWithContainer()

Заключает вывод в один элемент. Для containerTag по умолчанию используется "div", а для containerAttrs — {}. Значения атрибутов экранируются. Имена атрибутов записываются как задано, поэтому используйте имена HTML, например class. Никогда не передавайте пользовательский ввод в качестве тега.

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

nuclo/polyfill

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

Небольшой DOM, поэтому Nuclo может строить деревья в Node и Bun. Импортируйте его один раз, перед первым рендерингом. Большинству приложений требуется только простой импорт.

  • При отсутствии window устанавливаются globalThis.document, Node, Element и HTMLElement, но только те, которые отсутствуют. Он не создает window.
  • В браузере или под jsdom ничего не делает.
  • Event и CustomEvent — собственные конструкторы среды выполнения. Node, Element и HTMLElement — это псевдонимы классов полифиллов.

Это DOM для построения деревьев, а не браузер. У него нет innerHTML и getElementById, querySelector() возвращает null, а обработчики событий игнорируются. Используйте renderToString(), чтобы получить HTML.

css()

#
fnПубличный API
function css(style: Style): StyleResult function css(name: string, style: Style): StyleResult type StyleResult = { className: string }

Превращает типизированный объект стиля в сгенерированные классы CSS. Передайте результат любой функции построения элементов. В браузере правила объединены в один элемент <style id="nuclo-styles">. На сервере их возвращает getCssText().

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

const Card = () => div(card, 'Simple card')
  • css() создает один класс для базовых объявлений, а также по одному классу для каждого псевдоключа, селектора и медиа-блока. Карточка выше получает два класса.
  • Имена классов представляют собой хэши содержимого. Идентичные стили имеют общий класс, а сервер и клиент создают одинаковые имена.
  • Определите стили один раз на уровне модуля. css() кэширует по объектам, поэтому каждый раз новый объект при каждом вызове обрабатывается заново.
  • Изменение объекта стиля после его передачи в css() ничего не дает.
  • Каждый стиль остается зарегистрированным на протяжении всего срока службы страницы или серверного процесса. Для значений, которые изменяются для каждого элемента, например ширины из данных, используйте атрибут style .

Именованные стили

Сначала передайте имя, чтобы получить читаемое имя класса:

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

pageTitle.className // "page-title page-title-<hash>"
  • Базовый класс - это именно то имя. Остальные блоки получают name-<hash>.
  • Имя должно быть допустимым именем класса. В противном случае Nuclo выдает предупреждение и генерирует его.
  • Имена являются глобальными. Если два разных стиля используют одно и то же имя, Nuclo выдает предупреждение.

Использование результата

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

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

String(card) и card.className возвращают имена классов.

Объекты стилей

#

Объект, который вы передаете в css(), использует общие свойства CSS в camelCase, набор сокращений и вложенные ключи для псевдосостояний, селекторов и медиа-запросов. Для всех этих свойств доступно автодополнение TypeScript. Свойства вне типизированного набора, такие как borderTopLeftRadius, fill или paddingInline, помещаются в raw (см. ниже).

Значения

  • Числа получают единицы px, за исключением 0 и свойств без единиц измерения: zIndex, opacity, fontWeight, lineHeight, flex, flexGrow, flexShrink, order, aspectRatio, gridColumn и gridRow. В raw, zoom, scale, column-count, orphans, widows, tab-size и animation-iteration-count тоже безразмерные.
  • Строки используются так, как написано. null, undefined и false пропускаются.
  • lineHeight (и leading) не имеет единиц измерения, поэтому leading: 24 означает 24-кратный размер шрифта. Напишите "24px" для пикселей.
example.ts
css({ p: 16, m: 0, opacity: 0.5, leading: 1.5, z: 10, w: '50%' })
// padding:16px; margin:0; opacity:0.5; line-height:1.5; z-index:10; width:50%

Сокращения

КлючCSS
p, pt, pr, pb, plвнутренние отступы со всех сторон или с отдельной стороны
px / pyвнутренние отступы слева и справа / сверху и снизу
m, mt, mr, mb, ml, mx, myто же для внешних отступов
w, h, minW, maxW, minH, maxHШирина, высота, а также их минимальное и максимальное значение
sizeширина и высота
bgфон
text / font / weightfont-size / font-family / font-weight
leading / trackingline-height / letter-spacing
align / items / justifytext-align / align-items / justify-content
z / rounded / shadow / selectz-index / border-radius / box-shadow / user-select

Эти ключи принимают true:

КлючCSS
rowdisplay: flex; flex-direction: row
coldisplay: flex; flex-direction: column
centeralign-items: center; justify-content: center (свойство display не задается)
truncateoverflow: hidden; text-overflow: ellipsis; white-space: nowrap

Псевдосостояния

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 и firstLetter. Их можно вкладывать друг в друга: hover: { focus: {…} } дает :hover:focus.

Селекторы и правила

Ключ, начинающийся с &, является селектором, и каждый & становится классом. Ключ, который начинается с @, является правилом: @media, @container или @supports.

menu.ts
const menu = css({
  p: 8,
  '& > li': { py: 4 },
  '& > li:hover': { color: 'red' },
  '&:is(.dark *)': { color: 'white' },
  '@media (min-width: 768px)': { p: 16 },
})
  • Ключ должен начинаться с &. Такой ключ, как ".dark &", игнорируется, поэтому напишите "&:is(.dark *)".
  • Два вложенных блока @media объединяются через and. Запрос с медиатипом (print, only screen) ставится первым, поскольку тип разрешен только в начале. Если запросы нельзя объединить из-за двух медиатипов или начального not, выводится предупреждение и используется только внутренний запрос. Любое другое вложенное правило заменяет внешнее.

raw

Используйте raw для любого свойства с именем, написанным точно так же, как в CSS. Числа по-прежнему получают px, поэтому передайте строки:

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

Любой другой плоский ключ преобразуется в kebab-case и записывается как есть, поэтому опечатка, такая как colr: 'red', становится объявлением, которое браузер удаляет. Вложенный объект под ключом, который не является псевдосостоянием, экраном или ключом & или @, игнорируется. TypeScript сообщает об обоих.

fnПубличный API
function cx(...inputs: ClassInput[]): StyleResult type ClassInput = StyleResult | string | false | null | undefined | ClassInput[]

Сочетает стили. Когда два аргумента устанавливают одно и то же свойство, побеждает последний аргумент.

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

cx(base, blue)                  // padding: 8px; color: blue
cx(base, false, null, 'extra')  // base's classes plus "extra"
div(cx(base, blue), 'Blue text')
  • Ложные значения пропускаются, вложенные массивы разворачиваются, строки разбиваются на имена классов, а дубликаты удаляются.
  • Созданные классы для одного и того же блока (базовые объявления, одинаковое псевдосостояние или одинаковый медиазапрос) объединяются в один новый класс. Другие имена классов проходят без изменений.
  • Только cx() знает порядок аргументов. При использовании div(base, blue) добавляются оба класса, и побеждает правило, созданное последним. Поместите стили, которые переопределяют друг друга, в один вызов cx().
  • Когда блок именованного стиля объединяется с блоком другого входа, объединенным классом является name-<hash>, а не простое имя. Блоки, которые не объединяются, сохраняют свой класс, поэтому cx(pageTitle, 'extra') все еще имеет page-title.

Динамические классы

Передайте функцию, которая всегда возвращает cx(...):

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

let active = false

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

Функция должна возвращать результат стиля при первом запуске. () => active && activeButton отображает текст «false» вместо класса.

createCss()

#
fnПубличный API
function createCss<const T extends ThemeConfig>(theme?: T): CssInstance<T> // CssInstance<T> = { css, cx, variants, keyframes, globalStyle, theme }

Возвращает свои собственные css, cx, variants, keyframes и globalStyle, привязанные к теме. Тема дает имена значениям: в приведенной ниже теме borderColor: 'border' записывает #e5e7eb, а TypeScript автоматически дополняет имена. Каждая запись в screens становится ключом стиля для медиа-запроса, например md: {…}.

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

const panel = css({
  p: 12,
  rounded: 'card',
  border: '1px solid',
  borderColor: 'border',
  md: { p: 24 },
})
Ключ темыИспользуется
colorsbg, backgroundColor, color, borderColor и с каждой стороны, outlineColor, caretColor, textDecorationColor
fontsfont, fontFamily
shadowsshadow, boxShadow
radiirounded, borderRadius
screensНовые ключи стилей, такие как md: {…}
  • Имя темы разрешается только в том случае, если оно является полным значением. border: "1px solid primary" не подставляет значение токена.
  • Значение экрана без @ становится @media <value>. Значение, начинающееся с @, используется в том виде, в котором оно записано, например "@container (min-width: 400px)".
  • Экраны существуют только на экземплярах, созданных с помощью createCss(). Глобальный css() не имеет их, но принимает ключи @media.
  • Все экземпляры и глобальные вспомогательные функции используют одну таблицу стилей. Медиаправила из screens выводятся в том порядке, в котором экраны объявлены, а не в том порядке, в котором вы их используете, поэтому перечисляйте экраны min-width от меньшего к большему. Используйте один экземпляр createCss() для каждого приложения, чтобы порядок соответствовал вашей теме.
  • Поле theme в результате содержит переданную тему.
fnПубличный API
function variants(config: { base?: Style variants?: { [group: string]: { [value: string]: Style } } defaultVariants?: { [group: string]: value } compoundVariants?: ({ [group: string]: value } & { css: Style })[] }): (props?: { [group: string]: value }) => StyleResult

Создает функцию, которая возвращает стиль для набора параметров, таких как назначение и размер кнопки.

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

button(buttonClass(), 'Save')                                                // primary, sm
button(buttonClass({ intent: 'danger', size: 'lg', block: true }), 'Delete') // bold, full width
  • Стили объединяются в следующем порядке: base, выбранное значение каждой группы, затем каждый подходящий составной вариант. Более поздние стили выигрывают по каждому свойству.
  • Отсутствующий параметр undefined или null использует значение по умолчанию. Группа без значения по умолчанию пропускается.
  • Группа, содержащая только ключи true и false, принимает логическое значение.
  • Составной вариант применяется, когда каждая группа, указанная в нем, совпадает, включая значения по умолчанию.
  • Каждое сочетание параметров обрабатывается один раз и кэшируется.

keyframes() и globalStyle()

#
fnПубличный API
function keyframes(frames: KeyframeFrames): string function globalStyle(selector: string, style: FlatStyle): void

keyframes() регистрирует анимацию и возвращает ее сгенерированное имя. Ключами кадров являются from, to и проценты, например "50%" или "0%, 100%". Фреймы принимают только плоские свойства. Идентичные кадры возвращают одно и то же имя.

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

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

globalStyle() добавляет правило для любого селектора:

reset.ts
globalStyle('*, *::before, *::after', { boxSizing: 'border-box' })
globalStyle('body', { m: 0, font: 'system-ui, sans-serif' })
  • Селектор используется точно так, как написано.
  • Работают только плоские свойства. Ключи псевдосостояний, экраны и ключи @ игнорируются, поэтому globalStyle() не может записывать медиа-правила.
  • Одно и то же правило добавляется один раз. Другой стиль для того же селектора добавляет второе правило, и побеждает более позднее.

getCssText() и resetStyles()

#
fnПубличный API
function getCssText(): string // from 'nuclo/ssr' or 'nuclo' function resetStyles(): void // from 'nuclo'

getCssText() возвращает все правила, созданные css(), cx(), variants(), keyframes() и globalStyle() в этом процессе: сначала базовые правила, затем @media, @container и Блоки @supports. Используйте его для серверного рендеринга.

  • Вызывайте его после renderToString(). Включены правила, созданные во время рендеринга.
  • Он охватывает весь процесс, а не одну страницу или запрос, и только растет. Это безопасно, поскольку имена классов представляют собой хеши контента.
  • Поместите его в <style id="nuclo-styles">. Клиент повторно использует этот элемент и пропускает уже содержащиеся в нем правила. С другим идентификатором или без него клиент снова добавляет вторую таблицу стилей со всеми правилами.
  • Текст не экранируется, поэтому никогда не вводите ненадежные входные данные в значения стиля.

resetStyles() — вспомогательная функция для тестов. Он удаляет все правила и элемент #nuclo-styles. Созданные ранее стили сохраняют свои имена классов, но теряют свой CSS. Не вызывайте его в приложении или на сервере.

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

beforeEach(() => resetStyles())
Отдельный пакет: nuclo-router не является частью основного пакета Nuclo. Установите его рядом с nuclo. Это предварительная версия, поэтому ее API еще может измениться.

Маршрутизация для Nuclo: серверный рендеринг, гидратация, маршруты с разделением кода и предварительная загрузка в режиме ожидания, построенные на list() и update(). Около 4.9 КБ после gzip-сжатия, без каких-либо зависимостей, кроме самого Nuclo, и без побочных эффектов, поэтому, когда он не используется, сборщик удаляет его из неиспользуемого кода (tree shaking).

terminal
$ npm install nuclo nuclo-router

Или используйте pnpm add, yarn add или bun add с теми же двумя именами. В качестве peer-зависимости требуется nuclo 0.3 или новее.

Маршрутизатор решает, что загрузить. Приложение указывает, где отобразить результат, через router.outlet(): один list() для стека слоев, одна строка на слой. Обычная страница занимает одну строку. Сервер и клиент строят одинаковое дерево, поэтому hydrate() использует серверные узлы, а не заменяет их. Все остальное — history, один делегированный обработчик click и requestIdleCallback.

Один импорт

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

start() разрешает модули соответствующего маршрута перед возвратом, поэтому первое дерево является полным как на сервере, так и на клиенте. После этого маршрутизатор считывает запущенный им маршрут: router.path, router.pending, router.go().

Целое приложение

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

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

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

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

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

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

Вот и вся интеграция. Ссылки работают, потому что <a href> — это просто <a href>. Один делегированный обработчик на document перехватывает ссылки, принадлежащие маршрутизатору, и оставляет все остальные ссылки браузеру.

На сервере start() возвращает Route для каждого запроса и оставляет маршрутизатор в покое, поэтому одновременные запросы не могут видеть маршрут друг друга. route.run() делает этот маршрут маршрутом, который маршрутизатор и, следовательно, outlet считывают на протяжении рендеринга. В браузере существует один маршрут для каждой загрузки страницы, и start() делает его активным маршрутом маршрутизатора. run() не требуется.

Страница получает RouteContext и PageProps как (ctx, { layer, data, outlet, beforeLeave }) и может принимать меньше аргументов. Он возвращает все, что принимает list(). Чтобы заполнить и другую часть макета, верните into() изнутри страницы, например div(article(…), into('sidebar', nav(…))). Этот контент уходит вместе со страницей. См. Страницы & Регионы.

Шаблоны и вложенные маршруты

#
ШаблонСовпаденияparams
/, /docs/intro именно этот путь{}
/blog/:slug/blog/hello{ slug: 'hello' }
/u/:id/edit/u/7/edit{ id: '7' }
/files/*rest/files/a/b.txt{ rest: 'a/b.txt' }
*все, что не совпало с другими маршрутами: ваша страница 404{ '*': '…' }

Сначала проверяются статические маршруты, затем динамические в порядке объявления, затем универсальные с приоритетом самого длинного префикса. Поэтому '*' совпадает только с тем, что не подошло другим маршрутам, и может находиться в любом месте таблицы. Завершающие и повторяющиеся слеши, а также процентное кодирование нормализуются. Значения параметров декодируются. Сопоставление сравнивает сегменты каждого шаблона, не создавая и не выполняя регулярных выражений.

Относительные маршруты

Шаблон, записанный с помощью ./, относится к родительскому элементу, который его объявляет, а href, записанный с помощью ./, относится к активному маршруту. Вместе они образуют раздел многократного использования, который означает одно и то же для каждого родителя. Значением таблицы является либо загрузчик, либо другая таблица, ключи которой относятся к ней:

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

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

Эти две строки создают все шесть шаблонов без двукратного написания пути. Оба родителя получают одни и те же объекты-загрузчики, поэтому они используют один кэш модулей и один import().

  • Внутри дочерней таблицы './x', '/x' и 'x' означают одно и то же, '/' означает сам родительский путь, а '*rest' дает разделу свой собственный всеобъемлющий путь. Вложенность может быть настолько глубокой, насколько вам хочется.
  • Ключи верхнего уровня используются в том виде, в котором они записаны, поэтому '*' остается '*'.
  • Дочерний маршрут внутри *catch-all вызывает ошибку createRouter(): универсальный параметр должен завершать шаблон.

Родительские страницы и outlet()

Родитель и его дочерние элементы представляют собой настоящую иерархию. Родитель выполняет рендеринг, а его дочерний объект рендерится внутри него, где бы он ни вызывал outlet():

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

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

При переходе между дочерними элементами родительский элемент не перестраивается. Его узел DOM — тот же узел, поэтому его фокус, положение прокрутки, недописанный текст в полях и любое состояние его замыкания — все сохраняется.

  • У страницы без дочернего маршрута outlet пуст, поэтому макет может вызывать его без проверки. Если родитель не вызывает outlet(), его дочерний маршрут не отображается.
  • Индексных маршрутов нет. '/' — это сам макет, поэтому родительский путь отображает макет с пустым outlet, а не с отдельной индексной страницей внутри него. Поместите целевой контент в макет или дайте ему настоящий дочерний путь.

Тот же вызов затем работает от любого родителя и от страницы, которая не знает, на какой из них она находится:

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

./x, ../x, . и .. разрешаются относительно пути текущего маршрута, а не каталога URL. Для /invoices/42 путь ./preview означает /invoices/42/preview, хотя браузер получил бы /invoices/preview. Завершающий слеш ничего не меняет. ../ поднимается на сегмент выше и останавливается в корне. Строка запроса и хеш сохраняются, base добавляется обратно, а в историю попадает абсолютный путь. Такое же отступление от правил разрешения URL использует React Router для <Link to="./x">.

  • Только ./ и ../ являются относительными по маршруту. Путь preview без префикса в href оставлен обычным правилам браузера.
  • Создавайте относительные ссылки с помощью router.href('./preview'), а не записывайте href="./preview" вручную. href() разрешается во время рендеринга, поэтому поставляемая разметка является абсолютной и по-прежнему корректной для сканера, для щелчка средней кнопкой мыши и с отключенным JavaScript.

Что сохраняется, а что перестраивается

УзелНавигация
родительская страница, шаблон и путь которого не изменились.сохраняется: тот же узел, а его load() не запускается повторно
соответствующая страницасохраняется, когда ее шаблон и путь не изменились и у нее нет load(), поэтому запрос или изменение хэша сохраняют ее. Маршрут с загрузчиком обновляется при каждом посещении.
родительский элемент, путь которого изменилсяпересобран вместе со всем, что находится под ним
добавленный слой отображает только соответствующую страницу. Его родители уже установлены в ряду под ним.

Сохраненная страница хранит ctx, с которым была создана. Значение ctx всегда остается исходным контекстом: текущие значения читайте из router.*. Фильтры, пагинация и вкладки, зависящие от строки запроса, читают router.search внутри динамического значения и меняют URL через router.go('?page=2', { replace: true }). Страница, фокус и недописанный текст сохраняются.

Страницы и регионы

#

router.outlet() помещает страницу в одно место. Страница, которой также требуется боковая панель, панель инструментов или заголовок в другом месте макета, заполняется into(). Макет отмечает это место с помощью region(), а страница возвращает into() рядом со своим собственным содержимым. Макет никогда не получает содержимое страницы в качестве параметра, а навигация никогда не перестраивает его.

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

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

Содержимое страницы представляет собой строку list() в outlet, а into() существует до тех пор, пока строка, в которой она содержится. Таким образом, содержимое боковой панели поступает вместе со страницей и удаляется вместе с ней: при переходе на страницу без into('sidebar', …) снова отображается содержимое региона empty. Страница может использовать свои ctx и data для обеих частей, поскольку обе они созданы с помощью одной и той же функции.

Что происходит при навигации

  • Сохраненная страница сохраняет содержимое регионов. Запрос или изменение хеша, которые сохраняет страницу , также не затрагивают содержимое региона. Прочитайте router.search внутри динамического значения, если от него зависит боковая панель.
  • Перестроенная страница заполняет регионы заново. При смене маршрута старая строка удаляется, а представления into() новой страницы занимают ее место. Старое содержимое не остается.
  • Добавленный слой добавляет представление. Он занимает более позднюю строку outlet, поэтому его into() становится новым представлением того же региона. При стандартном type: 'latest' оно скрывает содержимое страницы до закрытия слоя. При type: 'stack' оба представления видны одновременно.
  • Родительская страница также может заполнять регионы. Ее представления остаются, пока изменяются ее дочерние элементы, поскольку родительский элемент не перестраивается.
  • Серверный рендерингработает как есть. Сервер создает страницу внутри route.run(), поэтому его представления into() находятся в HTML, а hydrate() использует существующие узлы.

Несколько регионов на одной странице

Передайте объект для одновременного заполнения нескольких регионов. Основной контент страницы по-прежнему проходит через outlet, поэтому верните его рядом с into():

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

Какой инструмент для какой работы

  • outlet() предназначен для дочернего маршрута: содержимого, определяемого URL-адресом, отображаемого внутри родительской страницы. См. Вложенные маршруты.
  • into() предназначен для распределения соответствующей страницы по макету. Контент принадлежит странице и использует ее данные.
  • matches() предназначен для выбора макета только на основе URL-адреса, с собственной небольшой таблицей и без участия страницы. См. matches().

Все эти области относятся к одному совпавшему маршруту. Один URL не управляет двумя независимыми маршрутами: боковую панель заполняет страница, а не второй outlet. Если боковой панели нужны собственные маршрутизатор, загрузка и резервный маршрут, создайте маршрутизатор, но не запускайте его: используйте match(). См. Несколько маршрутизаторов и общая загрузка.

Загрузчики маршрутов и разделение кода

#

Модуль страницы может экспортировать функцию load. Она запускается до создания страницы, при каждом переходе по этому маршруту, и все, что она возвращает, передается на страницу как data:

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

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

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

Таблица маршрутов не изменяется. Загрузчик перемещается вместе со страницей. Он получает соответствующий RouteContext, поэтому доступны params, search и path. Он может быть синхронным или асинхронным, и он также работает на сервере, поэтому страница, отображаемая на сервере, поставляется с данными, уже находящимися в HTML, вместо извлечения после гидратации.

Модуль кэшируется. Данные — нет. Фрагмент маршрута извлекается один раз и используется повторно, а его загрузчик запускается снова при каждой навигации. Именно поэтому /blog/a и /blog/b нельзя путать, и почему отказавший загрузчик можно просто перезапустить.

СитуацияПоведение
пока он работаетrouter.pending верен, и страница, на которой вы были, остается активной
загрузчик выбрасывает исключение или возвращает отклоненный промис ошибка записывается в router.error. go() по-прежнему никогда не отклоняется, и навигацию можно повторить.
во время start() промис start() отклоняется. Объекта Route для записи ошибки пока нет.
добавленный слой запускает собственный загрузчик со своими собственными данными
предварительная загрузка в режиме ожиданиязагружает только модули: контекста для загрузчика пока нет, а данные могли бы запрашиваться для маршрута, который пользователь никогда не откроет.
повторный переход на тот же URL-адрес, загрузчик запускается снова и строка перестраивается. Маршрут без загрузчика сохраняет строку.

Для повторной проверки перейдите туда, где вы уже находитесь, не добавляя запись в историю:

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

Загрузка данных параллельно с чанком

load, экспортированный модулем страницы, может быть запущен только после прибытия этого модуля, поэтому холодная навигация извлекает фрагмент, а затем данные. Чтобы запустить оба сразу, объявите загрузчик в таблице с withLoad():

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

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

Этот загрузчик заменяет load модуля страницы и вызывается при каждом переходе. Если он завершится с ошибкой еще до загрузки чанка, ошибка также попадет в router.error.

Сериализация серверных данных в HTML не предусмотрена. Клиентский load повторно вызывается при гидратации. Если это имеет значение, пусть сервер выдает route.data, например, как window.__DATA__, и загрузчик принимает его первым. route.data — это только данные соответствующей страницы. Результат load() родительской страницы в него не входит.

Разделение кода и предварительная загрузка

Значением маршрута всегда является функция, возвращающая компонент страницы, поэтому доступ к компоненту происходит лениво:

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

Экспорт load возможен только для модульных форм. Выбор именованного компонента с помощью .then(m => m.BPage) передает маршрутизатору только эту функцию.

Каждый модуль импортируется один раз и кэшируется. Одновременные переходы по одному маршруту используют общий import(). После idleTimeout бездействия (по умолчанию секунда без касаний, клавиш, колесика и прокрутки) остальные маршруты предварительно загружаются в фоне: по одному за свободный период requestIdleCallback. Ввод пользователя или переход с ожиданием приостанавливает предварительную загрузку до следующего периода бездействия.

Предварительно загруженный или синхронно доступный маршрут не меняет router.pending и отображается до следующей отрисовки: без мигания индикатора и сдвига макета. router.pending имеет значение true только во время ожидания модуля или загрузчика; предыдущая страница остается на экране до завершения загрузки. Ошибка загрузки чанка попадает в router.error, а не в необработанное отклонение промиса. Загрузку можно повторить.

API маршрутизатора

#
fnПубличный API
// import { ... } from 'nuclo-router' function createRouter(table: RouteTable, options?: RouterOptions): Router function withLoad(loader: RouteLoader, load: DataLoader): RouteLoader interface RouterOptions { base?: string; preload?: boolean; idleTimeout?: number; onNavigate?: (ctx: RouteContext, kind: NavigationKind) => void }

createRouter(table, options?)

table сопоставляет шаблон загрузчику или вложенной таблице. Опции:

ПараметрПо умолчанию
base'/'URL префикс, под которым приложение обслуживается, например '/docs'. Оно совпадает только на границе сегмента: /docs/intro находится внутри '/docs', /docsearch — нет. URL-адреса за его пределами ничему не соответствуют, поэтому start() отклоняет (ваш серверный код 404), а ссылки на них остаются на усмотрение браузера.
preloadtrueЗагружайте модули остальных маршрутов в фоновом режиме, когда страница простаивает. false отключает его.
idleTimeout1000Миллисекунды без нажатий, клавиш, колесика или прокрутки перед началом предварительной загрузки или возобновлением после периода активности пользователя.
onNavigate(ctx, kind) => void, вызывается после каждой разрешенной навигации в браузере, включая начальную, после обновления DOM. kind — это 'start', 'push', 'replace', 'pop' (Назад, Вперед, close()) или 'layer'. Хук для document.title, метатегов, фокуса, прокрутки и аналитики. Во время SSR не вызывается.

Возвращает маршрутизатор с start(), match(), outlet(), matches() и всеми членами Route, кроме run(). Обращения к членам Route перенаправляются активному маршруту: результату последнего start() в браузере или маршруту, чей run() сейчас выполняется на сервере. Без активного маршрута чтение выбрасывает исключение. Таблица маршрутов, скомпилированный механизм сопоставления и кэш модулей хранятся в маршрутизаторе и используются всеми серверными запросами.

router.start(url?)

Загружает модули страниц для url (в браузере по умолчанию location.href) и возвращает Promise<Route>. Промис отклоняется, если совпадения нет и в таблице отсутствует '*'; на сервере это повод вернуть 404. В браузере метод делает Route активным, подключает обработчики popstate и делегированный click, планирует предварительную загрузку. Повторный start() останавливает предыдущий Route этого маршрутизатора. На сервере общее состояние не меняется: выполняйте рендеринг внутри route.run().

router.match(url?)

Сопоставляет URL с таблицей без загрузки модулей, изменения истории, подключения обработчиков и создания Route. Возвращает RouteContext или null, если URL находится вне base, не соответствует шаблону или в браузере указывает на другой origin. Принимает только абсолютные URL: для разрешения ./ нужен активный маршрут.

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

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

router.outlet()

Страницы активного маршрута в формате list(). Поместите его один раз, где бы ни находились страницы. Выдвинутый слой — это более поздняя его строка. Он пуст до разрешения start() и после stop(). На сервере он отображает маршрут, run() которого выполняется.

router.matches(table)

Отображает представление второй таблицы, которой соответствует путь активного маршрута, в виде list(), который вы размещаете где угодно. См. matches().

Route

Все члены, кроме run(), также находятся на маршрутизаторе, где приложение их считывает.

Член
path, pattern, params, search, hash, urlтекущее совпадение. search — это URLSearchParams. search, hash и url содержат текущие значения.
pendingнавигация ожидает модуль или загрузчик
errorпоследняя неудачная загрузка, очищается следующей навигацией
dataрезультат, который вернул load() соответствующей страницы для сериализации на сервере. Данные родительской страницы сюда не входят.
depthсколько слоев в стеке (1 для обычной страницы)
go(href, { replace })переходит по адресу, при необходимости загружая модуль. Промис не отклоняется: ошибки модуля, включая исключения и отклоненные промисы, записываются в error.
push(href)открывает маршрут новым верхним слоем. Промис завершается с результатом закрытия слоя. Принимает ./. Отклоняется при отсутствии совпадения или ошибке модуля.
href(path)префикс пути с base для a({ href }). Разрешает ./ и ../ для активного маршрута.
stop()удаляет обработчики, закрывает открытые слои без результата и очищает outlet. Повторный вызов ничего не меняет. Остановленный Route сохраняет последнее совпадение, а go() ничего не делает.
run(fn)вызывает fn, временно делая этот Route активным для маршрутизатора, и возвращает результат. На сервере: route.run(() => renderToString(App)). Работает только синхронно; после возврата из fn маршрут перестает быть активным.

RouteContext

Первый аргумент страницы: path, pattern, params, search, hash, url. Это контекст, в котором была создана страница. Прочтите router.* для получения текущих значений. Они различаются только в том случае, если запрос или хэш изменились без перестроения.

По умолчанию params имеет тип { [name]: string }. Страница может уточнить его по своему шаблону через Params: '/blog/:slug' превращается в { slug: string }, а '/files/*rest' — в { rest: string }:

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

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

PageProps

Второй аргумент страницы. Деструктурируйте то, что нужно странице.

Свойство
layerместо, где страница находится в стеке. См. Layer ниже.
dataрезультат, который вернул load() маршрута, или undefined без него. Тип задается через PageProps<TData>.
outletместо, где родительская страница отображает свой дочерний маршрут. См. Вложенные маршруты.
beforeLeaveрегистрирует проверку перед удалением страницы. См. Проверки перед уходом со страницы.

Layer

Положение страницы в стеке и способ закрыть ее слой.

Член
depth0 для базовой страницы, 1 и выше для добавленных слоев
push(href)открыть еще один слой изнутри страницы, так же, как Route.push()
close(result?)закройте этот слой и все, что находится над ним, разрешив push(), который его открыл. Без аргумента слой закрывается без результата. На глубине 0 ничего не делает.

DataLoader и withLoad()

DataLoader — это (ctx: RouteContext) => TData | Promise<TData>, дополнительный экспорт load страничного модуля. withLoad(loader, load) создает маршрут, load которого запускается параллельно с импортом чанка, а не после него. См. Загрузчики данных маршрутов .

Типы

Все типы доступны как именованные экспорты nuclo-router: типизируйте страницы, загрузчики и проверки без импорта из внутренних путей.

ТипЧто это такое
Params<'/blog/:slug'> — объект параметров для шаблона. RouteParams — это общая форма без конкретных имен параметров, а Match — результат сопоставления.
PageComponentстраница: (ctx, { layer, data, outlet, beforeLeave }) => …
RouteModuleмодуль страницы: сам компонент или { default, load? }, как результат import()
RouteLoader() => RouteModule | Promise<RouteModule>, дополнительно с load от withLoad()
RouteTableтаблица, которую принимает createRouter(): шаблон в загрузчик или во вложенную таблицу
RouterOptions{ base?, preload?, idleTimeout?, onNavigate? }
NavigateOptions{ replace? }, второй аргумент go()
NavigationKind'start' | 'push' | 'replace' | 'pop' | 'layer', второй аргумент onNavigate
Outletтип outlet() от PageProps
LeaveGuard(to) => boolean | void | Promise<…>, что принимает beforeLeave()
MatchViewпредставление для matches(): принимает параметры и возвращает содержимое или import() модуля страницы

Слои и проверки перед уходом

#

push() открывает маршрут поверх текущего, а не заменяет его, и разрешается с тем, чем этот слой закрывается. Поскольку страницы представляют собой один list() в стеке, открытие слоя представляет собой добавление. Страница внизу не перерисовывается, поэтому ее DOM, ее фокус и наполовину заполненная форма остаются на месте, когда слой закрывается.

Благодаря этому поток «Мне нужна запись, которая еще не существует» работает без потери формы, которую заполнял пользователь:

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

Слои можно вкладывать друг в друга. Диалог, которому нужен еще один диалог, вызывает layer.push() и ожидает результат, не обращаясь к Route.

  • URL указывает на верхний слой. push() добавляет запись в историю: на слой можно сослаться, а кнопка «Назад» закрывает его, завершая push() со значением undefined, как «Отмена». «Назад» и close() используют один механизм: close() сохраняет результат и возвращает историю назад, опираясь на глубину из history.state. Браузеры хранят около 50 записей на вкладку. Если после долгой навигации запись под слоем потеряна, close() закрывает слои на месте и заменяет текущую запись адресом оставшейся страницы.
  • Обычная навигация очищает стек. Щелчок по ссылке или go() не является слоем. Он заменяет все и закрывает каждый открытый слой, поэтому ни один вызывающий код не остается в ожидании.
  • Прямая загрузка открывает отдельную страницу. URL описывает верхний слой, но не стек под ним. Поэтому прямое открытие адреса показывает единственную страницу: глубина стека — 1, layer.depth === 0, а close() ничего не делает. Маршрут должен работать самостоятельно. Проверяйте layer.depth, чтобы предложить ссылку для выхода вместо неработающей кнопки «Отмена».
  • Промис не переживает перезагрузку: он хранится в замыкании. Используйте результат слоя как сокращенный путь в сценарии, который работает и через обычную навигацию.
  • Ничего не осталось в ожидании. stop() закрывает каждый открытый слой, а слой, оставленный в результате навигации, разрешается с помощью undefined.

Проверки перед уходом со страницы

Страница с данными, которые стоит сохранить, например с незаполненной до конца формой, регистрирует проверку перед уходом. Она вызывается перед удалением страницы. Возврат false или промиса с этим значением отменяет переход и сохраняет страницу:

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

export default function Edit(_ctx: RouteContext, { beforeLeave }: PageProps) {
  let dirty = false
  beforeLeave(() => !dirty || confirm('Discard your changes?'))
  return form(input({ onInput: () => (dirty = true) }), …)
}
  • Только при удалении страницы. Переход к дочернему маршруту внутри макета или изменение строки запроса либо хеша, сохраняющее страницу, не вызывает проверку. Проверка существует вместе со страницей, поэтому снимать регистрацию вручную не нужно.
  • Каждый выход. Ссылка, go(), Назад и вперед, close() слоя и go() на URL-адрес, которым не владеет маршрутизатор (выход из приложения, где to – это null). При отказе «Назад» или «Вперед» выполняется возврат, поэтому адресная строка остается на странице. Закрытие вкладки или перезагрузка — задача beforeunload.
  • to — RouteContext назначения. Проверки выполняются начиная с самой глубокой страницы и останавливаются при первом отказе. Исключение также отменяет переход. close(result) проходит через проверку, поэтому перед закрытием с результатом сбросьте флаг несохраненных изменений.
  • Маршрутизатор может просматривать только записанные им записи истории. При переходе «Назад» или «Вперед» к записи, созданной вне маршрутизатора, например обычной ссылкой #fragment или вызовом pushState другого скрипта, проверка не вызывается.

matches() и вложенные маршрутизаторы

#

router.outlet() отображает страницу. router.matches(table) отображает что-то еще, что зависит от того, где вы находитесь, например боковую панель, хлебные крошки или панель инструментов, из своей маленькой таблицы, без загрузки маршрута или добавления страницы:

app.ts
div(
  aside(
    router.matches({
      '/':           () => nav('Home links'),
      '/blog/new':   () => nav('Drafting tips'),
      '/blog/:slug': ({ slug }) => nav(`More like ${slug}`),
      '*':           () => import('./Sidebar.ts'),   // code-split, rendered once loaded
    }),
  ),
  main(router.outlet()),
)
  • Те же правила, что у таблицы маршрутов. Используются те же шаблоны, приоритеты, сопоставление исходного пути и удаление префикса base. Поэтому представление и страница одинаково трактуют URL. Читается путь верхнего слоя, как в router.path.
  • Представление — (params, ctx) => content. Тип params выводится из шаблона, а ctx — RouteContext с pattern и params представления. Вместо содержимого можно вернуть import() модуля страницы. После загрузки его компонент вызывается с ctx.
  • Ничего не соответствует, ничего не отображается. Добавьте представление '*' в качестве запасного варианта. До start() и после stop() пусто, как и outlet.
  • Представление перестраивается только при смене маршрута. Любой переход, сохраняющий страницу, также сохраняет DOM представления.
  • Отложенное представление ничего не отображает, пока не прибудет его модуль. Неудачный import() регистрируется с помощью console.error, не устанавливает router.error и повторяется при следующей навигации. Обычный update() не повторяет попытку.
  • На сервере отложенное представление ничего не выводит: его модуль загрузится на клиенте. Для содержимого, которое должно присутствовать в исходном HTML, используйте синхронное представление.

Вложенные маршрутизаторы

Маршруты поддерживают вложенность, поэтому вложенный маршрутизатор обычно заменяет вложенная таблица. Маршрутизатор остается один: страница '/' раздела задает его оболочку, а дочерние маршруты отображаются в ее outlet(). Оболочка сохраняется при переходах внутри раздела:

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

Или дайте этой функции собственный маршрутизатор, созданный, но так и не запущенный. Родительский элемент владеет единственной всеобъемлющей записью, а дочерний элемент разрешает хвост по своим собственным шаблонам, параметрам и резервным вариантам:

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

Главное — не вызывать start(): именно он подключает обработчики, а два запущенных маршрутизатора конфликтуют. В этом варианте дочерний маршрутизатор только сопоставляет URL, поэтому загрузчики данных нужно вызывать самостоятельно. При смене оставшейся части пути оболочка тоже перестраивается: основной маршрутизатор видит один маршрут, а остальное разрешает оболочка. Вложенная таблица сохраняет родительскую страницу смонтированной. Предпочитайте ее, если разделу не нужны отдельные маршрутизатор, base и резервный маршрут.

Несколько маршрутизаторов и общая загрузка

#

В приложении может быть несколько маршрутизаторов, по одному на область. Правило простое: запускайте ровно один на документ. Остальные только сопоставляют URL через match(). Только запущенный маршрутизатор управляет URL, историей и обработчиком кликов. Независимые области могут загружаться отдельно, каждая со своим индикатором.

Общая загрузка: цепочка загрузчиков

Каждая страница в сопоставленной цепочке может экспортировать load(). При холодной навигации к дочернему маршруту загрузчик макета и загрузчик страницы запускаются параллельно , а один router.pending покрывает всю цепочку: предыдущая страница остается на экране до завершения обоих загрузчиков, поэтому наполовину загруженное дерево никогда не рисуется. Каждый компонент получает свой собственный data:

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

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

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

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

Оба загрузчика запускаются одновременно. router.pending имеет значение true, пока выполняется хотя бы один из них, и false в остальное время. Предварительно загруженный или синхронно доступный маршрут не меняет этот флаг. При переходе между разделами макет сохраняется, поэтому повторно запускается только загрузчик раздела. Данные макета, его DOM и недописанный текст сохраняются. Повторный переход на тот же URL заново запускает загрузчик конечной страницы и перестраивает ее строку.

Множественная загрузка: второй маршрутизатор со своим индикатором загрузки

Независимая область — боковая панель, лоток, активная область — получает собственный маршрутизатор, созданный, но никогда не запускавшийся, и собственный флаг загрузки. router.pending принадлежит запущенному маршрутизатору и ничего не говорит об этой области, поэтому оба могут загружаться параллельно, и любой из них может завершить работу первым:

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

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

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

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

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

Второй маршрутизатор только сопоставляет URL: match() возвращает контекст, а боковая панель не добавляется в основную таблицу маршрутов. onNavigate вызывается после каждого завершенного перехода, включая начальный, и позволяет синхронизировать независимую область с URL.

  • pending принадлежит запущенному маршрутизатору. Никогда не запускавшийся маршрутизатор не имеет маршрута, pending и error. Область отслеживает свои собственные флаги, а второй спиннер может сидеть рядом с первым. В демо-версии боковая панель загружается 800 мс, а загрузчики маршрута работают 900 мс, поэтому страница может завершиться раньше боковой панели или наоборот.
  • match() не подключает обработчики. Документ может использовать любое число незапущенных маршрутизаторов. У каждого своя таблица, base и кэш модулей, и ни один не меняет историю. Для них нужны только таблица и механизм сопоставления.
  • Его загрузчики не запускаются автоматически. Запущенный маршрутизатор вызывает load() совпавших страниц. Незапущенный лишь сопоставляет URL. Если представлению нужны данные, загружайте их самостоятельно, как в refreshSidebar(). Чтобы поручить загрузку основному маршрутизатору, используйте вложенную таблицу или регион, заполняемый страницей.
  • Игнорируйте устаревшие ответы. Медленный ответ для боковой панели не должен применяться после перехода в другой раздел. Увеличивайте счетчик поколения при каждом обновлении и отбрасывайте ответы с прежним значением — так же маршрутизатор отбрасывает результаты устаревших переходов.
  • Отложенное представление matches() также не имеет pending. Оно ничего не отображает, пока не загрузится его модуль. Три системы загрузки, три независимых часа: pending маршрута, свои флаги и ленивый вид, которого просто пока нет.

Вычисляемые значения

#

В Nuclo нет специального API для вычисляемых значений. Используйте простую функцию и вызывайте ее из динамических значений:

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

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

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

filter = 'an'
update()

Асинхронность и загрузка

#

Используйте простой async/await и вызывайте update() каждый раз при изменении состояния. При загрузке данных это означает один раз до await и один раз после:

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

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

Рекомендации

#
  • Пакетные изменения. Измените все необходимые состояния, затем один раз вызовите update().
  • Функции динамических значений должны быть быстрыми и без побочных эффектов. Все они работают на каждом update().
  • Используйте scope() для небольших, частых обновлений, таких как ввод текста или таймеры.
  • Рендеринг в документ. Узлы, которых нет в документе при запуске update(), перестают обновляться.
  • Используйте when() и list() для изменяющихся дочерних узлов вместо функции, возвращающей узлы.
  • Используйте className, а не class, и используйте () => cx(...) или { className: () => … } для динамических классов.
  • Определите стили один раз на уровне модуля.
  • Серверный рендеринг: сначала рендеринг , затем поместите getCssText() в <style id="nuclo-styles">. Передавайте данные запроса в качестве аргументов и сохраняйте комментарии HTML в выходных данных.

Указатель API

#

Все публичные функции и точки входа, из которых они доступны.

nuclo (глобальный и именованный экспорт)

nuclo (только именованный экспорт)

nuclo/ssr

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

nuclo/polyfill

nuclo-router (отдельный пакет)

  • createRouter(table, options?): маршрутизатор с start(), match(), outlet(), matches(), go(), push(), href() и stop().
  • withLoad(loader, load): маршрут, загрузчик данных которого запускается параллельно с импортом чанка.
  • Типы : RouteContext, PageProps, Params, DataLoader, RouteTable и другие.

Только глобальные версии

Специальные ключи

Частые вопросы

#

Обновляет ли изменение состояния UI?

Нет. DOM остается неизменным до тех пор, пока вы не вызовете update().

Использует ли Nuclo сигналы, прокси или виртуальный DOM?

Нет. Состояние остается обычными изменяемыми данными JavaScript. При рендеринге дерева Nuclo создает реальные элементы и текстовые узлы. update() запускает каждое зарегистрированное динамическое значение или значение внутри заданных областей и обновляет те же узлы, значения которых изменились. Здесь нет графа зависимостей и виртуального дерева.

Почему на моей странице отображается «false»?

Логические значения отображаются как текст. div(ok && span('Done')) показывает «false». Используйте when(() => ok, span('Done')) для дочерних узлов и ok ? 'Done' : '' для текста.

Почему строка списка не меняется при редактировании элемента?

Строки для элементов, которые все еще находятся в списке, сохраняются и не отображаются повторно. Используйте динамические значения, такие как () => todo.text, для полей, которые изменяются. См. list().

Почему часть моего UI перестает обновляться?

Обычно его узлы отсутствовали в документе при запуске update(). Nuclo перестает отслеживать эти узлы даже после того, как вы их прикрепите, поэтому визуализируйте их в контейнер, который находится в документе. Две другие причины: when(), условие которого выдает ошибку, прекращает обновление, а текстовая функция, которая выдает ошибку при первом запуске, остается пустой. См. update().

Когда следует использовать forceUpdate()?

Только для редких изменений статических значений, например переключения языка. Для всего остального используйте update(). См. forceUpdate().

Как поместить страницу в макет, не передавая ее в качестве параметра?

Отметьте это место region({ id: 'main' }) и возвращайте со страницы into('main', …). См. region() & into().

Как отключить приложение?

Заверните его в when() и отключите условие. Самостоятельное удаление DOM не запускает onDestroy. См. Жизненный цикл.

Включает ли Nuclo маршрутизатор?

В основном пакете его нет. Маршрутизация поставляется отдельно в nuclo-router: серверный рендеринг, гидратация, разделение кода и предварительная загрузка в периоды бездействия. Установите пакет командой npm install nuclo nuclo-router. См. nuclo-router.

Имеет ли Nuclo эффекты?

Нет. onMount и onDestroy вызываются один раз для каждого элемента при вставке и удалении. Изменение состояния не запускает их повторно.