Questa pagina è stata tradotta automaticamente e può contenere errori. Leggi l'originale in inglese

Documentazione

JSX e componenti

Le app NSH descrivono la loro UI con elementi e componenti, in Rust o in TSX. Rust costruisce gli elementi con funzioni e metodi builder tipizzati (view().gap_4().child(..)); TSX li scrive come JSX, che il compilatore di NSH trasforma in chiamate dirette allo stesso renderer nativo, senza React e senza virtual DOM. In entrambi i casi, un componente è una funzione che viene eseguita una sola volta per costruire la sua parte di albero. Ciò che mantiene la UI aggiornata in seguito è la reattività: i binding che leggono i signal vengono seguiti in modo nativo, e solo loro vengono rieseguiti.

guide-components.tsx
guide-components.tsxLive
import { type InputRef, type JSX, mount, Scroll, signal, split, Text, View } from "nsh";
import { Input, type InputProps } from "nsh/base";
import { button, buttonText, desktop, field, tint, ui, wide } from "./palette";

/** A component: a function of its props, run once. */
function Badge(props: { label: string; color?: string }) {
  return (
    <View style={{ alignSelf: "flex-start", borderRadius: 999, paddingHorizontal: 10, paddingVertical: 4, backgroundColor: props.color ?? tint(ui().accent, 0.14) }}>
      <Text style={{ fontSize: 13, fontWeight: "600", color: ui().text }}>{props.label}</Text>
    </View>
  );
}

/** Children are whatever the caller put between the tags. */
function Card(props: { title: string; children?: JSX.Element }) {
  return (
    <View
      style={{ ...ui().card, borderRadius: 16, padding: 14, gap: 10 }}
    >
      <Text style={{ fontSize: 13, fontWeight: "600", color: ui().muted }}>{props.title}</Text>
      {props.children}
    </View>
  );
}

/** Own props are split off; the rest is spread onto the input and stays reactive. */
function Field(props: InputProps & { label: string }) {
  const [own, input] = split(props, ["label"]);
  return (
    <View style={{ gap: 4 }}>
      <Text style={{ fontSize: 13, color: ui().muted }}>{own.label}</Text>
      <Input {...field()} {...input} />
    </View>
  );
}

function App() {
  const name = signal("");
  const taps = signal(0);
  let nameInput: InputRef | undefined;

  return (
    <Scroll style={{ flex: 1, backgroundColor: ui().page }}>
      <View
        style={
          wide()
            ? { padding: 20, paddingTop: 24, gap: 16, display: "grid", gridTemplateColumns: "repeat(2, 1fr)", alignItems: "start" }
            : { padding: 20, paddingTop: 24, gap: 16 }
        }
      >
        <Card title="Props are live">
          <Badge label={`${desktop ? "Clicked" : "Tapped"} ${taps()} times`} color={taps() % 2 ? tint(ui().warn, 0.16) : tint(ui().accent, 0.14)} />
          <View onPress={() => taps.update((n) => n + 1)} {...button("primary")}>
            <Text style={buttonText("primary")}>{desktop ? "Click" : "Tap"}</Text>
          </View>
        </Card>

        <Card title="split, spread and ref">
          <Field label="Your name" placeholder="Type here" value={name()} onChange={({ value }) => name.set(value)} ref={(node) => (nameInput = node)} />
          <Text style={{ color: ui().text }}>{name() ? `Hello, ${name()}!` : "Nobody yet."}</Text>
          <View style={{ flexDirection: "row", gap: 8 }}>
            <View onPress={() => nameInput?.focus()} {...button("accent")}>
              <Text style={buttonText("accent")}>Focus</Text>
            </View>
            <View onPress={() => (name.set(""), nameInput?.blur())} {...button()}>
              <Text style={buttonText()}>Clear</Text>
            </View>
          </View>
        </Card>
      </View>
    </Scroll>
  );
}

mount(App);

#Elementi

Gli host element sono ciascuno supportato da una vista nativa. Rust li crea con le funzioni del prelude; TSX li importa da nsh come componenti, e solo If e Each sono globali che non richiedono import:

RustTSXVista nativaPagina
view()<View>UIView / ViewGroup: un contenitore per layout, stile e tocchiView
text(..)<Text>Una vista di testo nativa; gli elementi di testo annidati sono spanText
image(src)<Image>Una vista immagine (asset locali e URL)Image
scroll()<Scroll>UIScrollView / ScrollViewScroll
input()<Input>UITextField / UITextView / EditTextInput
virtual_list(count, row)<List>Una lista virtualizzata con riciclo delle visteList
canvas(..), web_view(url), video(url)<Canvas>, <WebView>, <Video>Un disegno, una pagina web, un video playerElementi
nsh_base::dialog()<Dialog> da nsh/baseUn livello presentatoElementi
when(..), each(..)<If>, <Each>Flusso di controllo, senza vista propriaFlusso di controllo
use nsh_core::prelude::*;   // view, text, image, scroll, input, virtual_list, ..., when, each

In Rust, gli stili sono metodi tipizzati con gli stessi nomi delle utility di Tailwind (.px_4(), .rounded_lg(), .bg(..)), e anche i listener sono metodi (.on_press(..)); le impostazioni specifiche di una piattaforma hanno metodi propri. In TSX, le prop mantengono i nomi in camelCase (onPress, allowFontScaling, backgroundColor); ogni elemento accetta style, class / classList (Tailwind), ref, gestori di tocco, prop di accessibilità e testID, mentre le prop specifiche di una piattaforma vanno in ios={{…}} e android={{…}}, dove viene inviato solo l’oggetto della piattaforma corrente:

view()
    .rounded(px(12.0))
    .bg(move || theme().background)
    .on_press(move || open.set(true))
    .child(text("Open").font_weight(FontWeight::SEMIBOLD))

I tag TSX sono in PascalCase in modo che TypeScript verifichi ciascuno rispetto al binding che nomina: un tag in minuscolo viene verificato rispetto a un unico tipo globale per nome, quindi un componente generico come List o Each non potrebbe dedurre il tipo delle sue righe da each / of, e i componenti propri di un’app non sarebbero tipizzati affatto. Gli import non costano nulla a run time: il compilatore riconosce le primitive tramite i loro import (anche con alias) e crea l’elemento nativo sul posto, quindi <View> è una chiamata diretta al renderer e l’import viene rimosso dal bundle. Un tag in minuscolo è un errore di build che indica cosa scrivere:

x lowercase tag <view>: tags are PascalCase components: write <View> and
| `import { View } from "nsh"`

#Componenti

L’aspetto di un’app è dell’app stessa. NSH offre due livelli: le primitive (gli elementi qui sopra, gli stili, le classi Tailwind) e i componenti base senza stile di nsh/base (nsh_base in Rust), che gestiscono comportamenti come navigazione, dialog, tastiera, status bar e safe area. Il terzo livello, la libreria di componenti, è vostro: pulsanti, card e ogni altro pezzo della vostra UI sono componenti che scrivete su quei due livelli, nel crate della vostra app (Sviluppare con Rust) o in TSX, oppure una libreria della community (Tre livelli).

Un componente Rust è un #[component] fn che riceve la sua struct di props (e, quando ha eventi, un Scope per emetterli) e restituisce i suoi elementi. #[component] crea anche il suo costruttore, con il nome della funzione in snake case: Badge si posiziona con badge(), come view() posiziona una vista, con un setter per ogni prop (.label(..), .color(..): un valore, un signal o una closure). Un componente TSX è una funzione che riceve un oggetto di props e restituisce JSX:

#[derive(Debug, Clone, PartialEq, Props)]
pub struct BadgeProps {
    /// The text.
    pub label: Prop<SharedString>,
    /// The background (light blue when left out).
    #[prop(default = Hsla::from(rgb(0xddf4ff)))]
    pub color: Prop<Hsla>,
}

/// A small rounded label.
#[component]
pub fn Badge(props: BadgeProps) -> impl IntoElement {
    view()
        .rounded_full()
        .px(px(10.0))
        .py_1()
        .bg(move || props.color.get())
        .child(text(props.label).text_size(px(13.0)).font_weight(FontWeight::SEMIBOLD))
}

// Placed in another component's view:
badge()
    .label(move || format!("{} new", unread.get()))
    .color(rgb(0xfff8c5))

La funzione viene eseguita una sola volta, quando il componente viene creato, e non traccia ciò che legge: non esiste re-rendering. Il codice nel suo corpo (creare signal, avviare effect, calcolare costanti) viene eseguito una volta sola. Gli elementi che restituisce restano collegati alle prop e ai signal che leggono, quindi Badge qui sopra aggiorna il suo testo ogni volta che unread cambia, senza rieseguire Badge.

Scrivete un #[component] quando la UI ha prop o eventi propri, o quando un entry TSX lo importa: un componente Rust viene registrato con il suo tipo (registry.register::<Badge>()), con le prop tipizzate a partire dalla struct (Sviluppare con Rust). Per un pezzo di UI usato solo da Rust basta una semplice funzione che restituisce impl IntoElement, con le prop come argomenti. In TSX, un componente può restituire un elemento, una stringa o un numero, un array, null, o una funzione che restituisce uno di questi (che il renderer segue); il tipo Component<P> da nsh ne descrive uno:

fn row(title: impl Into<SharedString>, content: impl IntoElement) -> impl IntoElement {
    view()
        .flex_row()
        .gap_2()
        .child(text(title.into()))
        .child(content)
}

#Cosa viene rieseguito

Un valore passato a un elemento viene impostato una sola volta; un binding segue ciò che legge. In Rust un binding è una closure (oppure un signal, un memo, una prop) passata dove va un valore. In TSX, il compilatore decide per ogni espressione nel JSX: un’espressione cambia quando chiama una funzione o legge una proprietà (al di fuori delle funzioni annidate), e tutto il resto viene impostato una volta sola:

text(move || format!("{}", count.get()))     // follows count
text(props.title)                            // follows the prop
text(label.clone())                          // a plain value: set once
view().opacity(move || if busy.get() { 0.5_f32 } else { 1.0 })   // follows busy

Una prop o un figlio che cambia è un piccolo effect nativo che imposta quella singola prop o sostituisce quel singolo figlio. In Rust gli stili vengono confrontati con un diff, quindi uno stile che cambia invia solo le proprietà modificate.

I gestori di eventi vengono impostati una sola volta: una closure in Rust, una funzione o un nome in TSX (onPress={() => …}, onPress={save}). In TSX, le altre espressioni vengono seguite (onPress={props.onPress} segue la prop).

#Props

Le props di un componente Rust sono i campi della sua struct #[derive(Props)], ciascuno un Prop<T>: un valore, oppure un binding passato dal genitore. Leggetene una in un binding (text(props.title), move || props.title.get()) per seguirla; props.title.get() nel corpo la legge una sola volta. I campi con #[prop(default)] o #[prop(default = …)], e gli Option, sono opzionali. Il costruttore parte con ogni prop al suo valore predefinito, e un genitore chiama i setter di quelle che gli servono: badge().label("New").

Una prop TSX a cui viene passata un’espressione che cambia viene passata come getter: leggere props.title lo chiama, ed è questo che permette al JSX che la legge di seguirla. Le prop statiche sono valori semplici. In entrambi i casi, le props vengono lette dove sono usate:

// Wrong: reads the prop once, when the component runs.
#[component]
fn Title(props: TitleProps) -> impl IntoElement {
    let content = props.text.get();
    text(content)
}

// Right: binds the text to the prop.
#[component]
fn Title(props: TitleProps) -> impl IntoElement {
    text(props.text)
}

Un componente TSX che scompone le sue props nel parametro, function Title({ text }: …), le legge allo stesso modo una volta sola, e la build lo segnala indicando le props da leggere al suo posto:

warn App.tsx:3:16: Title takes its props apart (`{ text }`): each is read once, when Title runs, and does not follow changes; take `props` and read `props.text` where it is used (`split(props, [...])` to pass the rest on)

Una prop che il componente vuole solo una volta, come un id con cui parte, si legge nel corpo (props.id.get_untracked(), const { id } = props;), che lo rende esplicito.

In TSX lo stesso vale per i valori predefiniti: scrivete props.color ?? "#ddf4ff" nel JSX invece di scomporre con un valore predefinito. Per calcolare qualcosa a partire dalle props, usate una closure o computed:

#[component]
fn Price(props: PriceProps) -> impl IntoElement {
    let formatted = move || format!("${:.2}", props.cents.get() as f64 / 100.0);
    text(formatted)
}

#Children

Ciò che un genitore inserisce in un componente arriva come i suoi children: un campo children: Prop<Children> in Rust, che il genitore riempie con .child(..); props.children in TSX, un getter che costruisce quegli elementi quando viene letto:

#[derive(Debug, Clone, PartialEq, Props)]
pub struct CardProps {
    /// The title.
    pub title: Prop<SharedString>,
    /// The content.
    pub children: Prop<Children>,
}

#[component]
pub fn Card(props: CardProps) -> impl IntoElement {
    view()
        .p(px(14.0))
        .rounded(px(12.0))
        .bg(move || theme().background)
        .gap_2()
        .child(text(props.title).font_weight(FontWeight::SEMIBOLD))
        .child(props.children)
}

card()
    .title("Account")
    .child(text(move || format!("Signed in as {}", user.get().name)))

In TSX, ogni lettura di props.children ricostruisce i children (nuove viste native): leggetelo una sola volta, nel punto in cui vanno i children. Tra più children, quello che cambia (un <If>, un <Each>, {cond() ? a : b}) arriva come funzione, e {props.children} aggiorna solo quello: i suoi fratelli mantengono le loro viste, un input il suo testo e il focus. I fragment (<>…</>) raggruppano elementi senza una vista.

I children possono anche essere creati da una funzione che il componente chiama con degli argomenti, una render callback:

fn repeat<E: IntoElement>(times: usize, row: impl Fn(usize) -> E) -> impl IntoElement {
    view().child((0..times).map(row))
}

repeat(3, |i| text(format!("Row {i}")))

#Flusso di controllo

when e each in Rust, <If> e <Each> in TSX, sono costrutti di flusso di controllo integrati. Non renderizzano alcuna vista propria.

when(move || user.get().is_some(), move || {
    text(move || format!("Hello, {}", user.get().map(|u| u.name).unwrap_or_default()))
})
.otherwise(|| text("Signed out"))

each(todos, |todo| todo.id, |todo, index| {
    text(move || format!("{}. {}", index.get() + 1, todo.get().title))
})
  • when / <If> mostra il suo ramo finché la condizione è vera, altrimenti il ramo otherwise / else. Renderizza di nuovo un ramo solo quando cambia il fatto che la condizione sia vera, non ogni volta che la condizione viene rivalutata. In TSX, un figlio funzione riceve un accessor del valore di when, tipizzato senza null e undefined: u() è l’utente finché il ramo è mostrato, e lo segue quando cambia.
  • each / <Each> renderizza una riga per elemento, creata dalla sua funzione di riga. Le righe vengono mantenute per chiave (Rust: la funzione key; TSX: key, per impostazione predefinita l’elemento stesso, gli oggetti per identità e i primitivi per valore): quando la lista cambia, le righe mantenute vengono spostate anziché ricostruite, e nuove righe vengono create solo per le chiavi nuove. La funzione di riga riceve i signal dell’elemento e del suo indice (todo.get() e index.get() in Rust, todo() e index() in TSX), perché l’elemento e la posizione di una riga mantenuta possono cambiare. In TSX, key="index" (o key={(item, i) => i}) mantiene invece le righe per posizione: una lista di stringhe che cambia sul posto, come un testo ricevuto in streaming, mantiene le sue righe e cambia solo il loro item().

Una scelta fatta in una closure (dynamic(move || if busy.get() { … } else { … }) in Rust, un ternario o && in TSX) ricostruisce il suo ramo ogni volta che cambia qualcosa che legge. Preferite when / <If> quando un ramo è composto da più di pochi elementi.

Per liste lunghe o infinite, usate List (virtual_list in Rust): crea viste solo per le righe a schermo e le ricicla. Vedere Flusso di controllo per i dettagli.

#Ref

Un ref identifica un host element per le chiamate imperative: focus(), blur(), scroll_to() / scrollTo(), scroll_to_index() / scrollToIndex(), measure() (dove si trova una vista), … In Rust, element_ref() ne crea uno e .element_ref(&handle) lo applica a un elemento; in TSX, ref fornisce il ref dell’elemento, il cui tipo è quello dell’elemento stesso (InputRef qui, ViewRef per un View; vedere metodi dei ref):

fn search() -> impl IntoElement {
    let field = element_ref();
    view()
        .child(input().element_ref(&field).placeholder("Search"))
        .child(text("Focus").on_press(move || field.focus()))
}

Un ref Rust è un handle Copy; le sue chiamate vanno all’elemento che lo porta, e non fanno nulla una volta che il suo proprietario non c’è più. In TSX, ciò che ref={…} nomina riceve il nodo:

  • una variabile dichiarata con let lo riceve per assegnazione;
  • un campo (ref={refs.input}, ref={refs[name]}) lo riceve per assegnazione;
  • un signal viene impostato con esso, così ciò che legge il signal lo segue;
  • una funzione viene chiamata con esso (<Scroll ref={(node) => (scroller = node)} />).

Un const o un import contiene un signal o una funzione, come in const input = signal<InputRef>() con <Input ref={input} /> e input()?.focus(); uno che contiene altro viene segnalato quando l’elemento viene creato, con ciò che bisogna scrivere.

Il ref viene impostato quando l’elemento viene creato, prima che il componente ritorni, quindi è disponibile negli effect e nei gestori di eventi. Le chiamate vengono inviate alla vista in ordine insieme alle modifiche fatte prima di esse. Anche i componenti base e i componenti Rust del vostro crate accettano ref (in Rust component_ref(), applicato all’elemento con .component_ref(&r): dialog().component_ref(&r)).

#Spread delle props

In Rust, un genitore chiama i setter delle props che gli servono e le altre mantengono i loro valori predefiniti, e un wrapper passa le proprie Prop così come sono (card().title(props.title)): un Prop continua a seguire ciò che gli è stato dato.

In TSX, fate lo spread di un oggetto di props su un elemento o su un componente con {...props}. I getter restano getter, quindi le props passate con lo spread continuano a seguire ciò che leggono. Per prendere alcune props per sé e passare le altre, usate split:

import { split, Text, View } from "nsh";
import { Input, type InputProps } from "nsh/base";

function Field(props: InputProps & { label: string }) {
  const [own, rest] = split(props, ["label"]);
  return (
    <View style={{ gap: 4 }}>
      <Text>{own.label}</Text>
      <Input style={{ borderWidth: 1, borderRadius: 8, padding: 8 }} {...rest} />
    </View>
  );
}

<Field label="Email" value={email()} onChange={({ value }) => email.set(value)} keyboardType="email-address" />

split(props, keys) restituisce [picked, rest], entrambi ancora in lettura diretta dalle props originali (scomporre { label, ...rest } = props leggerebbe ogni prop una volta sola). Le props dell’elemento che compaiono più avanti nel JSX hanno la precedenza su quelle precedenti.

<If> e <Each> non accettano props con spread, e i children con spread ({...items}) non sono supportati: usate <Each> o un’espressione array.

#Tag e nomi

Un elemento Rust è ciò che il codice chiama: la funzione di una primitiva, il costruttore di un componente (badge(), nsh_base::button()), o una funzione vostra. Un tag TSX nomina un binding nello scope, come di consueto: <Badge />, <Theme.Provider>, un <Button> vostro. Un tag che non nomina nulla nello scope è un errore di build, tranne <If> e <Each>, che sono globali.

/// The app's button: a press target styled by the app.
fn primary_button(title: &'static str, on_press: impl Fn() + 'static) -> impl IntoElement {
    view()
        .role(AccessibilityRole::Button)
        .rounded_lg()
        .bg(rgb(0x2563eb))
        .px_5()
        .py_3()
        .active(|s| s.opacity(0.6))
        .on_press(on_press)
        .child(text(title).text_base().font_weight(FontWeight::SEMIBOLD).text_color(white()))
}

primary_button("Save", save)

Il Button di nsh_base (button() in Rust, <Button> da nsh/base) è il target di pressione senza stile con gli stati disabled e selected e la relativa accessibilità; è l’app a dargli il suo aspetto: button().disabled(move || saving.get()).on_press(move || save()).px_4().active(|s| s.opacity(0.6)).child(text("Save")).

In TSX, i vostri binding hanno la precedenza sui globali: un componente chiamato Each nello scope fa sì che <Each> sia quel componente. Una primitiva viene riconosciuta dal suo import, quindi import { View as Box } from "nsh" fa di <Box> una vista, e un componente vostro chiamato Text è semplicemente il vostro componente. Anche le primitive sono veri componenti: const Tag = inline ? Text : View; e poi <Tag>…</Tag> funziona.

I componenti sono tipizzati dalle proprie dichiarazioni: le struct di props di Rust sono verificate dal compilatore, e TypeScript verifica le props di un componente TSX ovunque venga usato, senza nulla da generare. Un componente Rust importato da un entry TSX ottiene dichiarazioni TypeScript generate dalla sua struct.

#Montaggio e root

La root di un’app Rust è il componente nominato da "root" di nsh.json; nsh run e nsh package lo montano. Un entry TSX chiama mount(App) per renderizzare il componente root (mount(App, props) gli passa le props). La vista root riempie la superficie (la finestra, o la vista in cui un’app incorpora NSH). Date alla root flex: 1 in modo che riempia lo schermo, e un colore di sfondo: lo sfondo della superficie dipende dalla piattaforma.

// nsh.json: { "rust": ".", "root": "App" }
#[component]
pub fn App() -> impl IntoElement {
    view()
        .flex_1()
        .bg(move || theme().background)
        .child(home())
}

#Correlati