Esta página se tradujo automáticamente y puede contener errores. Leer el original en inglés

Documentación

JSX y componentes

Las apps de NSH describen su UI con elementos y componentes, en Rust o en TSX. Rust construye los elementos con funciones y métodos builder tipados (view().gap_4().child(..)); TSX los escribe como JSX, que el compilador de NSH convierte en llamadas directas al mismo renderizador nativo, sin React ni DOM virtual. En ambos casos, un componente es una función que se ejecuta una sola vez para construir su parte del árbol. Lo que mantiene la UI actualizada después es la reactividad: los bindings que leen signals se siguen de forma nativa, y solo ellos se ejecutan de nuevo.

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);

#Elementos

Los elementos host están respaldados cada uno por una vista nativa. Rust los crea con las funciones del prelude; TSX los importa de nsh como componentes, y solo If y Each son globales que no necesitan importación:

Rust TSX Vista nativa Página
view() <View> UIView / ViewGroup: una caja para layout, estilos y toques View
text(..) <Text> Una vista de texto nativa; los elementos de texto anidados son spans Text
image(src) <Image> Una vista de imagen (recursos locales y URLs) Image
scroll() <Scroll> UIScrollView / ScrollView Scroll
input() <Input> UITextField / UITextView / EditText Input
virtual_list(count, row) <List> Una lista virtualizada que recicla sus filas List
canvas(..), web_view(url), video(url) <Canvas>, <WebView>, <Video> Un dibujo, una página web, un reproductor de vídeo Elementos
nsh_base::dialog() <Dialog> de nsh/base Una capa presentada Elementos
when(..), each(..) <If>, <Each> Control de flujo, sin vista propia Control de flujo
use nsh_core::prelude::*;   // view, text, image, scroll, input, virtual_list, ..., when, each

En Rust, los estilos son métodos tipados con los nombres de las utilidades de Tailwind (.px_4(), .rounded_lg(), .bg(..)), y los listeners también son métodos (.on_press(..)); los ajustes exclusivos de una plataforma tienen sus propios métodos. En TSX, las props conservan sus nombres en camelCase (onPress, allowFontScaling, backgroundColor); todo elemento acepta style, class / classList (Tailwind), ref, manejadores de toques, props de accesibilidad y testID, y las props exclusivas de una plataforma van en ios={{…}} y android={{…}}, donde solo se envía el objeto de la plataforma actual:

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

Las etiquetas TSX se escriben en PascalCase para que TypeScript compruebe cada una contra el binding que nombra: una etiqueta en minúsculas se comprueba contra un único tipo global por nombre, de modo que un componente genérico como List o Each no podría inferir el tipo de sus filas a partir de each / of, y los componentes propios de una app no tendrían ningún tipo. Las importaciones no cuestan nada en tiempo de ejecución: el compilador reconoce las primitivas por sus importaciones (también con alias) y crea el elemento nativo en el lugar, así que <View> es una llamada directa al renderizador y la importación se elimina del bundle. Una etiqueta en minúsculas es un error de compilación que indica qué escribir:

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

#Componentes

El aspecto de una app es propio de la app. NSH ofrece dos capas: las primitivas (los elementos anteriores, los estilos, las clases de Tailwind) y los componentes base sin estilo de nsh/base (nsh_base en Rust), que se encargan de comportamientos como la navegación, los diálogos, el teclado, la barra de estado y las áreas seguras. La tercera capa, la biblioteca de componentes, es tuya: los botones, las tarjetas y cualquier otra pieza de tu UI son componentes que escribes sobre esas dos capas, en el crate de tu app (Building with Rust) o en TSX, o una biblioteca de la comunidad (Three layers).

Un componente de Rust es un #[component] fn que recibe su struct de props (y, cuando tiene eventos, un Scope para emitirlos) y devuelve sus elementos. #[component] también crea su constructor, con el nombre de la función en snake case: Badge se coloca con badge(), igual que view() coloca una vista, con un setter por prop (.label(..), .color(..): un valor, un signal o un closure). Un componente de TSX es una función que recibe un objeto de props y devuelve 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 función se ejecuta una vez, cuando se crea el componente, y no rastrea lo que lee: no hay re-renderizado. El código de su cuerpo (crear signals, iniciar effects, calcular constantes) se ejecuta una sola vez. Los elementos que devuelve permanecen conectados a las props y los signals que leen, de modo que Badge arriba actualiza su texto cada vez que cambia unread, sin volver a ejecutar Badge.

Escribe un #[component] cuando la UI tenga props o eventos propios, o cuando una entrada TSX lo importe: un componente de Rust se registra bajo su tipo (registry.register::<Badge>()), con props tipadas a partir del struct (Building with Rust). Para una pieza de UI que solo usa Rust, basta una función simple que devuelva impl IntoElement, con sus argumentos como props. En TSX, un componente puede devolver un elemento, una cadena o un número, un array, null, o una función que devuelva cualquiera de estos (que el renderizador sigue); el tipo Component<P> de nsh describe uno:

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

#Qué se ejecuta de nuevo

Un valor dado a un elemento se establece una vez; un binding sigue lo que lee. En Rust, un binding es un closure (o un signal, un memo, una prop) dado donde va un valor. En TSX, el compilador decide para cada expresión del JSX: una expresión cambia cuando llama a una función o lee una propiedad (fuera de funciones anidadas), y todo lo demás se establece una vez:

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 hijo cambiante es un pequeño effect nativo que establece esa única prop o reemplaza ese único hijo. En Rust los estilos se comparan por diferencias, de modo que un estilo que cambia solo envía las propiedades que cambiaron.

Los manejadores de eventos se establecen una vez: un closure en Rust, una función o un nombre en TSX (onPress={() => …}, onPress={save}). En TSX, las demás expresiones se siguen (onPress={props.onPress} sigue la prop).

#Props

Las props de un componente de Rust son los campos de su struct #[derive(Props)], cada uno un Prop<T>: un valor, o un binding que pasó el padre. Léelo dentro de un binding (text(props.title), move || props.title.get()) para seguirlo; props.title.get() en el cuerpo lo lee una sola vez. Los campos con #[prop(default)] o #[prop(default = …)], y los Option, son opcionales. El constructor inicia cada prop con su valor por defecto, y un padre llama a los setters de las que necesita: badge().label("New").

Una prop de TSX a la que se le da una expresión cambiante se pasa como getter: al leer props.title se la invoca, y eso es lo que permite que el JSX que la lee la siga. Las props estáticas son valores simples. En ambos casos, las props se leen donde se usan:

// 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 de TSX que desestructura sus props en el parámetro, function Title({ text }: …), las lee igualmente una sola vez, y la compilación lo advierte indicando las props que se deben leer en su lugar:

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 que el componente quiere solo una vez, como un id con el que empieza, se lee en el cuerpo (props.id.get_untracked(), const { id } = props;), lo que deja claro su intención.

En TSX lo mismo se aplica a los valores por defecto: escribe props.color ?? "#ddf4ff" en el JSX en lugar de desestructurar con un valor por defecto. Para calcular algo a partir de las props, usa un closure o computed:

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

#Hijos

Lo que un padre coloca dentro de un componente llega como sus hijos: un campo children: Prop<Children> en Rust, que el padre rellena con .child(..); props.children en TSX, un getter que construye esos elementos al leerse:

#[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)))

En TSX, cada lectura de props.children vuelve a construir los hijos (nuevas vistas nativas): léelo una sola vez, en el lugar donde van los hijos. Entre varios hijos, uno que cambia (un <If>, un <Each>, {cond() ? a : b}) llega como una función, y {props.children} actualiza solo ese: sus hermanos conservan sus vistas, y un input conserva su texto y su foco. Los fragmentos (<>…</>) agrupan elementos sin vista.

Los hijos también pueden crearse mediante una función que el componente llama con argumentos, un 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}")))

#Control de flujo

when y each en Rust, <If> y <Each> en TSX, son estructuras de control de flujo integradas. No renderizan ninguna vista propia.

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> muestra su rama mientras se cumple la condición y, si no, la rama otherwise / else. Solo vuelve a renderizar una rama cuando cambia si la condición se cumple, no cada vez que se reevalúa la condición. En TSX, un hijo función recibe un accessor del valor de when, tipado sin null ni undefined: u() es el usuario mientras se muestra la rama, y lo sigue cuando cambia.
  • each / <Each> renderiza una fila por elemento, creada por su función de fila. Las filas se conservan por clave (Rust: la función de clave; TSX: key, por defecto el propio elemento, los objetos por identidad y los primitivos por valor): cuando la lista cambia, las filas conservadas se mueven en lugar de reconstruirse, y solo se crean filas nuevas para las claves nuevas. La función de fila recibe signals del elemento y de su índice (todo.get() y index.get() en Rust, todo() y index() en TSX), porque el elemento y la posición de una fila conservada pueden cambiar. En TSX, key="index" (o key={(item, i) => i}) conserva las filas por posición: una lista de cadenas que cambia en el sitio, como el texto que llega en streaming, conserva sus filas y solo cambia su item().

Una elección hecha en un closure (dynamic(move || if busy.get() { … } else { … }) en Rust, un ternario o && en TSX) vuelve a construir su rama cada vez que cambia algo de lo que lee. Prefiere when / <If> cuando una rama tiene más de unos pocos elementos.

Para listas largas o infinitas, usa List (virtual_list en Rust): crea vistas solo para las filas en pantalla y las recicla. Consulta Control de flujo para ver los detalles.

#Refs

Un ref nombra un elemento host para llamadas imperativas: focus(), blur(), scroll_to() / scrollTo(), scroll_to_index() / scrollToIndex(), measure() (dónde está una vista), … En Rust, element_ref() crea uno y .element_ref(&handle) lo coloca en un elemento; en TSX, ref te da el ref del elemento, cuyo tipo es el propio del elemento (InputRef aquí, ViewRef para un View; consulta ref methods):

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 de Rust es un handle Copy; sus llamadas van al elemento que lo lleva y no hacen nada cuando su propietario ya no existe. En TSX, lo que nombra ref={…} recibe el nodo:

  • una variable declarada con let lo recibe por asignación;
  • un campo (ref={refs.input}, ref={refs[name]}) lo recibe por asignación;
  • un signal se establece con él, de modo que lo que lee el signal lo sigue;
  • una función se llama con él (<Scroll ref={(node) => (scroller = node)} />).

Un const o una importación contiene un signal o una función, como en const input = signal<InputRef>() con <Input ref={input} /> y input()?.focus(); si contiene cualquier otra cosa, se informa al crear el elemento, indicando qué escribir.

El ref se establece cuando se crea el elemento, antes de que el componente devuelva, por lo que está disponible en effects y en los manejadores de eventos. Las llamadas se envían a la vista en orden junto con los cambios hechos antes de ellas. Los componentes base y los componentes de Rust del crate propio también aceptan ref (en Rust component_ref(), colocado en el elemento con .component_ref(&r): dialog().component_ref(&r)).

#Propagación de props

En Rust, un padre llama a los setters de las props que necesita y las demás conservan sus valores por defecto, y un wrapper pasa sus propios Prop tal cual (card().title(props.title)): un Prop sigue lo que se le dio.

En TSX, propaga un objeto de props sobre un elemento o un componente con {...props}. Los getters siguen siendo getters, de modo que las props propagadas siguen lo que leen. Para quedarte con algunas props y pasar el resto, usa 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) devuelve [picked, rest], y ambos siguen leyendo a través de las props originales (desestructurar { label, ...rest } = props leería cada prop una vez). Las props de elemento que vienen después en el JSX prevalecen sobre las anteriores.

<If> y <Each> no admiten props propagadas, y los hijos propagados ({...items}) no están soportados: usa <Each> o una expresión de array.

#Etiquetas y nombres

Un elemento de Rust es lo que el código llame: la función de una primitiva, el constructor de un componente (badge(), nsh_base::button()) o una función propia. Una etiqueta TSX nombra un binding en el ámbito, como de costumbre: <Badge />, <Theme.Provider>, un <Button> propio. Una etiqueta que no nombra nada en el ámbito es un error de compilación, excepto <If> y <Each>, que son globales.

/// 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)

El Button de nsh_base (button() en Rust, <Button> de nsh/base) es el objetivo de pulsación sin estilo, con los estados deshabilitado y seleccionado y su accesibilidad; la app le da su aspecto: button().disabled(move || saving.get()).on_press(move || save()).px_4().active(|s| s.opacity(0.6)).child(text("Save")).

En TSX, tus bindings prevalecen sobre los globales: un componente llamado Each en el ámbito hace que <Each> sea ese componente. Una primitiva se reconoce por su importación, así que import { View as Box } from "nsh" hace de <Box> una vista, y un componente propio llamado Text es simplemente tu componente. Las primitivas también son componentes reales: const Tag = inline ? Text : View; y después <Tag>…</Tag> funciona.

Los componentes se tipan por sus propias declaraciones: el compilador comprueba los structs de props de Rust, y TypeScript comprueba las props de un componente TSX dondequiera que se use, sin nada que generar. Un componente de Rust que una entrada TSX importa obtiene declaraciones de TypeScript generadas a partir de su struct.

#Montaje y raíz

La raíz de una app de Rust es el componente que nombra el "root" de nsh.json; nsh run y nsh package lo montan. Una entrada TSX llama a mount(App) para renderizar el componente raíz (mount(App, props) le pasa props). La vista raíz llena la superficie (la ventana, o la vista en la que una app incrusta NSH). Dale a la raíz flex: 1 para que llene la pantalla, y un color de fondo: el fondo propio de la superficie depende de la plataforma.

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

#Relacionado