使用 <For/> 遍历更复杂的数据
Iterating over More Complex Data with <For/>
本章将更深入地探讨嵌套数据结构的迭代。它与另一篇关于迭代的章节放在一起,但如果你现在想继续学习更简单的内容,可以随时跳过本章以后再回来。
This chapter goes into iteration over nested data structures in a bit more depth. It belongs here with the other chapter on iteration, but feel free to skip it and come back if you’d like to stick with simpler subjects for now.
问题
The Problem
我刚才说过,除非键(key)发生变化,否则框架不会重新渲染行中的任何项。这起初听起来可能很有道理,但它很容易让你栽跟头。
I just said that the framework does not rerender any of the items in one of the rows, unless the key has changed. This probably makes sense at first, but it can easily trip you up.
让我们来看一个例子,其中每一行中的每一项都是某种数据结构。例如,假设这些项来自某个包含键和值的 JSON 数组:
Let’s consider an example in which each of the items in our row is some data structure. Imagine, for example, that the items come from some JSON array of keys and values:
#[derive(Debug, Clone)]
struct DatabaseEntry {
key: String,
value: i32,
}
让我们定义一个简单的组件,它将遍历这些行并显示每一行:
Let’s define a simple component that will iterate over the rows and display each one:
#[component]
pub fn App() -> impl IntoView {
// 以一组三行数据开始
// start with a set of three rows
let (data, set_data) = 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
leptos::logging::log!("{:?}", data.get());
}>
"Update Values"
</button>
// 遍历行并显示每个值
// iterate over the rows and display each value
<For
each=move || data.get()
key=|state| state.key.clone()
let(child)
>
<p>{child.value}</p>
</For>
}
}
注意这里的
let(child)语法。在前一章中,我们介绍了带有children属性的<For/>。实际上,我们可以直接在<For/>组件的子节点中创建这个值,而无需跳出view宏:上面的let(child)结合<p>{child.value}</p>相当于children=|child| view! { <p>{child.value}</p> }为了方便,你也可以选择对数据模式进行解构:
<For each=move || data.get() key=|state| state.key.clone() let(DatabaseEntry { key, value }) >Note the
let(child)syntax here. In the previous chapter we introduced<For/>with achildrenprop. We can actually create this value directly in the children of the<For/>component, without breaking out of theviewmacro: thelet(child)combined with<p>{child.value}</p>above is the equivalent ofchildren=|child| view! { <p>{child.value}</p> }For convenience, you can also choose to destructure the pattern of your data:
<For each=move || data.get() key=|state| state.key.clone() let(DatabaseEntry { key, value }) >
当你点击 “Update Values” 按钮时……什么都没有发生。或者更确切地说:信号更新了,新值也被记录了,但每一行的 {child.value} 并没有更新。
When you click the Update Values button... nothing happens. Or rather:
the signal is updated, the new value is logged, but the {child.value}
for each row doesn’t update.
让我们看看:是不是因为我们忘了添加闭包来使其具有响应性?让我们试试 {move || child.value}。
Let’s see: is that because we forgot to add a closure to make it reactive?
Let’s try {move || child.value}.
……不,还是不行。
...Nope. Still nothing.
问题出在这里:正如我所说,只有当键发生变化时,每一行才会重新渲染。我们更新了每一行的值,但没有更新任何一行的键,所以没有任何内容重新渲染。如果你查看 child.value 的类型,它是一个普通的 i32,而不是响应式的 ReadSignal<i32> 或类似的东西。这意味着即使我们用闭包包裹它,这一行中的值也永远不会更新。
Here’s the problem: as I said, each row is only rerendered when the key changes.
We’ve updated the value for each row, but not the key for any of the rows, so
nothing has rerendered. And if you look at the type of child.value, it’s a plain
i32, not a reactive ReadSignal<i32> or something. This means that even if we
wrap a closure around it, the value in this row will never update.
我们有四种可能的解决方案:
We have four possible solutions:
-
更改
key,使其在数据结构发生变化时总是更新 -
更改
value,使其具有响应性 -
采用数据结构的响应式切片(slice),而不是直接使用每一行
-
使用
Store -
change the
keyso that it always updates when the data structure changes -
change the
valueso that it’s reactive -
take a reactive slice of the data structure instead of using each row directly
-
use a
Store
方案 1:更改键
Option 1: Change the Key
只有当键发生变化时,每一行才会重新渲染。上面的行没有重新渲染,是因为键没有变。那么:为什么不直接强制键发生变化呢?
Each row is only rerendered when the key changes. Our rows above didn’t rerender, because the key didn’t change. So: why not just force the key to change?
<For
each=move || data.get()
key=|state| (state.key.clone(), state.value)
let(child)
>
<p>{child.value}</p>
</For>
现在我们将键和值都包含在 key 中。这意味着每当一行的值发生变化时,<For/> 都会将其视为一个全新的行,并替换之前的行。
Now we include both the key and the value in the key. This means that whenever the
value of a row changes, <For/> will treat it as if it’s an entirely new row, and
replace the previous one.
优点
Pros
这非常简单。我们可以通过在 DatabaseEntry 上派生 PartialEq、Eq 和 Hash 来使其变得更加简单,在这种情况下,我们只需 key=|state| state.clone() 即可。
This is very easy. We can make it even easier by deriving PartialEq, Eq, and Hash
on DatabaseEntry, in which case we could just key=|state| state.clone().
缺点
Cons
这是四种方案中效率最低的。 每当一行的值发生变化时,它都会丢弃之前的 <p> 元素,并用一个全新的元素替换它。换句话说,它并没有对文本节点进行细粒度的更新,而是确实在每次更改时重新渲染整行,其成本与该行 UI 的复杂度成正比。
This is the least efficient of the four options. Every time the value of a row
changes, it throws out the previous <p> element and replaces it with an entirely new
one. Rather than making a fine-grained update to the text node, in other words, it really
does rerender the entire row on every change, and this is expensive in proportion to how
complex the UI of the row is.
你会注意到,我们最终还会克隆整个数据结构,以便 <For/> 可以保留一份键的副本。对于更复杂的结构,这很快就会变成一个坏主意!
You’ll notice we also end up cloning the whole data structure so that <For/> can hold
onto a copy of the key. For more complex structures, this can become a bad idea fast!
方案 2:嵌套信号
Option 2: Nested Signals
如果我们确实想要针对该值的细粒度响应性,一种选择是将每一行的 value 包裹在一个信号中。
If we do want that fine-grained reactivity for the value, one option is to wrap the value
of each row in a signal.
#[derive(Debug, Clone)]
struct DatabaseEntry {
key: String,
value: RwSignal<i32>,
}
RwSignal<_> 是一个 “读写信号”,它将 getter 和 setter 组合在一个对象中。我在这里使用它是因为它比单独的 getter 和 setter 稍微容易存储在结构体中。
RwSignal<_> is a “read-write signal,” which combines the getter and setter in one object.
I’m using it here because it’s a little easier to store in a struct than separate getters
and setters.
#[component]
pub fn App() -> impl IntoView {
// 以一组三行数据开始
// start with a set of three rows
let (data, _set_data) = signal(vec![
DatabaseEntry {
key: "foo".to_string(),
value: RwSignal::new(10),
},
DatabaseEntry {
key: "bar".to_string(),
value: RwSignal::new(20),
},
DatabaseEntry {
key: "baz".to_string(),
value: RwSignal::new(15),
},
]);
view! {
// 点击时更新每一行,
// 将其值翻倍
// when we click, update each row,
// doubling its value
<button on:click=move |_| {
for row in &*data.read() {
row.value.update(|value| *value *= 2);
}
// 打印信号的新值
// log the new value of the signal
leptos::logging::log!("{:?}", data.get());
}>
"Update Values"
</button>
// 遍历行并显示每个值
// iterate over the rows and display each value
<For
each=move || data.get()
key=|state| state.key.clone()
let(child)
>
<p>{child.value}</p>
</For>
}
}
这个版本可以工作!如果你在浏览器的 DOM 检查器中查看,你会发现与之前的版本不同,在这个版本中只有单个文本节点被更新。直接将信号传入 {child.value} 是可行的,因为如果你将信号传递给视图,它们会保持响应性。
This version works! And if you look in the DOM inspector in your browser, you’ll
see that unlike in the previous version, in this version only the individual text
nodes are updated. Passing the signal directly into {child.value} works, as
signals do keep their reactivity if you pass them into the view.
注意,我将 set_data.update() 更改为 data.read()。.read() 是一种非克隆访问信号值的方法。在这种情况下,我们只是更新内部值,而不是更新值列表:因为信号维护自己的状态,我们实际上根本不需要更新 data 信号,所以这里使用不可变的 .read() 就可以了。
Note that I changed the set_data.update() to a data.read(). .read() is a
non-cloning way of accessing a signal’s value. In this case, we are only updating
the inner values, not updating the list of values: because signals maintain their
own state, we don’t actually need to update the data signal at all, so the immutable
.read() is fine here.
实际上,这个版本并没有更新
data,所以<For/>本质上就像上一章中的静态列表一样,这可以只是一个普通的迭代器。但如果我们以后想添加或删除行,<For/>就很有用了。In fact, this version doesn’t update
data, so the<For/>is essentially a static list as in the last chapter, and this could just be a plain iterator. But the<For/>is useful if we want to add or remove rows in the future.
优点
Pros
这是最高效的选择,并且直接符合框架其余部分的思维模型:随时间变化的值被包裹在信号中,以便界面可以对其做出响应。
This is the most efficient option, and fits directly with the rest of the mental model of the framework: values that change over time are wrapped in signals so the interface can respond to them.
缺点
Cons
如果你正从 API 或其他你无法控制的数据源接收数据,并且你不想创建一个将每个字段都包裹在信号中的不同结构体,那么嵌套响应性可能会很繁琐。
Nested reactivity can be cumbersome if you’re receiving data from an API or another data source you don control, and you don’t want to create a different struct wrapping each field in a signal.
方案 3:记忆化切片 (Memoized Slices)
Option 3: Memoized Slices
Leptos 提供了一个名为 Memo 的原语,它创建一个派生计算,仅在其值发生变化时才触发响应式更新。
Leptos provides a primitive called a Memo,
which creates a derived computation that only triggers a reactive update when its value
has changed.
这允许你为较大数据结构的子字段创建响应式值,而无需将该结构的字段包裹在信号中。结合 <ForEnumerate/>,这将允许我们仅重新渲染更改后的数据值。
This allows you to create reactive values for subfields of a larger data structure, without needing
to wrap the fields of that structure in signals. In combination with
<ForEnumerate/>, this
will allow us to rerender only changed data values.
应用程序的大部分内容可以与最初(损坏的)版本保持一致,但 <For/> 将更新为:
Most of the application can remain the same as the initial (broken) version, but the <For/>
will be updated to this:
<ForEnumerate
each=move || data.get()
key=|state| state.key.clone()
children=move |index, _| {
let value = Memo::new(move |_| {
data.with(|data| data.get(index.get()).map(|d| d.value).unwrap_or(0))
});
view! {
<p>{value}</p>
}
}
/>
你会在这里注意到一些不同之处:
You’ll notice a few differences here:
-
我们使用的是
ForEnumerate而不是For,这样我们就可以访问index信号 -
我们明确使用了
children属性,以便更容易地运行一些非view代码 -
我们定义了一个
value记忆 (memo) 并在视图中使用它。这个value字段实际上并未使用传递到每一行的child。相反,它使用索引并回溯到原始data以获取值。 -
we use
ForEnumeraterather thanFor, so we have access to anindexsignal -
we use the
childrenprop explicitly, to make it easier to run some non-viewcode -
we define a
valuememo and use that in the view. Thisvaluefield doesn’t actually use thechildbeing passed into each row. Instead, it uses the index and reaches back into the originaldatato get the value.
现在,每当 data 发生变化时,每个记忆都会被重新计算。如果其值发生了变化,它将更新其文本节点,而不会重新渲染整行。
Now every time data changes, each memo will be recalculated. If its value has changed,
it will update its text node, without rerendering the whole row.
注意:在此情况下,将 For 与枚举迭代器(enumerated iterator)一起使用是不安全的,正如本示例的早期版本所示:
Note: It is not safe to use For for this with an enumerated iterator, as in an earlier version of this example:
<For
each=move || data.get().into_iter().enumerate()
key=|(_, state)| state.key.clone()
children=move |(index, _)| {
let value = Memo::new(move |_| {
data.with(|data| data.get(index).map(|d| d.value).unwrap_or(0))
});
view! {
<p>{value}</p>
}
}
/>
在这种情况下,data 中值的更改会被响应,但顺序的更改则不会,因为 Memo 将始终使用它最初创建时的 index。如果移动了任何项,这将导致渲染输出中出现重复项。
In this case, changes to values in data will be reacted to, but changes to ordering will not, as
the Memo will always use the index it was initially created with. This will result in duplicate
entries in the rendered output if any items are moved.
优点
Pros
我们获得了与信号包装版本相同的细粒度响应性,而无需将数据包裹在信号中。
We get the same fine-grained reactivity of the signal-wrapped version, without needing to wrap the data in signals.
缺点
Cons
在 <ForEnumerate/> 循环内部设置这种每一行一个记忆(memo-per-row)比使用嵌套信号稍微复杂一些。例如,你会注意到我们必须通过使用 data.get(index.get()) 来防止 data[index.get()] 因崩溃而导致 panic 的可能性,因为这个记忆可能会在行被移除后立即被触发重新运行一次。(这是因为每一行的记忆和整个 <ForEnumerate/> 都依赖于同一个 data 信号,并且依赖于同一个信号的多个响应式值的执行顺序是不确定的。)
It’s a bit more complex to set up this memo-per-row inside the <ForEnumerate/> loop rather than
using nested signals. For example, you’ll notice that we have to guard against the possibility that
the data[index.get()] would panic by using data.get(index.get()), because this memo may be
triggered to re-run once just after the row is removed. (This is because the memo for each row and
the whole <ForEnumerate/> both depend on the same data signal, and the order of execution for
multiple reactive values that depend on the same signal isn’t guaranteed.)
还要注意,虽然记忆化会记忆它们的响应式更改,但每次仍然需要重新运行相同的计算来检查值,因此嵌套响应式信号在进行精准更新时仍然会更高效。
Note also that while memos memoize their reactive changes, the same calculation does need to re-run to check the value every time, so nested reactive signals will still be more efficient for pinpoint updates here.
方案 4:Store
Option 4: Stores
其中一些内容在此处关于使用 Store 进行全局状态管理的章节中有所重复。由于这两个章节都是中级/可选内容,所以我认为重复一些内容并无大碍。
Some of this content is duplicated in the section on global state management with stores here. Both sections are intermediate/optional content, so I thought some duplication couldn’t hurt.
Leptos 0.7 引入了一种名为 “Store” 的新型响应式原语。Store 旨在解决本章到目前为止所描述的问题。它们还处于实验阶段,因此需要在你的 Cargo.toml 中添加一个名为 reactive_stores 的额外依赖。
Leptos 0.7 introduces a new reactive primitive called “stores.” Stores are designed to address
the issues described in this chapter so far. They’re a bit experimental, so they require an additional dependency called reactive_stores in your Cargo.toml.
Store 让你能够对结构体的各个字段以及 Vec<_> 等集合中的各个项进行细粒度的响应式访问,而无需像上面给出的方案那样手动创建嵌套信号或记忆。
Stores give you fine-grained reactive access to the individual fields of a struct, and to individual items in collections like Vec<_>, without needing to create nested signals or memos manually, as in the options given above.
Store 建立在 Store 派生宏之上,该宏为结构体的每个字段创建一个 getter。调用此 getter 可以对该特定字段进行响应式访问。读取它将仅跟踪该字段及其父级/子级,更新它将仅通知该字段及其父级/子级,而不会通知兄弟级。换句话说,修改 value 不会通知 key,以此类推。
Stores are built on top of the Store derive macro, which creates a getter for each field of a struct. Calling this getter gives reactive access to that particular field. Reading from it will track only that field and its parents/children, and updating it will only notify that field and its parents/children, but not siblings. In other words, mutating value will not notify key, and so on.
我们可以调整前面示例中使用的数据类型。
We can adapt the data types we used in the examples above.
Store 的顶层始终需要是一个结构体,因此我们将创建一个带有单个 rows 字段的 Data包装器。
The top level of a store always needs to be a struct, so we’ll create a Data wrapper with a single rows field.
#[derive(Store, Debug, Clone)]
pub struct Data {
#[store(key: String = |row| row.key.clone())]
rows: Vec<DatabaseEntry>,
}
#[derive(Store, Debug, Clone)]
struct DatabaseEntry {
key: String,
value: i32,
}
将 #[store(key)] 添加到 rows 字段允许我们对 Store 的字段进行键控访问(keyed access),这在下面的 <For/> 组件中非常有用。我们可以简单地使用 key,这与我们在 <For/> 中使用的键相同。
Adding #[store(key)] to the rows field allows us to have keyed access to the fields of the store, which will be useful in the <For/> component below. We can simply use key, the same key that we’ll use in <For/>.
<For/> 组件非常直观:
The <For/> component is pretty straightforward:
<For
each=move || data.rows()
key=|row| row.read().key.clone()
children=|child| {
let value = child.value();
view! { <p>{move || value.get()}</p> }
}
/>
因为 rows 是一个键控字段,它实现了 IntoIterator,我们可以直接使用 move || data.rows() 作为 each 属性。这将对 rows 列表的任何更改做出响应,就像在我们的嵌套信号版本中 move || data.get() 所做的那样。
Because rows is a keyed field, it implements IntoIterator, and we can simply use move || data.rows() as the each prop. This will react to any changes to the rows list, just as move || data.get() did in our nested-signal version.
key 字段调用 .read() 来访问行的当前值,然后克隆并返回 key 字段。
The key field calls .read() to get access to the current value of the row, then clones and returns the key field.
在 children 属性中,调用 child.value() 为我们提供了对具有此键的行的 value 字段的响应式访问。如果行被重新排序、添加或删除,键控 Store 字段将保持同步,以便此 value 始终与正确的键相关联。
In children prop, calling child.value() gives us reactive access to the value field for the row with this key. If rows are reordered, added, or removed, the keyed store field will keep in sync so that this value is always associated with the correct key.
在更新按钮处理程序中,我们将遍历 rows 中的项,更新每一项:
In the update button handler, we’ll iterate over the entries in rows, updating each one:
for row in data.rows().iter_unkeyed() {
*row.value().write() *= 2;
}
优点
Pros
我们获得了嵌套信号和记忆版本的细粒度响应性,而无需手动创建嵌套信号或记忆化切片。我们可以处理普通数据(结构体和 Vec<_>),并使用派生宏进行标注,而不是使用特殊的嵌套响应式类型。
We get the fine-grained reactivity of the nested-signal and memo versions, without needing to manually create nested signals or memoized slices. We can work with plain data (a struct and Vec<_>), annotated with a derive macro, rather than special nested reactive types.
就个人而言,我认为 Store 版本是这里最优雅的。这并不奇怪,因为它是最新的 API。我们花了几年的时间思考这些问题,而 Store 包含了我们学到的一些经验教训!
Personally, I think the stores version is the nicest one here. And no surprise, as it’s the newest API. We’ve had a few years to think about these things and stores include some of the lessons we’ve learned!
缺点
Cons
另一方面,它是最新的 API。在撰写本文(2024 年 12 月)时,Store 仅发布了几周时间;我确信仍然有一些错误或边缘情况有待解决。
On the other hand, it’s the newest API. As of writing this sentence (December 2024), stores have only been released for a few weeks; I am sure that there are still some bugs or edge cases to be figured out.
完整示例
Full Example
这是完整的 Store 示例。你可以在此处找到另一个更完整的示例,并在本书的此处查看更多讨论。
Here’s the complete store example. You can find another, more complete example here, and more discussion in the book here.
use reactive_stores::Store;
#[derive(Store, Debug, Clone)]
pub struct Data {
#[store(key: String = |row| row.key.clone())]
rows: Vec<DatabaseEntry>,
}
#[derive(Store, Debug, Clone)]
struct DatabaseEntry {
key: String,
value: i32,
}
#[component]
pub fn App() -> impl IntoView {
// 我们不是创建一个包含行的信号,而是为 Data 创建一个 store
// instead of a signal with the rows, we create a store for Data
let data = Store::new(Data {
rows: 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 |_| {
// 允许遍历可迭代 store 字段中的条目
// allows iterating over the entries in an iterable store field
use reactive_stores::StoreFieldIterator;
// 调用 rows() 让我们访问这些行
// calling rows() gives us access to the rows
for row in data.rows().iter_unkeyed() {
*row.value().write() *= 2;
}
// 打印信号的新值
// log the new value of the signal
leptos::logging::log!("{:?}", data.get());
}>
"Update Values"
</button>
// 遍历行并显示每个值
// iterate over the rows and display each value
<For
each=move || data.rows()
key=|row| row.read().key.clone()
children=|child| {
let value = child.value();
view! { <p>{move || value.get()}</p> }
}
/>
}
}