Эта страница переведена машинным способом и может содержать ошибки. Читать оригинал на английском

Документация

Реактивность

NSH обновляет UI с помощью сигналов (signals): значений, которые знают, кто их читает. Когда сигнал меняется, NSH заново запускает ровно те вычисления, которые его читали (текст, стиль, эффект), и ничего больше. Компоненты не перерисовываются. Модель взята из Solid, а сам реактивный граф нативный: компоненты на Rust и TSX работают на одном графе, написанном на Rust, поэтому и в TSX планирование, отслеживание зависимостей и очистка происходят вне интерпретатора.

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 берут всё с этой страницы из prelude; TSX импортирует это из nsh:

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

#Сигналы

signal(value) создаёт сигнал. Читайте его через get() в Rust и вызовом в TSX; set и update записывают значение:

let count = signal(0);

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

Чтение внутри отслеживаемой области (привязка, computed, effect) подписывает эту область на сигнал. Запись уведомляет подписчиков, если значение изменилось: сравнение выполняется через PartialEq в Rust и через === в 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())))
}

Нажатие обновляет единственный текстовый узел, который читает count. Сам counter() / Counter выполняется один раз. Сигнал в Rust — это handle Copy, поэтому замыкания захватывают его через move и могут свободно использовать.

Сигнал может считать каждую запись изменением, а TSX позволяет сравнивать значения по-своему:

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

В TSX signal<T>() без значения начинается с undefined и имеет тип Signal<T | undefined>; в Rust то же самое выражает Option (signal(None::<User>)).

#Объекты и массивы

В Rust update изменяет значение на месте и уведомляет читателей; set заменяет его. В TSX сигналы сравниваются через ===, поэтому значение, изменённое на месте, не считается изменением: записывайте новый объект или массив.

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> сопоставляет строки по элементу, поэтому изменение одного элемента перестраивает только его строку (JSX и компоненты).

#computed

computed(fn) вычисляет значение из других сигналов. Он выполняет fn, запоминает результат и запускает его снова, когда меняется прочитанный им сигнал. Читается он как сигнал:

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

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

Используйте computed, когда производное значение читается в нескольких местах или дорого вычисляется: он выполняется один раз на изменение, сколько бы у него ни было читателей, а читатели уведомляются, только когда меняется его результат. Для дешёвого значения, которое читается один раз, достаточно обычного замыкания:

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

В TSX fn получает предыдущее значение (undefined в первый раз), что удобно для накапливаемых значений:

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

#effect

effect(fn) выполняет fn и запускает его снова, когда меняется прочитанный им сигнал. Эффекты предназначены для побочных действий: логирования, таймеров, сетевых запросов, императивных вызовов у 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)))
}

Эффект отслеживает чтения, выполненные во время его синхронной работы. Чтения в задаче, в колбэке .then или после await не отслеживаются.

Эффект, вызывающий ref, действует на элемент, к которому привязан этот ref; в TSX эффекты запускаются после того, как созданы элементы того же прохода, поэтому refs уже установлены:

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

#resource

resource(source, fetcher) загружает значение асинхронно и хранит состояние загрузки, поэтому компонент показывает данные, индикатор загрузки или ошибку, не управляя ими сам:

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 отслеживается: всякий раз, когда он выдаёт новое значение, fetcher(value, info) загружает данные заново. В Rust это замыкание, возвращающее Option, и None ничего не загружает; в TSX это accessor (сигнал, функция) или значение, и undefined, null или false ничего не загружают: например, когда пользователь ещё не вошёл или ничего не выбрано. Без источника Rust передаёт || Some(()), а resource(fetcher) в TSX загружает один раз, вызывая fetcher(info). Fetcher в Rust возвращает Result; в TSX — значение или promise, а выброшенное исключение считается ошибкой загрузки.

RustTSXЧто это
quote.get()posts()Последнее значение: отсутствует, пока не завершилась первая загрузка; сохраняется при повторной загрузке и после ошибки.
quote.loading()posts.loading()Выполняется ли загрузка.
quote.error()posts.error()Ошибка последней загрузки; отсутствует, как только загрузка дала значение.
quote.state()posts.state()Unresolved / "unresolved" (нечего загружать), Pending (первая загрузка), Ready, Refreshing (повторная загрузка, значение сохраняется), Errored.
quote.refetch()posts.refetch()Загружает заново из того же источника: повторная попытка, обновление жестом «потянуть».
quote.mutate(value)posts.mutate(value)Устанавливает значение локально без загрузки (оптимистичное обновление) и сбрасывает ошибку.

Ресурс в Rust — это handle Copy, как и сигналы; члены в TSX привязаны к ресурсу, поэтому onRefresh={posts.refetch} и const loading = posts.loading работают как есть.

Учитывается только последняя загрузка. Новая отменяет выполняющуюся, так же как и уничтожение компонента; в TSX info.signal прерывает переданный ему fetch. Загрузка, которая завершилась после того, как её заменили, ничего не меняет. info.refetching означает, что загрузку запустил refetch(). resource_with_value(value, source, fetcher) в Rust и { initialValue } в TSX задают ресурсу значение с самого начала.

#cleanup

cleanup(fn) регистрирует fn, который выполнится перед очередным запуском текущего эффекта или когда компонент или эффект, которому он принадлежит, будет уничтожен:

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

В Rust задачи, таймеры и интервалы (spawn, set_timeout, set_interval) уже завершаются вместе с владельцем, под которым были созданы, поэтому собственным часам представления очистка не нужна:

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

Эффект в TSX также может вернуть свою очистку, как в React: функция, которую возвращает fn, выполняется в те же моменты, что и функция, переданная в cleanup.

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

Компонент уничтожается, когда перестаёт отображаться: его ветка when / <If> скрыта, его строка each / <Each> удалена или его страница снята со стека. Всё, что он создал (эффекты, вычисляемые значения, задачи, очистки, вложенные компоненты), уничтожается вместе с ним.

#batch

Каждая запись сразу уведомляет подписчиков. Чтобы несколько записей считались одним изменением, оберните их в batch: зависимые вычисления выполнятся один раз, после последней записи.

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

Обработчики событий автоматически не группируются: две записи в обработчике нажатия запустят эффект, читающий оба сигнала, дважды. batch возвращает то, что возвращает его функция.

#untracked

untracked(fn) выполняет fn, не подписываясь на то, что тот читает. Используйте его в отслеживаемой области, чтобы прочитать значение, не завися от него:

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

get_untracked() в Rust читает один сигнал таким же образом. Тело компонента уже не отслеживается: чтение сигнала там происходит один раз.

#context

Контекст — это значение, которое компонент предоставляет всему, что он отображает, на любой глубине. В Rust provide_context(value) предоставляет значение своего типа, а use_context::<T>() находит ближайшее; в TSX context(defaultValue) создаёт контекст, его вызов читает ближайшее предоставленное значение (или значение по умолчанию), а <Ctx.Provider value> предоставляет значение:

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

Предоставленное значение читается один раз, в момент поиска. Чтобы предоставить изменяющееся значение, предоставьте сигнал (или структуру сигналов) и читайте его в привязке:

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

Читайте контекст в теле компонента (или в коде, который выполняется во время создания компонентов), а не в обработчике событий или таймере, где нет компонента, от которого можно вести поиск. Компоненты на Rust получают тему приложения тем же способом: theme() из nsh_base (Тема).

#Границы ошибок

Граница ошибок (error boundary) удерживает сбой в пределах одной части представления. Когда что-то под ней выбрасывает исключение (в Rust — паникует) при отрисовке или выполнении, например в теле компонента, привязке, эффекте или обработчике события, упавшая часть удаляется, а на её месте граница показывает fallback; остальное представление остаётся как есть. В Rust это catch_error(view, fallback); в TSX — <ErrorBoundary fallback> из nsh. Fallback получает ошибку (в TSX — само выброшенное значение, обычно Error) и reset, который отображает представление заново:

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

Сбой попадает в ближайшую границу выше места, где он произошёл, а сбой внутри fallback — в границу выше неё. Без границы сбой сообщается как необработанная ошибка (оверлей ошибок разработки, лог). В TSX элемент fallback показывается как есть (fallback={<Text>Unavailable</Text>}), а функция вызывается с ошибкой и reset, какие бы параметры она ни объявляла.

#Что и когда выполняется

Вы пишетеВыполняетсяВыполняется снова, когда
Тело компонентаОдин раз, при созданииНикогда (кроме hot reload)
Привязка: замыкание, переданное в text, стиль или проп в Rust; {expr} или prop={expr}, которые вызывают или читают свойство, в TSXОдин раз, при создании элементаМеняется прочитанный ею сигнал; обновляется только этот проп или дочерний элемент
computed(fn)При созданииМеняется прочитанный им сигнал
effect(fn)После того как созданы элементы текущего проходаМеняется прочитанный им сигнал
when(cond, ..) / <If when>При созданииМеняется выполнение условия
Строка each(..) / <Each of>Когда её ключ появляется впервыеНикогда; вместо этого меняются сигналы её элемента и индекса

Окружение реактивно таким же образом: window(), dark(), font_scale(), safe_area() и keyboard_height() в Rust, windowSize(), colorScheme() и fontScale() из nsh, а также safeArea() и keyboardFrame() из nsh в TSX, поэтому привязки, которые их читают, следуют за поворотом экрана, тёмной темой или клавиатурой.

#Типичные ошибки

Чтение сигнала в теле компонента в расчёте на обновления.

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
}

Однократное чтение пропов. Проп в Rust, прочитанный через props.title.get() в теле, читается один раз; читайте его в привязке (text(props.title), move || props.title.get()). В TSX const { title } = props читает title один раз: читайте props.title там, где используете, или разделяйте пропы через split.

Мутация вместо установки значения в TSX. list().push(x) меняет массив, но никого не уведомляет. Записывайте новый массив, как это делает за вас update в Rust.

Вывод состояния через эффекты. effect(() => total.set(price() * qty())) работает, но оставляет total устаревшим между записью и эффектом и стоит второго прохода. Используйте computed.

Создание эффектов вне компонентов. Эффекты и вычисляемые значения принадлежат компоненту, который их создал, и уничтожаются вместе с ним. Если создать их в обработчике события или колбэке таймера, у них не будет владельца и они будут жить вечно. Создавайте их в теле компонента и позволяйте им читать сигналы, которые меняются.

Ожидание, что асинхронный код отслеживается. Отслеживаются только чтения, выполненные синхронно во время работы эффекта или вычисляемого значения. Сначала прочитайте сигналы, затем запускайте асинхронную работу.

#См. также