소개개요
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,没有编译器,没有代理(proxy),也没有信号(signal)。

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 个标签构建器:112 个用于 HTML(div、span、button……),63 个用于 SVG,带 Svg 后缀(svgSvg、pathSvg……)。参见标签构建器。
  • 16 个辅助函数:update、scope、when、list、region、into、on、render、hydrate、forceUpdate、css、cx、createCss、variants、keyframes 和 globalStyle。

这个导入必须在任何调用构建器的代码之前运行。ES 模块按导入顺序执行,所以下面的写法会失败:

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

把 import 'nuclo' 放在入口文件的最前面,或者在每个使用这些全局变量的文件中导入它。

命名导入

辅助函数同时也是命名导出,与全局变量是同一批函数。getCssText() 和 resetStyles() 只有命名导出。标签构建器只有全局形式,所以 import { div } from 'nuclo' 不可用。

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

入口点

  • nuclo:注册全局变量,并导出 16 个辅助函数以及 getCssText 和 resetStyles。
  • nuclo/ssr:renderToString、renderManyToString、renderToStringWithContainer 和 getCssText。参见服务端渲染。
  • nuclo/polyfill:供 Node 和 Bun 使用的小型 DOM。参见 nuclo/polyfill。
  • nuclo/types:仅类型。参见 TypeScript。

TypeScript

#

Nuclo 自带类型定义。你需要两样东西:

  • "moduleResolution": "bundler"。目前尚不支持 node16 和 nodenext。
  • DOM lib。不设置 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') 返回一个构建器:一个稍后创建元素的函数。当你用 render() 挂载它,或把它放进另一个构建器、when() 或 list() 中时,Nuclo 才会调用它。每次使用都会创建一个新元素。

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)
实时构建器接受属性、静态子节点和动态文本。重命名会改变状态,然后调用 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。

自定义修饰器

声明了至少一个参数的函数会在元素构建期间按参数顺序运行一次。它的第一个参数是元素。第二个参数是内部索引,不要依赖它。如果它返回字符串或数字,该值会成为文本;返回 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 用 setAttribute 设置所有 SVG 属性,所以像 "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();Save 的 disabled 和 aria-busy 随之变化。打开 ↗

值是如何设置的

  • 如果 HTML 元素有同名属性(value、checked、disabled、htmlFor、tabIndex……),Nuclo 设置该属性(property)。否则调用 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 不会补单位。请写 "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* 属性。当你需要为同一事件注册多个监听器、需要监听器选项,或需要像 "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 在元素插入后运行一次。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。返回的 Promise 永远不会被当作清理函数。请在回调内部启动异步工作:{ onMount: (el) => { void load(el) } }。
  • 钩子从不在服务端运行。在客户端,它们在 hydrate() 之后运行。
注意:钩子不是 effect。状态变化时它们不会再次运行。每个钩子在每个元素上最多运行一次。

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

在事件处理器、定时器、fetch 回调或 onMount 中调用 update()。不要在动态值内部调用它。

动态值

#

无参数的函数就是动态值。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) => … 的 length 为 0,所以它们是动态值。

动态文本

  • string、number、bigint 或 boolean 渲染为 String(value)。() => ok && 'Saved' 会显示 "false",所以应写成 () => (ok ? 'Saved' : '')。
  • null 和 undefined 渲染为空文本。
  • 首次运行返回 css() 或 cx() 结果的函数会成为动态类名,而不是文本。参见 cx()。
  • 节点、构建器、数组或普通对象不渲染任何内容,并且永不更新。

动态属性

  • Nuclo 只在值与上次写入的值不同时才写入。它不与实际 DOM 比较,所以如果用户在输入框中输入了内容,而你的函数返回了与之前相同的值,用户输入的文本会保留。
  • 返回 null 或 undefined 不会写入任何内容,旧值保留。className 是例外:任何假值都会移除动态类名并保留静态类名。
  • style 函数在每次更新时应用返回的对象。

错误

如果动态值抛出错误,Nuclo 会捕获它,更新的其余部分继续运行。文本变为空或保留旧值,属性保留旧值。两者都会在下一次成功的 update() 中恢复。例外情况:在第一次运行时抛出错误的文本函数会永久保持为空。

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

对页面上所有动态值同步运行一遍。传入 id 时,只更新标记了这些 scope() id 的元素内部的内容。每一遍按顺序执行以下步骤:

  1. list():重新读取 items,并添加、移动或移除行。
  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

给它所传入的元素一个或多个作用域 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
  • 一个元素可以有多个 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() 时重新检查条件。第一个为真的条件生效。如果都不为真,则渲染 .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

为每个 item 渲染一行。每次 update() 时,Nuclo 重新调用 items(),并按同一性(===)把新 item 与现有行匹配。

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

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

render(Fruits)

fruits.push('Cherry')
update()

行是如何匹配的

  • 仍然存在的 item 保留它的行。Nuclo 会在需要时移动该行,并且不会再次调用 renderItem。
  • 新 item 得到新行。被移除的 item 失去它的行,其 onDestroy 钩子运行。
  • 重复的 item 各自拥有独立的行。
  • 可以原地修改数组(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"

副本是新的 item,所以用 todos.map((t) => ({ ...t })) 替换数组会重建所有行。

实时行按标识匹配。勾选一行,然后点 Reverse:同一个 <li> 连同复选框一起移动。打开 ↗

renderItem 的返回值

  • 返回一个元素,例如 li(...)、tr(...) 或某个 SVG 构建器。返回 null 或 undefined 可跳过某个 item。字符串、文本节点和 fragment 不渲染任何内容。
  • 不要直接返回 when() 或 list()。把它们放进行元素内部:li(todo.text, when(() => todo.done, ' ✓'))。
  • 要隐藏 item,请在 items() 中过滤,而不是在 renderItem 中。
  • 保持 renderItem 无副作用。Nuclo 可能会对同一个 item 调用它多次。

索引

index 是该行创建时 item 所在的位置。行移动时它不会更新。要获取当前位置,请使用动态值: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() 可以从任意其他地方、任意深度填充它。布局不会以 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() 行被移除时,内容离开区域。再次打开只会添加一份,不会有两份。
  • 当区域消失时(例如其 when() 关闭),它的视图会重新等待。之后以相同 id 构建的区域会显示它们。
  • id 不匹配任何区域的 into() 不渲染任何内容,也不报错,所以请检查拼写。
  • id 在树中唯一。在一个仍存活的 id 下再构建一个区域会记录一条警告。
  • 区域支持服务端渲染。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() } }),
        ),
      ),
    ),
  )

来自列表行的 toast

list() 的每一行都是一个视图。堆叠区域按顺序显示它们,从数组中移除一个 item 就会移除对应的 toast。

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() 的行同样按位置匹配,而不是按 item,所以被复用的行元素可能最终显示另一个 item。
  • 新构建中不再设置的属性不会被移除。
注意:在组件函数内部声明的状态会被重置,因为函数会再次运行。需要保留的状态请放在组件外部。

显式形式。forceUpdate(App(), parent) 重建 parent 中的一棵树并返回其根。它不会为后续调用注册该根。与 hydrate() 一样,它期望根是 parent 中的第一个节点。如果第一个节点的标签不同,它会被替换。

在服务端,forceUpdate() 什么也不做。

render()

#
fn公共 API
function render(nodeModFn: NodeModFn | (() => NodeModFn), parent?: Element, index?: number): ExpandedElement

构建你的树并把根元素追加到 parent 中。parent 默认为 document.body。返回根元素。

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

render(App, document.getElementById('app')!)
  • 可以传组件(render(App))或它返回的构建器(render(App()))。生成的 DOM 相同。只有 render(App) 允许 forceUpdate() 之后重建该根,所以优先使用它。
  • 它是追加操作。它不会清空 parent,所以调用两次会添加两份。
  • render() 返回时,树中所有的 onMount 都已运行。
  • 请渲染到位于文档中的元素。参见 update()。
  • 根必须是一个元素,例如 div(...),不能是 when() 或 list()。
  • index 是内部参数。它不决定元素插入的位置。请省略它。

以 render(App) 形式传入的组件必须没有参数。必选参数是类型错误。可选参数能通过类型检查,但 render() 会在运行时抛出错误。接受 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() 一样,请传 App 而不是 App(),这样 forceUpdate() 之后才能重建该根。App 必须没有参数。带可选参数时,hydrate() 会静默地不附加任何东西。接受 props 的组件请包一层:hydrate(() => Page(props), root)。
  • 如果没有服务端 HTML,使用 render()。

服务端与客户端不一致时

以客户端为准:

  • 根标签不同:该节点被替换为新的 DOM。容器为空:追加新的 DOM。
  • 不同的文本会被修补。不同的 list() item 和 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')!)

请保留 style 标签上的 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() 条件抛出错误,或者缺少 polyfill,它会记录错误并返回 ""。如果请求应当失败,请检查结果是否为空。抛出错误的文本或属性函数只会渲染为空。

renderManyToString()

等同于 inputs.map(renderToString)。

renderToStringWithContainer()

把输出包在一个元素中。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

一个小型 DOM,让 Nuclo 能在 Node 和 Bun 中构建树。在第一次渲染之前导入一次即可。大多数应用只需要这个普通导入。

  • 没有 window 时,它会设置 globalThis.document、Node、Element 和 HTMLElement,但只设置缺失的那些。它不会创建 window。
  • 在浏览器中,或在 jsdom 下,它什么也不做。
  • Event 和 CustomEvent 是运行时自带的构造函数。Node、Element 和 HTMLElement 是 polyfill 类的别名。

它是一个用于构建树的 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() 为基础声明生成一个类,再为每个伪类键、选择器和媒体块各生成一个类。上面的 card 会得到两个类。
  • 类名是内容的哈希。相同的样式共享同一个类,服务端和客户端生成的名字也一致。
  • 在模块级别定义样式,只定义一次。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() 的对象使用 camelCase 的常见 CSS 属性、一组简写,以及用于伪类状态、选择器和媒体查询的嵌套键。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 *)"。
  • 两个嵌套的 @media 块用 and 合并。以媒体类型(print、only screen)开头的查询会被放在前面,因为语法只允许类型出现在开头。无法扁平化的组合(两个媒体类型,或以 not 开头)会发出警告并只使用内层查询。其他任何嵌套的 at 规则都会替换外层的。

raw

任何属性都可以放在 raw 中,名字按 CSS 原样书写。数字仍会加上 px,所以请传字符串:

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

其他任何平铺的键都会被转换为 kebab-case 并原样输出,所以像 colr: 'red' 这样的拼写错误会变成一条被浏览器丢弃的声明。放在非伪类状态、非 screen、非 & 或 @ 键下的嵌套对象会被忽略。这两种情况 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" 不会被解析。
  • 不带 @ 的 screen 值会变成 @media <value>。以 @ 开头的值按原样使用,例如 "@container (min-width: 400px)"。
  • screen 只存在于用 createCss() 创建的实例上。全局 css() 没有 screen,但接受 @media 键。
  • 所有实例和全局辅助函数共享同一张样式表。screen 的媒体规则按 screen 声明的顺序输出,而不是你使用它们的顺序,所以 min-width 的 screen 要从小到大列出。每个应用只用一个 createCss() 实例,这样顺序就是你主题里的顺序。
  • 结果上的 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

构建一个函数,根据一组选项(比如按钮的 intent 和 size)返回样式。

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 键的分组接受布尔值。
  • 当 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' })
  • 选择器按原样使用。
  • 只支持平铺属性。伪类键、screen 和 @ 键会被忽略,所以 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">。客户端会复用这个元素并跳过其中已有的规则。换成别的 id 或不带 id,客户端会再添加一张包含所有规则的样式表。
  • 文本未经转义,所以绝不要把不可信的输入放进样式值。

resetStyles() 是测试辅助函数。它移除所有规则和 #nuclo-styles 元素。之前创建的样式保留类名,但失去 CSS。不要在应用或服务端调用它。

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

beforeEach(() => resetStyles())

nuclo-router

#
独立包:nuclo-router 不属于 Nuclo 核心包。请与 nuclo 一起安装。它目前是预览版,API 仍可能变动。

Nuclo 的路由方案:服务端渲染、水合、代码分割的路由和空闲预加载,构建在 list() 和 update() 之上。gzip 后约 4.9 KB,除 Nuclo 本身外没有任何依赖,也没有副作用,所以未使用时会被 tree-shake 掉。

terminal
$ npm install nuclo nuclo-router

也可以用 pnpm add、yarn add 或 bun add 安装同样的两个包。它需要 nuclo 0.3 或更高版本作为 peer dependency。

路由器决定加载什么。应用通过 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() 让这个 Route 在整个渲染期间成为路由器(也就是 outlet)读取的那一个。在浏览器中,每次页面加载只有一个 Route,start() 会把它设为路由器自己的,不需要 run()。

页面以 (ctx, { layer, data, outlet, beforeLeave }) 的形式接收它的 RouteContext 和 PageProps,可以只接收更少的参数。它返回 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{ '*': '…' }

优先级依次为:静态模式,然后按声明顺序的动态模式,最后是前缀最长者优先的 catch-all。所以 '*' 只匹配其他模式都不匹配的内容,可以放在路由表的任何位置。末尾斜杠、重复斜杠和百分号编码都会被规范化,参数值到达时已经解码。匹配是对每个模式做逐段比较,不构建也不运行任何正则。

相对路由

以 ./ 书写的模式相对于声明它的父级;以 ./ 书写的 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。嵌套深度不限。
  • 顶层键按原样使用,所以 '*' 仍然是 '*'。
  • 在 *catch-all 父级之下放子级会导致 createRouter() 报错,因为 catch-all 必须结束它的模式。

父页面与 outlet()

父级和它的子级是真正的层级关系。父级渲染,子级在父级调用 outlet() 的位置渲染在它内部:

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

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

在子级之间导航不会重建父级。它的 DOM 节点还是同一个节点,所以焦点、滚动位置、输入了一半的内容以及闭包里的任何状态都会保留。

  • 没有子路由的页面,其 outlet 不渲染任何内容,所以布局可以无条件调用它。有子级却从不调用 outlet() 的父级会把子级吞掉。
  • 没有索引路由。'/' 就是布局本身,所以父级路径渲染的是带空 outlet 的布局,而不是布局内嵌一个独立的索引页。把落地内容放进布局,或者给它一个真实的子路径。

于是同一个调用在任一父级下都能工作,在不知道自己处于哪个父级下的页面中也能工作:

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

./x、../x、. 和 .. 相对于当前路由的路径解析,而不是 URL 的"目录"。从 /invoices/42 出发,./preview 是 /invoices/42/preview,而浏览器会得出 /invoices/preview;URL 末尾的斜杠不改变任何结果。../ 向上走一段,到根为止。查询串和哈希会被带上,base 会被重新加上,解析后的绝对路径才是写入 history 的内容。这与 React Router 为 <Link to="./x"> 所做的、对 URL 解析规则的偏离是一样的。

  • 只有 ./ 和 ../ 是路由相对的。href 中的裸 preview 交给普通的浏览器规则。
  • 用 router.href('./preview') 构建相对链接,而不是手写 href="./preview"。href() 在渲染时解析,所以输出的标记是绝对路径,对爬虫、中键点击和禁用 JavaScript 的情况依然正确。

什么被保留,什么被重建

节点导航时
模式和路径都未变的父级保留:同一个节点,且它的 load() 不会重新运行
匹配到的页面当模式和路径未变且没有 load() 时保留,所以查询串或哈希的变化会保留它。带加载器的路由每次访问都会刷新。
路径变化的父级重建,连同它下面的一切
推入的图层只渲染匹配到的页面。它的父级已经挂载在下面那一行中。

被保留的页面保留它构建时的 ctx。这就是 ctx 在各处的含义:实时值请读 router.*。由查询串驱动的状态(如筛选、分页和选项卡)在动态值中读 router.search,用 router.go('?page=2', { replace: true }) 写入。页面、它的焦点和输入了一半的内容都保持原地不动。

页面与区域

#

router.outlet() 把页面放在一个位置。如果页面还需要在布局的其他地方放侧栏、工具栏或标题,用 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))))),
  )
}

页面的内容是 outlet 那个 list() 的一行,而 into() 与承载它的那一行同生共死。所以侧栏内容随页面到来,也随页面离开:导航到一个没有 into('sidebar', …) 的页面时,区域会重新显示它的 empty 内容。页面可以在两部分中同时使用它的 ctx 和 data,因为两者由同一个函数构建。

导航时会发生什么

  • 被保留的页面保留它的区域。保留页面的查询串或哈希变化同样不会触碰区域内容。如果侧栏依赖它,请在动态值中读 router.search。
  • 被重建的页面重新填充区域。路由变化时,旧行被移除,新页面的 into() 视图接管。不会有任何残留。
  • 推入的图层添加一个视图。图层是 outlet 中较后的一行,所以它的 into() 是同一区域的更新视图。默认 type: 'latest' 下,它会覆盖页面的视图直到图层关闭,之后页面的视图重新显示。type: 'stack' 下两者同时显示。
  • 父页面也可以填充区域。它的视图在子级变化时保持不变,因为父级不会被重建。
  • 服务端渲染原样可用。服务端在 route.run() 内构建页面,所以它的 into() 视图就在 HTML 中,hydrate() 会接管它们。

一个页面填充多个区域

传一个对象即可同时填充多个区域。页面的主内容仍然经由 outlet,所以把它放在 into() 旁边返回:

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

各工具的适用场景

  • outlet() 用于子路由:由 URL 决定、渲染在父页面内部的内容。见嵌套路由。
  • into() 用于让匹配到的页面把自己铺到布局的各处。内容属于页面,使用页面的数据。
  • matches() 用于布局仅凭 URL 自行决定,使用它自己的小路由表,不涉及任何页面。见 matches()。

这些都属于唯一匹配到的那条路由。路由器不会用一个 URL 驱动两个独立区域,所以带有自己路由的侧栏是页面填充的一个区域,而不是第二个 outlet。真正想要自己的路由器、加载和回退的侧栏,可以保留一个未启动的路由器并用 match() 解析——见多路由器与组合加载。

路由加载器与代码分割

#

页面模块可以导出一个 load 函数。它在页面构建之前运行,每次导航到该路由都会运行,返回的任何内容都会作为 data 交给页面:

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

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

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

路由表不变。加载器随页面一起走。它拿到匹配的 RouteContext,所以 params、search 和 path 都可用。它可以是同步或异步的,也会在服务端运行,所以服务端渲染的页面带着数据一起出现在 HTML 中,而不是在水合之后再去获取。

模块会被缓存,数据不会。路由的 chunk 只获取一次并复用,而它的加载器每次导航都会再次运行。这就是 /blog/a 和 /blog/b 不会混淆的原因,也是失败的加载器可以直接重试的原因。

情形行为
运行期间router.pending 为 true,当前所在的页面保持显示
抛出或 reject落到 router.error 上。go() 仍然永不 reject,导航可以重试。
在 start() 期间rejection 从 start() 抛出。此时还没有 Route 可以承载它。
推入的图层运行它自己的加载器,拥有自己的数据
空闲预加载器只预热模块。它没有上下文可传,否则就是在为一个你可能永远不会打开的路由获取数据。
重新导航到同一 URL加载器再次运行,该行重建。没有加载器的路由保留它的行。

要重新验证数据,导航到当前所在位置且不新增 history 条目:

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

让数据与 chunk 同时开始

由页面模块导出的 load 只能在模块到达后才运行,所以冷导航先获取 chunk,再获取数据。要让两者同时开始,用 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,并同样在每次访问时运行。chunk 仍在加载时发生的 rejection 和其他情况一样落到 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)。任何输入,或用户正在等待的导航,都会暂停预加载,直到页面再次空闲。

已预加载或 eager 的路由永远不会翻转 router.pending,并在下一次绘制之前落地:没有 spinner 帧,没有布局抖动。router.pending 只在必须等待某个模块或加载器时为 true,此时离开中的页面保持在屏幕上。加载失败的 chunk 以 router.error 的形式呈现,绝不会成为未处理的 rejection,可以直接重试。

路由器 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、meta 标签、焦点、滚动和统计分析的钩子。SSR 期间绝不会被调用。

返回路由器:start()、match()、outlet()、matches(),以及除 run() 之外的所有 Route 成员,它们透传到活动的 Route。活动 Route 在浏览器中是最后一次 start() 返回的那个,在服务端是其 run() 正在执行的那个。两者都没有时读取成员会抛出。路由表、编译后的匹配器和模块缓存都放在路由器上,所以在服务端跨请求共享。

router.start(url?)

把 url(浏览器中默认为 location.href)解析到它的页面模块,返回 Promise<Route>。没有任何匹配且路由表没有 '*' 路由时 reject。在服务端,这就是你的 404。在浏览器中,它还会把这个 Route 设为路由器的活动 Route,挂上 popstate 和委托的 click 监听器,并安排预加载。第二次 start() 会替你退役上一个 Route。在服务端它不触碰任何共享状态,所以请在 route.run() 内渲染。

router.match(url?)

用路由表解析一个 URL,不加载任何东西:没有 import,没有 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)

渲染第二张表中与活动路由路径匹配的视图,以 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 还是抛出)都落到 error 上。
push(href)把一条路由作为新图层打开在顶部,以它关闭时的值 resolve。接受 ./。无匹配或模块失败时 reject。
href(path)给路径加上 base 前缀,用于 a({ href })。相对于活动路由解析 ./ 和 ../。
stop()卸下所有监听器,关闭打开的图层,清空 outlet。幂等。已停止的 Route 仍以最后一次匹配作答,go() 不做任何事。
run(fn)以此 Route 作为路由器的活动 Route 调用 fn 并返回其结果:服务端上的 route.run(() => renderToString(App))。仅限同步。fn 返回后该 Route 不再是活动的。

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

页面在栈中的位置,以及它的退出方式。

成员
depth基础页面为 0,推入的图层从 1 起
push(href)从页面内部再打开一个图层,与 Route.push() 相同
close(result?)关闭此图层及其上的所有图层,resolve 打开它的那个 push()。不带参数表示取消。在 depth 0 时无操作。

DataLoader 与 withLoad()

DataLoader 是 (ctx: RouteContext) => TData | Promise<TData>,即页面模块可选的 load 导出。withLoad(loader, load) 创建一条路由,其 load 与 chunk 的 import 同时开始,而不是在其之后。见路由加载器。

类型

每个类型都是 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() 的第二个参数
NavigationKind'start' | 'push' | 'replace' | 'pop' | 'layer',onNavigate 的第二个参数
OutletPageProps 中 outlet() 的类型
LeaveGuard(to) => boolean | void | Promise<…>,beforeLeave() 接受的类型
MatchViewmatches() 视图的类型:输入 params,输出内容或页面的 import()

图层与离开守卫

#

push() 把一条路由打开在当前路由之上而不是替换它,并以该图层关闭时的值 resolve。因为页面是覆盖整个栈的单个 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() 并 await 它,无需引用 Route。

  • URL 就是顶层图层。push() 添加一条 history 条目,所以图层可以被链接,后退会关闭它,并把它的 push() 以取消的方式 resolve,和点击取消完全一样。后退和 close() 是同一条代码路径:close() 记录结果并把 history 往回走,history.state 中的深度是权威来源。浏览器每个标签页大约保留 50 条条目,所以在图层间走了很长一段之后,下面那条条目可能已经不在:此时 close() 就地关闭图层,并把当前条目重写为留下显示的那个页面。
  • 普通导航会清空栈。链接点击或 go() 不是图层。它替换一切并关闭所有打开的图层,所以没有调用方会一直 await 下去。
  • 冷加载是独立页面。URL 只指明顶层图层,不指明它下面的栈,所以直接打开某个图层的 URL 会以 depth 1 渲染它,此时 layer.depth === 0,close() 不起作用。被推入的路由必须能独立成立。根据 layer.depth 分支,提供一个离开的链接,而不是一个失效的取消按钮。
  • promise 无法在重新加载后存活,因为它存在于闭包中。把结果当作一条流程的捷径,而这条流程绕远路也同样能走通。
  • 不会有任何悬而未决的 await。stop() 关闭所有打开的图层,被导航抛弃的图层以 undefined resolve。

离开守卫

持有值得保留的内容(比如填了一半的表单)的页面可以注册一个守卫。在页面被丢弃之前会询问它,返回 false(或 resolve 为 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(),以及 go() 到路由器不拥有的 URL(离开应用,此时 to 为 null)。被拒绝的后退或前进会被走回去,所以地址栏停留在当前页面。关闭标签页或重新加载是 beforeunload 的工作。
  • to 是导航要前往的 RouteContext。守卫从最内层开始运行,在第一个拒绝处停止。抛出异常的守卫会保留页面。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() 之后是空的,和 outlet 一样。
  • 只在路由变化时重新渲染,所以凡是保留页面的情况,视图也保留它的 DOM。
  • 惰性视图在模块到达前不渲染任何内容。失败的 import() 用 console.error 记录,不会设置 router.error,并在下一次导航时重试。单纯的 update() 不会重试。
  • 在服务端惰性视图不输出任何内容,因为它的模块在客户端到达。首屏 HTML 必须包含的内容请使用同步视图。

子路由器

路由可以嵌套,所以子路由器通常就是一张嵌套表。只有一个路由器:分区自己的 '/' 页面是它的外壳,它的子级渲染在该页面的 outlet() 中,所以在分区内部导航时外壳保持不变:

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

或者给这个功能一个自己的路由器,只创建、永不启动。父级持有单独一条 catch-all,子级用自己的模式、params 和回退解析剩余部分:

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() 才会挂上监听器,两个已启动的路由器会冲突。代价是解析由子级负责,所以本该由已启动路由器运行的路由加载器需要你自己调用。这种形态在尾部变化时也会重建外壳,因为路由器只看到一条路由,其余部分由外壳自己解析。嵌套表才能让路由器保持父级挂载,所以除非该功能确实需要自己的路由器、base 和回退,否则优先用嵌套表。

多路由器与组合加载

#

应用有多少个区域,就可以有多少个路由器。让这一切保持简单的规则是:每个 document 只启动一个,其余路由器一律不启动,用 match() 解析 URL。只有已启动的路由器拥有 URL、history 和点击监听器。其余的只是供你查询的表——而且每个区域可以按自己的节奏加载,拥有自己的 spinner。

组合加载:一串加载器

匹配链上的每个页面都可以导出 load()。冷导航到子路由时,布局的加载器和页面的加载器并行运行,一个 router.pending 覆盖整条链:前一个页面保持在屏幕上直到两者都 resolve,所以半加载的树永远不会被绘制出来。每个组件接收它自己的 data:

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

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

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

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

两个加载器同时开始,而不是一个接一个。任一加载器仍在等待时 router.pending 为 true,其余时间为 false——已预加载或 eager 的路由永远不会翻转它。在 section 之间移动会保留布局,所以只有 section 的加载器再次运行;布局的数据、它的 DOM 和输入了一半的内容都会保留。重新导航到同一 URL 会重新运行叶子的加载器并重建它的行,和单页面时完全一样。

多重加载:带自己 spinner 的第二个路由器

一个独立区域——侧栏、托盘、实时区域——拥有自己的路由器(只创建、永不启动)和自己的加载标记。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 属于已启动的路由器。从未启动的路由器没有 Route,没有 pending,也没有 error。区域维护自己的标记,第二个 spinner 可以放在第一个旁边。在演示中,侧栏加载 800ms,而路由的加载器运行 900ms,所以页面可能先于侧栏完成,也可能反过来。
  • match() 不挂任何东西。任意数量的未启动路由器可以共存于同一 document;每个都有自己的表、base 和模块缓存,没有一个会触碰 history。它们的开销只是一张表和一个匹配器。
  • 它的加载器永远不会运行。已启动的路由器运行它匹配到的页面的 load()。从未启动的路由器只做解析,所以如果它的视图需要数据,请自己获取——就像演示中的 refreshSidebar()。如果你想要加载器,嵌套表或由页面填充的区域可以把这项工作留给已启动的路由器。
  • 防范过期的响应。缓慢的侧栏请求不能落到你已经离开的 section 上。每次刷新递增一个代数计数器,丢弃不再匹配的响应,就像路由器自己退役被超越的导航那样。
  • 惰性的 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()。对于一次 fetch,这意味着 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() 处理小而频繁的更新,比如输入或定时器。
  • 渲染到 document 中。update() 运行时不在 document 中的节点会停止更新。
  • 使用 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

  • 一个副作用导入,另有 document、Event、CustomEvent、Node、Element、HTMLElement 和各 Nuclo* 类。

nuclo-router(独立包)

仅全局

  • 175 个标签构建器:div、span、…、var_,以及 svgSvg、pathSvg、…。
  • 类型:NodeModFn、NodeModLike、ExpandedElement 等。StyleResult 等样式类型也可以从 'nuclo' 导入。

特殊键

常见问题

#

修改状态会更新 UI 吗?

不会。在你调用 update() 之前,DOM 保持原样。

Nuclo 使用 signal、proxy 或虚拟 DOM 吗?

不。状态就是普通的 JavaScript。渲染一棵树时,Nuclo 创建真实的元素和文本节点。update() 运行所有已注册的动态值(或给定作用域内的动态值),并在值发生变化的地方修补这些同样的节点。没有依赖图,也没有虚拟树。

为什么我的页面显示 "false"?

布尔值会被渲染为文本。div(ok && span('Done')) 会显示 "false"。子节点请用 when(() => ok, span('Done')),文本请用 ok ? 'Done' : ''。

为什么我编辑了列表项,对应的行却没有变化?

仍在列表中的项,其行会被保留,而不是重新渲染。对会变化的字段使用动态值,比如 () => todo.text。见 list()。

为什么我的 UI 有一部分停止更新了?

通常是因为 update() 运行时这些节点不在 document 中。Nuclo 会停止跟踪这些节点,即使你之后再把它们挂上,所以请渲染到一个位于 document 中的容器。另外两个原因:条件抛出异常的 when() 会停止更新,第一次运行就抛出的文本函数会保持为空。见 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 有 effect 吗?

没有。onMount 和 onDestroy 每个元素运行一次,分别对应插入和移除。不会因为某个状态变化而再次运行任何东西。