IntroduçãoVisão geral
NUCLO / 12

Язык

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

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

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

Быстрый стартnpm create nuclo@latest
Быстрый старт
v0.3.29
Текущий выпуск
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, …). См. Tag Builders.
  • 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() для условий.
  • A простой объект: атрибуты. См. Attributes.
  • A css(), cx() или variants() result: добавляет свои классы.
  • on(...): прослушиватель событий или перехватчик жизненного цикла.
  • Другой строитель, when() или list(): ребенок.
  • A DOM Node: добавлено как есть.
  • (el, index) => …: пользовательский модификатор. См. ниже.

Есть ошибки

  • div(isAdmin && span('Admin')) отображает текст «false». Напишите div(when(() => isAdmin, span('Admin'))).
  • Массивы не являются дочерними. Распространите их: 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) => …) или собственный модификатор. Вы также можете самостоятельно вызвать функцию builder, чтобы создать отдельный элемент:

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() непосредственно разработчику, и будет выведен тип элемента: 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 является исключением: любой ложный результат удаляет динамические классы и сохраняет статические.
  • Функция style применяет возвращаемый объект при каждом обновлении.

Ошибки

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

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().
  • Errors. Ошибки текста и атрибутов перехватываются, и передача продолжается. Если возникает условие when(), ошибка регистрируется и when() прекращает обновление. Если функция list() items или renderItem выдает ошибку, ошибка переходит в update(), а остальные шаги не выполняются.
  • Server. 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') не касается внешнего.
  • Идентификатор того, что ни один элемент в документе не имеет обновлений nothing и не вызывает ошибок, поэтому проверьте написание.
  • 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, поэтому его двойной вызов добавляет две копии.
  • Каждый onMount в дереве был запущен, когда render() вернулся.
  • Рендеринг в элемент, который находится в документе. См. 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, plpadding и каждый сторона
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 (дисплей не устанавливается)
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.
  • Все экземпляры и глобальные помощники используют одну таблицу стилей. Правила мультимедиа на экране выводятся в том порядке, в котором экраны объявлены, а не в том порядке, в котором вы их используете, поэтому перечисляйте экраны 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 KB в сжатом виде, без каких-либо зависимостей, кроме самого Nuclo, и без побочных эффектов, поэтому, когда он не используется, он удаляется в виде дерева.

terminal
$ npm install nuclo nuclo-router

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

Маршрутизатор решает what загрузить. Приложение говорит , где с 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() делает этот маршрут маршрутом, который маршрутизатор и, следовательно, розетка, считывает длину рендеринга. В браузере существует один маршрут для каждой загрузки страницы, и 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(), проглатывает его.
  • Индексных маршрутов нет. '/' — это сам макет, поэтому родительский путь отображает макет с пустым выходом, а не с отдельной индексной страницей внутри него. Поместите целевой контент в макет или дайте ему настоящий дочерний путь.

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

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, а косая черта в конце URL-адреса ничего не меняет. ../ проходит сегмент вверх и останавливается в корне. Запрос и хеш выполняются, base добавляется повторно, а разрешенный абсолютный путь попадает в историю. Это то же отклонение от разрешения URL-адресов, которое React Router допускает для <Link to="./x">.

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

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

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

На сохраненной странице хранится ctx, с помощью которого она была создана. В этом везде смысл ctx: читайте router.*, чтобы узнать текущие значения. Состояние, управляемое запросом, такое как фильтры, нумерация страниц и вкладки, считывает router.search внутри динамического значения и записывает с помощью 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() торговой точки, а into() существует до тех пор, пока строка, в которой она содержится. Таким образом, содержимое боковой панели поступает вместе со страницей и удаляется вместе с ней: при переходе на страницу без into('sidebar', …) снова отображается содержимое региона empty. Страница может использовать свои ctx и data для обеих частей, поскольку обе они созданы с помощью одной и той же функции.

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

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

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

Передайте объект для одновременного заполнения нескольких регионов. Основной контент страницы по-прежнему проходит через выход, поэтому верните его рядом с 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, поэтому боковая панель со своими маршрутами — это область, которую заполняет страница, а не второй выход. Боковая панель, которой действительно нужен собственный маршрутизатор, загрузка и возврат к резерву, не запускает один из них и разрешается с помощью 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(). Рута пока нет, чтобы его поставить.
a выдвинутый слой запускает собственный загрузчик со своими собственными данными
неработающий прелоадер прогревает только модули. У него нет контекста для передачи, и он будет получать данные для маршрута, который вы, возможно, никогда не откроете.
повторный переход на тот же 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 верно только для модуля или загрузчика, который нужно дождаться, и исходящая страница остается на экране, пока она есть. Чанк, который не может загрузить поверхности как 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(), считывается до активного маршрута. Это тот, который последний start() вернул в браузер, или тот, чей run() выполняется на сервере. Чтение члена ни с одним броском. Таблица маршрутов, скомпилированное средство сопоставления и кэш модулей находятся на маршрутизаторе, поэтому они используются совместно для всех запросов на сервере.

router.start(url?)

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

router.match(url?)

Сопоставляет URL-адрес с таблицей, ничего не загружая: без импорта, без истории, без прослушивателей, без маршрутизации. Возвращает RouteContext или null, если URL-адрес находится за пределами base, не соответствует шаблону или в браузере находится в другом источнике. Только абсолютные 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()отсоедините каждого слушателя, закройте открытые слои, опустошите выход. Идемпотент. Остановленный маршрут по-прежнему отвечает своим последним совпадением, а go() ничего не делает.
run(fn)Вызовите fn с этим маршрутом в качестве активного маршрутизатора и верните его результат: 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 — результат сопоставления.
PageComponenta страница: (ctx, { layer, data, outlet, beforeLeave }) => …
RouteModulea модуль страницы: сам компонент или { 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() добавляет запись истории, поэтому слой можно связать, а Back закрывает его, разрешая его push() как отклоненный, точно так же, как Отмена. Назад и close() представляют собой один путь кода: close() записывает свой результат и возвращает историю назад, а глубина в history.state является авторитетом. Браузеры хранят около 50 записей на вкладке, поэтому после долгого просмотра слоев запись под ней может исчезнуть: тогда close() закрывает слои на месте и перезаписывает текущую запись на оставшуюся страницу.
  • Обычная навигация очищает стек. Щелчок по ссылке или go() не является слоем. Он заменяет все и закрывает каждый открытый слой, поэтому ни один вызывающий абонент не остается в ожидании.
  • A холодная загрузка — это отдельная страница. URL-адрес называет верхний слой, но не стек под ним, поэтому открытие 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.
  • A view — это (params, ctx) => content, где params набирается по его шаблону, а ctx — это RouteContext с pattern и params представления. Вместо этого он может вернуть import() страничного модуля, который вызывается с помощью ctx после его прибытия.
  • Ничего не соответствует, ничего не отображается. Добавьте представление '*' в качестве запасного варианта. До start() и после stop() пусто, как и розетка.
  • Он выполняет повторную визуализацию только при изменении маршрута, поэтому представление сохраняет свой DOM для всего, что содержит страницу.
  • A отложенное представление ничего не отображает, пока не прибудет его модуль. Неудачный import() регистрируется с помощью console.error, не устанавливает router.error и повторяется при следующей навигации. Обычный update() не повторяет попытку.
  • На сервере ленивый просмотр ничего не доставляет, так как его модуль оказывается на клиенте. Используйте синхронное представление для всего, что должен содержать первый HTML.

Субмаршрутизаторы

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

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-адреса с помощью 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 истинно, пока ожидается любой из них, и 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
  },
})

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

  • pending принадлежит запущенному маршрутизатору. Никогда не запускавшийся маршрутизатор не имеет маршрута, pending и error. Область отслеживает свои собственные флаги, а второй спиннер может сидеть рядом с первым. В демо-версии боковая панель загружается 800 мс, а загрузчики маршрута работают 900 мс, поэтому страница может завершиться раньше боковой панели или наоборот.
  • match() ничего не прикрепляет. Любое количество незапущенных маршрутизаторов может совместно использовать документ; у каждого есть свой собственный табличный, базовый и модульный кеш, и ни один из них не касается истории. Они стоят стола и спички.
  • Его загрузчики никогда не запускаются. Запущенный маршрутизатор запускает load() страниц, которым он соответствует. Никогда не запускаемый маршрутизатор только разрешает, поэтому, если его представлениям нужны данные, извлеките их самостоятельно — демо-версия refreshSidebar(). Если вам нужны загрузчики, вложенная таблица или регион , заполняемый страницей, сохраняет эту работу с запущенным маршрутизатором.
  • Защитить устаревшие ответы. Медленная загрузка боковой панели не должна приходить к разделу, из которого вы перешли. Увеличьте счетчик генерации за каждое обновление и отбросьте ответы, которые больше не совпадают, так же, как сам маршрутизатор удаляет устаревшую навигацию.
  • A ленивый вид 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 и другие.

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

  • 175 создателей тегов: div, span, …, var_ и svgSvg, pathSvg, ….
  • Типы: NodeModFn, NodeModLike, ExpandedElement и другие. Типы стилей, такие как StyleResult, также можно импортировать из 'nuclo'.

Специальные клавиши

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

#

Обновляет ли изменение состояния 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 запускаются один раз для каждого элемента, привязанного к вставке и удалению. Ничего не запускается снова, потому что какое-то состояние изменилось.