مقدمةنظرة عامة
NUCLO / 12

言語

ドキュメント

Nuclo ドキュメント

Nuclo のインストール、明示的な更新による構築、型付き CSS によるスタイリング、サーバーサイド HTML のレンダリングのための実践的なリファレンス。

クイックスタートnpm create nuclo@latest
クイックスタート
v0.3.29
現在のリリース
175
タグビルダー
~18.4 KB
コア、gzip 圧縮時
明示的
ランタイムモデル

概要

#

Nuclo は TypeScript 向けの小さな DOM ライブラリです。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 は、Nuclo を使う準備が整った Vite と TypeScript のプロジェクトを生成します。

$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 個のタグビルダー: HTML 用が 112 個(div、span、button、…)、SVG 用が 63 個で、SVG 用には Svg サフィックスが付きます(svgSvg、pathSvg、…)。タグビルダーを参照してください。
  • 16 個のヘルパー: update、scope、when、list、region、into、on、render、hydrate、forceUpdate、css、cx、createCss、variants、keyframes、globalStyle。

このインポートは、ビルダーを呼び出すどのコードよりも先に実行される必要があります。ES モジュールはインポート順に実行されるため、次のコードは失敗します。

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

エントリーファイルの先頭に import 'nuclo' を置くか、グローバルを使うすべてのファイルでインポートしてください。

名前付きインポート

ヘルパーは名前付きエクスポートとしても提供されます。グローバルと同じ関数です。getCssText() と resetStyles() は名前付きエクスポートのみです。タグビルダーはグローバルのみなので、import { div } from 'nuclo' は動作しません。

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

エントリーポイント

  • nuclo: グローバルを登録し、16 個のヘルパーに加えて getCssText と resetStyles をエクスポートします。
  • nuclo/ssr: renderToString、renderManyToString、renderToStringWithContainer、getCssText。サーバーサイドレンダリングを参照してください。
  • nuclo/polyfill: Node と Bun 向けの小さな DOM。nuclo/polyfill を参照してください。
  • nuclo/types: 型のみ。TypeScript を参照してください。

TypeScript

#

Nuclo は独自の型定義を同梱しています。必要なのは次の 2 点です。

  • "moduleResolution": "bundler"。node16 と nodenext はまだサポートされていません。
  • DOM ライブラリ。lib を設定しない場合、TypeScript はデフォルトでこれを含めます。

プロジェクト内のいずれかのファイルが 'nuclo' をインポートしていれば、すべてのファイルからグローバル型が見えます。それ以外に必要なものはありません。どのファイルもインポートしていない場合、たとえばテスト用の別の tsconfig では、型を自分で追加します。

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

または、.d.ts ファイルに /// <reference types="nuclo/types" /> を追加します。

便利な型

これらの型はグローバルなので、インポートせずに使えます。

  • 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') はビルダー、つまり後で要素を作る関数を返します。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

引数

ビルダーは任意の数の引数を受け取り、左から右へ順に適用します。

  • string、number、bigint: テキストノード。
  • () => value: update() で変化するテキスト。動的な値を参照してください。
  • null、undefined: スキップされます。
  • true、false: テキスト "true" または "false" としてレンダリングされます。条件には when() を使ってください。
  • プレーンオブジェクト: 属性。属性を参照してください。
  • css()、cx()、variants() の結果: そのクラスを追加します。
  • on(...): イベントリスナーまたはライフサイクルフック。
  • 別のビルダー、when()、list(): 子要素。
  • DOM の Node: そのまま追加されます。
  • (el, index) => …: カスタムモディファイア。後述します。

注意点

  • div(isAdmin && span('Admin')) はテキスト "false" をレンダリングします。div(when(() => isAdmin, span('Admin'))) と書いてください。
  • 配列は子要素になりません。スプレッドしてください: ul(...names.map((n) => li(n)))。変化するリストには list() を使います。
  • タグ名と同じ名前のローカル変数はビルダーを隠します。function Row(label: string) の中では label(...) を呼び出せません。label、title、data、p、a、i のようなローカル変数はリネームしてください。
  • ブラウザーは id を持つすべての要素をグローバル変数として公開します。Nuclo が読み込まれる前にページに <main id="main"> があると、Nuclo はそのグローバルをそのまま残すため、main(...) は TypeError を投げます。別の id を使ってください。

カスタムモディファイア

少なくとも 1 つのパラメーターを宣言した関数は、要素の構築中に引数の順序で 1 回だけ実行されます。第 1 引数は要素です。第 2 引数は内部的なインデックスなので、依存しないでください。文字列または数値を返すと、それがテキストになります。Node は追加されます。オブジェクトは属性として適用されます。

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

要素の取得

render() はルート要素を返します。ツリーの中では、on("mount", (el) => …) またはカスタムモディファイアを使います。ビルダー関数を自分で呼び出して、切り離された要素を作ることもできます。

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

次の update() より前にドキュメントへ追加してください。update() の実行時にドキュメント内にないノードは、以後更新されなくなります。

SVG

SVG ビルダーも同じ引数を受け取ります。Nuclo はすべての SVG 属性を setAttribute で設定するため、"stroke-width" のような名前はそのまま書いて動作します。

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

すべてのビルダー

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

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

コンポーネントは、button(...) のようなビルダーを返すプレーンな関数です。コンポーネント API は存在しません。レンダリングするかツリーに配置するまで、何も作られません。

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

render(() => div(Counter('A'), Counter('B')))
ライブコンポーネントはプレーンな関数です。Counter() の各呼び出しは自分のカウントを持ちます。開く ↗

関数の内側で宣言した状態はそのインスタンスに属します。外側で宣言した状態はすべてのインスタンスで共有されます。forceUpdate() はコンポーネントを再度呼び出すため、内側で宣言した状態はリセットされます。

子要素は単なる引数です。

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

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

属性

#

属性を設定するには、プレーンオブジェクトを渡します。複数のオブジェクトを渡すこともできます。同じキーについては後の値が優先されますが、className と style はマージされます。値は静的な値か、update() ごとに再実行されるパラメーターなしの関数のどちらでも構いません。

example.ts
let busy = false

const save = button(
  { type: 'submit', disabled: () => busy, 'aria-busy': () => String(busy), 'data-id': 42 },
  'Save',
)
ライブ属性の値は関数にできます。2 つ目のボタンは busy を反転して update() を呼び出し、Save の disabled と aria-busy がそれに従います。開く ↗

値の設定方法

  • 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()

HTML 要素では class キーを使わないでください。これは生の属性を設定し、css() のクラスを含め、それ以前に適用されたすべてのクラスを置き換えます。

インラインスタイル

style は、camelCase のキーを持つオブジェクト、またはそれを返す関数を受け取ります。

example.ts
let progress = 30

const bar = div({ style: () => ({ width: `${progress}%`, backgroundColor: 'teal' }) })
  • Nuclo は単位を付加しません。12 ではなく "12px" と書いてください。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

イベントをリッスンする方法は 2 つあります: 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 になります。イベントごとにハンドラーは 1 つで、後の 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() は何もしません。
  • 1 つの on() の値を複数の要素で再利用できます。

カスタムイベントの場合は、イベント型をジェネリクスで渡します。

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

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

使い分け

通常のケースでは on* 属性を使います。1 つのイベントに複数のリスナーが必要な場合、リスナーオプションが必要な場合、"my-event" のようなカスタムイベント名が必要な場合は on() を使います。

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 は要素が挿入された後に 1 回実行されます。onDestroy は Nuclo が要素を削除するときに 1 回実行されます。属性として書くか、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 にしないでください。返された Promise はクリーンアップとして使われることはありません。非同期処理はその内側で開始してください: { onMount: (el) => { void load(el) } }。
  • フックはサーバー上では実行されません。クライアント上では hydrate() の後に実行されます。
注: フックはエフェクトではありません。状態が変わっても再実行されることはありません。各フックは要素ごとに最大 1 回だけ実行されます。

Nuclo が自動的にページを更新することはありません。状態を変更し、その後 update() を呼び出します。

counter.ts
let count = 0

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

render(Counter)
ライブcount++ は変数を変えるだけです。テキストは次の update() で追いつきます。ボタンからでも、このページの他の何かからでも。開く ↗

Clicked 0 times

必要なだけ状態を変更し、その後 update() を 1 回呼び出します。

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

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

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

update() はイベントハンドラー、タイマー、fetch のコールバック、onMount から呼び出します。動的な値の内側からは呼び出さないでください。

動的な値

#

パラメーターを持たない関数は動的な値です。Nuclo は要素の構築時に 1 回実行し、その後 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)を確認します。パラメーターを宣言した関数はカスタムモディファイアであり、1 回だけ実行されます。(x = 0) => … と (...args) => … の length は 0 なので、動的な値になります。

動的なテキスト

  • string、number、bigint、boolean は String(value) としてレンダリングされます。() => ok && 'Saved' は "false" を表示するので、() => (ok ? 'Saved' : '') と書いてください。
  • null と undefined は空のテキストとしてレンダリングされます。
  • 初回実行で css() または cx() の結果を返すと、その関数はテキストではなく動的なクラスになります。cx() を参照してください。
  • ノード、ビルダー、配列、プレーンオブジェクトは何もレンダリングせず、更新もされません。

動的な属性

  • Nuclo は、最後に自身が書き込んだ値と異なる場合にだけ値を書き込みます。実際の DOM とは比較しないため、ユーザーが input に入力した後に関数が以前と同じ値を返した場合、入力されたテキストはそのまま残ります。
  • 結果が null または undefined の場合は何も書き込まれず、以前の値が残ります。className は例外で、falsy な結果はすべて動的なクラスを削除し、静的なクラスを保持します。
  • style 関数は、返されたオブジェクトを更新ごとに適用します。

エラー

動的な値が例外を投げた場合、Nuclo はエラーをキャッチし、残りの更新は引き続き実行されます。テキストは空になるか以前の値を保持し、属性は以前の値を保持します。どちらも、次に成功した update() で回復します。例外は、初回の実行で例外を投げたテキスト関数で、これは以後ずっと空のままになります。

fn公開 API
function update(...scopeIds: string[]): void

ページ上のすべての動的な値に対して、同期的なパスを 1 回実行します。id を渡した場合は、scope() でその id が付けられた要素の内側だけを更新します。各パスは次のステップを順に実行します。

  1. list(): アイテムを再度読み取り、行を追加・移動・削除します。
  2. when(): 条件を再度確認します。ブランチは、別のブランチがアクティブになったときにだけ再構築されます。
  3. 動的な属性。style と className を含みます。
  4. 動的なテキスト。
  5. ステップ 1 と 2 で挿入された要素の onMount。
  • すべてを実行します。データが変わっていなくても、呼び出しごとにすべての動的な値が実行されます。スキップされるのは DOM への書き込みだけです。動的な値は高速に、副作用のないものにしてください。
  • ドキュメント内のノードのみ。update() の実行時にノードがドキュメント内にない場合、Nuclo はそのノードの追跡を停止します。後で追加しても、再び更新されることはありません。これは list() と when() にも当てはまります。ドキュメント内にあるコンテナーへレンダリングしてください。
  • 静的な値は変化しません。プレーンな文字列と属性値は、要素の構築時に固定されます。それらを再構築するには、forceUpdate() を使います。
  • エラー。テキストと属性のエラーはキャッチされ、パスは続行されます。when() の条件が例外を投げた場合、エラーはログに出力され、その when() は更新を停止します。list() の items または renderItem 関数が例外を投げた場合、エラーは update() の外へ伝播し、残りのステップは実行されません。
  • サーバー。update() はサーバー上では何もしません。
ライブすべての update() はすべての動的な値を実行します。データが変わっていなくてもです。このページの他のデモもカウントを増やします。開く ↗

This dynamic value has run 1 times.

fn公開 API
function scope(...ids: string[]): NodeModFn

渡された要素に 1 つ以上のスコープ id を付けます。すると 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
  • 1 つの要素に複数の id を付けられます: scope('cart', 'sidebar')。
  • update('a', 'b') は a または b の内側にあるすべてを更新します。複数の要素が同じ id を使っている場合、そのすべてが更新されます。
  • スコープがネストしている場合、update('outer') は内側のスコープも含みます。update('inner') は外側には触れません。
  • ドキュメント内のどの要素も持たない id は何も更新せず、エラーも発生しないため、スペルを確認してください。
  • 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() ごとに条件を再確認します。最初に true になった条件が採用されます。どれも true でない場合は、.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…

条件

  • 状態を扱う場合は関数を渡します。プレーンなブーリアンは 1 回だけ読み取られます: when(open, …) は決して変化しません。
  • TypeScript では、関数はブーリアンを返す必要があります。() => items.length > 0 や () => user != null のように書いてください。

更新されるもの

  • 同じブランチがアクティブなままの間、その要素は保持されます。その内側の動的な値、リスト、when() ブロックは引き続き更新されます。
  • 別のブランチがアクティブになると、古いコンテンツは削除され、その onDestroy フックが実行されます。新しいブランチはゼロから構築され、その onMount フックが実行されます。入力テキストやフォーカスなど、古い要素の状態は失われます。

ブランチのコンテンツ

ブランチには子要素を置きます: テキスト、動的なテキスト、ビルダー、list()、ネストした when()。DOM ノードではなく、p('Hi') のようなビルダーを渡してください。DOM ノードは、そのブランチが一度非表示になると更新されなくなります。

ブランチ内の属性オブジェクトと on() は親要素に適用され、ブランチが非表示になった後も残ります。条件付きの属性には、動的な属性を使ってください: { className: () => (active ? 'active' : '') }。

チェーン

.when() と .else() は新しいビルダーを返し、元のビルダーを変更しません。.else() を 2 回呼び出した場合、最後の呼び出しが優先されます。

fn公開 API
function list<T>( items: () => readonly T[] | Iterable<T>, renderItem: (item: T, index: number) => ListRenderResult, ): ListModifier

アイテムごとに 1 行をレンダリングします。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 ビルダーなど、1 つの要素を返します。アイテムをスキップするには null または undefined を返します。文字列、テキストノード、フラグメントは何もレンダリングしません。
  • when() や list() を直接返さないでください。行要素の内側に置きます: li(todo.text, when(() => todo.done, ' ✓'))。
  • アイテムを非表示にするには、renderItem ではなく items() の中でフィルタリングしてください。
  • renderItem には副作用を持たせないでください。Nuclo は同じアイテムに対して複数回呼び出すことがあります。

インデックス

index は、行が作成された時点のアイテムの位置です。行が移動しても更新されません。現在の位置が必要な場合は、動的な値を使います: li(() => `${names.indexOf(name) + 1}. ${name}`)。

イテラブル

任意のイテラブルが使えます: 配列、Set、ジェネレーター、map.values()。毎回同じ配列や Set を返しても問題ありません。ジェネレーターや map.values() のようなイテレーターは 1 回のパスで消費されるため、呼び出しごとに items() の内側で作成してください。Map を直接イテレートすると毎回新しい [key, value] 配列が作られ、すべての行が再構築されます。代わりに map.values() を使ってください。

配置

行は、兄弟要素の中で list() が置かれた位置に、2 つのコメントマーカーの間に現れます。親の他の子要素には触れません。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() は、ツリーの他のどこからでも、どの深さからでも、そこを埋めます。レイアウトはコンテンツを prop として受け取らず、コンテンツを構築するコンポーネントはそれがどこに配置されるかを知りません。

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() は待機し、リージョンが構築された瞬間に表示されます。ビューは書かれた順序で表示されます。
  • コンテンツはビューと同じ寿命を持ちます。into() を保持する when() が閉じたり、その list() の行が削除されたりすると、コンテンツはリージョンから離れます。再度開くと、2 つではなく 1 つのコピーが追加されます。
  • リージョンがなくなると、たとえばその when() が閉じると、そのビューは再び待機します。後で同じ id で構築されたリージョンがそれらを表示します。
  • どのリージョンにも一致しない id の into() は何もレンダリングせず、エラーも発生しないため、スペルを確認してください。
  • id はツリー内で一意です。生存中の id で 2 つ目のリージョンを構築すると、警告がログに出力されます。
  • リージョンはサーバーサイドレンダリングでも動作します。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'))),
  )

複数のリージョンを一度に

1 つの into() から複数のリージョンを埋めるには、オブジェクトを渡します。値は 1 つの子要素でも、子要素の配列でも構いません。どちらの形式もネストできます。別のビューのコンテンツ内にある 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() は 1 回だけ書かれています。ユーザーがサインアウトしている間は 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 は 1 つの 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 内の 1 つのツリーを再構築し、そのルートを返します。後の呼び出しのためにルートを登録することはありません。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 は同じです。forceUpdate() が後でルートを再構築できるのは render(App) だけなので、こちらを推奨します。
  • 追加のみ行います。parent をクリアしないため、2 回呼び出すとコピーが 2 つ追加されます。
  • render() が戻った時点で、ツリー内のすべての onMount は実行済みです。
  • ドキュメント内にある要素へレンダリングしてください。update() を参照してください。
  • ルートは when() や list() ではなく、div(...) のような要素でなければなりません。
  • index は内部用です。要素の挿入位置を決めるものではありません。省略してください。

render(App) として渡すコンポーネントは、パラメーターを取ってはいけません。必須パラメーターは型エラーになります。オプショナルなパラメーターは型チェックを通りますが、render() は実行時に例外を投げます。props を取るコンポーネントはラップしてください。

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

renderToString() が生成した HTML を引き継ぎます。既存の要素を再利用し、イベントリスナーを取り付け、動的な値、when()、list() を接続するため、その後は update() が動作します。続いて onMount フックを実行します。parent のデフォルトは document.body です。ルート要素を返します。

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

hydrate(App, document.getElementById('root')!)
  • サーバーと同じコンポーネント、同じデータを使ってください。
  • 専用のコンテナーへハイドレーションしてください。アプリのルートはその最初のノードでなければなりません。ただし空白は問題ありません。別のノードが先にある場合、Nuclo はそのノードを置き換え、サーバー側のコピーは残されます。
  • render() と同様に、forceUpdate() が後でルートを再構築できるよう、App() ではなく App を渡してください。App はパラメーターを取ってはいけません。オプショナルなパラメーターがある場合、hydrate() は何も取り付けずに静かに終了します。props を取るコンポーネントはラップしてください: hydrate(() => Page(props), root)。
  • サーバー側の HTML がない場合は、render() を使ってください。

サーバーとクライアントが異なる場合

クライアントが優先されます。

  • ルートのタグが異なる場合、そのノードは新しい DOM に置き換えられます。コンテナーが空の場合、新しい DOM が追加されます。
  • 異なるテキストはパッチされます。異なる list() のアイテムと when() のブランチはクライアントに従います。
  • サーバー側に余分な子要素があれば削除され、不足していれば作成されます。
  • サーバーが書き込んだがクライアントが設定しない属性は、そのまま残されます。

renderToString() は、各テキストノードの前の <!-- text-0 --> のように、HTML コメントをマーカーとして書き込みます。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')!)

style タグの id="nuclo-styles" は残してください。クライアントはその要素を再利用し、新しいルールだけを追加します。getCssText() を参照してください。

サーバー上で実行されるもの

  • renderToString() の各呼び出しはツリーを構築し、各動的な値を 1 回実行します。
  • イベントハンドラー、onMount、onDestroy は実行されません。update()、scope()、forceUpdate() は何もしません。
  • レンダリングは同期的です。まずデータを読み込み、その後レンダリングしてください。
  • レンダリング後には何も登録されたまま残らないため、1 つのプロセスで多数のリクエストを処理できます。リクエストデータは 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()

出力を 1 つの要素でラップします。containerTag のデフォルトは "div"、containerAttrs のデフォルトは {} です。属性値はエスケープされます。属性名は与えられたとおりに書き出されるため、class のような HTML の名前を使ってください。タグにユーザー入力を渡さないでください。

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

Nuclo が Node と Bun でツリーを構築できるようにする小さな DOM です。最初のレンダリングの前に 1 回インポートしてください。ほとんどのアプリにはプレーンなインポートだけで十分です。

  • window がない場合、globalThis.document、Node、Element、HTMLElement を設定しますが、存在しないものだけを設定します。window は作成しません。
  • ブラウザー内、または jsdom 上では何もしません。
  • Event と CustomEvent はランタイム自身のコンストラクターです。Node、Element、HTMLElement はポリフィルのクラスのエイリアスです。

これはツリーを構築するための DOM であり、ブラウザーではありません。innerHTML も getElementById もなく、querySelector() は null を返し、イベントリスナーは無視されます。HTML を取得するには renderToString() を使ってください。

css()

#
fn公開 API
function css(style: Style): StyleResult function css(name: string, style: Style): StyleResult type StyleResult = { className: string }

型付きのスタイルオブジェクトを、生成された CSS クラスに変換します。結果は任意のタグビルダーに渡せます。ブラウザでは、ルールは 1 つの <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() は基本の宣言に対して 1 つのクラスを作り、さらに各疑似キー、セレクター、メディアブロックごとに 1 つずつクラスを作ります。上のカードは 2 つのクラスを得ます。
  • クラス名は内容のハッシュです。同一のスタイルはクラスを共有し、サーバーとクライアントは同じ名前を生成します。
  • スタイルはモジュールレベルで一度だけ定義してください。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 は警告を出して名前を生成します。
  • 名前はグローバルです。2 つの異なるスタイルが同じ名前を使うと、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左右の padding / 上下の padding
m、mt、mr、mb、ml、mx、mymargin についても同様
w、h、minW、maxW、minH、maxHwidth、height と、それぞれの min と max
sizewidth と height
bgbackground
text / font / weightfont-size / font-family / font-weight
leading / trackingline-height / letter-spacing
align / items / justifytext-align / align-items / justify-content
z / rounded / shadow / selectz-index / border-radius / box-shadow / user-select

次のキーは true を取ります。

キーCSS
rowdisplay: flex; flex-direction: row
coldisplay: flex; flex-direction: column
centeralign-items: center; justify-content: center(display は設定しません)
truncateoverflow: hidden; text-overflow: ellipsis; white-space: nowrap

疑似状態

hover、focus、focusVisible、focusWithin、active、visited、disabled、enabled、checked、required、invalid、valid、readOnly、first(:first-child)、last(:last-child)、only(:only-child)、odd、even、empty、placeholderShown、placeholder、before、after、selection、marker、firstLine、firstLetter。これらはネストできます。hover: { focus: {…} } は :hover:focus になります。

セレクターと at 規則

& で始まるキーはセレクターで、各 & がクラスに置き換わります。@ で始まるキーは at 規則です: @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 *)" と書いてください。
  • ネストした 2 つの @media ブロックは and で結合されます。メディアタイプ(print、only screen)で始まるクエリは先頭に置かれます。文法上、タイプは先頭にしか置けないためです。平坦化できない組み合わせ(2 つのメディアタイプ、または先頭の not)は警告を出し、内側のクエリだけを使います。その他のネストした at 規則は外側のものを置き換えます。

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[]

スタイルを結合します。2 つの入力が同じプロパティを設定している場合、後の入力が優先されます。

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')
  • 偽値の入力はスキップされ、配列は平坦化され、文字列は分割されて重複が除かれます。
  • 同じブロック(基本、同じ疑似状態、同じメディアクエリ)に対する生成クラスは 1 つの新しいクラスにマージされます。その他のクラス名はそのまま通過します。
  • 引数の順序を知っているのは cx() だけです。div(base, blue) では両方のクラスが追加され、最後に作成されたルールが優先されます。互いに上書きし合うスタイルは 1 つの 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
screensmd: {…} のような新しいスタイルキー
  • テーマ名は値全体がその名前であるときだけ解決されます。border: "1px solid primary" は解決されません。
  • @ を含まないスクリーン値は @media <value> になります。@ で始まる値は書かれたとおりに使われます。例: "@container (min-width: 400px)"。
  • スクリーンは createCss() で作ったインスタンスにしか存在しません。グローバルな css() にはありませんが、@media キーは受け付けます。
  • すべてのインスタンスとグローバルヘルパーは 1 つのスタイルシートを共有します。スクリーンのメディアルールは使用順ではなくスクリーンの宣言順に出力されるため、min-width のスクリーンは小さい順に並べてください。アプリごとに createCss() インスタンスを 1 つにすれば、その順序はテーマで書いた順序になります。
  • 結果の theme は渡したテーマそのものです。

variants()

#
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、各グループで選ばれた値、そして一致する各 compound。プロパティごとに後のスタイルが優先されます。
  • 未指定、undefined、null のオプションはデフォルトを使います。デフォルトのないグループはスキップされます。
  • true と false のキーだけを持つグループは boolean を取ります。
  • compound は、列挙したすべてのグループが一致したとき(デフォルトを含む)に適用されます。
  • 各選択は一度だけ構築され、キャッシュされます。

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() でメディアルールは書けません。
  • 同じルールは一度だけ追加されます。同じセレクターに対する別のスタイルは 2 つ目のルールを追加し、後のものが優先されます。

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() の後に呼び出してください。レンダリング中に作成されたルールも含まれます。
  • 対象は 1 ページや 1 リクエストではなくプロセス全体で、増える一方です。クラス名が内容のハッシュなので、これは安全です。
  • <style id="nuclo-styles"> に入れてください。クライアントはその要素を再利用し、すでに含まれているルールをスキップします。別の id の場合や id がない場合、クライアントはすべてのルールを含む 2 つ目のスタイルシートを追加します。
  • テキストはエスケープされないので、信頼できない入力をスタイル値に入れてはいけません。

resetStyles() はテスト用ヘルパーです。すべてのルールと #nuclo-styles 要素を削除します。以前に作成したスタイルはクラス名を保ちますが CSS を失います。アプリやサーバーで呼び出してはいけません。

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

beforeEach(() => resetStyles())
別パッケージ: nuclo-router は Nuclo のコアパッケージには含まれません。nuclo と並べてインストールしてください。プレビューリリースのため、API は今後変わる可能性があります。

Nuclo のためのルーティングです。サーバーサイドレンダリング、ハイドレーション、コード分割されたルート、アイドル時のプリロードを、list() と update() の上に構築しています。gzip 後で約 4.9 KB、Nuclo 自体以外の依存はなく副作用もないため、未使用ならツリーシェイクで消えます。

terminal
$ npm install nuclo nuclo-router

または、同じ 2 つの名前で pnpm add、yarn add、bun add を使ってください。peer dependency として nuclo 0.3 以降が必要です。

ルーターは何を読み込むかを決めます。アプリは router.outlet() でどこに置くかを示します。これはレイヤースタック全体に対する 1 つの list() で、レイヤーごとに 1 行なので、通常のページは 1 行だけです。サーバーとクライアントは同じツリーを構築するため、hydrate() はサーバーのノードを置き換えるのではなく引き継ぎます。それ以外はすべて history、1 つの委譲された click リスナー、そして requestIdleCallback です。

1 つのインポート

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 上の 1 つの委譲リスナーが、ルーターが所有するリンクを捕まえ、それ以外のリンクはすべてブラウザに任せます。

サーバーでは、start() はリクエストごとに Route を返し、ルーター自体には触れないため、同時リクエストが互いのルートを見ることはありません。route.run() は、レンダリングの間、その Route をルーター(つまりアウトレット)が読む対象にします。ブラウザではページ読み込みごとに 1 つの Route があり、start() がそれをルーター自身のものにします。run() は必要ありません。

ページは RouteContext と PageProps を (ctx, { layer, data, outlet, beforeLeave }) として受け取り、引数を減らしても構いません。戻り値は list() が受け付けるものなら何でもよいです。レイアウトの別の部分も埋めたい場合は、div(article(…), into('sidebar', nav(…))) のようにページ内から into() を返します。そのコンテンツはページとともに去ります。ページとリージョンを参照してください。

パターンとネストしたルート

#
パターン一致するもの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 はアクティブなルートに対して相対です。この 2 つを組み合わせると、どの親の下でも同じ意味になる再利用可能なセクションができます。テーブルの値はローダーか、別のテーブル(そのキーはそのテーブルに対して相対)のどちらかです。

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'),
})

この 2 行で、パスを二度書くことなく 6 つのパターンすべてが生まれます。両方の親が同じローダーオブジェクトを受け取るので、モジュールキャッシュと import() を 1 つ共有します。

  • 子テーブルの中では、'./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 末尾のスラッシュは何も変えません。../ は 1 セグメント上がり、ルートで止まります。クエリとハッシュは引き継がれ、base が再付与され、解決後の絶対パスが history に入ります。これは React Router が <Link to="./x"> で行っているのと同じ、URL 解決からの逸脱です。

  • ルート相対になるのは ./ と ../ だけです。href 内の素の preview は通常のブラウザの規則に任されます。
  • 相対リンクは href="./preview" を手書きするのではなく router.href('./preview') で組み立ててください。href() はレンダリング時に解決するため、出力されるマークアップは絶対パスになり、クローラー、中クリック、JavaScript 無効の環境でも正しく動きます。

何が保持され、何が再構築されるか

ノードナビゲーション時
パターンとパスが変わらない親保持: 同じノードで、load() は再実行されません
一致したページパターンとパスが変わらず、load() を持たない場合は保持されるので、クエリやハッシュの変更ではページが保たれます。ローダーを持つルートは訪問のたびに更新されます。
パスが変わった親その下のすべてとともに再構築
プッシュされたレイヤー一致したページだけを描画します。その親はすでに下の行にマウントされています。

保持されたページは、構築時の ctx を保ちます。これがあらゆる場所での ctx の意味です。ライブな値は router.* から読んでください。フィルター、ページネーション、タブのようなクエリ駆動の状態は、動的な値の中で router.search を読み、router.go('?page=2', { replace: true }) で書き込みます。ページ、そのフォーカス、入力途中の値はそのまま残ります。

ページとリージョン

#

router.outlet() はページを 1 か所に置きます。レイアウトの別の場所にサイドバー、ツールバー、見出しも必要なページは、into() でそこを埋めます。レイアウトは region() でその場所を示し、ページは自身のコンテンツの隣に into() を返します。レイアウトがページのコンテンツを prop として受け取ることはなく、ナビゲーションがレイアウトを再構築することもありません。

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() の 1 行であり、into() はそれを保持する行と同じ寿命を持ちます。したがってサイドバーのコンテンツはページとともに到着し、ページとともに去ります。into('sidebar', …) を持たないページに移動すると、リージョンの empty コンテンツが再び表示されます。両方の部分は同じ関数で構築されるので、ページは ctx と data をどちらにも使えます。

ナビゲーション時に起こること

  • 保持されたページはリージョンも保持します。ページを保持するクエリやハッシュの変更は、リージョンのコンテンツにも触れません。サイドバーがクエリに依存する場合は、動的な値の中で router.search を読んでください。
  • 再構築されたページはリージョンを埋め直します。ルートが変わると古い行が削除され、新しいページの into() ビューが引き継ぎます。何も残りません。
  • プッシュされたレイヤーはビューを追加します。レイヤーはアウトレットの後の行なので、その into() は同じリージョンのより新しいビューです。デフォルトの type: 'latest' では、レイヤーが閉じるまでページのビューを覆い、閉じるとページのビューが再び表示されます。type: 'stack' では両方が表示されます。
  • 親ページもリージョンを埋められます。親は再構築されないため、子が変わってもそのビューは残ります。
  • サーバーサイドレンダリングはそのまま動きます。サーバーは route.run() の中でページを構築するので、その into() ビューは HTML に含まれ、hydrate() がそれらを引き継ぎます。

1 つのページから複数のリージョンへ

複数のリージョンを一度に埋めるにはオブジェクトを渡します。ページの主コンテンツは引き続きアウトレットを通るので、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() を参照してください。

これらはすべて、1 つの一致したルートに属します。ルーターは 1 つの URL から 2 つの独立した領域を駆動しないため、独自のルートを持つサイドバーは、2 つ目のアウトレットではなくページが埋めるリージョンです。本当に独自のルーター、ローディング、フォールバックを持ちたいサイドバーは、開始しないルーターを 1 つ保持して 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 が true になり、現在のページは表示されたままです
throw または reject したrouter.error に入ります。go() は引き続き決して reject せず、ナビゲーションは再試行できます。
start() の間reject は start() から出てきます。それを載せる Route がまだないためです。
プッシュされたレイヤー独自のローダーを実行し、独自のデータを持ちます
アイドル時のプリローダーモジュールのみを温めます。渡すコンテキストがなく、開かれないかもしれないルートのデータをフェッチすることになるためです。
同じ URL への再ナビゲーションローダーが再実行され、行が再構築されます。ローダーのないルートは行を保持します。

再検証するには、history エントリを追加せずに現在地へナビゲートします。

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 を置き換え、同様に訪問ごとに実行されます。チャンクの読み込み中の reject は、他と同様に 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) で名前付きコンポーネントを選ぶと、ルーターには関数だけが渡されます。

各モジュールは一度だけインポートされ、キャッシュされます。同じルートへの同時ナビゲーションは 1 つの import() を共有します。ユーザーがページを idleTimeout(デフォルトで 1 秒。タップ、キー、ホイール、スクロールがない状態)の間放置すると、残りのルートがバックグラウンドで、アイドルスライス(requestIdleCallback)ごとに 1 つずつ読み込まれます。何らかの入力や、ユーザーが待っているナビゲーションがあると、ページが再びアイドルになるまで一時停止します。

プリロード済みまたは eager なルートは router.pending を決して切り替えず、次の描画の前に着地します。スピナーのフレームもレイアウトシフトもありません。router.pending は、待たなければならないモジュールやローダーがあるときだけ true になり、その間は離脱中のページが画面に残ります。読み込みに失敗したチャンクは、未処理の reject ではなく 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() は reject し(サーバー側の 404)、それらへのリンクはブラウザに任されます。
preloadtrueページがアイドルになったら、残りのルートのモジュールをバックグラウンドで読み込みます。false で無効になります。
idleTimeout1000プリロードを開始する(またはユーザーの操作後に再開する)までの、タップ、キー、ホイール、スクロールのないミリ秒数。
onNavigate(ctx, kind) => void。ブラウザで解決されたすべてのナビゲーション(最初のものを含む)の後、DOM が追いついた時点で呼ばれます。kind は 'start'、'push'、'replace'、'pop'(戻る、進む、close())、'layer' のいずれかです。document.title、メタタグ、フォーカス、スクロール、アナリティクスのためのフックです。SSR 中は呼ばれません。

ルーターを返します: start()、match()、outlet()、matches()、および run() を除くすべての Route メンバーで、これらはアクティブな Route を読み通します。アクティブな Route とは、ブラウザでは最後の start() が返したもの、サーバーでは run() が実行中のものです。どちらもない状態でメンバーを読むと throw します。ルートテーブル、コンパイル済みのマッチャー、モジュールキャッシュはルーター上にあるので、サーバーではリクエスト間で共有されます。

router.start(url?)

url(デフォルト: ブラウザでは location.href)をページモジュールに解決し、Promise<Route> を返します。何にも一致せず、テーブルに '*' ルートがない場合は reject します。サーバーでは、それが 404 です。ブラウザではさらに、その Route をルーターのアクティブなものにし、popstate と委譲された click リスナーを取り付け、プリロードをスケジュールします。2 回目の start() は以前の Route を自動的に退役させます。サーバーでは共有されるものには何も触れないので、route.run() の中でレンダリングしてください。

router.match(url?)

何も読み込まずに URL をテーブルに対して解決します。インポートも、history も、リスナーも、Route もありません。RouteContext を返すか、URL が base の外、どのパターンにも一致しない、またはブラウザで別オリジンの場合は null を返します。./ の解決にはアクティブなルートが必要なため、絶対 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() が実行中の Route を描画します。

router.matches(table)

アクティブなルートのパスが一致する、2 つ目のテーブルのビューを、任意の場所に置ける list() として描画します。matches() を参照してください。

Route

run() 以外のすべてのメンバーはルーター上にもあり、アプリはそこから読みます。

メンバー
path、pattern、params、search、hash、urlアクティブな一致。search は URLSearchParams です。search、hash、url はライブです。
pendingナビゲーションがモジュールまたはローダーを待っています
error最後に失敗した読み込み。次のナビゲーションでクリアされます
data一致したページの load() が返したもの。サーバーがシリアライズするためのものです。親のものは含まれません。
depthスタック上のレイヤー数(通常のページでは 1)
go(href, { replace })必要ならモジュールを読み込んでナビゲートします。決して reject しません。reject または throw で失敗したモジュールは error に入ります。
push(href)ルートを新しいレイヤーとして上に開き、そのレイヤーが閉じる際の値で解決します。./ を受け付けます。一致なしやモジュールの失敗では reject します。
href(path)a({ href }) のために、パスに base を前置します。./ と ../ をアクティブなルートに対して解決します。
stop()すべてのリスナーを外し、開いているレイヤーを閉じ、アウトレットを空にします。冪等です。停止した Route は最後の一致を引き続き返し、go() は何もしません。
run(fn)この Route をルーターのアクティブなものとして fn を呼び、その結果を返します: サーバーでの route.run(() => renderToString(App))。同期のみです。fn が戻ると Route はアクティブでなくなります。

RouteContext

ページの第 1 引数: 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

ページの第 2 引数。ページに必要なものを分割代入してください。

プロパティ
layerスタック内でのページの位置。下の Layer を参照してください。
dataルートの load() が返したもの。ない場合は undefined。PageProps<TData> で型付けします。
outlet親ページが子ルートを描画する場所。ネストしたルートを参照してください。
beforeLeaveこのページが破棄される前に問い合わせられるガードを登録します。離脱ガードを参照してください。

Layer

スタック内でのページの位置と、そこから出る方法。

メンバー
depthベースページでは 0、プッシュされたレイヤーでは 1 以上
push(href)ページ内から別のレイヤーを開きます。Route.push() と同じです
close(result?)このレイヤーとその上のすべてを閉じ、開いた push() を解決します。引数なしは「閉じられた(dismissed)」を意味します。depth 0 では何もしません。

DataLoader と withLoad()

DataLoader は (ctx: RouteContext) => TData | Promise<TData> で、ページモジュールの任意の load エクスポートです。withLoad(loader, load) は、チャンクのインポートの後ではなく、その隣で load を開始するルートを作ります。ルートローダーを参照してください。

型

すべての型は nuclo-router の名前付きエクスポートなので、ページ、ローダー、ガードはパスからインポートせずに型付けできます。

型内容
Params<'/blog/:slug'>パターンに対する params オブジェクト。RouteParams は型なしの形、Match はマッチャーの結果です。
PageComponentページ: (ctx, { layer, data, outlet, beforeLeave }) => …
RouteModuleページモジュール: コンポーネントそのもの、または import() が返す { default, load? }
RouteLoader() => RouteModule | Promise<RouteModule>。withLoad() 由来の load を持つこともあります
RouteTablecreateRouter() が受け取るテーブル: パターンからローダー、またはネストしたテーブルへ
RouterOptions{ base?, preload?, idleTimeout?, onNavigate? }
NavigateOptions{ replace? }。go() の第 2 引数
NavigationKind'start' | 'push' | 'replace' | 'pop' | 'layer'。onNavigate の第 2 引数
OutletPageProps の outlet() の型
LeaveGuard(to) => boolean | void | Promise<…>。beforeLeave() が受け取るもの
MatchViewmatches() のビュー: params を受け取り、コンテンツまたはページの import() を返します

レイヤーと離脱ガード

#

push() は、現在のルートを置き換えるのではなくその上にルートを開き、そのレイヤーが閉じる際の値で解決します。ページはスタック全体に対する 1 つの list() なので、レイヤーを開くことは追加(append)です。下のページは再レンダリングされないため、その 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

レイヤーはネストします。独自のダイアログを必要とするダイアログは、Route を参照せずに layer.push() を呼んで await します。

  • URL は最上位のレイヤーです。push() は history エントリを追加するので、レイヤーはリンク可能で、「戻る」で閉じられ、その push() は Cancel とまったく同じく「閉じられた」として解決します。「戻る」と close() は 1 つのコードパスです。close() は結果を記録して history を戻し、history.state 内の depth が正となります。ブラウザはタブごとに約 50 エントリしか保持しないため、レイヤーを長く辿った後には下のエントリが消えていることがあります。その場合 close() はレイヤーをその場で閉じ、現在のエントリを表示されたまま残るページに書き換えます。
  • 通常のナビゲーションはスタックをクリアします。リンクのクリックや go() はレイヤーではありません。すべてを置き換え、開いているレイヤーをすべて閉じるので、await したまま取り残される呼び出し元はありません。
  • コールドロードは独立したページです。URL は最上位のレイヤーを示しますが、その下のスタックは示さないため、レイヤーの URL を直接開くと、depth 1 で、layer.depth === 0 かつ無効な close() を持つ状態で描画されます。プッシュされるルートは単独でも意味を持つ必要があります。layer.depth で分岐して、機能しない Cancel ボタンの代わりに外へ出るリンクを用意してください。
  • Promise はリロードを生き延びられません。クロージャの中にあるためです。結果は、遠回りでも動くフローの近道として扱ってください。
  • 何も await したまま取り残されません。stop() は開いているレイヤーをすべて閉じ、ナビゲーションによって放棄されたレイヤーは undefined で解決します。

離脱ガード

入力途中のフォームのような、残しておく価値のあるものを持つページはガードを登録します。ガードはページが破棄される前に問い合わせられ、false(またはその Promise)を返すとページが保持されます。

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()、そしてルーターが所有しない URL への go()(アプリを離れる場合。to は null)。拒否された戻る・進むは巻き戻されるので、アドレスバーはそのページのままです。タブを閉じたりリロードしたりするのは beforeunload の仕事です。
  • to はナビゲーションの行き先の RouteContext です。ガードは最も内側から順に実行され、最初の拒否で止まります。throw したガードはページを保持します。close(result) もガードを通るので、結果を持って閉じる前にフラグをクリアしてください。
  • ルーターは自身が書いた history エントリしか巻き戻せません。ネイティブの #fragment リンクや別のスクリプトの pushState のような、ルーターが書いていないエントリへの戻る・進むでは問い合わせられません。

matches() とサブルーター

#

router.outlet() はページを描画します。router.matches(table) は、サイドバー、パンくず、ツールバーのように現在地に依存する別のものを、独自の小さなテーブルから、ルートを読み込んだりページを追加したりせずに描画します。

app.ts
div(
  aside(
    router.matches({
      '/':           () => nav('Home links'),
      '/blog/new':   () => nav('Drafting tips'),
      '/blog/:slug': ({ slug }) => nav(`More like ${slug}`),
      '*':           () => import('./Sidebar.ts'),   // code-split, rendered once loaded
    }),
  ),
  main(router.outlet()),
)
  • ルートテーブルと同じマッチング。パターン、優先順位、生パスのマッチング、base の除去はルーター自身のものなので、ビューとページは URL について常に一致します。router.path と同様に、最上位レイヤーのパスを読みます。
  • ビューは (params, ctx) => content です。params はそのパターンから型付けされ、ctx はビューの pattern と params を持つ RouteContext です。代わりにページモジュールの import() を返してもよく、その場合は到着後に ctx で呼び出されます。
  • 何も一致しなければ何も描画されません。フォールバックには '*' ビューを追加してください。アウトレットと同様に、start() の前と stop() の後は空です。
  • 再描画されるのはルートが変わったときだけなので、ページを保持するあらゆる変更の間、ビューは DOM を保ちます。
  • 遅延ビューはモジュールが届くまで何も描画しません。失敗した import() は console.error でログされ、router.error は設定せず、次のナビゲーションで再試行されます。単なる update() では再試行されません。
  • サーバーでは遅延ビューは何も出力しません。そのモジュールはクライアントで着地するためです。最初の HTML に含めなければならないものには同期ビューを使ってください。

サブルーター

ルートはネストするので、サブルーターは通常、単にネストしたテーブルです。ルーターは 1 つで、セクション自身の '/' ページがそのクロームとなり、子はそのページの outlet() に描画されるため、セクション内のナビゲーションの間クロームは生き残ります。

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

あるいは、その機能に専用のルーターを与えます。作成はするが決して開始しないルーターです。親は単一のキャッチオールエントリを所有し、子は残りのパスを自身のパターン、パラメーター、フォールバックに対して解決します。

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

決して開始しないことが要点です。start() がリスナーを取り付けるものであり、開始された 2 つのルーターは衝突します。その代償として、解決は子が所有するので、開始されたルーターが実行するルートローダーは自分で呼び出すことになります。またこの形では、ルーターはルートを 1 つしか見ておらず、残りはシェルが自ら解決するため、残りのパスが変わるとシェルが再構築されます。ルーターがマウントし続ける親を得られるのはネストしたテーブルなので、その機能が本当に独自のルーター、base、フォールバックを必要とするのでなければ、ネストしたテーブルを優先してください。

複数のルーターと複合ローディング

#

アプリは領域の数だけルーターを持てます。これを単純に保つ規則は、ドキュメントごとにちょうど 1 つだけ開始すること、そして他のすべてのルーターは開始せず、match() で URL を解決させることです。URL、history、クリックリスナーを所有するのは開始されたルーターだけです。残りは問い合わせるためのテーブルであり、各領域は独自のタイミングで、独自のスピナーとともに読み込めます。

複合ローディング: ローダーの連鎖

一致した連鎖上のすべてのページは load() をエクスポートできます。子ルートへのコールドナビゲーションでは、レイアウトのローダーとページのローダーが並行して実行され、1 つの 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 はどちらかが await されている間 true で、それ以外は false です。プリロード済みまたは eager なルートは決してこれを切り替えません。セクション間の移動ではレイアウトが保持されるので、セクションのローダーだけが再実行されます。レイアウトのデータ、その DOM、入力途中の値はすべて生き残ります。同じ URL への再ナビゲーションは、単一ページの場合とまったく同様に、末端のローダーを再実行してその行を再構築します。

複数のローディング: 独自のスピナーを持つ 2 つ目のルーター

サイドバー、トレイ、ライブリージョンのような独立した領域には、作成はするが決して開始しない専用のルーターと、独自のローディングフラグを与えます。router.pending は開始されたルーターのものであり、この領域については何も語らないため、2 つは並んで読み込め、どちらが先に終わっても構いません。

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
  },
})

2 つ目のルーターは問い合わせられるだけで、指示されることはありません。match() は URL を読んでコンテキストを返し、サイドバーに関するものはメインのルートテーブルには一切入りません。onNavigate は解決されたすべてのナビゲーション(最初のものを含む)の後に実行されるので、独立した領域を URL と同期させる唯一の場所です。

  • pending は開始されたルーターのものです。開始されていないルーターには Route も、pending も、error もありません。領域は独自のフラグを管理し、2 つ目のスピナーを最初のものの隣に置けます。デモではサイドバーが 800ms、ルートのローダーが 900ms かけて読み込むので、ページが先に終わることも、逆になることもあります。
  • match() は何も取り付けません。開始されていないルーターはいくつでもドキュメントを共有できます。それぞれが独自のテーブル、base、モジュールキャッシュを持ち、どれも history には触れません。コストはテーブルとマッチャー 1 つ分です。
  • そのローダーは決して実行されません。開始されたルーターは、一致したページの load() を実行します。開始されていないルーターは解決するだけなので、そのビューにデータが必要なら自分でフェッチしてください。デモの refreshSidebar() がそれです。ローダーを使いたいなら、ネストしたテーブルかページが埋めるリージョンにすれば、その仕事は開始されたルーターに残ります。
  • 古い応答を防いでください。遅いサイドバーのフェッチが、すでに離れたセクションに着地してはいけません。更新ごとに世代カウンターを進め、一致しなくなった応答を捨ててください。ルーター自身が追い越されたナビゲーションを退役させるのと同じ方法です。
  • 遅延 matches() ビューにも pending はありません。モジュールが着地するまで何も描画しません。3 つのローディングの仕組みに 3 つの独立したタイミングがあります: ルートの pending、自前のフラグ、そして単にまだ存在しない遅延ビューです。

Nuclo には算出値(computed)の 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() を使う。変化する子には、ノードを返す関数ではなくこれらを使ってください。
  • class ではなく className を使う。動的なクラスには () => 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 は更新されますか?

いいえ。update() を呼び出すまで DOM はそのままです。

Nuclo はシグナル、プロキシ、仮想 DOM を使っていますか?

いいえ。状態は普通の JavaScript のままです。ツリーがレンダリングされると、Nuclo は実際の要素とテキストノードを作成します。update() は登録されたすべての動的な値(または指定したスコープ内のもの)を実行し、値が変わった箇所で同じノードにパッチを当てます。依存グラフも仮想ツリーもありません。

ページに "false" が表示されるのはなぜですか?

boolean はテキストとして描画されます。div(ok && span('Done')) は "false" を表示します。子には when(() => ok, span('Done')) を、テキストには ok ? 'Done' : '' を使ってください。

項目を編集してもリストの行が変わらないのはなぜですか?

リストに残っている項目の行は保持され、再レンダリングされません。変化するフィールドには () => todo.text のような動的な値を使ってください。list() を参照してください。

UI の一部が更新されなくなるのはなぜですか?

多くの場合、update() の実行時にそのノードがドキュメント内になかったためです。Nuclo はそのようなノードの追跡を止め、後で取り付けても再開しないので、ドキュメント内にあるコンテナにレンダリングしてください。他に 2 つの原因があります: 条件が throw する when() は更新を止め、初回実行で throw するテキスト関数は空のままになります。update() を参照してください。

forceUpdate() はいつ使うべきですか?

言語の切り替えのような、静的な値のまれな変更にだけ使ってください。それ以外はすべて update() を使います。forceUpdate() を参照してください。

prop として渡さずにページをレイアウトに入れるにはどうすればよいですか?

region({ id: 'main' }) で場所を示し、ページに into('main', …) を返させてください。region() と into() を参照してください。

アプリをアンマウントするにはどうすればよいですか?

when() で包み、条件をオフにしてください。DOM を自分で削除しても onDestroy は実行されません。ライフサイクルを参照してください。

Nuclo にはルーターが含まれていますか?

コアパッケージには含まれません。ルーティングは nuclo-router として別途提供され、サーバーサイドレンダリング、ハイドレーション、コード分割されたルート、アイドル時のプリロードを備えています。npm install nuclo nuclo-router でインストールしてください。nuclo-router を参照してください。

Nuclo にはエフェクトがありますか?

いいえ。onMount と onDestroy は要素ごとに一度だけ、挿入と削除に結び付いて実行されます。状態が変わったからといって再実行されるものはありません。