Rust in Everything
基于 dioxus 0.7.10

context 与全局 signal

主线位置:首屏主线上「每个请求新建一个 VirtualDom,在根上提供 ServerDocument、FullstackContext……」那一步。根 scope 的 context 表就是这一页讲的存储位置,全局 signal 的值也放在那里,所以它跟着请求的 VirtualDom 生、跟着它死。更新主线上,全局 signal 的读写和普通 signal 完全一样。

flowchart TB
  st["static COUNT"] --> key["构造函数 + GlobalKey"]
  key --> res["resolve 时"]
  res --> rt["Runtime::current"]
  rt --> root["ROOT scope 的 context 表"]
  root --> ctxmap["GlobalLazyContext"]
  ctxmap --> hit{"有这个 key"}
  hit -->|"有"| sig1["返回 Signal"]
  hit -->|"没有"| make["在 ROOT 里调构造函数"]
  make --> ins["插入表"]
  ins --> sig1

context 表本身(每个 scope 一个 Vec<Box<dyn Any>>、按类型查、沿 parent_id 往上找)在 Scope 与 hooks 里讲过,这一页讲 dioxus-hooks 在它上面包的几个 hook,以及 dioxus-signals 怎么用它实现「全局」signal。代码在 hooks/src/use_context.rs、use_root_context.rs 和 signals/src/global/mod.rs(319 行)。

原生实验依赖 dioxus =0.7.10、dioxus-core =0.7.10、dioxus-ssr =0.7.10、tokio;fullstack 实验是 exp-dioxus/fs 模板改的,dx build --platform web --fullstack(dx 0.7.9)构建,./server 监听 127.0.0.1:18770,用 curl --noproxy '*' 请求。平台 M2 Max、macOS 27、rustc 1.99.0。

四个 context hook

pub fn try_use_context<T: 'static + Clone>() -> Option<T> {
    use_hook(|| try_consume_context::<T>())
}

pub fn use_context<T: 'static + Clone>() -> T {
    use_hook(|| consume_context::<T>())
}

pub fn use_context_provider<T: 'static + Clone>(f: impl FnOnce() -> T) -> T {
    use_hook(|| provide_context(f()))
}

(try_use_context、use_context、use_context_provider,文档注释已删。)三个都是 use_hook 包一层:只在组件第一次渲染时查找或提供一次,之后每次渲染都拿 hook 里存的 clone。由此有两条推论:

  • 提供方后来换了值,已经 use_context 过的组件看不到。要能跟着变,提供的东西本身得是响应式的句柄:Signal<T>、Store<T>,或者包着它们的 newtype。句柄不变,变的是句柄指向的值,读它的组件照常订阅。
  • 按类型查,同一个 scope 里一种类型只有一个值。本站 crates/app/src/main.rs 里根组件提供了 Signal<bool>(登录框开关)和 Signal<Option<SessionUser>> 两个 signal,类型不同所以不冲突;同一个 scope 里再提供一个 Signal<bool> 就会替换前一个,子树里也会遮住上层的。crates/modules/search/src/search.rs 的搜索框开关同样是一个 Signal<bool>,它包成了 newtype SearchOpen(Signal<bool>) 再提供。

use_root_context 是给库用的:

pub fn use_root_context<T: 'static + Clone>(new: impl FnOnce() -> T) -> T {
    use_hook(|| {
        try_consume_context::<T>()
            // If no context is provided, create a new one at the root
            .unwrap_or_else(|| provide_root_context(new()))
    })
}

先沿父链找;找不到就调 provide_root_context,放进 ScopeId::ROOT 的表里(runtime.get_state(ScopeId::ROOT).provide_context(context))。根是所有 scope 的祖先,之后任何组件来找都能找到,库不必要求用户在根组件里先 use_context_provider。渲染器从外面也能往根上放:VirtualDom::with_root_context / provide_root_context,fullstack 服务端每个请求的 ServerDocument、FullstackContext 就是这么放进去的(一次请求的渲染)。

GlobalSignal:static 里没有值

static COUNT: GlobalSignal<i32> = Signal::global(|| 0);

GlobalSignal<T> 是 Global<Signal<T>, T>,GlobalMemo<T> 是 Global<Memo<T>, T>,dioxus-stores 的 GlobalStore<T> 是 Global<Store<T>, T>。Global 只有两样东西:

pub struct Global<T, R = T> {
    constructor: fn() -> R,
    key: GlobalKey<'static>,
    phantom: std::marker::PhantomData<fn() -> T>,
}

没有值,也没有锁,所以它能放进 static(只要 const fn 能构造)。Global::new 和 Signal::global 都标了 #[track_caller],key 取的是写 Signal::global(..) 那一处的 Location::caller(),变成 GlobalKey::File { file, line, column, index: 0 }。不同的 static 在源码里位置不同,key 就不同;with_name 可以手动指定。

值在第一次用到时由 resolve 建出来。Readable / Writable 的每个方法都先调它,所以 COUNT()、COUNT.write()、"{COUNT}" 每次都要走一遍:

    pub fn resolve(&self) -> T
    where
        T: 'static,
    {
        let key = self.key();

        let context = get_global_context();

        // Get the entry if it already exists
        let mut evicted_stale_entry = false;
        {
            let read = context.map.borrow();
            if let Some(signal) = read.get(&key) {
                if let Some(signal) = signal.downcast_ref::<T>() {
                    return signal.clone();
                }
                evicted_stale_entry = true;
            }
        }

        if evicted_stale_entry {
            context.map.borrow_mut().remove(&key);
        }
        // Otherwise, create it
        // Constructors are always run in the root scope
        let signal = dioxus_core::Runtime::current().in_scope(ScopeId::ROOT, || {
            T::initialize_from_function(self.constructor)
        });
        context
            .map
            .borrow_mut()
            .insert(key, Box::new(signal.clone()));
        signal
    }

get_global_context:

pub fn get_global_context() -> GlobalLazyContext {
    let rt = Runtime::current();
    match rt.has_context(ScopeId::ROOT) {
        Some(context) => context,
        None => rt.provide_context(ScopeId::ROOT, Default::default()),
    }
}

GlobalLazyContext 是一个 Rc<RefCell<HashMap<GlobalKey, Box<dyn Any>>>>,作为普通 context 放在根 scope 的 context 表里。拼起来:

  • 「全局」的范围是 Runtime::current(),也就是当前线程上正在跑的那个 VirtualDom。没有任何进程级的存储。
  • 构造函数在 in_scope(ScopeId::ROOT, ..) 里调,Signal::new 用的 Owner 是根 scope 的(generational-box),所以这个 signal 不会因为哪个组件卸载而被回收,活到 VirtualDom drop。GlobalMemo 的 Memo::new 同样在根上建,它的 ReactiveContext 也属于根。
  • 不在任何 VirtualDom 里调用(没有 Runtime),Runtime::current() 直接 panic。
💡TIP
打个比方:寄存牌

static COUNT 是一张印着柜台号的寄存牌(GlobalKey,即文件、行、列),牌背面写着「没存过就按这个方子做一份」(constructor)。每家店(VirtualDom)门口都有自己的寄存处(根 scope 的 GlobalLazyContext)。拿着同一张牌去不同的店,取到的是各店自己做的那一份;店关门(VirtualDom drop),寄存处连同里面的东西一起清掉;不在任何店里掏出这张牌,没有柜台可交(Must be called from inside a Dioxus runtime.)。

比方到这里为止:真正的「店」由线程局部的 runtime 栈决定,同一个线程可以轮流进出多家店(in_runtime),牌本身不记得自己在哪家店用过。

实验:一个进程,三个 VirtualDom

static COUNT: GlobalSignal<i32> = Signal::global(|| {
  log("  [COUNT constructor runs]");
  0
});
static DOUBLE: GlobalMemo<i32> = Signal::global_memo(|| COUNT() * 2);
static SHARED: Mutex<i32> = Mutex::new(0);

fn app() -> Element {
  use_hook(|| *SHARED.lock().unwrap() += 1); // once per mount
  rsx! { "COUNT={COUNT} DOUBLE={DOUBLE} SHARED={SHARED.lock().unwrap()}" }
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  log(format!("size_of_val(&COUNT) = {}", std::mem::size_of_val(&COUNT)));
  let mut dom1 = VirtualDom::new(app);
  dom1.rebuild_in_place();
  log(format!("dom1: {}", dioxus_ssr::render(&dom1)));
  let mut dom2 = VirtualDom::new(app);
  dom2.rebuild_in_place();
  log(format!("dom2: {}", dioxus_ssr::render(&dom2)));
  dom1.in_runtime(|| *COUNT.write() += 100);
  dom1.process_events();
  dom1.render_immediate_to_vec();
  log(format!("dom1 after +100: {}", dioxus_ssr::render(&dom1)));
  log(format!("dom2 untouched:  {}", dioxus_ssr::render(&dom2)));
  log(format!("COUNT id in dom1 = {:?}", dom1.in_runtime(|| COUNT.id())));
  log(format!("COUNT id in dom2 = {:?}", dom2.in_runtime(|| COUNT.id())));
  drop(dom1);
  let mut dom3 = VirtualDom::new(app);
  dom3.rebuild_in_place();
  log(format!("dom3 (after dom1 dropped): {}", dioxus_ssr::render(&dom3)));
  *SHARED.lock().unwrap() = 99;
  let woke = tokio::time::timeout(std::time::Duration::from_millis(50), dom3.wait_for_work()).await.is_ok();
  log(format!("SHARED = 99 -> dom3 has work: {woke}"));
  dom3.in_runtime(|| *COUNT.write() = 7);
  let woke = tokio::time::timeout(std::time::Duration::from_millis(50), dom3.wait_for_work()).await.is_ok();
  log(format!("COUNT = 7  -> dom3 has work: {woke}"));
  dom3.render_immediate_to_vec();
  log(format!("dom3: {}", dioxus_ssr::render(&dom3)));
  let r = std::panic::catch_unwind(|| COUNT.peek().clone());
  log(format!("outside any VirtualDom: is_err={}", r.is_err()));
}
❗IMPORTANT
动脑筋:构造函数跑几次?

这个程序一共会打印几行 [COUNT constructor runs]?dom3 第一次渲染时 COUNT 是 0 还是 100?

SSR:两个并发请求

本站的服务端每个请求新建一个 VirtualDom(一次页面请求),所以上面的结论直接对应到「请求之间会不会串」。fullstack 版本:

static VISITS: GlobalSignal<u32> = Signal::global(|| 0);
static SHARED: AtomicU32 = AtomicU32::new(0);

#[post("/api/slow")]
async fn slow(ms: u64) -> Result<u64, ServerFnError> {
  tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
  Ok(ms)
}

#[get("/api/peek")]
async fn peek_global() -> Result<u32, ServerFnError> {
  Ok(*VISITS.peek())
}

fn app() -> Element {
  // once per VirtualDom (once per request on the server)
  let (global_at_mount, shared_at_mount) = use_hook(|| {
    *VISITS.write() += 1;
    (*VISITS.peek(), SHARED.fetch_add(1, Ordering::SeqCst) + 1)
  });
  let waited = use_server_future(|| async { slow(300).await })?;
  rsx! {
    div { id: "out",
      "global_at_mount={global_at_mount} shared_at_mount={shared_at_mount} "
      "after_wait: global={VISITS} shared={SHARED.load(Ordering::SeqCst)} waited={waited():?}"
    }
  }
}

每个请求挂载时把全局 signal 和进程级的 AtomicU32 各加一,然后在 use_server_future 上挂起 300 毫秒,回来再读一次两者。刚启动的服务端,同时发两个 GET /,再发第三个(curl -w ' t=%{time_total}',只截 #out 这个 div):

<div id="out" data-node-hydration="0"><!--node-id1-->global_at_mount=1 shared_at_mount=2 <!--#--><!--node-id2-->after_wait: global=1 shared=2 waited=Some(Ok(300))<!--#--></div>
 t=0.303963
<div id="out" data-node-hydration="0"><!--node-id1-->global_at_mount=1 shared_at_mount=1 <!--#--><!--node-id2-->after_wait: global=1 shared=2 waited=Some(Ok(300))<!--#--></div>
 t=0.302841
-- third, sequential
<div id="out" data-node-hydration="0"><!--node-id1-->global_at_mount=1 shared_at_mount=3 <!--#--><!--node-id2-->after_wait: global=1 shared=3 waited=Some(Ok(300))<!--#--></div>
  • 两个请求都只用了 0.30 秒,是真并发,各自在挂起中等了 300 毫秒。
  • global 在每个请求里都是 1,等待前后都是:每个 VirtualDom 都从构造函数重新建了一份。
  • shared 串了:第二个请求挂载时看到 1、第一个看到 2,等待之后两个都读到 2,即对方请求的写入。第三个请求接着从 3 开始。

在 server function 里读全局 signal:

HTTP/1.1 500 Internal Server Error
content-length: 1239
date: Wed, 28 Oct 2026 11:58:37 GMT

Server function panicked: task 23 panicked with message "Must be called from inside a Dioxus runtime.\n\nHelp: Some APIs in dioxus require a global runtime to be present.\n ...

server function 由 spawn_pinned 放到本地线程池上跑,外面套的是 FullstackContext,不在任何 VirtualDom 里,Runtime::current() panic。线程池把 panic 变成 JoinError,match result 回 500;响应体在 debug 下是 format!("Server function panicked: {}", err),把整段 panic 文字(含帮助说明)发给了客户端;--release 构建同一个请求返回的是 content-length: 21 的 Internal Server Error。同理,axum 中间件、main 里的初始化代码都不能碰全局 signal。服务端渲染时 use_server_future 直接调用 server function 的函数体,那时它在请求的 VirtualDom 里(一次页面请求),读得到当前请求的值;同一个函数被浏览器通过 HTTP 调用时就 panic,两条路径行为不一致。在组件里加一行 let peeked = use_server_future(|| async { peek_global().await })?; 并把它渲染出来,curl 首屏得到的是:

<div id="out" data-node-hydration="0"><!--node-id1-->global_at_mount=1 shared_at_mount=1 <!--#--><!--node-id2-->after_wait: global=1 shared=1 waited=Some(Ok(300)) peeked=Some(Ok(1))<!--#--></div>

换成 static + Mutex 会怎样

上面两个实验已经给出了三点:

GlobalSignalstatic + Mutex / 原子量
值在哪每个 VirtualDom 根上的 GlobalLazyContext进程里一份
SSR 时每个请求一份,请求结束就丢所有请求、所有用户共享,并发请求互相看到对方的写入
浏览器里每个标签页一个 VirtualDom,一份wasm 实例里一份,和服务端那份毫无关系
改了之后订阅者 mark_dirty,组件重跑没有订阅者,wait_for_work 不返回(实验里 has work: false),要等别的原因重跑才看得到
不在 VirtualDom 里用panic可以

最后一行是 static 唯一的优势,适合真正进程级、只读或和渲染无关的东西:本站 crates/core 的配置缓存、i18n 字典、模块注册表都是 OnceLock / RwLock,它们不随请求变化,也不需要触发重渲染。凡是「某个用户的状态」「这次请求的状态」,放进 static 在服务端就是把一个用户的数据渲染进另一个用户的页面。

还有一个水合上的后果:服务端渲染时 static 的值进了 HTML,浏览器里同一个 static 是 wasm 自己的初始值,客户端第一次渲染算出来的结果和服务端不同(水合要求两边首帧一致)。GlobalSignal 两边都从构造函数开始,只要构造函数不依赖环境,首帧一致。

本站唯一的全局 signal 是课程定价里的 REGION(GlobalSignal<Option<Region>>,初值 None)。use_region 在 use_effect 里才读 localStorage 和浏览器时区、写 Some(region):effect 不在服务端跑,服务端和客户端首帧都是 None、按默认地区渲染,水合之后再切到访客的地区。按上面的实验,这个 signal 在服务端是每个请求一份,不会把一个访客的地区带给另一个访客。

📝NOTE
冷知识:热补丁改了全局 signal 的类型,0.7.3 之前会 panic

resolve 里那个 evicted_stale_entry 分支来自 PR #4809(「signals: Rebuild stale global cache entries automatically」)。之前那一行是 return signal.downcast_ref::<T>().cloned().unwrap();。PR 说明:「When rebuilding with dx serve --hot-patch, changing the type of the value held in a GlobalSignal currently results in a panic, due to a downcast failure.」热补丁替换了代码,但 VirtualDom 没重建,根上的表里还存着旧类型的值,key(文件、行、列)却没变。修法是「evict the existing value ... rather than panicking」,作者也考虑过把类型放进 key:「I considered making the type part of the key, but this feels gentler, since this is a rare occurrence.」说明里提到要打一条警告,但合并前的最后一个提交是「remove log」,0.7.10 的代码里是静默丢弃、重新构造。首次出现在 v0.7.3。

动脑筋答案

size_of_val(&COUNT) = 40
  [COUNT constructor runs]
dom1: COUNT=0 DOUBLE=0 SHARED=1
  [COUNT constructor runs]
dom2: COUNT=0 DOUBLE=0 SHARED=2
dom1 after +100: COUNT=100 DOUBLE=200 SHARED=2
dom2 untouched:  COUNT=0 DOUBLE=0 SHARED=2
COUNT id in dom1 = 0x101a9f218@1
COUNT id in dom2 = 0x759102c428@1
  [COUNT constructor runs]
dom3 (after dom1 dropped): COUNT=0 DOUBLE=0 SHARED=3
SHARED = 99 -> dom3 has work: false
COUNT = 7  -> dom3 has work: true
dom3: COUNT=7 DOUBLE=14 SHARED=99
thread 'main' (1943975) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-core-0.7.10/src/runtime.rs:100:17:
Must be called from inside a Dioxus runtime.
...
outside any VirtualDom: is_err=true

三行,每个 VirtualDom 第一次 resolve 时各一次;dom3 里是 0,dom1 的 100 随 dom1 一起没了。

  • size_of_val 是 40 字节:一个函数指针加一个 GlobalKey,没有值。
  • 两个 COUNT.id() 指向不同的 generational box:同一个 static,两个 signal。dom1 加 100 只有 dom1 看得到,dom2 不受影响。DOUBLE 也一样,各自在自己的根上建了一个 memo。
  • SHARED 是进程级的:dom1 重跑时读到的是 dom2 挂载时加过的 2;改成 99 不会让任何 VirtualDom 有活干,下一次因 COUNT 重跑时才顺带显示出来。
  • 最后在所有 VirtualDom 之外 peek,panic,被 catch_unwind 接住。

小结

  • use_context / try_use_context / use_context_provider 都是 use_hook 包一层,只在第一次渲染时查或提供;要跟着变就提供 Signal 这类句柄。use_root_context 找不到就提供到 ScopeId::ROOT。
  • GlobalSignal / GlobalMemo / GlobalStore 的 static 只存构造函数和调用位置做成的 GlobalKey;值由 resolve 懒建在当前 Runtime 根 scope 的 GlobalLazyContext 里,构造在根 scope 里执行,活到 VirtualDom drop。
  • 同一个进程里每个 VirtualDom 一份;fullstack 服务端每个请求一个 VirtualDom,并发请求互不可见。server function、中间件里没有 runtime,读全局 signal 会 panic,debug 下 panic 文字原样回给客户端。
  • static + Mutex 是进程级的:SSR 时所有用户共享,并发请求互相看到写入;没有订阅者,改了不触发重渲染;浏览器里是另一份。只适合和请求、用户无关的东西。
  • 热补丁改了全局 signal 的类型,0.7.3 起静默重建(#4809),之前是 downcast 失败 panic。

💬 关联讨论

发起讨论
加载中...