Diese Seite wurde maschinell übersetzt und kann Fehler enthalten. Das englische Original lesen

Dokumentation

Reaktivität

NSH aktualisiert die UI mit Signals: Werten, die wissen, wer sie liest. Ändert sich ein Signal, führt NSH genau die Berechnungen erneut aus, die es gelesen haben (ein Text, ein Style, ein Effect), und sonst nichts. Komponenten werden nicht neu gerendert. Das Modell stammt von Solid, und der Reaktivitätsgraph selbst ist nativ: Rust-Komponenten und TSX laufen auf demselben, in Rust geschriebenen Graphen, sodass auch bei TSX Scheduling, Abhängigkeitsverfolgung und Cleanup außerhalb des Interpreters stattfinden.

guide-reactivity.tsx
guide-reactivity.tsxLive
import { batch, cleanup, computed, context, effect, mount, Scroll, signal, Text, untracked, View } from "nsh";
import { button, buttonText, focusRing, tint, ui, wide } from "./palette";

/** The accent's palette key: a key, not a colour, so it follows the theme. */
type Swatch = "accent" | "info" | "up" | "violet";
const Accent = context(signal<Swatch>("accent"));

/** A button filled with a palette colour instead of a surface. */
const filled = (color: () => string) => () => ({ backgroundImage: "none", backgroundColor: color(), borderColor: "rgba(0,0,0,0)", boxShadow: "none" });

/** A stopwatch: the effect starts a timer and its cleanup stops it. */
function Stopwatch() {
  const running = signal(false);
  const tenths = signal(0);
  effect(() => {
    if (!running()) return;
    const id = setInterval(() => tenths.update((t) => t + 1), 100);
    cleanup(() => clearInterval(id));
  });
  const accent = Accent();
  return (
    <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
      <Text style={{ flex: 1, fontSize: 28, fontWeight: "600", fontVariant: ["tabular-nums"], color: ui().text }}>
        {(tenths() / 10).toFixed(1)} s
      </Text>
      <View onPress={() => running.set(!running())} {...button("primary", { style: filled(() => ui()[accent()]) })}>
        <Text style={{ ...buttonText(), color: ui().page }}>{running() ? "Stop" : "Start"}</Text>
      </View>
      <View onPress={() => tenths.set(0)} {...button()}>
        <Text style={buttonText()}>Reset</Text>
      </View>
    </View>
  );
}

function Section(props: { title: string; children: any }) {
  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>
  );
}

function App() {
  const price = signal(12);
  const quantity = signal(1);
  const total = computed(() => price() * quantity());
  const writes = signal(0);
  const accent = Accent();

  // Runs again whenever `total` changes; reads of `writes` are not tracked.
  const log = signal<string[]>([]);
  effect(() => {
    const t = total();
    const n = untracked(writes);
    log.update((l) => [`total ${t} after ${n} writes`, ...l].slice(0, 3));
  });

  const step = (dp: number, dq: number) => {
    batch(() => {
      price.update((p) => Math.max(0, p + dp));
      quantity.update((q) => Math.max(0, q + dq));
      writes.update((w) => w + 2);
    });
  };

  const swatches: Swatch[] = ["accent", "info", "up", "violet"];

  return (
    <Scroll style={{ flex: 1, backgroundColor: ui().page }}>
      <View
        style={
          wide()
            ? { padding: 20, paddingTop: 24, gap: 16, display: "grid", gridTemplateColumns: "repeat(2, 1fr)" }
            : { padding: 20, paddingTop: 24, gap: 16 }
        }
      >
        <Section title="signal + computed">
          <Text style={{ fontSize: 17, color: ui().text }}>
            {price()} × {quantity()} = <Text style={{ fontWeight: "700", color: ui()[accent()] }}>{total()}</Text>
          </Text>
          <View style={{ flexDirection: "row", gap: 8 }}>
            <View onPress={() => step(1, 0)} {...button()}>
              <Text style={buttonText()}>price +1</Text>
            </View>
            <View onPress={() => step(0, 1)} {...button()}>
              <Text style={buttonText()}>qty +1</Text>
            </View>
            <View
              onPress={() => step(1, 1)}
              {...button("secondary", { style: () => ({ backgroundImage: "none", backgroundColor: tint(ui().warn, 0.14), borderColor: tint(ui().warn, 0.3), boxShadow: "none" }) })}
            >
              <Text style={{ ...buttonText(), color: ui().warn }}>both (batch)</Text>
            </View>
          </View>
        </Section>

        <Section title="effect + untracked">
          <Each of={log()}>{(line) => <Text style={{ fontSize: 13, color: ui().muted }}>{line()}</Text>}</Each>
        </Section>

        <Section title="effect + cleanup">
          <Stopwatch />
        </Section>

        <Section title="context (the value is a signal)">
          <View style={{ flexDirection: "row", gap: 10 }}>
            <Each of={swatches}>
              {(c) => (
                <View
                  onPress={() => accent.set(c())}
                  style={{ width: 36, height: 36, borderRadius: 18, backgroundColor: ui()[c()], borderWidth: 3, borderColor: accent() === c() ? ui().text : "transparent" }}
                  hoverStyle={{ opacity: 0.88 }}
                  focusStyle={focusRing()}
                  activeStyle={{ opacity: 0.7 }}
                />
              )}
            </Each>
          </View>
        </Section>
      </View>
    </Scroll>
  );
}

mount(() => (
  <Accent.Provider value={signal<Swatch>("accent")}>
    <App />
  </Accent.Provider>
));

Rust-Komponenten beziehen alles auf dieser Seite aus dem Prelude; TSX importiert es aus nsh:

use nsh_core::prelude::*;   // signal, computed, effect, resource, batch, untracked, cleanup, provide_context, use_context

#Signals

signal(value) erzeugt ein Signal. In Rust liest du es mit get(), in TSX durch Aufruf; set und update schreiben es:

let count = signal(0);

count.get();                    // 0, and tracks the read
count.set(5);
count.update(|n| *n += 1);      // 6

Ein Lesezugriff innerhalb eines Tracking-Scopes (ein Binding, computed, effect) abonniert diesen Scope beim Signal. Ein Schreibzugriff benachrichtigt die Subscriber, wenn sich der Wert geändert hat: Verglichen wird in Rust mit PartialEq, in TSX mit ===.

fn counter() -> impl IntoElement {
    let count = signal(0);
    view()
        .on_press(move || count.update(|n| *n += 1))
        .child(text(move || format!("Tapped {} times", count.get())))
}

Ein Tippen aktualisiert den einen Textknoten, der count liest. counter() / Counter selbst läuft nur einmal. Ein Rust-Signal ist ein Copy-Handle, daher übernehmen Closures es mit move und können es frei verwenden.

Ein Signal kann jeden Schreibzugriff als Änderung zählen, und TSX kann Werte auf eigene Weise vergleichen:

let tick = signal_with(0, SignalEquals::Never);   // every write notifies

In TSX startet signal<T>() ohne Wert als undefined und ist als Signal<T | undefined> typisiert; in Rust sagt ein Option dasselbe (signal(None::<User>)).

#Objekte und Arrays

In Rust ändert update den Wert direkt an Ort und Stelle und benachrichtigt die Leser; set ersetzt ihn. In TSX vergleichen Signals mit ===, ein an Ort und Stelle veränderter Wert ist also keine Änderung: Schreibe ein neues Objekt oder Array.

let todos = signal(Vec::<Todo>::new());

todos.update(|list| list.push(Todo { id: 3, title: "New".into(), done: false }));   // notifies
todos.update(|list| {
    if let Some(todo) = list.iter_mut().find(|t| t.id == id) {
        todo.done = true;
    }
});

each / <Each> vergibt Schlüssel für Zeilen pro Element, sodass die Änderung eines Elements nur diese Zeile neu aufbaut (JSX und Komponenten).

#computed

computed(fn) leitet einen Wert aus anderen Signals ab. Es führt fn aus, merkt sich das Ergebnis und führt es erneut aus, wenn sich ein gelesenes Signal ändert. Das Lesen funktioniert wie bei einem Signal:

let price = signal(12);
let quantity = signal(2);
let total = computed(move || price.get() * quantity.get());

text(move || format!("Total: {}", total.get()))

Verwende computed, wenn ein abgeleiteter Wert an mehreren Stellen gelesen wird oder teuer zu berechnen ist: Es läuft pro Änderung einmal, egal wie viele Leser es hat, und die Leser werden nur benachrichtigt, wenn sich sein Ergebnis ändert. Für einen günstigen Wert, der nur einmal gelesen wird, genügt eine einfache Closure:

let label = move || format!("{} items", quantity.get());
text(label)

In TSX erhält fn den vorherigen Wert (beim ersten Mal undefined), für Werte, die sich akkumulieren:

const max = computed((prev: number | undefined) => Math.max(prev ?? 0, speed()));

#effect

effect(fn) führt fn aus und erneut, sobald sich ein gelesenes Signal ändert. Effects sind für Seiteneffekte gedacht: Logging, Timer, Netzwerkanfragen, imperative Aufrufe auf Refs.

#[derive(Debug, Clone, PartialEq, Props)]
pub struct SearchProps {
    /// What to search for.
    pub query: Prop<SharedString>,
}

#[component]
pub fn Search(props: SearchProps) -> impl IntoElement {
    let results = signal(Vec::<SharedString>::new());
    effect(move || {
        let q = props.query.get();          // tracked: runs again when the prop changes
        if q.is_empty() {
            return;
        }
        spawn(async move {
            if let Ok(response) = fetch(format!("https://api.example.com/search?q={q}")).await {
                results.set(response.text().lines().map(|line| SharedString::from(line.to_owned())).collect());
            }
        });
    });
    view().child(each(results, |item| item.clone(), |item, _| text(item)))
}

Ein Effect verfolgt die Lesezugriffe, die während seiner synchronen Ausführung stattfinden. Lesezugriffe in einem Task, einem .then-Callback oder nach einem await werden nicht verfolgt.

Ein Effect, der ein Ref aufruft, wirkt auf das Element, an dem das Ref hängt; in TSX laufen Effects, nachdem die im selben Durchlauf erzeugten Elemente existieren, sodass Refs gesetzt sind:

let input = element_ref();
effect(move || {
    if editing.get() {
        input.focus();
    }
});

#resource

resource(source, fetcher) lädt einen Wert asynchron und merkt sich, wie weit der Ladevorgang ist, sodass eine Komponente Daten, einen Spinner oder einen Fehler anzeigt, ohne sie selbst zu verwalten:

let url = signal(SharedString::from("https://api.example.com/quote"));
let quote = resource(move || Some(url.get()), |url, _info| async move {
    let response = fetch(url.to_string()).await?;
    if !response.ok() {
        return Err(format!("HTTP {}", response.status()));
    }
    Ok(response.text())
});

view()
    .child(text(move || match (quote.get(), quote.error()) {
        (_, Some(error)) => format!("Failed: {error}"),
        (Some(quote), None) => quote,
        (None, None) => "Loading…".to_owned(),
    }))
    .child(text("Reload").on_press(move || quote.refetch()))

source wird verfolgt: Sobald es einen neuen Wert liefert, lädt fetcher(value, info) erneut. In Rust ist es eine Closure, die ein Option zurückgibt, und None lädt nichts; in TSX ist es ein Accessor (ein Signal, eine Funktion) oder ein Wert, und undefined, null oder false laden nichts: für einen Nutzer, der noch nicht angemeldet ist, oder wenn nichts ausgewählt ist. Ohne Source übergibt Rust || Some(()), und das resource(fetcher) von TSX lädt einmal und ruft fetcher(info) auf. Der Rust-Fetcher gibt ein Result zurück; der TSX-Fetcher einen Wert oder ein Promise, und ein throw gilt als Fehler.

Rust TSX Bedeutung
quote.get() posts() Der neueste Wert: keiner, bevor der erste Ladevorgang endet; er bleibt beim erneuten Laden und nach einem Fehler erhalten.
quote.loading() posts.loading() Ob ein Ladevorgang läuft.
quote.error() posts.error() Der Fehler des letzten Ladevorgangs; keiner, sobald ein Ladevorgang einen Wert liefert.
quote.state() posts.state() Unresolved / "unresolved" (nichts zu laden), Pending (der erste Ladevorgang), Ready, Refreshing (lädt erneut, der Wert bleibt erhalten), Errored.
quote.refetch() posts.refetch() Lädt erneut aus derselben Source: ein Retry, Pull-to-Refresh.
quote.mutate(value) posts.mutate(value) Setzt den Wert lokal ohne Laden, ein optimistisches Update, und löscht den Fehler.

Eine Rust-Resource ist wie Signals ein Copy-Handle; die TSX-Member sind gebunden, daher funktionieren onRefresh={posts.refetch} und const loading = posts.loading so, wie sie sind.

Nur der neueste Ladevorgang zählt. Ein neuer verwirft den laufenden, ebenso das Verschwinden der Komponente; in TSX bricht info.signal das ihm übergebene fetch ab. Ein Ladevorgang, der endet, nachdem er abgelöst wurde, ändert nichts. info.refetching besagt, dass refetch() den Ladevorgang gestartet hat. resource_with_value(value, source, fetcher) in Rust und { initialValue } in TSX geben der Resource von Anfang an einen Wert.

#cleanup

cleanup(fn) registriert fn, das ausgeführt wird, bevor der aktuelle Effect erneut läuft oder wenn die Komponente bzw. der Effect, zu dem es gehört, verschwindet:

effect(move || {
    if !running.get() {
        return;
    }
    let ticker = spawn(async move {
        loop {
            timer(Duration::from_millis(100)).await;
            tick();
        }
    });
    cleanup(move || ticker.cancel());   // before the next run, or on removal
});

In Rust enden Tasks, Timer und Intervalle (spawn, set_timeout, set_interval) bereits mit dem Owner, unter dem sie erzeugt wurden, sodass die eigene Uhr einer View kein Cleanup braucht:

use std::time::Duration;

use nsh_core::time::unix_ms;

fn clock() -> impl IntoElement {
    let now = signal(unix_ms());
    set_interval(Duration::from_secs(1), move || now.set(unix_ms()));   // ends with the view clock() made
    text(move || format!("{:.0}", now.get() / 1000.0))
}

Ein TSX-Effect kann sein Cleanup auch zurückgeben, wie in React: Eine Funktion, die fn zurückgibt, läuft zu denselben Zeitpunkten wie eine, die es an cleanup übergibt.

effect(() => {
  const id = setInterval(tick, 100);
  return () => clearInterval(id);
});

Eine Komponente verschwindet, wenn sie nicht mehr gerendert wird: Ihr when / <If>-Zweig wird ausgeblendet, ihre each / <Each>-Zeile wird entfernt oder ihre Seite wird entfernt (pop). Alles, was sie erzeugt hat (Effects, Computed Values, Tasks, Cleanups, verschachtelte Komponenten), wird mit ihr entsorgt.

#batch

Jeder Schreibzugriff benachrichtigt seine Subscriber sofort. Damit mehrere Schreibzugriffe als eine Änderung zählen, packe sie in batch: Abhängige laufen einmal, nach dem letzten Schreibzugriff.

let save = move || {
    batch(|| {
        first_name.set(form.first.clone());
        last_name.set(form.last.clone());
        dirty.set(false);
    })
};

Event-Handler werden nicht automatisch gebündelt: Zwei Schreibzugriffe in einem Press-Handler führen einen Effect, der beide Signals liest, zweimal aus. batch gibt zurück, was seine Funktion zurückgibt.

#untracked

untracked(fn) führt fn aus, ohne das Gelesene zu abonnieren. Verwende es in einem Tracking-Scope, um einen Wert zu lesen, ohne von ihm abzuhängen:

effect(move || {
    let q = query.get();                          // runs again when query changes
    let filters = untracked(|| active_filters.get());   // read, but not a reason to run again
    search(q, filters);
});

Das get_untracked() von Rust liest ein einzelnes Signal auf dieselbe Weise. Ein Komponenten-Body ist bereits untracked: Das Lesen eines Signals dort liest es einmal.

#context

Ein Context ist ein Wert, den eine Komponente allem bereitstellt, was sie rendert, egal wie tief. In Rust stellt provide_context(value) einen Wert seines Typs bereit, und use_context::<T>() findet den nächstgelegenen; in TSX erzeugt context(defaultValue) einen Context, ein Aufruf liest den nächsten bereitgestellten Wert (oder den Default), und <Ctx.Provider value> stellt einen bereit:

#[derive(Clone, Copy)]
struct Dark(bool);

fn label(content: &'static str) -> impl IntoElement {
    let dark = use_context::<Dark>().is_some_and(|d| d.0);
    text(content).text_color(if dark { rgb(0xffffff) } else { rgb(0x1f2328) })
}

fn screen() -> impl IntoElement {
    provide_context(Dark(true));
    view().child(label("Hello"))
}

Ein bereitgestellter Wert wird einmal gelesen, nämlich beim Nachschlagen. Um etwas bereitzustellen, das sich ändert, stelle ein Signal (oder ein Struct aus Signals) bereit und lies es in einem Binding:

#[derive(Clone, Copy)]
struct Accent(Signal<Hsla>);

#[component]
fn App() -> impl IntoElement {
    provide_context(Accent(signal(rgb(0x0969da).into())));
    view().child(swatch())
}

fn swatch() -> impl IntoElement {
    let Accent(accent) = use_context::<Accent>().expect("an accent");
    view()
        .w(px(40.0))
        .h(px(40.0))
        .bg(move || accent.get())
        .on_press(move || accent.set(rgb(0x8250df).into()))
}

Lies einen Context im Komponenten-Body (oder in Code, der läuft, während Komponenten erzeugt werden), nicht in einem Event-Handler oder einem Timer, wo es keine Komponente gibt, von der aus nachgeschlagen werden könnte. Rust-Komponenten beziehen auch das Theme der App auf diese Weise: theme() aus nsh_base (Theme).

#Error Boundaries

Eine Error Boundary beschränkt einen Fehler in einem Teil einer View auf diesen Teil. Wirft etwas darunter (Rust: Panic) beim Rendern oder Ausführen, etwa der Body einer Komponente, ein Binding, ein Effect oder ein Event-Handler, wird der fehlgeschlagene Teil entfernt, und die Boundary zeigt stattdessen ihren Fallback; der Rest der View bleibt unverändert. In Rust ist es catch_error(view, fallback); in TSX <ErrorBoundary fallback> aus nsh. Der Fallback erhält den Fehler (in TSX den geworfenen Wert selbst, meist ein Error) und ein Reset, das die View erneut rendert:

fn profile_card(user: Signal<Option<User>>) -> impl IntoElement {
    catch_error(
        move || profile(user),
        |error, reset| {
            view()
                .child(text(format!("Something went wrong: {error}")))
                .child(text("Try again").on_press(move || reset.reset()))
        },
    )
}

Ein Fehler geht an die nächste Boundary oberhalb der Stelle, an der er aufgetreten ist, und ein Fehler in einem Fallback an die Boundary darüber. Ohne Boundary wird der Fehler als nicht abgefangener Fehler gemeldet (das Development-Error-Overlay, das Log). In TSX wird ein fallback-Element unverändert angezeigt (fallback={<Text>Unavailable</Text>}), und eine Funktion wird mit dem Fehler und reset aufgerufen, unabhängig davon, welche Parameter sie deklariert.

#Was wann läuft

Du schreibst Läuft Läuft erneut, wenn
Komponenten-Body Einmal, bei der Erzeugung Nie (Hot Reload ausgenommen)
Ein Binding: eine Closure, die in Rust an text, einen Style oder eine Prop übergeben wird; {expr} oder prop={expr}, die in TSX eine Property aufruft oder liest Einmal, wenn das Element erzeugt wird sich ein gelesenes Signal ändert; nur diese Prop bzw. dieses Child wird aktualisiert
computed(fn) Bei der Erzeugung sich ein gelesenes Signal ändert
effect(fn) Nachdem die Elemente des aktuellen Durchlaufs existieren sich ein gelesenes Signal ändert
when(cond, ..) / <If when> Bei der Erzeugung sich ändert, ob die Bedingung zutrifft
each(..) / <Each of>-Zeile Wenn ihr Schlüssel zum ersten Mal erscheint Nie; stattdessen ändern sich ihre Item- und Index-Signals

Die Umgebung ist auf dieselbe Weise reaktiv: window(), dark(), font_scale(), safe_area() und keyboard_height() in Rust, windowSize(), colorScheme() und fontScale() aus nsh sowie safeArea() und keyboardFrame() aus nsh in TSX, sodass Bindings, die sie lesen, Rotation, Dark Mode oder Tastatur folgen.

#Häufige Fehler

Ein Signal im Komponenten-Body lesen und Updates erwarten.

fn bad() -> impl IntoElement {
    let n = count.get();                       // read once
    text(format!("{n}"))                       // never changes
}

fn good() -> impl IntoElement {
    text(move || format!("{}", count.get()))   // a binding: follows count
}

Props nur einmal lesen. Eine Rust-Prop, die im Body mit props.title.get() gelesen wird, wird einmal gelesen; lies sie in einem Binding (text(props.title), move || props.title.get()). In TSX liest const { title } = props title einmal: Lies props.title dort, wo du es verwendest, oder teile Props mit split auf.

In TSX mutieren statt setzen. list().push(x) ändert das Array, benachrichtigt aber niemanden. Schreibe ein neues Array, wie es update in Rust für dich übernimmt.

Zustand mit Effects ableiten. effect(() => total.set(price() * qty())) funktioniert, lässt aber total zwischen dem Schreibzugriff und dem Effect veraltet und kostet einen zweiten Durchlauf. Verwende computed.

Effects außerhalb von Komponenten erzeugen. Effects und Computed Values gehören zu der Komponente, die sie erzeugt hat, und werden mit ihr entsorgt. In einem Event-Handler oder Timer-Callback erzeugt, haben sie keinen Owner und leben ewig. Erzeuge sie im Komponenten-Body und lass sie die Signals lesen, die sich ändern.

Erwarten, dass asynchroner Code verfolgt wird. Nur Lesezugriffe, die synchron während der Ausführung eines Effects oder Computed Values stattfinden, werden verfolgt. Lies zuerst die Signals und starte dann die asynchrone Arbeit.

#Siehe auch