Rust in Everything
基于 dioxus 0.7.10

Store

主线位置:更新主线上「Signal::write → WriteLock drop 时通知订阅者 → ReactiveContext::mark_dirty」这一段的另一种实现。Store 写入时不通知「所有读过这个值的人」,而是按写入的路径在一棵订阅树上挑出该通知的 ReactiveContext,之后的 SchedulerMsg、render_immediate 不变。

flowchart TB
  rootn["路径 空 整个 Profile"] --> n0["路径 0 name"]
  rootn --> n1["路径 1 age"]
  rootn --> n2["路径 2 bio"]
  wr["age += 1"] --> mk["mark_dirty 路径 1"]
  mk --> anc["祖先节点 只通知深订阅"]
  mk --> self1["路径 1 全部订阅者"]
  mk --> desc["路径 1 之下 全部订阅者"]
  anc --> rootn
  self1 --> n1

Signal<T> 只有一个订阅者集合:读过它的 ReactiveContext 全在里面,写一次全部标脏(Signal)。值是一个大结构体时,改一个字段也会让只读另一个字段的组件重跑。常见的绕法是 Signal<Vec<Signal<T>>> 这样一层层套 signal,代价是数据结构里到处是 signal。0.7 新增的 dioxus-stores 换了个办法:数据还是普通的 struct / Vec / HashMap,只放在一个 CopyValue 里;订阅单独记在一棵树上,树的每个节点对应数据里的一个位置。src/ 一共约 2900 行,核心在 subscriptions.rs(501 行,含测试)和 scope.rs(224 行),其余是 Vec、HashMap、BTreeMap、Option、Result 各自的方法和 dioxus-stores-macro 的 derive。dioxus 在 signals feature(默认开启)下依赖它,prelude 里有 Store、use_store、GlobalStore、ReadStore、WriteStore。

实验在原生 VirtualDom 上跑:依赖 dioxus =0.7.10、dioxus-core =0.7.10、dioxus-stores =0.7.10,平台 M2 Max、macOS 27、rustc 1.99.0,debug 构建。每个组件第一行把自己的名字记进一个线程局部列表;step 清空列表,用 dom.in_runtime 执行一次写入,再 process_events + render_immediate_to_vec,打印这一轮重跑了哪些组件。

实验:改一个字段

#[derive(Store, Clone, PartialEq)]
struct Profile {
  name: String,
  age: u32,
  bio: String,
}

// ---- Signal<Profile>
fn sig_app() -> Element {
  let p = use_signal(profile);
  SIG.set(Some(p));
  ran("SigApp");
  rsx! { SigName { p } SigAge { p } SigBio { p } }
}
#[component]
fn SigName(p: Signal<Profile>) -> Element { ran("SigName"); rsx! { "{p.read().name}" } }
#[component]
fn SigAge(p: Signal<Profile>) -> Element { ran("SigAge"); rsx! { "{p.read().age}" } }
#[component]
fn SigBio(p: Signal<Profile>) -> Element { ran("SigBio"); rsx! { "{p.read().bio}" } }

// ---- Store<Profile>
fn store_app() -> Element {
  let p = use_store(profile);
  STORE.set(Some(p));
  ran("StoreApp");
  rsx! { StName { p } StAge { p } StBio { p } StWhole { p } }
}
#[component]
fn StName(p: Store<Profile>) -> Element { ran("StName"); rsx! { "{p.name()}" } }
#[component]
fn StAge(p: Store<Profile>) -> Element { ran("StAge"); rsx! { "{p.age()}" } }
#[component]
fn StBio(p: Store<Profile>) -> Element { ran("StBio"); rsx! { "{p.bio()}" } }
#[component]
fn StWhole(p: Store<Profile>) -> Element {
  ran("StWhole");
  let n = p.read().name.len(); // read the whole struct: deep subscription at the root path
  rsx! { "{n}" }
}
  step(&mut dom, "Signal: p.write().age += 1", || SIG.get().unwrap().write().age += 1);
  step(&mut dom, "Signal: p.write().name = ..", || SIG.get().unwrap().write().name = "bob".into());
  // ...
  step(&mut dom, "Store: p.age() += 1", || {
    let mut age = STORE.get().unwrap().age();
    age += 1;
  });
  step(&mut dom, "Store: p.name().set(..)", || STORE.get().unwrap().name().set("bob".into()));
  step(&mut dom, "Store: p.write().age += 1", || STORE.get().unwrap().write().age += 1);
Signal: p.write().age += 1         reran: ["SigName", "SigAge", "SigBio"]  edits=1
Signal: p.write().name = ..        reran: ["SigName", "SigAge", "SigBio"]  edits=1
Store: p.age() += 1                reran: ["StAge", "StWhole"]  edits=1
Store: p.name().set(..)            reran: ["StName", "StWhole"]  edits=1
Store: p.write().age += 1          reran: ["StName", "StAge", "StBio", "StWhole"]  edits=1
  • Signal<Profile>:改哪个字段,三个子组件都重跑,最后只有一条 SetText。
  • Store<Profile>:通过 p.age() 写,只有读 age 的 StAge 和读了整个结构体的 StWhole 重跑。
  • 通过 p.write() 写整个结构体,Store 不知道改了哪个字段,所有读者都重跑,和 Signal 一样。想要细粒度,写入也要从投影上走。

derive(Store) 生成了什么

cargo expand 看 Profile(只保留 age,name、bio 同理):

trait ProfileStoreExt<__Lens> {
    fn age(
        self,
    ) -> dioxus_stores::Store<
        u32,
        dioxus_stores::macro_helpers::dioxus_signals::MappedMutSignal<
            u32,
            __Lens,
            fn(&Profile) -> &u32,
            fn(&mut Profile) -> &mut u32,
        >,
    >;
    // ...
    fn transpose(self) -> ProfileStoreTransposed<__Lens>
    where
        Self: ::std::marker::Copy;
}
impl<__Lens> ProfileStoreExt<__Lens> for dioxus_stores::Store<Profile, __Lens> {
    // ...
    fn age(
        self,
    ) -> dioxus_stores::Store<
        u32,
        // ...
    > {
        let __map_field: fn(&Profile) -> &u32 = |value| &value.age;
        let __map_mut_field: fn(&mut Profile) -> &mut u32 = |value| &mut value.age;
        let scope = self.into_selector().child(1, __map_field, __map_mut_field);
        ::std::convert::Into::into(scope)
    }
    // ...
}

每个字段一个方法,做两件事:给路径追加字段的序号(name 是 0、age 是 1、bio 是 2),再套一层 MappedMutSignal,读写时用两个函数指针从 &Profile 投影到 &u32。transpose 一次取出所有字段的投影,放进生成的 ProfileStoreTransposed 结构体,方便解构。枚举也能 derive:每个变体生成 is_xxx 方法和对应的投影(derive_store 按 Data::Struct / Data::Enum 分开处理,union 报错)。

投影只是一个值,不分配任何东西:Store 只包着一个 SelectorScope:

pub struct SelectorScope<Lens> {
    path: TinyVec,
    store: StoreSubscriptions,
    write: Lens,
}
  • path:从根到这个位置的路径,TinyVec 是一个定长数组 [u16; 16] 加长度。超过 16 层,push panic SelectorPath is full;large-path feature 把上限改成 32。
  • store:整棵订阅树的句柄,Copy,所有投影共用一个。
  • write:怎么拿到这个位置的数据。根上是 Store::new 建的 CopyValue(Store::new 用的是 CopyValue::new_maybe_sync,不是 Signal:数据本身没有订阅者集合),往下每一层套一个 MappedMutSignal 或 IndexWrite。

child 就是 self.child_unmapped(index).map(map, map_mut):路径多一段,write 多一层。use_store 是 use_hook(|| Store::new(init())),根 CopyValue 和订阅树都归调用它的组件所有。

订阅树

StoreSubscriptions 是一个 CopyValue<StoreSubscriptionsInner, SyncStorage>,里面是一棵 SelectorNode 树:

pub(crate) struct SelectorNode {
    subscribers: HashMap<ReactiveContext, SubscriptionDepth>,
    root: HashMap<PathKey, SelectorNode>,
}

节点按需创建:路径 [1] 没人读过,树上就没有这个节点。每个订阅者带一个 SubscriptionDepth:

深度谁产生含义
Deep读投影的值:SelectorScope 的 Readable::try_read_unchecked 先 self.track()这个位置或它下面任何位置被写,都要通知我
ShallowVec::len、is_empty、HashMap::contains_key 等,调 track_shallow() 后 peek()只有这个位置本身(比如长度、键的集合)变了才通知我

SelectorScope 的 Readable 实现:

impl<Lens: Readable> Readable for SelectorScope<Lens> {
    type Target = Lens::Target;
    type Storage = Lens::Storage;

    fn try_read_unchecked(&self) -> Result<ReadableRef<'static, Lens>, BorrowError> {
        self.track();
        self.write.try_read_unchecked()
    }

    fn try_peek_unchecked(&self) -> Result<ReadableRef<'static, Lens>, BorrowError> {
        self.write.try_peek_unchecked()
    }

    fn subscribers(&self) -> Subscribers {
        self.store.shallow_subscribers(&self.path)
    }
}

track_deep 和普通 signal 一样只看 ReactiveContext::current(),有的话让它订阅一个 StoreSubscribers(树的句柄 + 路径 + 深度)。这个类型实现了 dioxus-core 的 SubscriberList,ReactiveContext 重跑前清订阅时会回调它的 remove,把自己从树上摘掉,空了的节点顺手剪掉(prune_if_empty)。所以 Store 和普通 signal 共用同一套「每次重跑重新订阅」的机制,只是订阅者集合换成了树上的一个节点。

写入时,SelectorScope 的 Writable 先 self.mark_dirty() 再拿写锁。mark_dirty 分三步:

    pub(crate) fn mark_dirty(&self, key: &[PathKey]) {
        self.mark_node_dirty(key);
        let descendant_paths = {
            let read = &self.inner.read_unchecked();
            let mut paths = Vec::new();
            if let Some(node) = read.root.get(key) {
                for (child_key, child_node) in &node.root {
                    let mut child_path: Vec<PathKey> = key.into();
                    child_path.push(*child_key);
                    child_node.paths_under(&child_path, &mut paths);
                }
            }
            paths
        };
        for path in descendant_paths {
            self.mark_node_subscribers_dirty(&path);
        }
    }

    pub(crate) fn mark_node_dirty(&self, key: &[PathKey]) {
        for i in 0..key.len() {
            self.mark_ancestor_deep_subscribers_dirty(&key[..i]);
        }
        self.mark_node_subscribers_dirty(key);
    }
  1. 祖先:从根到父节点,只通知 Deep 的订阅者。读过整个结构体的 StWhole 在这里被通知;只读了 len() 的组件不会。
  2. 自己:这个节点上的所有订阅者,深浅都算。
  3. 后代:这个节点之下每个节点的所有订阅者。p.write() 写根,三个字段的读者都在这一步被通知。

通知本身还是 reactive_context.mark_dirty(),和 signal 一样发 SchedulerMsg。retain_subscribers 先在锁里把订阅者从节点上取出来,放锁之后再挨个标脏,注释写了原因:「mark_dirty can run user code which may cause a new subscriber to be added. If we hold the lock, we will deadlock.」这句注释第一版就有,但当时整棵树的写锁仍然握在外层;PR #4950(「Fix stores deadlock」,v0.7.2)才改成先收集要标脏的路径,再逐个节点「without holding the lock over the mark_dirty call」。

📝NOTE
冷知识:Deep / Shallow 是 0.7.6 才有的

0.7.0 到 0.7.5 的 SelectorNode.subscribers 是一个 HashSet<ReactiveContext>,没有深浅之分。读整个 store 时,订阅是靠在读的那一刻递归地订阅所有已有的子节点实现的;写一个子投影只通知这个子节点和它下面的节点,不往上走。issue #5363(「Store children writes don't always cause store parent subscribers to be notified」)报的就是:组件里 store.read(),再通过 store.val() 写,这个组件不重跑。PR #5365(「Fix: notify parent store when child is marked dirty」)把集合改成 HashMap<ReactiveContext, SubscriptionDepth>,加了上面的「祖先只通知深订阅」一步,提交说明里的理由是:「We need to walk all parents for deep subscriptions instead of relying on tracking all children recursively during the initial read since some children may not exist yet」。首次出现在 v0.7.6,下面的实验在 0.7.4 上复现了修之前的行为。

把上面实验里的 StWhole 挪到第一个渲染(rsx! { StWhole { p } StName { p } StAge { p } StBio { p } }),它 p.read() 时三个字段的节点还不存在。同一份代码分别依赖 =0.7.4(cargo 缓存里有)和 =0.7.10 跑,只看 Store 的三行:

# dioxus 0.7.4
Store: p.age() += 1                reran: ["StAge"]  edits=1
Store: p.name().set(..)            reran: ["StName"]  edits=1
Store: p.write().age += 1          reran: ["StWhole", "StName", "StAge", "StBio"]  edits=1
# dioxus 0.7.10
Store: p.age() += 1                reran: ["StWhole", "StAge"]  edits=1
Store: p.name().set(..)            reran: ["StWhole", "StName"]  edits=1
Store: p.write().age += 1          reran: ["StWhole", "StName", "StAge", "StBio"]  edits=1

0.7.4 上读整体的组件漏掉了通过字段的写入:它订阅时只覆盖了当时已有的节点(只有根)。顺序换回原样(StWhole 最后渲染),0.7.4 也会通知它,所以这个 bug 取决于渲染顺序。

Vec 和 HashMap

集合的方法不走通用的 Writable,而是自己决定标脏哪些路径。Vec 的写方法:

    pub fn push(&mut self, value: T) {
        self.selector().mark_dirty_shallow();
        self.selector().write_untracked().push(value);
    }

    pub fn remove(&mut self, index: usize) -> T {
        self.selector().mark_dirty_shallow();
        self.selector().mark_dirty_at_and_after_index(index);
        self.selector().write_untracked().remove(index)
    }

mark_dirty_shallow 就是 mark_node_dirty:列表节点本身加祖先的深订阅,不碰元素。remove / insert 另外标脏下标 >= index 的元素节点,因为它们指向的值换了。write_untracked 拿写锁但不再标脏。读的一侧,len 是 track_shallow() 加 peek();index(i) 给路径追加 i as u16,iter() 就是 (0..self.len()).map(|i| self.clone().index(i))。

HashMap 的键先哈希成 u16:hash 是 hasher.hash_one(index) % PathKey::MAX。两个键碰撞时共用一个节点,结果是多通知,不会漏通知。get 是 self.contains_key(&key).then(|| self.get_unchecked(key)),contains_key 对整个 map 做浅订阅;insert 浅标脏 map,再标脏这个键的节点(「Mark the existing value as dirty if it exists」,PR #4959 在 v0.7.2 补上的)。

fn app() -> Element {
  let list = use_store(|| vec![10, 20, 30]);
  let map = use_store(|| HashMap::from([("a".to_string(), 1), ("b".to_string(), 2)]));
  // ...
  rsx! {
    List { list }
    Cell_ { map, k: "a" }
    Cell_ { map, k: "b" }
    CellU { map, k: "a" }
  }
}

#[component]
fn List(list: Store<Vec<i32>>) -> Element {
  ran(format!("List(len={})", list.len()));
  rsx! {
    for i in 0..list.len() {
      Row { key: "{i}", list, i }
    }
  }
}

#[component]
fn Row(list: Store<Vec<i32>>, i: usize) -> Element {
  let item = list.index(i);
  ran(format!("Row{i}={}", item()));
  rsx! { "{item}" }
}

#[component]
fn Cell_(map: Store<HashMap<String, i32>>, k: String) -> Element {
  let v = map.get(k.clone()).map(|s| s());
  ran(format!("Cell[{k}]={v:?}"));
  rsx! { "{v:?}" }
}

#[component]
fn CellU(map: Store<HashMap<String, i32>>, k: String) -> Element {
  let v = map.get_unchecked(k.clone())();
  ran(format!("CellU[{k}]={v}"));
  rsx! { "{v}" }
}

依次执行 list.index(1) += 1、list.push(40)、list.remove(1)、list.write()[0] = 0,再对 map 做三次写:

rebuild                      ran:   ["App", "List(len=3)", "Row0=10", "Row1=20", "Row2=30", "Cell[a]=Some(1)", "Cell[b]=Some(2)", "CellU[a]=1"]
list.index(1) += 1           reran: ["Row1=21"]
list.push(40)                reran: ["List(len=4)", "Row3=40"]
list.remove(1)               reran: ["List(len=3)", "Row1=30", "Row2=40"]
list.write()[0] = 0          reran: ["List(len=3)", "Row0=0", "Row1=30", "Row2=40"]
map.get("b") += 5            reran: ["Cell[b]=Some(7)"]
map.insert("c", 3)           reran: ["Cell[a]=Some(1)", "Cell[b]=Some(7)"]
map.insert("a", 9)           reran: ["Cell[a]=Some(9)", "Cell[b]=Some(7)", "CellU[a]=9"]
  • 改一个元素只重跑这一行。List 只浅订阅了长度,不重跑。
  • push:List 因长度变了重跑,Row3 是新挂载的;Row0–Row2 的 props(list 和 i)没变,diff 时跳过。
  • remove(1):下标 1 之后的两行重跑,Row0 不动;原来的 Row3 随长度变回 3 被卸载。
  • list.write()[0] = 0:通过整个 Vec 写,等于标脏根路径和全部后代,所有读者都重跑。
  • map.insert("c", 3):两个 Cell_ 都重跑,因为 get 里的 contains_key 浅订阅了整个 map,任何键的增删都通知它们。用 get_unchecked 的 CellU[a] 没有重跑;最后一步覆盖它自己的键 "a",它才和两个 Cell_ 一起重跑。
📝NOTE
没有蠢问题

问: 既然 list.iter() 直接给出每个元素的 store,为什么 Row 不直接收一个 WriteStore<i32>,而要收整个 list 再自己 index(i)?

答: 能收,但 push 会让每一行都重跑。把 Row 改成 fn Row(i: usize, item: WriteStore<i32>)、在 List 里 for (i, item) in list.iter().enumerate() { Row { key: "{i}", i, item } },同样的操作得到:

list.index(1) += 1           reran: ["Row1=21"]
list.push(40)                reran: ["List(len=4)", "Row0=10", "Row1=21", "Row2=30", "Row3=40"]
list.remove(1)               reran: ["List(len=3)", "Row0=10", "Row1=30", "Row2=40"]

index(i) 返回的类型是 Store<i32, IndexWrite<usize, ..>>,IndexWrite 没有实现 PartialEq,不能直接做 props;转成 WriteStore<i32> 时 From 实现里调 WriteSignal::new(writer),每次都新建一个装箱的 CopyValue,而 WriteSignal 的 PartialEq 比的就是这个 CopyValue。List 每重跑一次,每行拿到的都是「不相等」的新 props(组件与 props 讲了 props 不等时子组件必重跑)。Store<Vec<i32>> 加 usize 两个都能按值比较,没变就跳过。

和其他 signal 的关系

Store<T, Lens> 实现了 Readable / Writable,store()、store.read()、store += 1、"{store}" 这些写法和 signal 一样,背后都转到 SelectorScope 上;它也能 .into() 成 ReadSignal<T> / WriteSignal<T> 传给只认 signal 的组件,代价是上面说的装箱。全局版本 GlobalStore<T> 是 Global<Store<T>, T>,和 GlobalSignal 一样存在每个 VirtualDom 的根上(context 与全局 signal)。

什么时候值得用:数据是一个嵌套结构,不同组件读它的不同部分,写入又能落到具体位置上(某个字段、某个元素、某个键)。如果写入总是整体替换(比如从服务器拉一份新的 Vec 直接 set),Store 会把它当成根路径写入,所有读者照样重跑,这时它和 Signal 没有区别。

❗IMPORTANT
动脑筋:在列表头部插入

还是上面的 List / Row,此时列表是 [0, 30, 40]。执行 list.insert(0, 5),这一轮哪些组件会重跑?

动脑筋答案

在 step 序列末尾加一步 step(&mut dom, "list.insert(0, 5)", || LIST.get().unwrap().insert(0, 5));:

list.insert(0, 5)            reran: ["List(len=4)", "Row3=40", "Row0=5", "Row1=0", "Row2=30"]

全部。insert 先浅标脏列表,再 mark_dirty_at_and_after_index(0):下标 0 及之后的每个元素节点都换了值,Row0–Row2 都被通知。List 先重跑(高度更低),diff 时新建了 Row3,所以它排在第二;之后才轮到三个被标脏的旧行。Store 的元素路径是下标,不是身份,在头部插入或删除,后面的每一项都要重跑,这一点和不带 key 的列表一样。key 只决定 DOM 节点怎么复用(keyed 列表),不改变 Store 按下标通知的方式。

小结

  • Store 的数据放在一个普通 CopyValue 里,订阅放在一棵按路径索引的 SelectorNode 树上;#[derive(Store)] 给每个字段生成一个方法,路径加上字段序号,再套一层 MappedMutSignal 投影。
  • 读投影是深订阅,len / contains_key 这类是浅订阅。写一个位置:祖先只通知深订阅者,自己和后代全部通知(Deep / Shallow 和向上通知是 #5365,0.7.6 起)。
  • 实验:改 Profile 的一个字段,Signal<Profile> 重跑 3 个读者,Store 只重跑读这个字段的和读整体的;通过 p.write() 写整体则全部重跑。
  • Vec::push 只通知长度的读者,remove / insert 通知该下标之后的元素;HashMap::get 会浅订阅整个 map,任何键的增删都让它重跑,get_unchecked 不会。
  • 元素 store 转成 WriteStore 当 props 会每次新建装箱,导致子组件每次都重跑;传父 store 加下标更省。

💬 关联讨论

发起讨论
加载中...