Nuclo 문서
Nuclo 설치, 명시적 업데이트를 통한 UI 구성, 타입이 지정된 CSS 스타일링, 서버 측 HTML 렌더링을 위한 실용적인 참조 문서입니다.
npm create nuclo@latest개요
#Nuclo는 TypeScript용 작은 DOM 라이브러리입니다. div() 및 button()와 같은 일반 함수를 사용하여 페이지를 만들고 일반 변수에 상태를 유지하며 페이지가 변경되어야 할 때 update()를 호출합니다.
Nuclo에는 가상 DOM, 컴파일러, 프록시 및 시그널이 없습니다.
import 'nuclo' let count = 0 const App = () => div( h1(() => `Count: ${count}`), button('Add one', { onClick: () => { count++; update() } }), ) render(App, document.getElementById('app')!)
동작 모델
- 태그 빌더를 사용하여 UI를 구축하세요.
- 상태에 따라 달라지는 모든 값에 대해 함수를 전달합니다.
- 일반 JavaScript로 상태를 변경합니다.
update()를 호출하세요. Nuclo는 전달된 함수를 다시 실행하고 DOM을 패치합니다.
변수를 변경해도 DOM에는 영향을 미치지 않습니다. 페이지는 update()를 호출할 때만 변경됩니다.
빠른 시작
#create-nuclo는 Nuclo 사용에 필요한 설정을 갖춘 Vite 및 TypeScript 프로젝트를 생성합니다.
프로젝트 이름을 묻습니다. 프롬프트를 건너뛰려면 이름과 플래그를 전달하세요. npm을 사용하면 플래그 앞에 --를 넣습니다.
$ npm create nuclo@latest my-app -- --template basic --yes
그런 다음 의존성을 설치하고 개발 서버를 시작합니다.
$ cd my-app $ npm install $ npm run dev
옵션
[project-name]: 대상 폴더. 기본값은nuclo-app입니다.-t, --template <name>: 복사할 템플릿입니다. 현재는basic만 존재합니다.-y, --yes: 메시지를 표시하지 않고 기본값을 사용합니다.-f, --force: 비어 있지 않은 폴더에 씁니다.-h, --help: 도움말을 출력합니다.
설치 및 가져오기
#기존 프로젝트에 Nuclo를 추가하려면:
$ 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접미사가 붙는 SVG 빌더 63개(svgSvg,pathSvg, …)입니다. 태그 빌더를 참조하세요. - 도우미 16개:
update,scope,when,list,region,into,on,render,hydrate,forceUpdate,css,cx,createCss,variants,keyframes및globalStyle.
Nuclo를 가져오는 코드가 빌더를 호출하는 코드보다 먼저 실행되어야 합니다. ES 모듈은 가져오기 순서대로 실행되므로 다음 코드는 실패합니다.
import { App } from './app' // app.ts calls div() → ReferenceError: div is not defined import 'nuclo'
진입 파일에 import 'nuclo'를 먼저 넣거나 전역을 사용하는 모든 파일에서 가져옵니다.
이름으로 가져오기
도우미는 이름으로 가져올 수도 있습니다. 전역에 등록된 함수와 동일한 함수입니다. getCssText() 및 resetStyles()는 명명된 내보내기 전용입니다. 태그 빌더는 전역 전용이므로 import { div } from 'nuclo'는 작동하지 않습니다.
import { render, update, getCssText } from 'nuclo'
진입점
nuclo: 전역을 등록하고 16개의 도우미와getCssText및resetStyles를 내보냅니다.nuclo/ssr:renderToString,renderManyToString,renderToStringWithContainer및getCssText. 서버 렌더링을 참조하세요.nuclo/polyfill: Node.js 및 Bun용 경량 DOM 구현. nuclo/polyfill를 참조하세요.nuclo/types: 타입만 제공합니다. TypeScript를 참조하세요.
TypeScript
#Nuclo는 자체 타입을 제공합니다. 두 가지가 필요합니다.
"moduleResolution": "bundler".node16및nodenext는 아직 지원되지 않습니다.DOM타입 라이브러리입니다.lib를 설정하지 않으면 TypeScript가 기본으로 포함합니다.
프로젝트의 파일 중 하나라도 'nuclo'를 가져오면 모든 파일에 전역 타입이 표시됩니다. 다른 것은 필요하지 않습니다. 예를 들어 테스트용 별도의 tsconfig에서 가져오는 파일이 없으면 직접 타입을 추가하세요.
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "types": ["nuclo/types"] } }
또는 /// <reference types="nuclo/types" />를 .d.ts 파일에 추가하세요.
유용한 타입
이러한 타입은 전역이므로 가져오기 없이 사용합니다.
NodeModFn<Tag>: 태그 빌더가 반환하는 것. 컴포넌트의 반환 타입으로 사용합니다.NodeModLike<Tag>: 태그 빌더가 인수로 허용하는 모든 것.ExpandedElement<Tag>:render()및hydrate()가 반환하는 내용입니다.ElementTagName: 모든 HTML 태그 이름.ExpandedElementAttributes<Tag>:on*핸들러,onMount및onDestroy를 포함하여 태그 빌더가 허용하는 속성 객체입니다. 속성을 전달하는 컴포넌트에 사용하세요.MountCallback및DestroyCallback:onMount및onDestroy핸들러.WhenBuilder,ListModifier:when()및list()가 반환하는 내용입니다.RegionOptions,RegionType:region()옵션에 사용하는 타입입니다.StyleResult,Style,ThemeConfig,CssInstance및 기타 스타일링 타입.import type { StyleResult } from 'nuclo'를 가져올 수도 있습니다.
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() 안에 배치할 때 이 함수를 호출합니다. 사용할 때마다 새 요소가 생성됩니다.
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)
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를 반환하면 해당 노드가 추가됩니다. 객체를 반환하면 속성으로 적용됩니다.
const field = input((el) => { el.autocomplete = 'off' })
요소 가져오기
render()는 루트 요소를 반환합니다. 트리 내부에서는 on("mount", (el) => …) 또는 사용자 정의 수정자를 사용합니다. 분리된 요소를 생성하기 위해 빌더 함수를 직접 호출할 수도 있습니다.
const el = div('Hello')() as HTMLDivElement document.body.append(el)
다음 update() 전에 연결하세요. update()가 실행될 때 문서에 없는 노드는 업데이트를 영구적으로 중지합니다.
SVG
SVG 빌더는 동일한 인수를 사용합니다. Nuclo는 모든 SVG 속성을 setAttribute로 설정하므로 "stroke-width"와 같은 이름은 작성된 대로 작동합니다.
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가 없습니다. 렌더링하거나 트리에 배치할 때까지는 아무 것도 생성되지 않습니다.
function Counter(label: string) { let count = 0 // one per call return button(() => `${label}: ${count}`, { onClick: () => { count++; update() }, }) } render(() => div(Counter('A'), Counter('B')))
함수 내부에 선언된 상태는 해당 인스턴스에 속합니다. 외부에 선언된 상태는 모든 인스턴스에서 공유됩니다. forceUpdate()는 컴포넌트를 다시 호출하여 내부에 선언된 상태를 재설정합니다.
자식 요소도 일반 인수로 전달합니다:
function Card(heading: string, ...children: NodeModLike<'div'>[]) { return div({ className: 'card' }, h2(heading), ...children) } render(() => Card('Hi', p('one'), p('two')))
속성을 설정하려면 일반 객체를 전달하세요. 여러 객체를 전달할 수 있습니다. 동일한 키의 경우 병합되는 className 및 style를 제외하고 나중 값이 우선합니다. 값은 정적이거나 모든 update()에서 다시 실행되는 매개변수가 없는 함수일 수 있습니다.
let busy = false const save = button( { type: 'submit', disabled: () => busy, 'aria-busy': () => String(busy), 'data-id': 42 }, 'Save', )
update()를 호출합니다. Save 버튼의 disabled와 aria-busy가 이에 따라 바뀝니다.열기 ↗값 설정 방법
- HTML 요소에 해당 이름(
value,checked,disabled,htmlFor,tabIndex, …)의 속성이 있는 경우 Nuclo가 속성을 설정합니다. 그렇지 않으면"aria-*"및"data-*"와 같이setAttribute(key, String(value))를 호출합니다. - SVG 요소에서 Nuclo는 항상
setAttribute를 호출합니다. null및undefined는 건너뜁니다.null또는undefined를 반환하는 동적 값은 이전 값을 유지합니다. 값을 지우려면""를 반환하고, 불리언 속성이라면false를 반환하세요.- 불리언은 버튼의
disabled,checked,hidden및required와 같은 DOM 불리언 속성에 대해 작동합니다. 다른 곳에서는true및false가 "true" 및 "false" 문자열로 작성됩니다. "data-*"키를 사용하세요.dataset는 지원되지 않습니다.innerHTML는 이스케이프되지 않으며 서버 렌더링에서는 이를 출력하지 않습니다. 사용을 피하세요.
클래스
className를 사용하세요. 정적 클래스 문자열, css() 결과 및 동적 className가 모두 병합됩니다. 동적 className는 정적 클래스를 유지하고 ""를 반환하면 정적 클래스만 남습니다.
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 키가 있는 객체 또는 이를 반환하는 함수를 사용합니다.
let progress = 30 const bar = div({ style: () => ({ width: `${progress}%`, backgroundColor: 'teal' }) })
- Nuclo는 단위를 자동으로 추가하지 않습니다.
12가 아닌"12px"를 작성하세요.opacity및zIndex와 같은 단위 없는 속성은 숫자를 허용합니다. "",null및undefined속성을 지웁니다.- 동적 스타일은 반환하는 키만 설정합니다. 반환을 중지한 키는 이전 값을 유지하므로
""를 반환하여 삭제하세요. - 스타일 문자열(
"color: red") 및 사용자 정의 속성("--gap")은 지원되지 않습니다. 대신css()를 사용하세요.raw를 통해 사용자 정의 속성을 사용합니다.
이벤트를 수신하는 방법에는 on* 속성과 on() 수정자의 두 가지 방법이 있습니다.
on* 속성
속성 객체에서 onClick(on 다음에 대문자가 옴)와 같은 키 아래의 함수는 동적 값이 아닌 이벤트 핸들러입니다.
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"를 수신합니다. - 핸들러에서 발생하는 오류는 포착되지 않습니다.
Type something, then press Escape.
on()
on()는 요소에서 addEventListener를 호출하는 수정자를 반환합니다.
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()값을 여러 요소에서 재사용할 수 있습니다.
사용자 정의 이벤트의 경우 이벤트 타입을 제네릭 인수로 전달합니다.
type PickEvent = CustomEvent<{ id: number }> const picker = ul(on<'pick', PickEvent>('pick', (e) => choose(e.detail.id)))
어느 것을 사용할 것인가
일반적인 경우에는 on* 속성을 사용하세요. 한 이벤트에 여러 리스너를 등록하거나, 리스너 옵션 또는 "my-event"와 같은 사용자 정의 이벤트 이름이 필요하면 on()를 사용하세요.
onMount는 요소가 삽입된 후 한 번 실행됩니다. onDestroy는 Nuclo가 요소를 제거할 때 한 번 실행됩니다. 속성으로 작성하거나 on()를 사용하여 작성하십시오. 두 형식은 동일하게 동작하며 요소 타입은 태그에서 추론됩니다.
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는 요소를 삽입한render(),hydrate(),update()또는forceUpdate()호출이 끝날 때 실행됩니다. 트리가 만들어지는 동안에는 절대로 실행되지 않습니다.onMount가 함수를 반환하는 경우 해당 함수는 요소의onDestroy콜백 이후 소멸 시 실행됩니다.onDestroy는 Nuclo가 요소를 제거할 때 실행됩니다. 즉,list()행이 제거되고,when()분기가 전환되고,into()가 해당 영역을 떠나거나,forceUpdate()가 요소를 삭제합니다. 제거 직전에 실행되므로 해당 요소는 여전히 문서에 있습니다.
DOM API로 직접 제거하기
node.remove() 또는 innerHTML = ''를 사용하여 요소를 직접 제거하면 해당 onDestroy 및 정리가 실행되지 않으며 타이머는 계속 실행됩니다. Nuclo에는 unmount()가 없습니다. 앱을 마운트 해제할 때 정리 함수도 실행하려면 when()로 래핑하세요.
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()이후에 실행됩니다.
Nuclo는 자체적으로 페이지를 업데이트하지 않습니다. 상태를 변경한 다음 update()를 호출하세요.
let count = 0 const Counter = () => button(() => `Clicked ${count} times`, { onClick: () => { count++; update() }, }) render(Counter)
Clicked 0 times
원하는 만큼 상태를 변경한 다음 update()를 한 번 호출합니다.
let first = 'Ada' let last = 'Lovelace' render(() => p(() => `${first} ${last}`)) first = 'Grace' last = 'Hopper' update() // one pass, both changes
이벤트 핸들러, 타이머, 페치 콜백 또는 onMount에서 update()를 호출합니다. 동적 값 내부에서 호출하지 마세요.
매개변수가 없는 함수는 동적 값입니다. Nuclo는 요소를 빌드할 때 한 번 실행하고 모든 update()에서 다시 실행합니다. 텍스트 자식 요소, 속성 값, style 및 className에 대해 동적 값을 사용합니다. 나타나거나 사라지거나 반복되는 자식 노드의 경우 when() 및 list()를 사용하세요.
let name = 'Alice' let busy = false const Profile = () => div( h1('Welcome'), // static p(() => `Hello, ${name}`), // dynamic text button({ disabled: () => busy }, 'Save'), )
update()마다 다시 계산됩니다. forceUpdate()는 트리를 다시 구성하므로 정적 텍스트도 반영됩니다.열기 ↗static: Hello, Alice
dynamic: Hello, Alice
Nuclo는 매개변수 개수(fn.length)를 확인합니다. 매개변수를 선언하는 함수는 사용자 정의 수정자이며 한 번만 실행됩니다. (x = 0) => … 및 (...args) => …의 길이는 0이므로 동적입니다.
동적 텍스트
- 문자열, 숫자, bigint 또는 불리언은
String(value)로 렌더링됩니다.() => ok && 'Saved'에는 "false"가 표시되므로() => (ok ? 'Saved' : '')를 작성합니다. null및undefined는 빈 텍스트로 렌더링됩니다.- 첫 번째 실행의
css()또는cx()결과는 함수를 텍스트가 아닌 동적 클래스로 만듭니다.cx()를 참조하세요. - 노드, 빌더, 배열 또는 일반 객체는 아무것도 렌더링하지 않으며 업데이트하지 않습니다.
동적 속성
- Nuclo는 Nuclo가 쓴 마지막 값과 다른 경우에만 값을 씁니다. 실제 DOM과 비교하지 않으므로 사용자가 입력 필드에 텍스트를 입력한 뒤 함수가 이전과 동일한 값을 반환하면 입력한 텍스트가 유지됩니다.
null또는undefined의 결과는 아무것도 쓰지 않으므로 이전 값이 유지됩니다.className는 예외입니다. 거짓으로 평가되는 값(falsy)을 반환하면 동적 클래스를 제거하고 정적 클래스는 유지합니다.style함수는 모든 업데이트에서 반환된 객체를 적용합니다.
오류
동적 값 함수에서 오류가 발생하면 Nuclo가 오류를 포착하고 나머지 업데이트를 계속합니다. 텍스트는 비어 있거나 이전 값을 유지하고 속성도 이전 값을 유지합니다. 이후 update()에서 함수가 정상 실행되면 복구됩니다. 단, 첫 번째 실행에서 오류가 발생한 텍스트 함수는 이후에도 빈 텍스트로 남습니다.
페이지의 모든 동적 값을 동기적으로 한 번씩 평가합니다. ID를 사용하면 해당 scope() ID로 표시된 요소 내부의 내용만 업데이트됩니다. 각 업데이트는 다음 단계를 순서대로 실행합니다.
list(): 항목을 다시 읽고 행을 추가, 이동 또는 제거합니다.when(): 조건을 다시 확인합니다. 다른 분기가 활성화될 때만 분기가 다시 생성됩니다.style및className를 포함한 동적 속성.- 동적 텍스트.
- 1단계와 2단계에서 삽입한 요소의
onMount를 실행합니다.
- 모든 동적 값을 실행합니다. 모든 동적 값은 데이터가 변경되지 않은 경우에도 모든 호출에서 실행됩니다. DOM 쓰기만 건너뜁니다. 동적 값 함수는 빠르게 실행되고 부수 효과가 없도록 작성하세요.
- 문서의 노드만.
update()가 실행될 때 문서에 노드가 없으면 Nuclo는 노드 추적을 중지합니다. 연결한 후에도 다시 업데이트되지 않습니다. 이는list()및when()에도 적용됩니다. 문서에 있는 컨테이너로 렌더링합니다. - 정적 값은 절대 변경되지 않습니다. 일반 문자열 및 속성 값은 요소가 빌드될 때 고정됩니다. 다시 빌드하려면
forceUpdate()를 사용하세요. - 오류. 텍스트와 속성 함수의 오류는 포착되며 나머지 업데이트는 계속됩니다.
when()조건에서 오류가 발생하면 오류가 기록되고 해당when()의 업데이트가 중지됩니다.list()의items또는renderItem함수에서 오류가 발생하면 오류가update()호출자에게 전파되고 나머지 단계는 실행되지 않습니다. - 서버.
update()는 서버에서 아무 작업도 수행하지 않습니다.
This dynamic value has run 1 times.
scope()
#전달받은 요소에 하나 이상의 스코프 ID를 지정합니다. 그런 다음 update('id')는 요소 자체 속성을 포함하여 해당 요소 내부에 있는 내용만 업데이트합니다.
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"
- 요소에는
scope('cart', 'sidebar')라는 여러 ID가 있을 수 있습니다. update('a', 'b')는a또는b내부의 모든 것을 업데이트합니다. 여러 요소가 동일한 ID를 사용하면 모두 업데이트됩니다.- 중첩된 범위를 사용하는
update('outer')에는 내부 범위가 포함됩니다.update('inner')는 외부에 닿지 않습니다. - 문서에 일치하는 요소가 없는 ID를 지정하면 아무것도 업데이트되지 않고 오류도 발생하지 않으므로 철자를 확인하세요.
scope()는 서버에서 아무 작업도 수행하지 않습니다.
조건에 따라 콘텐츠를 렌더링합니다. Nuclo는 update()를 호출할 때마다 조건을 다시 확인합니다. 가장 먼저 참인 조건의 분기를 선택하며, 참인 조건이 없으면 .else()의 콘텐츠를 렌더링합니다. .else()도 없으면 아무것도 렌더링하지 않습니다.
let status: 'loading' | 'error' | 'ready' = 'loading' const Status = () => div( when(() => status === 'loading', p('Loading…')) .when(() => status === 'error', p('Something went wrong')) .else(p('Ready')), )
Loading…
조건
- 상태의 경우 함수를 전달합니다. 일반 불리언은 한 번 읽혀집니다.
when(open, …)는 절대 변경되지 않습니다. - TypeScript에서 함수는 불리언을 반환해야 합니다.
() => items.length > 0또는() => user != null를 작성하세요.
업데이트되는 내용
- 동일한 분기가 활성 상태로 유지되는 동안 해당 요소는 유지됩니다. 내부의 동적 값, 목록 및
when()블록은 계속 업데이트됩니다. - 다른 분기가 활성화되면 이전 콘텐츠가 제거되고 해당
onDestroy훅이 실행됩니다. 새 분기는 처음부터 생성되며onMount훅이 실행됩니다. 입력 텍스트나 포커스와 같은 이전 요소의 상태는 손실됩니다.
분기 내용
분기에는 텍스트, 동적 텍스트, 빌더, list(), 중첩된 when()를 전달할 수 있습니다. DOM 노드를 직접 전달하지 말고 p('Hi')와 같은 빌더를 전달하세요. 직접 전달한 DOM 노드는 분기가 한 번 숨겨지면 업데이트가 중지됩니다.
분기의 속성 객체 및 on()는 상위 요소에 적용되며 분기가 숨겨진 후에도 유지됩니다. 조건부 속성의 경우 동적 속성({ className: () => (active ? 'active' : '') })을 사용합니다.
체인 연결
.when() 및 .else()는 새 빌더를 반환하고 원본을 변경하지 않습니다. .else()를 두 번 호출하면 마지막 호출이 적용됩니다.
list()
#항목당 하나의 행을 렌더링합니다. 모든 update()에서 Nuclo는 items()를 다시 호출하고 동일성(===)을 기준으로 항목을 기존 행과 연결합니다.
let fruits = ['Apple', 'Banana'] const Fruits = () => ul(list(() => fruits, (fruit) => li(fruit))) render(Fruits) fruits.push('Cherry') update()
행 일치 방법
- 여전히 존재하는 항목이 해당 행을 유지합니다. Nuclo는 필요한 경우 행을 이동하고
renderItem를 다시 호출하지 않습니다. - 새 항목은 새 행을 가져옵니다. 제거된 항목은 해당 행을 잃고 해당
onDestroy훅이 실행됩니다. - 중복 항목은 각각 고유한 행을 갖습니다.
- 현재 배열(
push,splice,sort)을 변경하거나 새 배열을 반환할 수 있습니다.
유지된 행은 다시 렌더링되지 않으므로 변경되는 필드에 동적 값을 사용하세요.
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 }))로 교체하면 모든 행이 다시 빌드됩니다.
renderItem가 반환하는 것
li(...),tr(...)또는 SVG 빌더와 같은 하나의 요소를 반환합니다. 항목을 건너뛰려면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()와 같은 반복자는 한 번의 순회 후에 모두 사용되므로 호출할 때마다 items() 내부에 생성하세요. Map를 반복하면 매번 새로운 [key, value] 배열이 직접 생성되므로 모든 행이 다시 생성됩니다. 대신 map.values()를 사용하세요.
배치
행은 형제 노드 사이에서 list()가 놓인 위치에 있는 두 주석 마커 사이에 삽입됩니다. 부모의 다른 자식은 건드리지 않습니다. list()는 SVG 빌더 내부에서도 작동합니다.
region()은 트리에서 이름이 있는 위치를 지정하고 into()는 트리의 어느 깊이에서든 그곳을 채웁니다. 레이아웃은 콘텐츠를 props로 받지 않으며 콘텐츠를 만드는 컴포넌트는 콘텐츠가 배치될 위치를 몰라도 됩니다.
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, 포커스, 폼 입력값이 유지됩니다. 페이지 위의 대화 상자와 같은 레이어에 사용하세요.
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()도 자신이 지정한 영역을 채웁니다.
const Page = () => into({ main: article(h1('Eager route')), sidebar: [h2('Related'), ul(li('One'), li('Two'))], })
트리 깊은 곳에서 열린 대화 상자
오버레이 영역은 루트에 위치합니다. 모든 깊이의 모든 컴포넌트는 into()를 when()로 래핑하여 대화 상자를 엽니다. when()를 닫으면 해당 영역에서 대화 상자가 제거됩니다.
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()의 각 행은 뷰입니다. 누적된 영역에는 해당 항목이 순서대로 표시되며 배열에서 항목을 제거하면 알림이 제거됩니다.
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 텍스트가 표시됩니다.
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()에서 새로 고쳐집니다.
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 영역이 없으므로 뷰가 기다립니다. 로그인하면 영역이 구축되고 뷰가 표시됩니다. 로그아웃하면 영역이 제거되고 뷰가 다시 기다립니다.
let user: { name: string } | null = null const App = () => div( when(() => user !== null, main(region({ id: 'main' })), ).else( LoginForm(), ), into('main', Dashboard()), )
자체 영역이 포함된 뷰
뷰는 region()를 포함할 수 있으며 다른 뷰는 이를 대상으로 할 수 있습니다. 여기서 편집기 페이지는 tabs 영역을 가져오고 각 탭이 이를 채웁니다. 편집기가 닫히면 해당 영역도 함께 제거됩니다.
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' }))), )
라이브 예시
import 'nuclo' let page: 'home' | 'article' | null = 'home' let dialog = false const go = (next: typeof page) => () => { page = next dialog = false update() } const Layout = () => div( nav( button('Home', { onClick: go('home') }), button('Article', { onClick: go('article') }), button('Close page', { onClick: go(null) }), ), main( region({ id: 'main', type: 'stack', empty: p('Nothing open.') }), ), aside(region({ id: 'side', empty: p('No related links.') })), ) // Pages fill the layout's regions from outside it. const Home = () => into('main', input({ placeholder: 'Type here, then open the dialog' }), button('Open dialog', { onClick: () => { dialog = true; update() }, }), ) const Article = () => into({ main: article(h2('Article'), p('One into() fills both regions.')), side: [h2('Related'), ul(li('One'), li('Two'))], }) // Stacks over Home, so Home keeps its DOM and the text you typed. const Dialog = () => into('main', div( p('On top of Home.'), button('Close', { onClick: () => { dialog = false; update() } }), ), ) export const RegionApp = () => div( Layout(), when(() => page === 'home', Home()), when(() => page === 'article', Article()), when(() => dialog, Dialog()), )
into() 한 번으로 두 영역을 채웁니다.열기 ↗No related links.
update()는 동적 값만 실행합니다. h1(labels[lang].title)의 텍스트와 같은 정적 값은 트리가 작성될 때 고정됩니다. forceUpdate()는 컴포넌트를 다시 호출하고 정적 값까지 새 결과와 일치하도록 실제 DOM을 업데이트합니다. 언어 전환과 같이 앱 전반에 걸쳐 드물게 변경하는 경우에 사용하세요. 다른 모든 것에는 update()를 사용하세요.
const labels = { en: { title: 'Hello' }, pt: { title: 'Olá' } } let lang: keyof typeof labels = 'en' const App = () => div(h1(labels[lang].title)) render(App, root) lang = 'pt' forceUpdate() // same <h1> element, now "Olá"
update()는 동적인 lang 행만 업데이트합니다. forceUpdate()는 App을 다시 호출해 같은 <h1>을 패치합니다. 명시적인 형식은 페이지 전체가 아닌 이 트리만 다시 구성합니다.열기 ↗lang = 'en'
Hello
Static text, fixed on build.
인수 없이 호출한 forceUpdate()는 render(App) 또는 hydrate(App)로 등록한 모든 루트를 다시 생성합니다. render(App())로 렌더링한 루트는 건너뜁니다. 컴포넌트에서 오류가 발생하면 오류를 기록하고 해당 루트의 기존 DOM을 유지합니다. 새 결과를 적용하는 도중에 발생한 오류도 기록하지만 이미 반영한 변경 사항은 유지됩니다.
남는 것과 변화하는 것
- 자식은 태그별로 순서대로 일치됩니다. 일치하는 요소가 재사용되므로 포커스와 입력값이 유지되고 해당
onMount훅이 다시 실행되지 않습니다. 자식 요소 하나의 태그가 다르면 해당 요소와 그 뒤의 모든 형제 요소를 새로 만듭니다. - 텍스트, 속성, 클래스, 스타일, 리스너,
when()분기 및list()행은 새 빌드를 따릅니다. - 새 요소가 생성되고 해당
onMount훅이 실행됩니다. 새 빌드에서 더 이상 만들지 않는 요소는 제거되고 해당onDestroy훅이 실행됩니다. list()행도 항목이 아닌 위치를 기준으로 연결하므로 재사용된 행 요소가 결국 다른 항목을 표시할 수 있습니다.- 새 빌드에서 더 이상 설정되지 않는 속성은 제거되지 않습니다.
명시적 호출 형식. forceUpdate(App(), parent)는 parent에서 하나의 트리를 다시 생성하고 해당 루트를 반환합니다. 이후 호출을 위해 루트를 등록하지 않습니다. hydrate()와 마찬가지로 루트가 parent의 첫 번째 노드가 될 것으로 예상합니다. 첫 번째 노드에 다른 태그가 있으면 교체됩니다.
서버에서 forceUpdate()는 아무 작업도 수행하지 않습니다.
render()
#트리를 생성하고 루트 요소를 parent에 추가합니다. parent의 기본값은 document.body입니다. 루트 요소를 반환합니다.
import 'nuclo' import { App } from './app.ts' render(App, document.getElementById('app')!)
- 컴포넌트(
render(App)) 또는 컴포넌트가 반환하는 빌더(render(App()))를 전달할 수 있으며 생성되는 DOM은 같습니다. 나중에forceUpdate()로 루트를 다시 생성할 수 있도록render(App)를 권장합니다. - 기존 내용 뒤에 추가합니다.
parent를 비우지 않으므로 두 번 호출하면 두 개의 복사본이 추가됩니다. render()가 반환되면 트리의 모든onMount가 실행되었습니다.- 문서에 있는 요소로 렌더링합니다.
update()를 참조하세요. - 루트는
when()또는list()가 아닌div(...)와 같은 요소여야 합니다. index는 내부용 매개변수이며 요소 삽입 위치를 지정하는 값이 아닙니다. 기본값을 그대로 사용하세요.
render(App)로 전달된 컴포넌트는 매개 변수를 사용하지 않아야 합니다. 필수 매개변수는 타입 오류입니다. 선택적 매개변수는 타입 검사를 통과하지만 런타임에 render()에서 오류가 발생합니다. props를 사용하는 컴포넌트를 래핑합니다.
const Page = (props: { name: string }) => div(h1('Hi ', props.name)) render(() => Page({ name: 'Ana' }), root)
hydrate()
#renderToString()로 생성한 HTML에 클라이언트 동작을 연결합니다. 기존 요소를 재사용하고, 이벤트 리스너를 연결하고, 동적 값, when() 및 list()를 연결하므로 나중에 update()가 작동합니다. 그런 다음 onMount 훅을 실행합니다. parent의 기본값은 document.body입니다. 루트 요소를 반환합니다.
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()항목과when()분기가 클라이언트를 따릅니다. - 서버 HTML에만 있는 자식 요소는 제거되고 누락된 자식 요소는 생성됩니다.
- 서버에서 작성했지만 클라이언트에서 설정하지 않은 속성은 그대로 유지됩니다.
renderToString()는 HTML 주석을 각 텍스트 노드 앞에 <!-- text-0 -->와 같은 마커로 씁니다. hydrate()에는 주석이 필요하므로 서버 HTML에서 주석을 제거하지 마십시오. 마커가 없으면 하이드레이션은 list() 행을 삭제하고 list() 및 when() 업데이트를 중지합니다.
서버 렌더링
#서버에서 컴포넌트를 HTML 문자열로 렌더링합니다. 브라우저에서 동일한 컴포넌트로 hydrate()를 호출하여 HTML을 대화형으로 만듭니다. 서버 런타임에는 document가 없으므로 먼저 nuclo/polyfill를 가져옵니다.
let count = 0 const heading = css({ color: 'red' }) export const App = () => main( h1(heading, () => `Count: ${count}`), button({ onClick: () => { count++; update() } }, '+1'), )
import 'nuclo/polyfill' import 'nuclo' import { renderToString, getCssText } from 'nuclo/ssr' import { App } from './app.ts' const html = renderToString(App) const page = `<!doctype html> <html> <head><style id="nuclo-styles">${getCssText()}</style></head> <body> <div id="root">${html}</div> <script type="module" src="/client.js"></script> </body> </html>`
import 'nuclo' import { App } from './app.ts' hydrate(App, document.getElementById('root')!)
스타일 태그에 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)를 전달합니다.
renderToString(p('Hello')) // '<p><!-- text-0 -->Hello</p>'
- 텍스트 및 속성 값은 HTML로 이스케이프됩니다.
script()및style()내부의 텍스트는 닫는 태그만 분리된 상태로 있는 그대로 작성되므로 사용자 입력을 거기에 넣지 마십시오. - 호출자에게 예외를 던지지 않습니다. 컴포넌트,
list()함수 또는when()조건에서 오류가 발생하거나 폴리필이 누락된 경우 오류를 기록하고""를 반환합니다. 렌더링 실패 시 요청도 실패로 처리해야 한다면 결과가 비어 있는지 확인하세요. 텍스트나 속성 함수에서 오류가 발생하면 해당 부분만 비어 있게 렌더링됩니다.
renderManyToString()
inputs.map(renderToString)와 동일합니다.
renderToStringWithContainer()
출력을 하나의 요소로 래핑합니다. containerTag의 기본값은 "div"이고, containerAttrs는 {}입니다. 속성 값이 이스케이프됩니다. 속성 이름은 주어진 대로 작성하므로 class와 같은 HTML 이름을 사용합니다. 사용자 입력을 태그로 전달하지 마십시오.
renderToStringWithContainer(p('Hi'), 'section', { id: 'main', class: 'page' }) // '<section id="main" class="page"><p><!-- text-0 -->Hi</p></section>'
nuclo/polyfill
#Nuclo가 Node.js 및 Bun에서 트리를 만들 수 있게 해 주는 경량 DOM 구현입니다. 첫 렌더링 전에 한 번 가져오세요. 대부분의 앱에서는 부수 효과 import만 사용하면 됩니다.
window가 없을 경우globalThis.document,Node,Element,HTMLElement를 설정하되, 누락된 것만 설정합니다.window를 생성하지 않습니다.- 브라우저나 jsdom에서는 아무 작업도 수행하지 않습니다.
Event및CustomEvent는 런타임 자체 생성자입니다.Node,Element및HTMLElement는 폴리필 클래스의 별칭입니다.
브라우저가 아닌 트리 구축용 DOM입니다. innerHTML 및 getElementById가 없으며 querySelector()는 null를 반환하고 이벤트 리스너는 무시됩니다. renderToString()를 사용하여 HTML을 얻으세요.
타입이 지정된 스타일 객체를 생성된 CSS 클래스로 변환합니다. 결과를 태그 빌더에 전달합니다. 브라우저에서 규칙은 하나의 <style id="nuclo-styles"> 요소로 들어갑니다. 서버에서는 getCssText()가 이를 반환합니다.
const card = css({ p: 16, rounded: 8, bg: '#fff7ed', hover: { bg: '#ffedd5' }, }) const Card = () => div(card, 'Simple card')
css()는 기본 선언에 대해 하나의 클래스를 만들고 각 가상 선택자 키, 선택자 및 미디어 블록에 대해 하나의 클래스를 만듭니다. 위의 카드는 두 가지 클래스를 얻습니다.- 클래스 이름은 콘텐츠의 해시입니다. 동일한 스타일은 클래스를 공유하며 서버와 클라이언트는 동일한 이름을 생성합니다.
- 모듈 최상위에서 스타일을 한 번 정의합니다.
css()는 객체별로 캐시하므로 호출할 때마다 새 객체가 매번 다시 처리됩니다. - 스타일 객체를
css()에 전달한 후 변경하면 아무 작업도 수행되지 않습니다. - 모든 스타일은 페이지 또는 서버 프로세스의 수명 동안 등록된 상태로 유지됩니다. 데이터의 너비와 같이 항목별로 변경되는 값의 경우
style속성을 사용하세요.
명명된 스타일
읽을 수 있는 클래스 이름을 얻으려면 먼저 이름을 전달하세요.
const pageTitle = css('page-title', { text: 24, hover: { color: 'red' } }) pageTitle.className // "page-title page-title-<hash>"
- 기본 클래스는 바로 이름입니다. 다른 블록은
name-<hash>를 얻습니다. - 이름은 유효한 클래스 이름이어야 합니다. 그렇지 않으면 Nuclo가 경고하고 생성합니다.
- 이름은 전역적입니다. 두 가지 다른 스타일이 동일한 이름을 사용하는 경우 Nuclo가 경고합니다.
결과 사용
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"를 작성하세요.
css({ p: 16, m: 0, opacity: 0.5, leading: 1.5, z: 10, w: '50%' }) // padding:16px; margin:0; opacity:0.5; line-height:1.5; z-index:10; width:50%
축약형
| 키 | CSS |
|---|---|
p, pt, pr, pb, pl | 패딩 및 각 사이드 |
px / py | 좌우 패딩 / 상하 패딩 |
m, mt, mr, mb, ml, mx, my | 여백도 동일 |
w, h, minW, maxW, minH, maxH | 폭, 높이 및 최소 및 최대 |
size | 폭 및 높이 |
bg | 배경 |
text / font / weight | font-size / font-family / font-weight |
leading / tracking | line-height / letter-spacing |
align / items / justify | text-align / align-items / justify-content |
z / rounded / shadow / select | z-index / border-radius / box-shadow / user-select |
이 키에는 true가 사용됩니다.
| 키 | CSS |
|---|---|
row | display: flex; flex-direction: row |
col | display: flex; flex-direction: column |
center | align-items: center; justify-content: center (display는 설정하지 않음) |
truncate | overflow: 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-rule(@media, @container 또는 @supports)입니다.
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
임의의 CSS 속성은 raw에 CSS 이름 그대로 작성할 수 있습니다. 숫자에는 여기서도 px가 붙으므로 단위를 직접 지정하려면 문자열을 전달하세요.
css({ raw: { '--gap': '8px', '-webkit-line-clamp': '2' } })
다른 일반 키는 케밥 케이스로 변환되어 그대로 작성되므로 colr: 'red'와 같은 오타는 브라우저가 삭제하는 선언이 됩니다. 가상 상태, screen 설정, & 또는 @ 키에 해당하지 않는 키 아래의 중첩 객체는 무시됩니다. TypeScript는 둘 다 보고합니다.
스타일을 결합합니다. 두 입력이 동일한 속성을 설정하면 나중에 입력이 우선합니다.
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')
- falsy 값을 건너뛰고, 배열을 평면화하고, 문자열을 분할하고 중복을 제거합니다.
- 동일한 블록(기본, 동일한 가상 선택자, 동일한 미디어 쿼리)에 대해 생성된 클래스가 하나의 새로운 클래스로 병합됩니다. 다른 클래스 이름은 변경되지 않고 전달됩니다.
cx()만이 인수 순서를 알고 있습니다.div(base, blue)를 사용하면 두 클래스가 모두 추가되고 마지막에 생성된 규칙이 적용됩니다. 하나의cx()호출에 서로 재정의되는 스타일을 넣습니다.- 명명된 스타일의 블록이 다른 입력의 블록과 병합될 때 병합된 클래스는 기본 이름이 아닌
name-<hash>입니다. 병합되지 않는 블록은 클래스를 유지하므로cx(pageTitle, 'extra')에는 여전히page-title가 있습니다.
동적 클래스
항상 cx(...)를 반환하는 함수를 전달합니다.
const baseButton = css({ px: 12, py: 8, border: '1px solid #ccc' }) const activeButton = css({ bg: '#ff3f00', color: 'white' }) let active = false const Toggle = () => button( () => cx(baseButton, active && activeButton), () => (active ? 'Active' : 'Inactive'), { onClick: () => { active = !active; update() } }, )
함수는 처음 실행 시 스타일 결과를 반환해야 합니다. () => active && activeButton는 클래스 대신 "false" 텍스트를 렌더링합니다.
테마에 바인딩된 자체 css, cx, variants, keyframes 및 globalStyle를 반환합니다. 테마는 값에 이름을 부여합니다. 아래 테마를 사용하면 borderColor: 'border'가 #e5e7eb를 쓰고 TypeScript가 이름을 자동 완성합니다. screens의 각 항목은 md: {…}와 같은 미디어 쿼리의 스타일 키가 됩니다.
export const { css, cx, variants } = createCss({ colors: { primary: '#ff3f00', border: '#e5e7eb' }, radii: { card: '12px' }, screens: { md: '(min-width: 768px)' }, }) const panel = css({ p: 12, rounded: 'card', border: '1px solid', borderColor: 'border', md: { p: 24 }, })
| 테마 키 | 사용 대상 |
|---|---|
colors | bg, backgroundColor, color, borderColor 및 각 측면, outlineColor, caretColor, textDecorationColor |
fonts | font, fontFamily |
shadows | shadow, boxShadow |
radii | rounded, borderRadius |
screens | md: {…}와 같은 새로운 스타일 키 |
- 테마 이름은 값 전체가 토큰 이름일 때만 치환됩니다.
border: "1px solid primary"는 치환되지 않습니다. @가 없는 screen 설정값은@media <value>가 됩니다.@로 시작하는 값은 작성된 대로 사용됩니다(예:"@container (min-width: 400px)").- screen 설정은
createCss()로 만든 인스턴스에만 존재합니다. 전역css()에는 screen 설정이 없지만@media키를 허용합니다. - 모든 인스턴스와 전역 도우미는 하나의 스타일시트를 공유합니다. 스크린 미디어 규칙은 스크린을 사용하는 순서가 아닌 선언된 순서대로 출력되므로
min-width스크린을 작은 것부터 큰 것 순으로 나열합니다. 앱당 하나의createCss()인스턴스를 사용하여 해당 순서가 테마의 순서가 되도록 합니다. - 반환값의
theme는 전달한 테마 객체입니다.
버튼의 의도 및 크기와 같은 옵션 세트에 대한 스타일을 반환하는 함수를 빌드합니다.
const buttonClass = variants({ base: { px: 12, py: 8, rounded: 6 }, variants: { intent: { primary: { bg: '#2563eb' }, danger: { bg: '#dc2626' } }, size: { sm: { text: 12 }, lg: { text: 18 } }, block: { true: { w: '100%' } }, }, defaultVariants: { intent: 'primary', size: 'sm' }, compoundVariants: [{ intent: 'danger', size: 'lg', css: { weight: 700 } }], }) button(buttonClass(), 'Save') // primary, sm button(buttonClass({ intent: 'danger', size: 'lg', block: true }), 'Delete') // bold, full width
- 스타일은
base, 각 그룹에서 선택한 값, 일치하는 각 복합 조건 순서로 병합됩니다. 속성마다 나중에 전달한 스타일이 우선합니다. - 생략하거나
undefined또는null로 전달한 옵션은 기본값을 사용합니다. 기본값이 없는 그룹은 건너뜁니다. true및false키만 있는 그룹은 불리언을 사용합니다.- 나열된 모든 그룹이 일치할 때 복합 조건의 스타일이 적용됩니다(기본값 포함).
- 각 선택 항목은 한 번만 작성되고 캐시됩니다.
keyframes()는 애니메이션을 등록하고 생성된 이름을 반환합니다. 프레임 키는 from, to 및 백분율(예: "50%" 또는 "0%, 100%")입니다. 프레임은 평면 속성만 사용합니다. 동일한 프레임은 동일한 이름을 반환합니다.
const fadeIn = keyframes({ from: { opacity: 0 }, to: { opacity: 1 }, }) const panel = css({ animation: `${fadeIn} 200ms ease-out` })
globalStyle()는 모든 선택자에 대한 규칙을 추가합니다.
globalStyle('*, *::before, *::after', { boxSizing: 'border-box' }) globalStyle('body', { m: 0, font: 'system-ui, sans-serif' })
- 선택자는 작성된 대로 정확하게 사용됩니다.
- 평면 속성만 작동합니다. 가상 선택자 키, screen 설정 및
@키는 무시되므로globalStyle()는 미디어 규칙을 작성할 수 없습니다. - 동일한 규칙은 한 번만 추가됩니다. 같은 선택자에 다른 스타일을 지정하면 규칙이 하나 더 추가되고 나중 규칙이 우선합니다.
getCssText() 및 resetStyles()
#getCssText()는 이 프로세스에서 css(), cx(), variants(), keyframes() 및 globalStyle()가 생성한 모든 규칙을 반환합니다. 기본 규칙을 먼저 반환한 다음 @media, @container 및 @supports 블록. 서버 렌더링에 사용합니다.
renderToString()이후에 호출하세요. 렌더링 중에 생성된 규칙이 포함됩니다.- 하나의 페이지나 요청이 아닌 전체 프로세스의 규칙을 포함하며, 규칙은 계속 누적됩니다. 클래스 이름은 콘텐츠 해시이므로 안전합니다.
<style id="nuclo-styles">에 넣으세요. 클라이언트는 해당 요소를 재사용하고 이미 포함된 규칙을 건너뜁니다. 다른 ID를 사용하거나 ID를 생략하면 클라이언트는 모든 규칙을 담은 스타일시트를 하나 더 추가합니다.- 텍스트는 이스케이프되지 않으므로 신뢰할 수 없는 입력을 스타일 값에 넣지 마십시오.
resetStyles()는 테스트 도우미입니다. 모든 규칙과 #nuclo-styles 요소를 제거합니다. 이전에 생성한 스타일은 클래스 이름을 유지하지만 CSS를 잃습니다. 앱이나 서버에서 호출하지 마세요.
import { resetStyles } from 'nuclo' beforeEach(() => resetStyles())
nuclo-router는 Nuclo 코어 패키지의 일부가 아닙니다. nuclo 옆에 설치하세요. 미리보기 릴리스이므로 API가 여전히 변경될 수 있습니다.Nuclo용 라우팅 라이브러리로, list()와 update()를 기반으로 서버 렌더링, 하이드레이션, 코드 분할 라우트, 유휴 시간 사전 로드를 제공합니다. gzip 압축 시 약 4.9 KB이며 Nuclo 외에는 의존성이 없습니다. 부수 효과가 없어 사용하지 않으면 트리 셰이킹으로 제거됩니다.
$ npm install nuclo nuclo-router
또는 동일한 두 이름으로 pnpm add, yarn add 또는 bun add를 사용합니다. 피어 의존성으로 nuclo 0.3 이상이 필요합니다.
라우터는 무엇을 로드할지 결정하고, 앱은 router.outlet()으로 어디에 표시할지 지정합니다. 레이어 스택을 하나의 list()로 렌더링하며 레이어마다 행이 하나씩 있으므로 일반 페이지는 행 하나입니다. 서버와 클라이언트가 동일한 트리를 만들기 때문에 hydrate()는 서버 노드를 교체하지 않고 재사용합니다. 나머지는 history, 위임된 click 리스너 하나, requestIdleCallback으로 구성됩니다.
한 번 정의하고 어디서든 가져오기
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()는 일치하는 라우트의 모듈을 모두 확인한 뒤 완료되므로 서버와 클라이언트 모두 첫 번째 트리를 완성된 상태로 만듭니다. 이후에는 라우터가 시작한 Route로 접근을 전달하므로 router.path, router.pending, router.go()로 해당 Route를 사용합니다.
전체 앱
// isomorphic: imported by the browser and the server import { createRouter } from 'nuclo-router' export const router = createRouter({ '/': () => import('./pages/Home.ts'), '/docs': () => import('./pages/Docs.ts'), '/blog/:slug': () => import('./pages/Post.ts'), '*': () => import('./pages/NotFound.ts'), }) export const App = () => div( header(a({ href: router.href('/') }, 'Home'), a({ href: router.href('/docs') }, 'Docs')), main( when(() => router.pending, Spinner()), when(() => router.error !== null, ErrorView()), router.outlet(), // where the pages land ), footer('© nuclo'), )
import type { RouteContext } from 'nuclo-router' import { router } from '../routes.ts' // A page is plain content export default function Post(ctx: RouteContext) { // A query-only change keeps the page, so the query is read live. return article(h1(ctx.params.slug), p(() => (router.search.has('preview') ? 'draft' : ''))) }
import 'nuclo' import { router, App } from './routes.ts' await router.start() hydrate(App, document.getElementById('app')!) // or render() without SSR
import 'nuclo/polyfill' import 'nuclo' import { renderToString } from 'nuclo/ssr' import { router, App } from './routes.ts' // one Route per request const route = await router.start(request.url) const html = route.run(() => renderToString(App))
이것이 전체 통합입니다. <a href>는 단지 <a href>이기 때문에 링크가 작동합니다. document의 위임된 리스너 중 하나는 라우터가 소유한 링크를 가로채고 다른 모든 링크를 브라우저에 남겨둡니다.
서버에서 start()는 요청마다 Route를 반환하고 라우터 자체는 변경하지 않으므로 동시 요청이 서로의 라우트를 참조하지 않습니다. route.run()은 렌더링하는 동안 해당 Route를 라우터의 활성 라우트로 설정하며, 아웃렛도 이 Route를 읽습니다. 브라우저에서는 페이지 로드당 Route가 하나이고 start()가 이를 라우터의 활성 Route로 설정하므로 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 처리) | { '*': '…' } |
정적 패턴이 우선하고, 다음은 선언 순서에 따른 동적 패턴, 마지막은 접두사가 긴 순서의 catch-all 패턴입니다. 따라서 '*'는 다른 패턴과 일치하지 않는 경로만 처리하며 테이블 어디에 선언해도 됩니다. 후행 슬래시, 중복 슬래시, 퍼센트 인코딩은 정규화되고 매개변수 값은 디코딩되어 전달됩니다. 패턴별로 세그먼트를 비교하며 정규식을 생성하거나 실행하지 않습니다.
상대 경로
./로 시작하는 패턴은 선언된 상위 패턴을 기준으로, ./로 시작하는 href는 활성 라우트를 기준으로 해석됩니다. 이를 함께 사용하면 어떤 상위 경로에 배치해도 동일하게 동작하는 섹션을 만들 수 있습니다. 테이블의 값은 로더 또는 상대 경로를 키로 사용하는 중첩 테이블입니다.
import type { RouteTable } from 'nuclo-router' // a section, with no idea where it lives export const recordSection: RouteTable = { '/': () => import('./Record.ts'), // the parent path itself './preview': () => import('./Preview.ts'), './preview/raw': () => import('./Raw.ts'), }
// declare it wherever it belongs createRouter({ '/': () => import('./pages/Home.ts'), '/invoices/:id': recordSection, '/customers/:id': recordSection, '*': () => import('./pages/NotFound.ts'), })
이 두 줄은 경로를 두 번 쓰지 않고 6개 패턴을 모두 생성합니다. 두 상위 경로 모두 동일한 로더 객체를 참조하므로 하나의 모듈 캐시와 하나의 import()를 공유합니다.
- 하위 테이블 내부에서
'./x','/x'및'x'는 모두 같은 의미이고,'/'는 상위 경로 자체를 의미하며,'*rest'는 섹션에 자체 catch-all을 제공합니다. 중첩은 원하는 만큼 깊어집니다. - 최상위 키는 작성된 대로 사용되므로
'*'는'*'를 유지합니다. *catch-all부모 아래의 자식은createRouter()오류입니다. catch-all은 패턴의 마지막에 와야 하기 때문입니다.
상위 페이지 및 outlet()
부모와 자식 페이지는 실제 계층 구조를 이룹니다. 부모 페이지가 렌더링되고, 자식 페이지는 부모가 outlet()을 호출한 위치에 렌더링됩니다.
import type { PageProps, RouteContext } from 'nuclo-router' // a parent page takes its outlet from (ctx, { outlet }) export default function Record(ctx: RouteContext, { outlet }: PageProps) { return div(Header(ctx), input({ id: 'notes' }), main(outlet())) }
하위 페이지 사이를 이동해도 상위 페이지는 다시 생성되지 않습니다. 같은 DOM 노드를 유지하므로 포커스, 스크롤 위치, 입력 중인 텍스트, 클로저의 모든 상태가 그대로 유지됩니다.
- 하위 라우트가 없는 페이지의 아웃렛은 비어 있으므로 레이아웃에서 조건 없이 호출할 수 있습니다. 하위 라우트가 있는데 상위 페이지가
outlet()을 호출하지 않으면 하위 페이지는 표시되지 않습니다. - 인덱스 경로가 없습니다.
'/'는 레이아웃 자체이므로 상위 경로는 내부에 별도의 인덱스 페이지가 아닌 빈 아웃렛이 있는 레이아웃을 렌더링합니다. 랜딩 콘텐츠를 레이아웃에 넣거나 실제 하위 경로를 제공하세요.
따라서 같은 호출이 어느 부모 아래에서든 동작하며, 페이지가 자신의 부모를 몰라도 사용할 수 있습니다.
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가 다시 붙으며, 해석된 절대 경로가 히스토리에 기록됩니다. 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()은 페이지를 한 위치에 표시합니다. 페이지가 레이아웃의 다른 위치에도 사이드바, 도구 모음 또는 제목을 표시해야 한다면 into()로 해당 영역을 채웁니다. 레이아웃은 region()로 장소를 표시하고 페이지는 자체 콘텐츠 옆에 into()를 반환합니다. 레이아웃은 페이지 콘텐츠를 props로 받지 않으며 탐색 시에도 다시 생성되지 않습니다.
export const App = () => div( aside( input({ placeholder: 'Filter' }), // keeps its text across navigations region({ id: 'sidebar', empty: p('Nothing to show.') }), ), main(router.outlet()), )
import type { PageProps, RouteContext } from 'nuclo-router' export default function Post(_ctx: RouteContext, { data: post }: PageProps<Post>) { return div( article(h1(post.title), p(post.body)), into('sidebar', nav(h2('In this post'), ul(list(() => post.headings, (h) => li(h))))), ) }
페이지의 콘텐츠는 아웃렛의 list() 행이며 into()는 이를 보유하는 행만큼 오래 유지됩니다. 따라서 사이드바 콘텐츠는 페이지와 함께 도착하고 페이지와 함께 떠납니다. into('sidebar', …) 없이 페이지로 이동하면 해당 영역의 empty 콘텐츠가 다시 표시됩니다. 두 부분을 같은 함수에서 생성하므로 페이지에서는 두 부분 모두에 대해 ctx 및 data를 사용할 수 있습니다.
내비게이션에서 발생하는 현상
- 페이지가 유지되면 영역도 유지됩니다. 페이지를 유지하는 쿼리·해시 변경은 영역 콘텐츠도 변경하지 않습니다. 사이드바가 쿼리에 의존한다면 동적 값 안에서
router.search를 읽으세요. - 재구성된 페이지는 이를 다시 채웁니다. 경로가 변경되면 이전 행이 제거되고 새 페이지의
into()보기가 이어받습니다. 아무것도 남지 않았습니다. - 푸시 레이어는 뷰를 추가합니다. 레이어는 아웃렛의 나중 행이므로
into()는 동일한 영역의 새로운 뷰입니다. 기본type: 'latest'를 사용하면 레이어가 닫힐 때까지 페이지 보기를 덮고 페이지 보기가 다시 표시됩니다.type: 'stack'를 사용하면 둘 다 표시됩니다. - 상위 페이지도 영역을 채울 수 있습니다. 상위 페이지가 다시 생성되지 않기 때문에 해당 하위 페이지가 변경되는 동안 해당 뷰는 그대로 유지됩니다.
- 서버 렌더링에서도 그대로 사용할 수 있습니다. 서버는
route.run()안에서 페이지를 생성하므로into()의 뷰도 HTML에 포함됩니다.hydrate()는 이 노드를 재사용합니다.
한 페이지의 여러 영역
한 번에 두 개 이상의 영역을 채우려면 객체를 전달하세요. 페이지의 주요 콘텐츠는 여전히 아웃렛을 통과하므로 into() 옆에 반환합니다.
export default function Report(ctx: RouteContext) { return div( table(…), into({ toolbar: [button('Export'), button('Print')], sidebar: nav(a({ href: router.href('./summary') }, 'Summary')), }), ) }
어떤 작업에 어떤 도구가 필요합니까?
outlet()는 하위 경로용입니다. URL이 결정하는 콘텐츠는 상위 페이지 내부에 렌더링됩니다. 중첩 경로를 참조하세요.into()는 레이아웃 전체에 퍼지는 일치하는 페이지용입니다. 콘텐츠는 페이지에 속하며 해당 데이터를 사용합니다.matches()는 페이지가 포함되지 않고 자체 작은 테이블을 사용하여 URL만으로 레이아웃을 결정하는 데 사용됩니다.matches()를 참조하세요.
이들 모두는 일치하는 하나의 경로에 속합니다. 라우터는 하나의 URL에서 두 개의 독립적인 영역을 구동하지 않으므로 자체 경로가 있는 사이드바는 두 번째 아웃렛이 아니라 페이지가 채우는 영역입니다. 자체 라우터, 로딩 및 폴백을 원하는 사이드바는 하나를 시작하지 않은 상태로 유지하고 match()로 해결합니다. 다중 라우터 & 결합 로딩를 참조하세요.
페이지 모듈은 load 함수를 내보낼 수 있습니다. 해당 라우트로 이동할 때마다 페이지를 만들기 전에 실행되며 반환값은 data로 페이지에 전달됩니다.
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이고 현재 있던 페이지는 그대로 유지됩니다. |
| 예외가 발생하거나 Promise가 거부될 때 | router.error에 오류가 설정됩니다. go()는 거부되지 않으며 탐색을 다시 시도할 수 있습니다. |
start() 실행 중 | start()가 반환한 Promise가 거부됩니다. 아직 오류를 저장할 Route가 없습니다. |
| 추가한 레이어 | 자체 데이터로 자체 로더를 실행합니다 |
| 유휴 시간 사전 로드 | 모듈만 미리 로드합니다. 전달할 컨텍스트가 없으며 사용자가 열지 않을 수도 있는 라우트의 데이터를 미리 가져오지 않습니다. |
| 동일한 URL로 다시 탐색 | 로더가 다시 실행되고 행이 다시 생성됩니다. 로더가 없는 경로는 해당 행을 유지합니다. |
재검증하려면 기록 항목을 추가하지 않고 이미 있는 위치로 이동하세요.
await router.go(router.url, { replace: true }) // runs load() again
청크와 데이터를 동시에 로드하기
페이지 모듈에서 내보낸 load는 해당 모듈이 도착한 후에만 실행될 수 있으므로 콜드 탐색은 청크를 가져온 다음 데이터를 가져옵니다. 두 가지를 동시에 시작하려면 withLoad()를 사용하여 테이블에서 로더를 선언하세요.
import { createRouter, withLoad } from 'nuclo-router' import { loadPost } from './data.ts' // small, in the main bundle createRouter({ '/blog/:slug': withLoad(() => import('./pages/Post.ts'), loadPost), })
모듈의 자체 load를 대체하고 방문할 때마다 실행됩니다. 청크가 여전히 로드되는 동안 거부는 다른 것과 마찬가지로 router.error에 도달합니다.
설계상 HTML에 서버 데이터가 직렬화되지 않습니다. 클라이언트의 load가 하이드레이션 시 다시 실행됩니다. 이것이 중요한 경우 서버가 route.data(예: window.__DATA__)를 내보내고 로더가 이를 먼저 가져오도록 합니다. route.data는 일치하는 페이지의 데이터만입니다. 부모의 load() 결과가 여기에 없습니다.
코드 분할 및 사전 로드
라우트의 값은 항상 페이지 컴포넌트를 반환하는 함수이므로 컴포넌트는 필요할 때 불러옵니다.
'/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 동안 사용자 조작이 없으면(기본 1초: 터치, 키 입력, 휠, 스크롤 없음) 유휴 시간 구간(requestIdleCallback)마다 하나씩 나머지 라우트를 백그라운드에서 로드합니다. 입력이 발생하거나 사용자가 기다리는 탐색이 있으면 페이지가 다시 유휴 상태가 될 때까지 사전 로드를 멈춥니다.
사전 로드되었거나 즉시 사용 가능한 라우트는 router.pending를 변경하지 않고 다음 화면 갱신 전에 표시됩니다. 스피너가 잠깐 나타나거나 레이아웃이 밀리지 않습니다. router.pending는 모듈이나 로더를 기다려야 할 때만 true이며, 그동안 기존 페이지는 화면에 유지됩니다. 청크 로드에 실패하면 처리되지 않은 Promise 거부 대신 router.error에 오류가 설정되며 다시 시도할 수 있습니다.
라우터 API
#createRouter(table, options?)
table는 패턴을 로더 또는 중첩 테이블에 매핑합니다. 옵션:
| 옵션 | 기본값 | |
|---|---|---|
base | '/' | URL 접두사(예: '/docs')에서 앱이 제공됩니다. 세그먼트 경계에서만 일치합니다. /docs/intro는 '/docs' 내부에 있고 /docsearch는 그렇지 않습니다. 외부의 URL은 일치하지 않으므로 start()는 거부하고(서버 측 404) 이에 대한 링크는 브라우저에 남아 있습니다. |
preload | true | 페이지가 유휴 상태가 되면 백그라운드에서 나머지 경로의 모듈을 로드합니다. false로 비활성화합니다. |
idleTimeout | 1000 | 터치, 키 입력, 휠, 스크롤 없이 지나야 하는 시간(밀리초)입니다. 이 시간이 지나면 사전 로드를 시작하거나 사용자 조작으로 중단된 사전 로드를 재개합니다. |
onNavigate | (ctx, kind) => void. 브라우저에서 최초 탐색을 포함해 각 탐색이 완료되고 DOM에 반영된 뒤 호출됩니다. kind는 'start', 'push', 'replace', 'pop'(뒤로, 앞으로, close()) 또는 'layer'입니다. document.title, 메타 태그, 포커스, 스크롤, 분석 처리를 위한 훅이며 SSR 중에는 호출되지 않습니다. |
반환된 라우터는 start(), match(), outlet(), matches()와 run()을 제외한 모든 Route 멤버를 제공합니다. Route 멤버는 활성 Route에서 읽습니다. 브라우저에서는 마지막 start()가 반환한 Route이고, 서버에서는 현재 run()을 실행 중인 Route입니다. 어느 쪽도 없을 때 멤버를 읽으면 오류가 발생합니다. 라우트 테이블, 컴파일된 매처, 모듈 캐시는 라우터에 저장되므로 서버의 여러 요청이 공유합니다.
router.start(url?)
url(브라우저 기본값: location.href)에 해당하는 페이지 모듈을 확인하고 Promise<Route>를 반환합니다. 일치하는 패턴도 테이블의 '*' 라우트도 없으면 Promise가 거부됩니다. 서버에서는 이를 404로 처리합니다. 브라우저에서는 해당 Route를 라우터의 활성 Route로 설정하고 popstate와 위임된 click 리스너를 연결하며 사전 로드를 예약합니다. start()를 다시 호출하면 이전 Route를 정리합니다. 서버에서는 공유 상태를 변경하지 않으므로 route.run() 안에서 렌더링하세요.
router.match(url?)
아무것도 로드하지 않고 URL을 테이블과 비교합니다. import, 히스토리 변경, 리스너 연결, Route 생성이 모두 없습니다. 일치하면 RouteContext를 반환합니다. URL이 base 밖에 있거나, 일치하는 패턴이 없거나, 브라우저에서 출처가 다르면 null을 반환합니다. ./를 해석하려면 활성 라우트가 필요하므로 절대 URL만 허용합니다.
// 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 }) | 필요한 모듈을 로드하고 이동합니다. Promise는 거부되지 않습니다. 모듈에서 예외가 발생하거나 Promise가 거부되면 error에 기록됩니다. |
push(href) | 새 라우트를 최상위 레이어로 열고, 닫을 때 전달된 값으로 Promise를 이행합니다. ./를 허용하며 일치하는 라우트가 없거나 모듈 로드에 실패하면 Promise가 거부됩니다. |
href(path) | a({ href })의 경우 경로 앞에 base를 붙입니다. 활성 경로에 대해 ./ 및 ../를 확인합니다. |
stop() | 모든 리스너를 분리하고, 열린 레이어를 닫고, 아웃렛을 비웁니다. 멱등성. 중지된 Route에서도 마지막 일치 결과를 읽을 수 있으며 go()는 아무 작업도 수행하지 않습니다. |
run(fn) | 이 Route를 라우터의 활성 Route로 설정하고 fn을 호출한 뒤 그 결과를 반환합니다. 서버에서는 route.run(() => renderToString(App))처럼 사용합니다. 동기 호출만 지원하며 fn이 반환되면 이 Route는 더 이상 활성 Route가 아닙니다. |
RouteContext
페이지의 첫 번째 인수: path, pattern, params, search, hash, url. 페이지가 작성된 컨텍스트입니다. 실시간 값을 보려면 router.*를 읽어보세요. 다시 생성하지 않고 쿼리나 해시가 변경된 경우에만 다릅니다.
params는 기본적으로 { [name]: string }입니다. 페이지는 Params를 사용하여 자체 패턴에서 매개변수 타입을 추론할 수 있으며, 이는 '/blog/:slug'를 { slug: string }로, '/files/*rest'를 { rest: string }로 바꿉니다.
import type { Params, RouteContext } from 'nuclo-router' export default function Post(ctx: RouteContext<Params<'/blog/:slug'>>) { return article(h1(ctx.params.slug)) // slug: string, nothing else exists }
PageProps
페이지의 두 번째 인수입니다. 필요한 속성을 구조 분해해서 사용하세요.
| 속성 | |
|---|---|
layer | 페이지가 스택에 있는 위치입니다. 아래 Layer를 참조하세요. |
data | 라우트의 load() 반환값이며 로더가 없으면 undefined입니다. PageProps<TData>로 타입을 지정합니다. |
outlet | 상위 페이지 안에 하위 라우트를 렌더링하는 위치입니다. 중첩 라우트를 참조하세요. |
beforeLeave | 페이지를 제거하기 전에 호출할 가드를 등록합니다. 페이지 이탈 가드를 참조하세요. |
Layer
페이지의 스택 내 위치와 레이어를 닫는 방법을 제공합니다.
| 멤버 | |
|---|---|
depth | 기본 페이지는 0, 추가한 레이어는 1 이상입니다. |
push(href) | 페이지 안에서 다른 레이어를 엽니다. Route.push()와 같습니다. |
close(result?) | 이 레이어와 그 위의 모든 레이어를 닫고, 이 레이어를 연 push()의 Promise를 이행합니다. 인수가 없으면 취소한 것으로 처리합니다. 깊이 0에서는 아무 작업도 하지 않습니다. |
DataLoader 및 withLoad()
DataLoader는 페이지 모듈의 선택적 load 내보내기인 (ctx: RouteContext) => TData | Promise<TData>입니다. 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를 포함합니다. |
RouteTable | createRouter()에 전달하는 테이블로, 패턴을 로더 또는 중첩 테이블에 매핑합니다. |
RouterOptions | { base?, preload?, idleTimeout?, onNavigate? } |
NavigateOptions | { replace? }. go()의 두 번째 인수입니다. |
NavigationKind | 'start' | 'push' | 'replace' | 'pop' | 'layer'. onNavigate의 두 번째 인수입니다. |
Outlet | PageProps의 outlet() 타입입니다. |
LeaveGuard | (to) => boolean | void | Promise<…>, beforeLeave()가 취하는 것 |
MatchView | matches()의 뷰입니다. 매개변수를 받아 콘텐츠 또는 페이지 모듈의 import()를 반환합니다. |
push()는 현재 페이지를 교체하는 대신 그 위에 새 라우트를 열며, 레이어가 닫힐 때 전달한 값으로 Promise를 이행합니다. 페이지 스택은 하나의 list()이므로 레이어를 열면 행 하나가 추가됩니다. 아래 페이지는 다시 렌더링되지 않아 레이어를 닫은 뒤에도 DOM, 포커스, 입력 중이던 폼이 그대로 유지됩니다.
이렇게 하면 사용자가 작성하고 있던 양식을 버리지 않고도 "아직 존재하지 않는 레코드가 필요합니다" 흐름이 작동합니다.
// the page underneath const created = await layer.push<Option>('/options/new') if (created) { options.push(created); selected = created.id; update() }
// the pushed page: (ctx, { layer }) layer.close(created) // resolves the push() above with the new option layer.close() // dismissed: resolves with undefined
레이어 중첩. 자체 대화 상자가 필요한 대화 상자는 layer.push()를 호출하고 Route를 직접 참조하지 않고 결과를 기다립니다.
- URL은 최상위 레이어를 나타냅니다.
push()는 히스토리 항목을 추가하므로 레이어에 직접 링크할 수 있습니다. 뒤로 가면 레이어가 닫히고 취소와 마찬가지로 해당push()가 이행됩니다. 뒤로 가기와close()는 같은 코드 경로를 사용합니다.close()는 결과를 저장하고 히스토리를 뒤로 이동하며, 깊이는history.state의 값을 기준으로 판단합니다. 브라우저는 탭당 약 50개 항목만 유지하므로 레이어를 오래 탐색하면 아래 항목이 사라질 수 있습니다. 이때close()는 레이어를 닫고 남아 있는 페이지에 맞춰 현재 히스토리 항목을 교체합니다. - 일반 탐색은 스택을 지웁니다. 링크 클릭 또는
go()는 레이어가 아닙니다. 모든 것을 대체하고 열려 있는 모든 레이어를 닫으므로 대기 중인 호출자가 없습니다. - URL에 직접 접속하면 독립 페이지로 표시됩니다. URL은 최상위 레이어만 나타내고 아래 스택은 나타내지 않습니다. 따라서 레이어 URL에 직접 접속하면 깊이가 1인 스택에
layer.depth === 0인 기본 페이지로 렌더링되며close()는 동작하지 않습니다. 레이어로 여는 라우트도 단독으로 사용할 수 있어야 합니다.layer.depth에 따라 분기하여 동작하지 않는 취소 버튼 대신 나가기 링크를 제공하세요. - 다시 로드하면 Promise는 유지되지 않습니다. Promise가 클로저에 저장되기 때문입니다. 결과 전달은 편의 수단으로 사용하되, 페이지에 직접 접속하는 방식으로도 같은 작업을 완료할 수 있게 하세요.
- 대기 중인 호출이 남지 않습니다.
stop()는 열린 레이어를 모두 닫으며 탐색으로 제거된 레이어의 Promise는undefined로 이행됩니다.
페이지 이탈 가드
입력 중인 폼처럼 보존할 내용이 있는 페이지는 가드를 등록합니다. 페이지를 제거하기 전에 가드를 호출하며, false 또는 false로 이행되는 Promise를 반환하면 페이지를 유지합니다.
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입니다. 가드는 가장 안쪽부터 실행되며 처음 거부한 지점에서 멈춥니다. 가드에서 오류가 발생해도 현재 페이지를 유지합니다.close(result)도 가드를 거치므로 결과를 반환하며 닫기 전에 변경 여부 플래그를 해제하세요.- 라우터는 자신이 만든 히스토리 항목 사이의 이동만 되돌릴 수 있습니다. 기본
#fragment링크나 다른 스크립트의pushState처럼 라우터가 만들지 않은 항목으로 뒤로·앞으로 이동할 때는 가드를 호출하지 않습니다.
matches() 및 하위 라우터
#router.outlet()가 페이지를 렌더링합니다. router.matches(table)는 경로를 로드하거나 페이지를 추가하지 않고도 자체 작은 테이블에서 사이드바, 이동 경로 또는 도구 모음과 같이 사용자의 위치에 따라 달라지는 다른 항목을 렌더링합니다.
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에 포함되어야 하는 뷰는 동기 함수로 작성하세요.
서브 라우터
라우트가 중첩을 지원하므로 하위 라우터는 보통 하나의 라우터 안에 중첩 테이블로 구성하면 됩니다. 섹션의 '/' 페이지가 공통 UI를 제공하고 그 안의 outlet()에 하위 페이지를 렌더링하므로 섹션 내부를 이동해도 공통 UI가 유지됩니다.
createRouter({ '/': …, '/docs': docsRoutes, '*': … })
또는 해당 기능에 라우터를 따로 만들되 시작하지 않고 사용할 수 있습니다. 상위 라우터는 catch-all 하나로 섹션을 처리하고 하위 라우터는 남은 경로를 자체 패턴, 매개변수, 폴백으로 해석합니다.
// parent table '/docs/*rest': () => import('./docs/Shell.ts'),
const docs = createRouter(docsRoutes, { base: '/docs' }) const hit = docs.match() // { pattern: '/:topic', … } const page = await docsRoutes[hit.pattern]() // the app's own lookup
이 라우터는 start()를 호출하지 않는 것이 핵심입니다. start()는 리스너를 연결하므로 라우터 두 개를 시작하면 충돌합니다. 하위 라우터가 경로 해석을 담당하는 대신, 시작된 라우터가 자동 실행해 주는 데이터 로더는 직접 호출해야 합니다. 또한 바깥 라우터는 하나의 라우트만 보고 나머지는 셸이 해석하므로 하위 경로가 바뀔 때 셸도 다시 생성됩니다. 중첩 테이블을 사용하면 상위 페이지가 계속 마운트된 상태로 유지됩니다. 해당 기능에 독립된 라우터, base, 폴백이 꼭 필요한 경우가 아니라면 중첩 테이블을 사용하세요.
앱에는 영역마다 라우터를 둘 수 있습니다. 규칙은 간단합니다. 문서당 하나만 시작하고 나머지는 시작하지 않은 채 match()로 URL을 해석합니다. 시작된 라우터만 URL, 히스토리, 클릭 리스너를 관리하며 나머지는 직접 조회하는 테이블입니다. 각 영역은 자체 스피너와 독립적인 로딩 상태를 가질 수 있습니다.
결합 로딩: 로더 체인
일치하는 라우트 체인의 각 페이지는 load()를 내보낼 수 있습니다. 하위 라우트에 처음 진입하면 레이아웃과 페이지의 로더가 병렬로 실행되며 하나의 router.pending가 전체 체인의 로딩 상태를 나타냅니다. 두 로더가 모두 완료될 때까지 기존 페이지가 유지되므로 일부만 로드된 트리는 표시되지 않습니다. 각 컴포넌트는 자신의 data를 받습니다.
'/shop': { '/': () => import('./pages/Shop.ts'), // layout, has a load() './:section': () => import('./pages/Section.ts'), // page, has a load() },
import type { DataLoader, PageProps, RouteContext } from 'nuclo-router' export const load: DataLoader<ShopData> = async () => { await wait(500) // a slow API return { sections: ['toys', 'books', 'music'] } } // the layout gets its own data, and its outlet for the child page export default function Shop(_ctx: RouteContext, { data, outlet }: PageProps<ShopData>) { return div( h1('Shop'), nav(...data.sections.map((s) => a({ href: router.href(`/shop/${s}`) }, s))), main(outlet()), ) }
export const load: DataLoader<SectionData> = async (ctx) => { await wait(900) // slower than the layout's return { section: ctx.params.section, items: catalog[ctx.params.section] } } // the page gets its own data: the two loaders ran beside each other export default function Section(_ctx: RouteContext, { data }: PageProps<SectionData>) { return article(h2(data.section), ul(...data.items.map((i) => li(i)))) }
두 로더가 차례로 시작되지 않고 함께 시작됩니다. router.pending는 둘 중 하나가 대기하는 동안 true이고 나머지 시간에는 false입니다. 사전 로드되거나 즉시 사용 가능한 라우트는 이 값을 true로 바꾸지 않습니다. 섹션 사이를 이동하면 레이아웃이 유지되므로 섹션의 로더만 다시 실행됩니다. 레이아웃의 데이터, DOM 및 입력 중인 텍스트는 모두 유지됩니다. 동일한 URL로 다시 탐색하면 단일 페이지와 마찬가지로 리프의 로더가 다시 실행되고 해당 행이 다시 생성됩니다.
다중 로딩: 자체 스피너가 있는 두 번째 라우터
독립 영역(사이드바, 트레이, 라이브 영역)은 생성되었지만 시작되지 않은 자체 라우터와 자체 로딩 플래그를 갖습니다. router.pending는 시작된 라우터에 속하며 이 영역에 대해 아무 말도 하지 않으므로 두 라우터가 나란히 로드될 수 있으며 둘 중 하나가 먼저 완료될 수 있습니다.
import { createRouter } from 'nuclo-router' // Created but never started: start() is what attaches the history and click // listeners, and only the main router is started. match() attaches nothing. const standIn = () => ({ default: () => div() }) // never rendered const sidebarRouter = createRouter({ '/shop': standIn, '/shop/:section': standIn, '*': standIn, }, { base: '/docs' }) // the same base as the main router export const sidebar = { loading: false, info: null as SidebarInfo | null } export async function refreshSidebar(at: string) { const hit = sidebarRouter.match(at) // the second router resolves if (!hit || (hit.pattern !== '/shop' && hit.pattern !== '/shop/:section')) { sidebar.info = null; update(); return } sidebar.loading = true // its own flag, its own spinner sidebar.info = null; update() const data = await fetchSidebar(hit.params.section) sidebar.loading = false; sidebar.info = data update() }
createRouter(routes, { onNavigate: (ctx, kind) => { announce(kind) refreshSidebar(ctx.url) // every navigation re-resolves the sidebar }, })
두 번째 라우터는 직접 조회할 때만 사용됩니다. match()는 URL을 읽고 컨텍스트를 반환하며, 기본 라우트 테이블에는 사이드바 관련 항목이 들어가지 않습니다. onNavigate는 최초 탐색을 포함한 모든 탐색이 완료된 뒤 실행되므로 독립 영역을 URL과 동기화하는 코드를 한곳에 둘 수 있습니다.
pending는 시작된 라우터에 속합니다. 시작되지 않은 라우터에는 Route,pending및error가 없습니다. 해당 영역은 자체 플래그를 추적하고 두 번째 스피너는 첫 번째 스피너 옆에 배치될 수 있습니다. 데모에서 사이드바는 800ms 동안 로드되는 반면 경로의 로더는 900ms 동안 실행되므로 페이지는 사이드바보다 먼저 완료되거나 그 반대 방향으로 완료될 수 있습니다.match()는 아무것도 첨부하지 않습니다. 시작되지 않은 라우터 수에 관계없이 문서를 공유할 수 있습니다. 각 라우터에는 자체 테이블, base 및 모듈 캐시가 있으며 어느 라우터도 히스토리를 변경하지 않습니다. 필요한 자원은 테이블과 매처뿐입니다.- 해당 라우터의 로더는 실행되지 않습니다. 시작된 라우터는 일치하는 페이지의
load()를 실행하지만 시작되지 않은 라우터는 경로만 해석합니다. 뷰에 데이터가 필요하면 직접 가져오세요(데모의refreshSidebar()). 자동 로더 실행이 필요하면 시작된 라우터 안에서 중첩 테이블이나 페이지가 채우는 영역으로 구성하세요. - 오래된 응답을 무시하세요. 느린 사이드바 요청의 응답이 이미 다른 섹션으로 이동한 화면에 적용되어서는 안 됩니다. 요청마다 세대 카운터를 증가시키고 현재 값과 일치하지 않는 응답은 버리세요. 라우터도 새 탐색이 앞선 탐색을 추월하면 같은 방식으로 이전 결과를 버립니다.
- 지연 로드
matches()뷰에도pending가 없습니다. 모듈이 로드되기 전에는 아무것도 렌더링하지 않습니다. 라우트의pending, 독립 영역의 자체 플래그, 아직 표시되지 않은 지연 로드 뷰는 각각 독립적으로 로딩됩니다.
링크 처리
#위임된 리스너는 SVG <a>를 포함해 <a href> 또는 그 안의 요소를 왼쪽 클릭할 때 처리합니다. 보조 키를 누른 클릭, 가운데 클릭, _self 이외의 target, download, rel="external", 출처가 다른 href, 현재 페이지의 해시 전용 링크, base 밖의 경로, 일치하는 라우트가 없는 경로(서버 렌더링된 404에 접근할 수 있도록), 앱 핸들러에서 이미 preventDefault()를 호출한 이벤트는 브라우저에 맡깁니다. data-nuclo-router="off"를 지정하면 해당 링크를 라우터 처리에서 제외할 수 있습니다.
스크롤링
푸시된 탐색은 현재 스크롤 위치를 유지합니다. 새 URL에 페이지의 요소와 일치하는 #hash가 있으면 해당 요소가 스크롤되어 표시됩니다. popstate는 브라우저에 스크롤을 남깁니다. 상단에 새 페이지를 표시하려면 onNavigate에서 수행하세요. 탐색이 어떻게 진행되었는지 알려줍니다.
let shown = '' createRouter(routes, { onNavigate: (ctx, kind) => { // Not on Back/Forward (the browser restores those), nor for a query change on the same page. if (kind === 'push' && ctx.path !== shown && !ctx.hash) window.scrollTo(0, 0) shown = ctx.path }, })
주의 사항
- 페이지 DOM을 캐시하지 않습니다. Route는 페이지 함수와 컨텍스트만 보관합니다. 다른 페이지로 이동하면 이전 페이지는 해제됩니다.
stop()는 리스너를 제거하고 진행 중인 로드를 무효화하며pending를 지우므로 폐기된 Route 때문에 스피너가 남지 않습니다.stop()이후에 완료된 모듈은 무시됩니다. - 서버에서 동시성 안전.
start()는 요청별 상태를 반환하고run()는 이를 하나의 동기 렌더링으로 범위 지정하므로 인터리브된 요청은 서로의 경로를 볼 수 없습니다. 모듈 캐시만 공유됩니다.run()의 함수가 반환되면 범위가 종료됩니다.renderToString()는 동기식이므로 충분합니다. 비동기 또는 스트리밍 렌더링에서는await전후로 Route를 유지해야 하지만run()은 이를 지원하지 않습니다. - 스크롤 복원은 브라우저가 담당합니다.
popstate시 브라우저의scrollRestoration설정에 따르며 라우터는 복원하지 않습니다. - 문서당 하나의 라우터를 시작합니다. 각
start()는 자체 위임click리스너를 추가하고 두 번째 라우터의start()는 첫 번째 리스너를 폐기하지 않습니다. 동일한 라우터에 있는 두 번째start()만 작동합니다. 다른 테이블에 대해 URL을 확인하려면 라우터를 만들고match()만 호출하세요. 아무것도 첨부하지 않습니다. 독립된 영역은 경로의pending옆에서 자체 로딩을 실행할 수 있습니다. 다중 라우터 & 결합 로딩를 참조하세요. - 의도적으로 제공하지 않는 기능: 해시·메모리 히스토리는 지원하지 않습니다. 라우트는 URL 경로에 표시되며 서버가 모든 경로를 제공해야 합니다. 하나의 URL로 독립된 두 영역을 라우팅하는 기능도 없습니다. 페이지가
into()로 여러 영역을 채울 수는 있지만 모두 같은 라우트에 속합니다. 독립 영역에 별도의 시작되지 않은 라우터와 로딩 상태를 두는 방법은 여러 라우터 및 통합 로딩을 참조하세요. 호버 시 사전 로드도 내장되어 있지 않으며mouseenter에서 해당 라우트의 모듈 로더를 호출해 구현할 수 있습니다.
예
저장소에서 실행 가능한 두 가지 예제는 서로를 보완하므로 모든 설정을 어딘가에서 관찰할 수 있습니다.
examples/router: 전체 API에 대한 클라이언트 측 둘러보기입니다. 모든 패턴 형식 및 우선 순위 규칙, 링크 처리 옵트아웃, 라이브 제어가 포함된 모든 경로 멤버, 하위 라우터, 3단계 깊이의 레이어 스택, 두 상위 항목에 마운트된 상대 섹션,pending및error가 있는 경로 로더. 해당 상태가 계속 표시되도록preload: false를 설정합니다.examples/router-ssr: 요청별renderToString(), 서버 노드 재사용을 확인할 수 있는 하이드레이션, 실제 404 상태 코드, 루트 이외의base, 네트워크 패널에서 확인할 수 있는 유휴 시간 사전 로드, 서버 데이터를 클라이언트에 전달해 하이드레이션 시 다시 가져오지 않는 로더를 보여줍니다.
Nuclo에는 계산된 값을 위한 별도 API가 없습니다. 일반 함수를 사용하고 동적 값에서 호출하세요.
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 이전에 한 번, 이후에 한 번을 의미합니다.
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(전역 및 명명된 내보내기)
update(...scopeIds): 모든 동적 값 또는 지정된 범위에 있는 값을 실행합니다.scope(...ids):update(id)에 대한 요소 범위 ID를 제공합니다.when(condition, ...content):.when()및.else()가 포함된 조건부 콘텐츠.list(items, renderItem): 항목당 하나의 행, 동일성을 기준으로 행을 연결합니다.region(options)및into(id, ...content): 트리의 명명된 위치 및 어디에서나 이를 채우는 콘텐츠입니다.on(type, listener, options?): 이벤트 리스너 또는on('mount')/on('destroy').render(App, parent?): 빌드 및 추가.hydrate(App, parent?): 서버 HTML에 클라이언트 동작을 연결합니다.forceUpdate(): 컴포넌트를 다시 호출하고 DOM을 패치합니다.css(name?, style): 스타일 객체에서 생성된 클래스.cx(...inputs): 스타일을 합성하며 나중에 전달한 스타일이 우선합니다.createCss(theme): 테마css,cx,variants,keyframes및globalStyle.variants(config): 옵션의 스타일.keyframes(frames)및globalStyle(selector, style).
nuclo(이름을 지정한 내보내기 전용)
nuclo/ssr
renderToString(input),renderManyToString(inputs),renderToStringWithContainer(input, tag?, attrs?)및getCssText().
nuclo/polyfill
- 부수 효과 import,
document,Event,CustomEvent,Node,Element,HTMLElement및Nuclo*클래스.
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'에서 가져올 수도 있습니다.
특수 키
- 속성:
className,style,on*핸들러,onMount및onDestroy. - 스타일 객체: 축약형, 가상 상태,
&선택자,@규칙 및raw.
자주 묻는 질문
#상태 변경이 UI를 업데이트합니까?
아니요. DOM은 update()를 호출할 때까지 그대로 유지됩니다.
Nuclo는 시그널, 프록시 또는 가상 DOM을 사용합니까?
아니요. 상태는 일반 JavaScript로 유지됩니다. 트리가 렌더링되면 Nuclo는 실제 요소와 텍스트 노드를 생성합니다. update()는 등록된 모든 동적 값 또는 지정된 범위 내의 값을 실행하고 값이 변경된 동일한 노드를 패치합니다. 의존성 그래프와 가상 트리가 없습니다.
내 페이지에 "false"가 표시되는 이유는 무엇입니까?
불리언은 텍스트로 렌더링됩니다. div(ok && span('Done'))에는 "false"가 표시됩니다. 자식 노드용으로는 when(() => ok, span('Done'))를, 텍스트용으로는 ok ? 'Done' : ''를 사용하세요.
항목을 편집할 때 목록 행이 변경되지 않는 이유는 무엇입니까?
목록에 아직 남아 있는 항목의 행은 유지되며 다시 렌더링되지 않습니다. 변경되는 필드에는 () => todo.text와 같은 동적 값을 사용하세요. list()를 참조하세요.
UI의 일부가 업데이트를 중지하는 이유는 무엇입니까?
대개 update()가 실행될 때 해당 노드가 문서에 없었기 때문입니다. 한 번 추적이 중지된 노드는 나중에 연결해도 다시 업데이트되지 않으므로 문서에 연결된 컨테이너에 렌더링하세요. 그 밖에 when() 조건에서 오류가 발생하면 해당 블록의 업데이트가 중지되고, 텍스트 함수가 첫 실행에서 오류를 던지면 계속 빈 텍스트로 남습니다. update()를 참조하세요.
언제 forceUpdate()를 사용해야 합니까?
언어 전환과 같이 정적 값이 거의 변경되지 않는 경우에만 해당됩니다. 그 밖의 모든 것에는 update()를 사용하세요. forceUpdate()를 참조하세요.
props으로 전달하지 않고 페이지를 레이아웃에 어떻게 넣나요?
region({ id: 'main' })로 장소를 표시하고 페이지가 into('main', …)를 반환하도록 합니다. region() & into()를 참조하세요.
앱을 어떻게 마운트 해제하나요?
when()로 감싸고 조건을 끕니다. DOM을 직접 제거하면 onDestroy가 실행되지 않습니다. 생명주기를 참조하세요.
Nuclo에는 라우터가 포함되어 있습니까?
코어 패키지에는 없습니다. 라우팅은 서버 렌더링, 하이드레이션, 코드 분할 경로 및 유휴 사전 로드 기능을 갖춘 nuclo-router로 별도로 배포됩니다. npm install nuclo nuclo-router로 설치하세요. nuclo-router를 참조하세요.
Nuclo에 이펙트가 있나요?
아니요. onMount 및 onDestroy는 요소당 한 번 실행되며 삽입 및 제거와 연결됩니다. 일부 상태가 변경되었기 때문에 아무 것도 다시 실행되지 않습니다.