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>,它包成了 newtypeSearchOpen(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
}
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 不会因为哪个组件卸载而被回收,活到VirtualDomdrop。GlobalMemo的Memo::new同样在根上建,它的ReactiveContext也属于根。 - 不在任何
VirtualDom里调用(没有Runtime),Runtime::current()直接 panic。
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()));
}
这个程序一共会打印几行 [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 会怎样
上面两个实验已经给出了三点:
GlobalSignal | static + 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 在服务端是每个请求一份,不会把一个访客的地区带给另一个访客。
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 里执行,活到VirtualDomdrop。- 同一个进程里每个
VirtualDom一份;fullstack 服务端每个请求一个VirtualDom,并发请求互不可见。server function、中间件里没有 runtime,读全局 signal 会 panic,debug 下 panic 文字原样回给客户端。 static+Mutex是进程级的:SSR 时所有用户共享,并发请求互相看到写入;没有订阅者,改了不触发重渲染;浏览器里是另一份。只适合和请求、用户无关的东西。- 热补丁改了全局 signal 的类型,0.7.3 起静默重建(#4809),之前是 downcast 失败 panic。