Итерация более сложных структур через <For/>

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

Задача

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

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

#[derive(Debug, Clone)]
struct DatabaseEntry {
    key: String,
    value: i32,
}

Давайте объявим простой компонент, который будет итерировать ряды и отображать каждый из них:

#[component]
pub fn App() -> impl IntoView {
	// start with a set of three rows
    let (data, set_data) = create_signal(vec![
        DatabaseEntry {
            key: "foo".to_string(),
            value: 10,
        },
        DatabaseEntry {
            key: "bar".to_string(),
            value: 20,
        },
        DatabaseEntry {
            key: "baz".to_string(),
            value: 15,
        },
    ]);
    view! {
		// when we click, update each row,
		// doubling its value
        <button on:click=move |_| {
            set_data.update(|data| {
                for row in data {
                    row.value *= 2;
                }
            });
			// log the new value of the signal
            logging::log!("{:?}", data.get());
        }>
            "Update Values"
        </button>
		// iterate over the rows and display each value
        <For
            each=data
            key=|state| state.key.clone()
            let:child
        >
            <p>{child.value}</p>
        </For>
    }
}

Обратите внимание на синтаксис let:child. В предыдущей главе мы познакомили вас с <For/> со свойством children. Мы можем создавать это значение напрямую внутри компонента <For> без выхода за пределы макроса view: let:child вкупе с <p>{child.value}</p> в коде выше эквивалентно

children=|child| view! { <p>{child.value}</p> }

При нажатии на кнопку Update Values... ничего не происходит. Или точнее: сигнал обновляется, новое значение логируется, но значение {child.value} для каждого ряда не обновляется.

Давайте посмотрим: это потому что мы забыли добавить замыкание, чтобы сделать его реактивным? Попробуем {move || child.value}.

...Нет. Ничего не поменялось.

Проблема вот в чём: как я говорил, каждый ряд рендерится повторно только когда его ключ меняется. Мы обновили значение для каждого ряда, но ключ ни для одного ряда не поменялся, поэтому ничего и не отрендерилось повторно. И если посмотреть на тип child.value, то это простой i32, а не реактивный ReadSignal<i32> или прочее подобное. Это значит даже если мы обернём его в замыкание, значение в этом ряду никогда не будет обновлено.

Есть три возможных решения:

  1. изменять значение key так, что он всегда меняется когда структура данных меняется
  2. изменить тип value чтобы он стал реактивным
  3. принимать реактивный срез структуры данных вместо использования каждого ряда напрямую

Вариант 1: Изменение ключ

Каждый ряд рендерится повторно лишь когда его ключ меняется. Наши ряды в примере выше не рендерились повторно, поскольку ключ не менялся. Так почему бы просто не заставить ключ меняться?

<For
	each=data
	key=|state| (state.key.clone(), state.value)
	let:child
>
	<p>{child.value}</p>
</For>

Теперь мы составляем key из ключа и значения. Это значит что при любом изменении значения ряда, <For/> будет считать, что это полностью новый ряд и будет заменять им старый.

Преимущества

Это очень легкий вариант. Мы можем сделать его даже легче добавив #[derive(PartialEq, Eq, Hash)] перед DatabaseEntry, в таком случае мы сможем написать просто key=|state| state.clone().

Недостатки

Это наиболее затратный вариант из трёх. Каждый раз когда значение ряда изменяется, предыдущий элемент <p> выбрасывается и заменяется полностью новым. Вместо мелкозернистого обновление текстового узла происходит настоящий повторный рендеринг целого ряда при каждом измении, и дороговизна этой операция пропорциональна тому насколько сложный UI у этого ряда.

Как можно заметить, мы также не избежим клонирования всей структуры данных, чтобы <For/> мог иметь копию ключа. Для более сложных структур это быстро становится дурной затеей!

Вариант 2: Вложенные Сигналы

Если мы всё же хотим мелкозернистую реактивность (англ. fine-grained reactivity) для значения, одним из вариантов явлется обернуть value каждого ряда в сигнал.

#[derive(Debug, Clone)]
struct DatabaseEntry {
    key: String,
    value: RwSignal<i32>,
}

RwSignal<_> это “read-write сигнал”, совмещающий геттер и сеттер в одном объекте. Я использую его здесь потому, что в структуре его хранить немного проще, чем отдельно геттер и сеттер.

#[component]
pub fn App() -> impl IntoView {
	// start with a set of three rows
    let (data, set_data) = create_signal(vec![
        DatabaseEntry {
            key: "foo".to_string(),
            value: create_rw_signal(10),
        },
        DatabaseEntry {
            key: "bar".to_string(),
            value: create_rw_signal(20),
        },
        DatabaseEntry {
            key: "baz".to_string(),
            value: create_rw_signal(15),
        },
    ]);
    view! {
		// when we click, update each row,
		// doubling its value
        <button on:click=move |_| {
            data.with(|data| {
                for row in data {
                    row.value.update(|value| *value *= 2);
                }
            });
			// log the new value of the signal
            logging::log!("{:?}", data.get());
        }>
            "Update Values"
        </button>
		// iterate over the rows and display each value
        <For
            each=data
            key=|state| state.key.clone()
            let:child
        >
            <p>{child.value}</p>
        </For>
    }
}

Эта версия работает! И если вы посмотрите через DOM инспектор в браузере, то увидите, что в этой версии, в отличие от предыдущей, лишь отдельные текстовые узлы обновляются. Передача сигнала напрямую в {child.value} работает, поскольку сигналы остаются реактивными при передаче их во view.

Обратите внимание, я заменил set_data.update() на data.with(). .with() это способ получить доступ к значению сигнала, не клонируя его. В данном случае мы лишь обновляем внутренние значение, не трогая список значений: поскольку сигналы имеют собственное состояние, нам в действительности вовсе не нужно обновлять сигнал data, так что иммутабельный вызов .with() вполне подходит.

Фактически эта версия не обновляет data, так что <For/> здесь в сущности выполняет роль статического списка из прошлой главы, и это мог бы быть обычный итератор. Но <For/> будет полезен если в будущем мы захотим добавлять или удалять ряды.

Преимущества

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

Недостатки

Вложенная реактивность может быть громоздкой если вы получаете данные из API или другого неподконтрольного источника, и не желаете создавать ещё одну структуру оборачивая каждое поле в сигнал.

Вариант 3: Мемоизированные Срезы

Leptos предоставляет примитив называемый create_memo, который создает производной вычисление, которое вызывает реактивное обновление только когда его значение изменилось.

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

Большая часть приложения может остаться неизменной относительно первоначальной (сломанной) версии, но <For/> будет выглядеть так:

<For
    each=move || data().into_iter().enumerate()
    key=|(_, state)| state.key.clone()
    children=move |(index, _)| {
        let value = create_memo(move |_| {
            data.with(|data| data.get(index).map(|d| d.value).unwrap_or(0))
        });
        view! {
            <p>{value}</p>
        }
    }
/>

Здесь вы заметите несколько отличий:

  • мы конвертируем сигнал data в нумерующий итератор
  • мы используем свойство children явным образом, чтобы упростить выполнение кода вне view
  • мы создаем мемоизированное значение value и используем его и во view. Это поле value, на самом деле, не использует значение child передаваемое в каждый ряд. Вместо этого, оно использует индекс и обращается непосредственно к data чтобы получить значение.

Теперь, каждый раз, когда data меняется, каждое мемоизированное значение будет пересчитано. Если его значение изменилось, оно поменяет свой текстовый узел без повторного рендеринга всего ряда.

Преимущества

Мы получаем ту же мелкозернистую реактивность, что и в версии с оборачиваем в сигналы, без необходимости оборачивать данные в сигналы.

Недостатки

Это создание мемоизированного значения для каждого ряда внутри цикла <For/> немного более сложно чем использовать вложенными сигналы. Например, вы заметите, что мы должны защищаться от возможной паники со стороны data[index], используя data.get(index), поскольку перезапуск memo может быть спровоцирован сразу после того как ряд был удален. (Это потому что и memo для каждого ряда и весь <For/> зависят от одного и того же сигнала data, а порядок выполнения для нескольких реактивных значений зависящих от одного сигнала не гарантирован.)

Также обратите внимание, несмотря на то, как мемоизированные значения запоминают свои реактивные изменения, одним и тем же вычисления все-таки нужно каждый раз выполняться повторно, чтобы проверять значение, так что вложенные реактивные сигналы всё ещё будут менее затратны при отслеживании обновлений в данном случае.