带 key 的列表
主线位置:更新主线 diff 那一步里的一个分支:新旧
VNode的同一个动态槽位里都是DynamicNode::Fragment(for循环、迭代器、if分支都会产出它),就进这一页的代码。
flowchart TB
frag["Fragment 对 Fragment"] --> both{"新旧第一项都有 key"}
both -->|"否"| nk["按位置配对"]
nk --> nk2["多的删 少的在末尾建"]
nk2 --> nk3["逐对 diff_node"]
both -->|"是"| ends["diff_keyed_ends 剥两端"]
ends --> done{"中间还有剩"}
done -->|"否"| fin["只建或只删 结束"]
done -->|"是"| mid["diff_keyed_middle"]
mid --> rm["删掉新列表里没有的 key"]
rm --> lis["新位置映射旧下标 求 LIS"]
lis --> mv["LIS 里的不动 其余移动或新建"]
代码全在 diff/iterator.rs,约 500 行。单个节点怎么比在 diff,这一页只讲「一串兄弟节点」怎么配对。
入口:两边都带 key 才走 keyed
rsx! 里的 for item in items { li { .. } } 展开成一个迭代器,经 IntoDynNode 收成 DynamicNode::Fragment(Vec<VNode>);迭代器是空的就变成占位符,所以 Fragment 永远不空。每个 VNode 有一个 key: Option<String>,就是循环体根上写的 key: "{id}"(根是组件时,写在组件上)。
pub(crate) fn diff_non_empty_fragment(
&mut self,
to: Option<&mut impl WriteMutations>,
old: &[VNode],
new: &[VNode],
parent: Option<ElementRef>,
) {
let new_is_keyed = new[0].key.is_some();
let old_is_keyed = old[0].key.is_some();
debug_assert!(
new.iter().all(|n| n.key.is_some() == new_is_keyed),
"all siblings must be keyed or all siblings must be non-keyed"
);
debug_assert!(
old.iter().all(|o| o.key.is_some() == old_is_keyed),
"all siblings must be keyed or all siblings must be non-keyed"
);
if new_is_keyed && old_is_keyed {
self.diff_keyed_children(to, old, new, parent);
} else {
self.diff_non_keyed_children(to, old, new, parent);
}
}
只看第一项有没有 key;debug 构建再检查一遍「要么全有、要么全没有」。
不带 key:按位置配对
fn diff_non_keyed_children(
&mut self,
mut to: Option<&mut impl WriteMutations>,
old: &[VNode],
new: &[VNode],
parent: Option<ElementRef>,
) {
use std::cmp::Ordering;
// Handled these cases in `diff_children` before calling this function.
debug_assert!(!new.is_empty());
debug_assert!(!old.is_empty());
match old.len().cmp(&new.len()) {
Ordering::Greater => self.remove_nodes(to.as_deref_mut(), &old[new.len()..], None),
Ordering::Less => self.create_and_insert_after(
to.as_deref_mut(),
&new[old.len()..],
old.last().unwrap(),
parent,
),
Ordering::Equal => {}
}
for (new, old) in new.iter().zip(old.iter()) {
old.diff_node(new, self, to.as_deref_mut());
}
}
先对齐长度:旧的长就删掉尾巴,新的长就在旧列表最后一项后面把多出来的建上;然后第 i 个旧节点和第 i 个新节点逐对 diff_node。它对「中间插了一项」一无所知:在第 1 位插入 X,算法看到的是「第 1 项从 B 变成 X,第 2 项从 C 变成 B,末尾多了一个 C」。
实验:中间插一项,元素和带状态的组件
组件读线程局部的 ITEMS,MODE 选四种写法之一;先用 [A, B, C] rebuild,再改成 [A, X, B, C],mark_dirty(ScopeId::APP) 后 render_immediate(&mut log)。Row 用 use_signal 存一份只在第一次渲染时根据 label 初始化的状态:
fn app() -> Element {
let items = items();
match MODE.with(|m| m.get()) {
0 => rsx! {
ul {
for (_id, label) in items {
li { "{label}" }
}
}
},
1 => rsx! {
ul {
for (id, label) in items {
li { key: "{id}", "{label}" }
}
}
},
2 => rsx! {
ul {
for (_id, label) in items {
Row { label }
}
}
},
_ => rsx! {
ul {
for (id, label) in items {
Row { key: "{id}", label }
}
}
},
}
}
/// Each row keeps a piece of local state, created from the label of its first render.
#[component]
fn Row(label: String) -> Element {
ROW_RUNS.with(|c| c.set(c.get() + 1));
let state = use_signal(|| format!("state of {label}"));
rsx! { li { "{label} | {state}" } }
}
fn main() {
let abc = [(1, "A"), (2, "B"), (3, "C")];
let axbc = [(1, "A"), (9, "X"), (2, "B"), (3, "C")];
run("elements, no key, insert X at index 1", 0, &abc, &axbc);
run("elements, key, insert X at index 1", 1, &abc, &axbc);
run("components with state, no key, insert X at index 1", 2, &abc, &axbc);
run("components with state, key, insert X at index 1", 3, &abc, &axbc);
// ...
}
M2 Max、macOS 27、rustc 1.99.0,debug。第一种写法连 rebuild 的指令一起打出来,看清各节点的 id:
===== elements, no key, insert X at index 1
--- rebuild: 12 edits
LoadTemplate <ul> index=0 id=1
LoadTemplate <li> index=0 id=2
CreateTextNode "A" id=3
ReplacePlaceholder path=[0] m=1
LoadTemplate <li> index=0 id=4
CreateTextNode "B" id=5
ReplacePlaceholder path=[0] m=1
LoadTemplate <li> index=0 id=6
CreateTextNode "C" id=7
ReplacePlaceholder path=[0] m=1
ReplacePlaceholder path=[0] m=3
AppendChildren id=0 m=1
before: <ul><li>A</li><li>B</li><li>C</li></ul>
--- update: 6 edits
LoadTemplate <li> index=0 id=8
CreateTextNode "C" id=9
ReplacePlaceholder path=[0] m=1
InsertAfter id=6 m=1
SetText "X" id=5
SetText "B" id=7
after: <ul><li>A</li><li>X</li><li>B</li><li>C</li></ul>
===== elements, key, insert X at index 1
before: <ul><li>A</li><li>B</li><li>C</li></ul>
--- update: 4 edits
LoadTemplate <li> index=0 id=8
CreateTextNode "X" id=9
ReplacePlaceholder path=[0] m=1
InsertBefore id=4 m=1
after: <ul><li>A</li><li>X</li><li>B</li><li>C</li></ul>
===== components with state, no key, insert X at index 1
before: <ul><li>A | state of A</li><li>B | state of B</li><li>C | state of C</li></ul>
--- update: 6 edits
LoadTemplate <li> index=0 id=8
CreateTextNode "C | state of C" id=9
ReplacePlaceholder path=[0] m=1
InsertAfter id=6 m=1
SetText "X | state of B" id=5
SetText "B | state of C" id=7
Row ran 3 times during the update
after: <ul><li>A | state of A</li><li>X | state of B</li><li>B | state of C</li><li>C | state of C</li></ul>
===== components with state, key, insert X at index 1
before: <ul><li>A | state of A</li><li>B | state of B</li><li>C | state of C</li></ul>
--- update: 4 edits
LoadTemplate <li> index=0 id=8
CreateTextNode "X | state of X" id=9
ReplacePlaceholder path=[0] m=1
InsertBefore id=4 m=1
Row ran 1 times during the update
after: <ul><li>A | state of A</li><li>X | state of X</li><li>B | state of B</li><li>C | state of C</li></ul>
元素,不带 key。 6 条指令:先在末尾(旧的最后一项 li id=6 之后)建一个新 li,内容是 "C";然后逐对 diff,第 1 项的文本(id=5)从 B 改成 X,第 2 项(id=7)从 C 改成 B。最终 DOM 是对的,只是做了多余的改写:插入点之后的每一项都要改一次。列表越长、插得越靠前,改写越多。
元素,带 key。 4 条指令:只建一个 X,InsertBefore id=4(B 的 li)。A、B、C 的节点一个字都没动。
组件带状态,不带 key:状态错位。 按位置配对时,旧的第 1 个 Row(scope 里存着 "state of B")配上了新的第 1 项 X 的 props,label 变了,它重跑,但 use_signal 的初始化闭包只在第一次渲染时执行,状态还是 "state of B"。于是第二行显示 X | state of B,第三行 B | state of C,末尾新建的第四个 Row 是全新的 scope,才得到 "state of C"。Row 跑了 3 次:第 1、2 项 props 变了各重跑一次,新建的第 3 项跑第一次;第 0 项 A 的 props 没变,被 memoize 跳过。真实应用里这份「状态」可能是输入框的内容、展开 / 收起、正在进行的请求:它们会粘在位置上,而不是跟着数据走。
组件带状态,带 key。 每个 Row 跟着自己的 key 走,X 是新建的,只有它跑了一次;B、C 的 props 没变,连重跑都没有,状态各归其主。
带 key:先剥两端
diff_keyed_children 在 debug 构建里先检查 key 唯一,然后交给 diff_keyed_ends:
fn diff_keyed_ends(
&mut self,
mut to: Option<&mut impl WriteMutations>,
old: &[VNode],
new: &[VNode],
parent: Option<ElementRef>,
) -> Option<(usize, usize)> {
let mut left_offset = 0;
for (old, new) in old.iter().zip(new.iter()) {
// abort early if we finally run into nodes with different keys
if old.key != new.key {
break;
}
old.diff_node(new, self, to.as_deref_mut());
left_offset += 1;
}
// If that was all of the old children, then create and append the remaining
// new children and we're finished.
if left_offset == old.len() {
self.create_and_insert_after(to, &new[left_offset..], &new[left_offset - 1], parent);
return None;
}
// if the shared prefix is less than either length, then we need to walk backwards
let mut right_offset = 0;
for (old, new) in old.iter().rev().zip(new.iter().rev()) {
// abort early if we finally run into nodes with different keys
if old.key != new.key {
break;
}
old.diff_node(new, self, to.as_deref_mut());
right_offset += 1;
}
// If that was all of the old children, then create and prepend the remaining
// new children and we're finished.
if right_offset == old.len() {
self.create_and_insert_before(
to,
&new[..new.len() - right_offset],
&new[new.len() - right_offset],
parent,
);
return None;
}
// If the right offset + the left offset is the same as the new length, then we just need to remove the old nodes
if right_offset + left_offset == new.len() {
self.remove_nodes(to, &old[left_offset..old.len() - right_offset], None);
return None;
}
// If the right offset + the left offset is the same as the old length, then we just need to add the new nodes
if right_offset + left_offset == old.len() {
self.create_and_insert_before(
to,
&new[left_offset..new.len() - right_offset],
&new[new.len() - right_offset],
parent,
);
return None;
}
Some((left_offset, right_offset))
}
从左往右,key 相同就就地 diff_node,直到第一个不同;再从右往左做一遍。前后缀覆盖了全部旧节点,说明只是在某处追加了新节点;覆盖了全部新节点,说明只是删了中间一段。这四种情况都不需要移动任何节点,直接结束。实际界面里最常见的操作(末尾追加、开头插入、中间插入一项、删掉一项)都在这里解决。
上面的「带 key 插入 X」就是第四种:前缀 A(left_offset = 1),后缀 C、B(right_offset = 2),1 + 2 等于旧长度 3,于是把新列表中间那段 [X] 建出来,插在后缀第一项 B 的第一个元素(id=4)前面。
中间一段:最长递增子序列
两端剥完还剩一段乱序的,进 diff_keyed_middle。源码开头的注释列了步骤:
/*
1. Map the old keys into a numerical ordering based on indices.
2. Create a map of old key to its index
3. Map each new key to the old key, carrying over the old index.
- IE if we have ABCD becomes BACD, our sequence would be 1,0,2,3
- if we have ABCD to ABDE, our sequence would be 0,1,3,MAX because E doesn't exist
now, we should have a list of integers that indicates where in the old list the new items map to.
4. Compute the LIS of this list
- this indicates the longest list of new children that won't need to be moved.
5. Identify which nodes need to be removed
6. Identify which nodes will need to be diffed
7. Going along each item in the new list, create it and insert it before the next closest item in the LIS.
- if the item already existed, just move it to the right place.
8. Finally, generate instructions to remove any old children.
9. Generate instructions to finally diff children that are the same between both
*/
(注释里的第 8 步「最后删除」和代码的顺序不一致:代码在求 LIS 之前就把旧节点里没有出现在新列表中的删掉了。)
核心是第 3、4 步。对新列表的每一项,查它的 key 在旧列表里的下标,得到一个整数序列 new_index_to_old_index;新出现的 key 记 usize::MAX。这个序列里递增的一段,意味着这些节点在新旧列表里的相对顺序没变;最长的那段就是可以原地不动的最多节点。剩下的节点要么移动、要么新建:
// 4. Compute the LIS of this list
let mut lis_sequence = Vec::with_capacity(new_index_to_old_index.len());
let mut allocation = vec![0; new_index_to_old_index.len() * 2];
let (predecessors, starts) = allocation.split_at_mut(new_index_to_old_index.len());
longest_increasing_subsequence::lis_with(
&new_index_to_old_index,
&mut lis_sequence,
|a, b| a < b,
predecessors,
starts,
);
// if a new node gets u32 max and is at the end, then it might be part of our LIS (because u32 max is a valid LIS)
if lis_sequence.first().map(|f| new_index_to_old_index[*f]) == Some(usize::MAX) {
lis_sequence.remove(0);
}
// Diff each nod in the LIS
for idx in &lis_sequence {
old[new_index_to_old_index[*idx]].diff_node(&new[*idx], self, to.as_deref_mut());
}
- LIS 用的是
longest-increasing-subsequencecrate 的lis_with,O(n log n),临时空间一次分配(predecessors和starts切自同一个Vec)。 - 这个 crate 返回的下标是倒序的(文档原话:"the indices of the LIS in reverse order from the end of the LIS first to the start of the LIS last"),所以代码里
lis_sequence.first()是 LIS 的最后一项,last()是第一项。 usize::MAX比任何真实下标都大,一个新节点如果排在末尾,可能被当成递增序列的最后一项选进 LIS。可它并不存在于旧列表,不能「不动」,所以检查 LIS 的末项(即first()),是MAX就去掉。- LIS 里的节点就地
diff_node,不发移动指令。
然后分三段处理 LIS 之外的节点:LIS 最后一项之后的,建出来或推上栈,InsertAfter LIS 最后一项;LIS 相邻两项之间的空隙,InsertBefore 后一项;LIS 第一项之前的,InsertBefore LIS 第一项。每段里的节点由 create_or_diff 处理:旧列表里有的,先 diff_node,再用 push_all_root_nodes 把它已有的根节点 PushRoot 到栈上(移动,不重建);旧列表里没有的,create 新建。
实验:一次移动
[A, B, C, D, E] 改成 [A, D, B, C, E],带 key 的元素列表(ids 和上面一样:A 的 li 是 2,B 是 4,C 是 6,D 是 8,E 是 10):
===== elements, key, move D before B
...
--- update: 2 edits
PushRoot id=8
InsertBefore id=4 m=1
after: <ul><li>A</li><li>D</li><li>B</li><li>C</li><li>E</li></ul>
| 步骤 | 值 |
|---|---|
| 剥两端 | 前缀 A,后缀 E;中间旧 [B, C, D],新 [D, B, C] |
new_index_to_old_index | D→2,B→0,C→1:[2, 0, 1] |
| LIS(值) | 0, 1,即新下标 1、2(B、C) |
| LIS 之前 | 新下标 0 的 D:旧列表里有,diff_node 后 PushRoot id=8,InsertBefore B(id=4) |
两条指令,D 的 li 原样被挪走,没有重建,B、C 一动不动。如果只是逐对比较,会改写三项文本。
push_all_root_nodes 推的是每个模板根的 ElementId。在 #5413("fix: diffing debug in push_all_root_nodes()")之前,它把「静态根」和「动态文本 / 占位符根」放在同一个分支里,都推 mount.root_ids[root_idx];而 root_ids 只给静态根填真实 id,动态根那一格一直是初始值 ElementId(0),也就是挂载的根容器。于是一个根是 " {id} " 这种动态文本、或是空列表变成的占位符的组件被移动时,指令变成「把根容器推上栈,插到某个子节点前面」。PR 说明里的现象:"caused panic when processing iterate components who have dynamic placeholder or text nodes (typically in multi layered iterate structures)... leading to a whole shutdown of app and DOM error message like: Failed to execute 'before' on 'Element': The new child element contains the parent"。修复是给 Placeholder | Text 单开一个分支,从 mounted_dynamic_nodes 取它真实的 id。首次发布在 v0.7.4,本站跑的 0.7.9 已包含。
重复的 key
diff_keyed_children 里 key 唯一的检查只在 cfg!(debug_assertions) 下做,而且只在更新时做(创建路径不检查)。重复 key 的列表第一次渲染完全正常:
fn main() {
// key 2 appears twice
ITEMS.with(|i| *i.borrow_mut() = vec![(1, "A"), (2, "B"), (2, "B2"), (3, "C")]);
let mut dom = VirtualDom::new(app);
dom.rebuild_in_place();
println!("rebuild ok: {}", dioxus_ssr::render(&dom));
ITEMS.with(|i| *i.borrow_mut() = vec![(3, "C"), (2, "B2"), (2, "B"), (1, "A")]);
dom.mark_dirty(ScopeId::APP);
let mut log = core1_exp::Log::default();
dom.render_immediate(&mut log);
log.print("update");
println!("{}", dioxus_ssr::render(&dom));
}
debug:
rebuild ok: <ul><li>A</li><li>B</li><li>B2</li><li>C</li></ul>
thread 'main' (979066) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-core-0.7.10/src/diff/iterator.rs:107:17:
assertion `left == right` failed: keyed siblings must each have a unique key
left: 4
right: 3
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
release(--release):
rebuild ok: <ul><li>A</li><li>B</li><li>B2</li><li>C</li></ul>
thread 'main' (977236) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-core-0.7.10/src/diff/mod.rs:44:15:
invalid key
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
两种构建进程都以 101 退出:这个 panic 发生在 diff 里,不在组件函数里,没有 catch_unwind 接着。release 的 invalid key 是 slab 的下标越界报错,出自 get_mounted_dyn_node 对 mounts 的索引:old_key_to_old_index 是 HashMap,重复的 key 只留下最后一个下标,两个新节点都去 diff 同一个旧节点,另一个旧节点既没被 diff 也没被删除,挂载信息就乱了。release 下的结果取决于列表的形状:另一组数据 [A, B, B2](B、B2 的 key 都是 2)改成 [C, A, B, B2] 时,后缀就把旧列表剥完了,只需在前面建一个 C,release 正常输出 <ul><li>C</li><li>A</li><li>B</li><li>B2</li></ul>;debug 照样在断言上 panic(left: 3, right: 2)。key 要用数据自己的唯一 id,别用可能重复的字段。
[A, B, C, D, E] 改成 [A, C, F, B, E](删 D、加 F、B 挪到后面),会发出几条指令?B 是被删掉重建,还是被移动?F 的 li 拿到几号 ElementId?带 key 的元素列表,rebuild 时 A、B、C、D、E 的 li 分别是 2、4、6、8、10,文本是 3、5、7、9、11。提示:先剥两端,写出中间段的 new_index_to_old_index,再想 LIS 是什么。
动脑筋答案
===== elements, key, remove D, add F, move B
...
--- update: 6 edits
Remove id=8
LoadTemplate <li> index=0 id=8
CreateTextNode "F" id=9
ReplacePlaceholder path=[0] m=1
PushRoot id=4
InsertAfter id=6 m=2
after: <ul><li>A</li><li>C</li><li>F</li><li>B</li><li>E</li></ul>
6 条,B 是移动。
- 剥两端:前缀 A、后缀 E;中间旧
[B, C, D],新[C, F, B],new_index_to_old_index = [1, MAX, 0]。 - 旧的里不在新列表中的 D 先删:
Remove id=8,它的li(8)和文本(9)都被回收。 [1, MAX, 0]的 LIS 是1, MAX(新下标 0、1),末项是MAX,去掉,剩 C。C 原地 diff,不发指令。- LIS 之后的新下标 1、2:F 是新的,
LoadTemplate新建,拿到刚回收的 8 和 9;B 是旧的,PushRoot id=4推上栈。两个一起InsertAfter id=6 m=2,插到 C 后面。
小结
for循环产出DynamicNode::Fragment,空的变成占位符;新旧第一项都有 key 才走 keyed 算法。- 不带 key:先在末尾删或建以对齐长度,再按位置逐对
diff_node。中间插一项,插入点之后每一项都被改写;组件按位置复用,use_signal之类的状态留在原位置,和数据错位(实验里第二行变成X | state of B)。 - 带 key:
diff_keyed_ends先从两端剥掉 key 相同的项;只是追加、在某处插入一段、删掉一段时到此结束,不移动任何节点。 - 剩下的中间段:删掉新列表里没有的 key,把新顺序映射成旧下标,求最长递增子序列;LIS 里的原地 diff,其余的旧节点
PushRoot后插到相邻的 LIS 节点旁,新节点直接创建。末尾的usize::MAX不能留在 LIS 里。 - key 唯一性只在 debug 构建、更新时检查,违反就 panic;release 下重复 key 可能看似正常,也可能 panic
invalid key,取决于列表的形状。