Работа с Сигналами

Пока что мы использовали простые примеры с create_signal, возвращающие геттер ReadSignal и сеттер WriteSignal.

Чтение и запись данных

Есть четыре простые операции с сигналами:

  1. .get() клонирует текущее значение сигнала и реактивно отслеживает будущие изменения.
  2. .with() принимает функцию, получающую текущее значеие сигнала по ссылке (&T) и отслеживает будущие изменения.
  3. .set() заменяет текущее значение сигнала и уведомляет об этом подписчиков.
  4. .update() принимает функцию, которая принимает мутабельную ссылку на текущее значение сигнала (&mut T) и уведомляет подписчиков. (.update() не возвращает значение возвращённое замыканием; для этого можно использовать .try_update(), если, например, при удалении элемента из Vec<_> нужно его вернуть.)

Вызов ReadSignal как функции это синтаксический сахар для.get(). Вызов WriteSignalкак функции это синтаксический сахар для .set(). Так что

let (count, set_count) = create_signal(0);
set_count(1);
logging::log!(count());

это то же самое, что

let (count, set_count) = create_signal(0);
set_count.set(1);
logging::log!(count.get());

Можно заметить, что и .get() и .set() могут быть реализованы при помощи .with() и .update(). Другими словами, count.get() идентично count.with(|n| n.clone()), а count.set(1) реализуется посредством count.update(|n| *n = 1).

Но конечно же, .get() и .set() (или простые вызовы функций!) это намного более приятный синтаксис.

Однако, и для .with() с .update() есть очень хорошее применение.

Например, возьмём сигнала с типом Vec<String>.

let (names, set_names) = create_signal(Vec::new());
if names().is_empty() {
	set_names(vec!["Alice".to_string()]);
}

С точки зрения логики, этот пример достаточно прост, но в нём скрываются существенные недостатки, влияющие на производительности.
Помните, что names().is_empty() это сахар для names.get().is_empty(), который клонирует значение (it’s names.with(|n| n.clone()).is_empty()). Это значит мы клонируем значение Vec<String> целиком, выполняем is_empty(), и тут же выбрасываем клонированное значение.

Аналогично и set_names заменяет значение новым Vec<_>. В общем-то ничего страшного, но мы можем просто мутировать исходный Vec<_> там где он лежит.

let (names, set_names) = create_signal(Vec::new());
if names.with(|names| names.is_empty()) {
	set_names.update(|names| names.push("Alice".to_string()));
}

Теперь наша функция просто принимает names по ссылке и запускает is_empty(), избегая клонирования.

Пользователи Clippy и те, у кого острый глаз, могли заметить, что можно сделать ещё лучше:

if names.with(Vec::is_empty) {
	// ...
}

В конце концов, .with() просто принимает функцию, которая принимает значение по ссылке. Поскольку Vec::is_empty принимает &self, мы можем передать её напрямую и избавиться от ненужного замыкания.

Есть вспомогательные макросы, упрощающие использование .with() и .update(), особенно при работе с несколькими сигналами сразу.

let (first, _) = create_signal("Bob".to_string());
let (middle, _) = create_signal("J.".to_string());
let (last, _) = create_signal("Smith".to_string());

Если хочется конкатенировать эти 3 сигнала вместе без ненужного клонирования, пришлось бы написать что-то вроде:

let name = move || {
	first.with(|first| {
		middle.with(|middle| last.with(|last| format!("{first} {middle} {last}")))
	})
};

Очень длинно и писать неприятно.

Вместо этого можно использовать макрос with!, чтобы получить ссылки на все эти сигналы сразу.

let name = move || with!(|first, middle, last| format!("{first} {middle} {last}"));

Это превращается в то, что было выше. Посмотрите документацию к with! для дополнительной информации, и макросам update!, with_value! и update_value!.

Делаем так, чтобы сигналы зависели друг от друга

Люди часто спрашивают о ситуациях, когда какой-то сигнал должен меняться в зависимости от значения другого сигнала. Для этого есть три хороших способа и один неидеальный, но сносный в контролируемых обстоятельства.

Хорошие варианты

1) Б это функция от А. Создадим сигнал для А и производный сигнал или memo для Б.

let (count, set_count) = create_signal(1);
let derived_signal_double_count = move || count() * 2;
let memoized_double_count = create_memo(move |_| count() * 2);

Рекомендации о том как выбирать между производным сигналом и memo можно найти в документации к create_memo

2) В это функция от А и Б. Создадим сигналы для А и Б, а также производный сигнал или memo для В.

let (first_name, set_first_name) = create_signal("Bridget".to_string());
let (last_name, set_last_name) = create_signal("Jones".to_string());
let full_name = move || with!(|first_name, last_name| format!("{first_name} {last_name}"));

3) А и Б — независимые сигналы, но иногда они обновляются одновременно.. Когда вы обновляете A, отдельно вызываете и обновление Б.

let (age, set_age) = create_signal(32);
let (favorite_number, set_favorite_number) = create_signal(42);
// use this to handle a click on a `Clear` button
let clear_handler = move |_| {
  set_age(0);
  set_favorite_number(0);
};

Если без этого никак...

4) Создайте эффект, чтобы писать в Б каждый раз когда А меняется. Так делать официально не рекомендуется по нескольким причинам: a) Это всегда будет более затратно, так как это значит, что каждый раз, когда А обновляется, будет два полных прохода через реактивный процесс.
(устанавливается значение А, это вызывает запуск этого эффекта, наряду с остальными, зависящими от А. Затем меняется Б, что вызовет выполнение всех зависящих от Б эффектов.) b) Это повышает вероятность нечаянно сделать бесконечный цикл или эффекты, выполняющиеся слишком часто. Это тот самый пинг-понг, происходивший в реактивном спагетти-коде в начале 2010-х годов, которого мы стараемся избежать посредством разделения чтения и записи, а также не рекомендуя писать в сигналы из эффектов.

В большинстве ситуаций лучше переписать всё таким образом, чтобы был чёткий поток данных сверху вниз, основанный на производных сигналах или мемоизированных значениях. Но это не конец света.

Я намеренно не привожу здесь пример кода. Прочтите документацию к create_effect понять как это должно работать.