Rust in Everything
基于 dioxus 0.7.10

列表不写 key:删掉一项,状态错到下一行

主线位置:更新主线的 diff 一步,diff_non_empty_fragment 比较 for 生成的一组兄弟节点。不带 key 时按位置配对,带 key 时按 key 配对。两种算法和「中间插一项」的实验见 带 key 的列表,本页讲删除时的状态错位、下标当 key、重复 key 三种写法的后果。

flowchart TB
  frag["一组兄弟节点"] --> k{"第一项有 key?"}
  k -->|没有| pos["按位置配对"]
  pos --> shift["第 i 个旧组件接收第 i 个新 props"]
  shift --> wrong["本地状态留在原位置"]
  k -->|有| bykey["按 key 配对"]
  bykey --> dup{"key 唯一?"}
  dup -->|是| ok["组件跟着数据走"]
  dup -->|否| bad["debug 断言 panic"]

本页实验在 M2 Max、macOS 27、rustc 1.99.0 上运行,依赖 dioxus = "=0.7.10",原生 VirtualDom。

现象

待办列表,每一行有一份本地状态(比如正在编辑的草稿),挂载时用这一行的文字初始化。pit1-exp/src/bin/missing_key.rs:

/// A row with local state: a draft that starts as the todo's text.
#[component]
fn TodoRow(text: &'static str) -> Element {
  let draft = use_signal(|| format!("draft of {text}"));
  rsx! { li { "{text} / {draft}" } }
}

fn app() -> Element {
  let todos = TODOS.with(|t| t.borrow().clone());
  match MODE.get() {
    0 => rsx! { ul { for (_id, text) in todos { TodoRow { text } } } },
    1 => rsx! { ul { for (i, (_id, text)) in todos.into_iter().enumerate() { TodoRow { key: "{i}", text } } } },
    2 => rsx! { ul { for (id, text) in todos { TodoRow { key: "{id}", text } } } },
    // keys taken from a field that is not unique
    _ => rsx! { ul { for (_id, text) in todos { TodoRow { key: "{text}", text } } } },
  }
}

三条待办 (1, "buy milk")、(2, "write docs")、(3, "fix bug"),删掉第一条后 mark_dirty 再 render_immediate_to_vec,打印前后的 HTML 和指令条数:

no key, delete the first todo
  before: <ul><li>buy milk / draft of buy milk</li><li>write docs / draft of write docs</li><li>fix bug / draft of fix bug</li></ul>
  after:  <ul><li>write docs / draft of buy milk</li><li>fix bug / draft of write docs</li></ul>  (3 edits)
key = index, delete the first todo
  before: <ul><li>buy milk / draft of buy milk</li><li>write docs / draft of write docs</li><li>fix bug / draft of fix bug</li></ul>
  after:  <ul><li>write docs / draft of buy milk</li><li>fix bug / draft of write docs</li></ul>  (3 edits)
key = id, delete the first todo
  before: <ul><li>buy milk / draft of buy milk</li><li>write docs / draft of write docs</li><li>fix bug / draft of fix bug</li></ul>
  after:  <ul><li>write docs / draft of write docs</li><li>fix bug / draft of fix bug</li></ul>  (1 edits)
  • 不写 key:文字对了,草稿全错了一位。「write docs」那一行显示的是已经删掉的「buy milk」的草稿。真实应用里就是用户在第二行输入框里打的字,删掉第一行之后跑到了第一行。
  • 下标当 key:和不写 key 完全一样。删掉第 0 项后,原来的第 1 项下标变成 0,key 也变成 "0",正好和旧的第 0 项配上。下标当 key 只是让编译器和断言闭嘴。
  • id 当 key:状态跟着数据走,只发 1 条指令(删掉第一个 li)。前两种要发 3 条:改两个文本、删最后一个。

两种错法都没有任何报错或日志。

key 重复是另一种错:(1, "buy milk")、(2, "fix bug")、(3, "fix bug") 用文字当 key,把最后一项挪到最前面。debug:

key = text, two todos with the same text, move the last one to the front
  before: <ul><li>buy milk / draft of buy milk</li><li>fix bug / draft of fix bug</li><li>fix bug / draft of fix bug</li></ul>

thread 'main' (2154353) 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: 3
 right: 2
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

release:

key = text, two todos with the same text, move the last one to the front
  before: <ul><li>buy milk / draft of buy milk</li><li>fix bug / draft of fix bug</li><li>fix bug / draft of fix bug</li></ul>
  after:  <ul><li>fix bug / draft of fix bug</li><li>buy milk / draft of buy milk</li><li>fix bug / draft of fix bug</li></ul>  (2 edits)
  • 第一次渲染不检查,重复的 key 能顺利上线;第一次更新这个列表时 debug 构建才断言失败,进程以 101 退出(断言在 diff 里,不在组件函数里,没有 catch_unwind)。
  • release 不断言,这组数据下静默完成。两个「fix bug」长得一样,看不出配错了没有;换一种列表形状,release 也可能 panic 在 invalid key 上(带 key 的列表 的「重复的 key」)。

出处

diff_non_empty_fragment 只看新旧列表的第一项有没有 key:

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

diff_non_keyed_children 截掉多余的尾巴,再把第 i 个旧节点和第 i 个新节点逐对 diff。TodoRow 是同一个组件函数,diff 只把新 props 交给旧的 scope 重跑,scope 里的 hook(draft)原样保留。所以「删第一项」在不带 key 的算法眼里是「改前两项的 props、删第三项」,也就是 3 条指令。

diff_keyed_children 开头在 cfg!(debug_assertions) 下检查唯一性,就是上面那条 keyed siblings must each have a unique key:把 key 放进一个集合,比较集合大小和列表长度(left: 3, right: 2,3 项只有 2 个不同的 key)。release 没有这段,后面用 HashMap 从 key 找旧下标时,重复的 key 只剩最后一个。

还有两处和 key 有关的提示。rsx! 在一个块里第一个节点之外写 key 时给编译警告(check_for_duplicate_keys):

warning: use of deprecated unit struct `app::{closure#2}::_::Warning`: Keys are only allowed on the first node in the block.
 --> src/bin/key_second.rs:9:19
  |
9 |         li { key: "{id}", "{text}" }
  |                   ^^^^^^
  |
  = note: `#[warn(deprecated)]` on by default

这是 for (id, text) in todos { span { "#" } li { key: "{id}", "{text}" } }:key 要写在 for 循环体的第一个节点上,它是整个循环体那一块的 key。另外,key 只在兄弟列表里起作用,单个组件上的 key 不会让它重挂载,实验见本章第 5 篇。

修法

key 用数据自己的、在这一组兄弟里唯一且稳定的 id:数据库主键、UUID、创建时分配的序号。上面的 key = id 就是修好的版本。

  • 数据没有 id 时,在数据进入状态的那一刻分配一个(比如一个递增计数器),和数据存在一起,而不是渲染时临时生成。渲染时用 enumerate() 的下标、随机数、Uuid::new_v4() 都不行:前者跟着位置走,后两者每次渲染都变,按 key 配对时一个旧节点也配不上。
  • 不要拿可能重复的字段(标题、文字、时间戳)当 key。
  • 列表项是纯元素、没有组件状态、也没有输入框时,不写 key 只是多发几条指令,结果是对的;有组件或者有用户输入的 DOM(input、textarea、details 的展开状态)时,必须写。

怎么确认没中招

  • 搜 for 循环和 .map(|..| rsx! 里的组件和表单元素,看第一个节点上有没有 key:,key 是不是 id。enumerate() 出来的下标当 key 等于没写。
  • 跑 debug 构建做一次「删除 / 重排列表」的操作:重复 key 会在第一次更新时断言失败(release 不会)。
  • 写测试:渲染一个三项列表,删掉第一项,断言每一行的本地状态还对应同一条数据,就像上面的实验。

小结

  • 不带 key 的列表按位置配对,删除或插入后组件状态(hook)留在原位置,文字却换成了下一条数据;下标当 key 效果完全一样。
  • 用数据的唯一 id 当 key,状态跟着数据走,指令也更少。
  • 重复 key:debug 在第一次更新时断言 keyed siblings must each have a unique key,release 不检查,可能静默配错也可能 invalid key panic。
  • key 写在循环体第一个节点上;单个组件上的 key 不起作用。

💬 关联讨论

发起讨论
加载中...