Esta página foi traduzida automaticamente e pode conter erros. Ler o original em inglês

Documentação

Reatividade

NSH atualiza a UI com signals: valores que sabem quem os lê. Quando um signal muda, o NSH executa novamente exatamente as computações que o leram (um texto, um estilo, um effect) e nada além disso. Os componentes não são renderizados de novo. O modelo é o do Solid, e o próprio grafo reativo é nativo: componentes Rust e TSX rodam no mesmo grafo, escrito em Rust, então também no TSX o agendamento, o rastreamento de dependências e a limpeza acontecem fora do interpretador.

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

Componentes Rust recebem tudo desta página do prelude; o TSX importa de nsh:

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

#Signals

signal(value) cria um signal. Leia-o com get() em Rust e, no TSX, chamando-o; set e update o escrevem:

let count = signal(0);

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

Uma leitura dentro de um escopo de rastreamento (um binding, computed, effect) inscreve esse escopo no signal. Uma escrita notifica os assinantes quando o valor mudou: a comparação é feita com PartialEq em Rust e com === no TSX.

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())))
}

Tocar atualiza o único nó de texto que lê count. counter() / Counter em si executa uma vez. Um signal em Rust é um handle Copy, então closures o recebem com move e o usam livremente.

Um signal pode contar toda escrita como mudança, e o TSX pode comparar valores do seu próprio jeito:

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

No TSX, signal<T>() sem valor começa como undefined e tem o tipo Signal<T | undefined>; em Rust, um Option diz o mesmo (signal(None::<User>)).

#Objetos e arrays

Em Rust, update altera o valor no lugar e notifica os leitores; set o substitui. No TSX, os signals comparam com ===, então um valor mutado no lugar não conta como mudança: escreva um novo objeto ou 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> indexa as linhas por item, então alterar um item reconstrói apenas aquela linha (JSX e componentes).

#computed

computed(fn) deriva um valor de outros signals. Ele executa fn, guarda o resultado e executa de novo quando um signal que ele leu muda. Lê-lo é como ler um signal:

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

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

Use computed quando um valor derivado é lido em vários lugares ou é caro de calcular: ele executa uma vez por mudança, não importa quantos leitores tenha, e os leitores só são notificados quando o resultado muda. Para um valor barato lido uma vez, uma closure simples basta:

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

No TSX, fn recebe o valor anterior (undefined na primeira vez), para valores que se acumulam:

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

#effect

effect(fn) executa fn e executa de novo sempre que um signal que ele leu muda. Effects servem para efeitos colaterais: logging, timers, requisições de rede, chamadas imperativas em 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)))
}

Um effect rastreia as leituras feitas enquanto executa de forma síncrona. Leituras em uma task, em um callback de .then ou depois de um await não são rastreadas.

Um effect que chama uma ref age sobre o elemento em que a ref está; no TSX, os effects executam depois que os elementos criados na mesma passada existem, então as refs já estão definidas:

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

#resource

resource(source, fetcher) carrega um valor de forma assíncrona e guarda em que ponto o carregamento está, de modo que um componente mostra dados, um spinner ou um erro sem precisar gerenciá-los por conta própria:

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 é rastreado: sempre que ele produz um novo valor, fetcher(value, info) carrega de novo. Em Rust, é uma closure que retorna um Option, e None não carrega nada; no TSX, é um accessor (um signal, uma função) ou um valor, e undefined, null ou false não carregam nada: serve para um usuário que ainda não entrou ou quando nada foi escolhido. Sem source, Rust passa || Some(()), e o resource(fetcher) do TSX carrega uma vez, chamando fetcher(info). O fetcher em Rust retorna um Result; o do TSX retorna um valor ou uma promise, e um throw é uma falha.

Rust TSX O que é
quote.get() posts() O valor mais recente: nenhum antes de o primeiro carregamento terminar, mantido durante um novo carregamento e após uma falha.
quote.loading() posts.loading() Se um carregamento está em execução.
quote.error() posts.error() O erro do último carregamento, nenhum quando um carregamento produz um valor.
quote.state() posts.state() Unresolved / "unresolved" (nada a carregar), Pending (o primeiro carregamento), Ready, Refreshing (carregando de novo, com o valor mantido), Errored.
quote.refetch() posts.refetch() Carrega de novo a partir da mesma source: uma nova tentativa, um pull to refresh.
quote.mutate(value) posts.mutate(value) Define o valor localmente, sem carregar, como uma atualização otimista, e limpa o erro.

Um resource em Rust é um handle Copy, assim como os signals; os membros do TSX já são vinculados, então onRefresh={posts.refetch} e const loading = posts.loading funcionam como estão.

Só o último carregamento conta. Um novo carregamento descarta o que estava em execução, e o componente deixar de existir também o descarta; no TSX, info.signal aborta o fetch que recebe. Um carregamento que termina depois de ter sido substituído não muda nada. info.refetching indica que refetch() iniciou o carregamento. resource_with_value(value, source, fetcher) em Rust e { initialValue } no TSX dão ao resource um valor desde o início.

#cleanup

cleanup(fn) registra fn para executar antes de o effect atual executar de novo, ou quando o componente ou o effect ao qual pertence deixa de existir:

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

Em Rust, tasks, timers e intervals (spawn, set_timeout, set_interval) já terminam junto com o owner sob o qual foram criados, então o relógio de uma view não precisa de cleanup:

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

Um effect do TSX também pode retornar seu cleanup, como no React: uma função que fn retorna executa nos mesmos momentos que uma passada a cleanup.

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

Um componente deixa de existir quando não é mais renderizado: seu branch when / <If> é ocultado, sua linha each / <Each> é removida ou sua página é desempilhada. Tudo o que ele criou (effects, valores computed, tasks, cleanups, componentes aninhados) é descartado junto com ele.

#batch

Cada escrita notifica seus assinantes imediatamente. Para que várias escritas contem como uma única mudança, envolva-as em batch: os dependentes executam uma vez, após a última escrita.

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

Os handlers de evento não são agrupados automaticamente: duas escritas em um handler de press executam duas vezes um effect que lê ambos os signals. batch retorna o que sua função retorna.

#untracked

untracked(fn) executa fn sem se inscrever no que ele lê. Use-o em um escopo de rastreamento para ler um valor sem depender dele:

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

O get_untracked() do Rust lê um único signal da mesma forma. O corpo de um componente já é untracked: ler um signal ali o lê uma vez.

#context

Um context é um valor que um componente fornece a tudo o que ele renderiza, por mais profundo que seja. Em Rust, provide_context(value) fornece um valor do seu tipo, e use_context::<T>() encontra o mais próximo; no TSX, context(defaultValue) cria um context, chamá-lo lê o valor fornecido mais próximo (ou o padrão), e <Ctx.Provider value> fornece um valor:

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

Um valor fornecido é lido uma vez, quando é consultado. Para fornecer algo que muda, forneça um signal (ou uma struct de signals) e leia-o em um 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()))
}

Leia um context no corpo de um componente (ou em código que executa enquanto os componentes estão sendo criados), não em um handler de evento nem em um timer, onde não há componente a partir do qual consultá-lo. Componentes Rust também recebem o tema do app dessa forma: theme() de nsh_base (Tema).

#Error boundaries

Um error boundary mantém uma falha em uma parte de uma view restrita a essa parte. Quando algo sob ele lança um erro (em Rust: entra em panic) ao renderizar ou executar, como o corpo de um componente, um binding, um effect ou um handler de evento, a parte que falhou é removida e o boundary mostra seu fallback no lugar; o resto da view permanece como está. Em Rust, é catch_error(view, fallback); no TSX, <ErrorBoundary fallback> de nsh. O fallback recebe o erro (no TSX, o próprio valor lançado, geralmente um Error) e um reset que renderiza a view de novo:

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()))
        },
    )
}

Uma falha vai para o boundary mais próximo acima de onde ocorreu, e uma falha em um fallback vai para o boundary acima desse. Sem um boundary, a falha é reportada como erro não capturado (o overlay de erro de desenvolvimento, o log). No TSX, um elemento fallback é exibido como está (fallback={<Text>Unavailable</Text>}), e uma função é chamada com o erro e reset, quaisquer que sejam os parâmetros que ela declare.

#O que executa e quando

Você escreve Executa Executa de novo quando
Corpo do componente Uma vez, quando é criado Nunca (exceto hot reload)
Um binding: uma closure passada a text, um estilo ou uma prop em Rust; {expr} ou prop={expr} que chama ou lê uma propriedade no TSX Uma vez, quando o elemento é criado Um signal que ele leu muda; apenas aquela prop ou filho é atualizado
computed(fn) Quando é criado Um signal que ele leu muda
effect(fn) Depois que os elementos da passada atual existem Um signal que ele leu muda
when(cond, ..) / <If when> Quando é criado Muda o resultado da condição
Linha de each(..) / <Each of> Quando sua chave aparece pela primeira vez Nunca; em vez disso, mudam seus signals de item e de índice

O ambiente é reativo da mesma forma: window(), dark(), font_scale(), safe_area() e keyboard_height() em Rust; windowSize(), colorScheme() e fontScale() de nsh e safeArea() e keyboardFrame() de nsh no TSX. Assim, os bindings que os leem acompanham a rotação, o modo escuro ou o teclado.

#Erros comuns

Ler um signal no corpo do componente e esperar atualizações.

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
}

Ler props uma só vez. Uma prop Rust lida com props.title.get() no corpo é lida uma vez; leia-a em um binding (text(props.title), move || props.title.get()). No TSX, const { title } = props lê title uma vez: leia props.title onde for usá-lo, ou divida as props com split.

Mutar em vez de definir, no TSX. list().push(x) altera o array, mas não notifica ninguém. Escreva um novo array, como o update do Rust faz por você.

Derivar estado com effects. effect(() => total.set(price() * qty())) funciona, mas deixa total desatualizado entre a escrita e o effect, e custa uma segunda passada. Use computed.

Criar effects fora de componentes. Effects e valores computed pertencem ao componente que os criou e são descartados junto com ele. Se forem criados em um handler de evento ou em um callback de timer, não têm owner e vivem para sempre. Crie-os no corpo de um componente e deixe que leiam os signals que mudam.

Esperar que código assíncrono seja rastreado. Só são rastreadas as leituras feitas de forma síncrona enquanto um effect ou um valor computed executa. Leia os signals primeiro e depois inicie o trabalho assíncrono.

#Relacionados