Pico-CRM 是一个用 Rust 全栈写的家政行业 CRM,前端 Leptos + WASM。如果你写过 WASM 前端,应该知道:在 Leptos 里做跨组件通信,正统路子是传 props 或用 context。
但 CRM 这东西吧,十几页,每页几十个操作------新建客户弹一句"保存成功",编辑订单弹一句"已更新",删错了弹一个确认框。如果每个页面都把 toast 信号一层层传下去,光样板代码就够喝一壶的。
写到第三页我就放弃了。
后来搞出来一套方案:组件 mount 时把自己注册到 thread_local,暴露几个裸函数,任何地方一行调用,零 props 零 context。 全站 90+ 个调用点,开发体验直接从传参地狱拉到一次 import 搞定。
下面按我搞这套东西的时间线来讲------有兴致勃勃的正向设计,也有找了一下午才发现组件加载时序不对的蠢事。
一、缘起:十几页 CRM,弹窗传到怀疑人生
Leptos 的跨组件通信,文档里基本就两条路:
- Props drilling :父组件持有
RwSignal,一层层往下传。一个十几层的组件树,每层都要加一个跟自己毫无关系的参数。 - provide_context / use_context :比 props 强点,但每个调用点还得写
use_context::<ToastContext>()。最大的问题是------如果忘了 provide 直接调,运行时直接 panic。
还有第三条:引入全局状态管理库。为了一对弹窗组件加个几千行的依赖,太重了。
说白了,Toast 和 MessageBox 这种东西,天然是全局单例。 它跟业务没关系,不参与数据流,就是"任何地方喊一声、固定位置亮一下"。用组件树传参来驱动它,模型就对不上。
写到第三个页面的时候,我在每个 #[component] 上给一个 fake 的 toast_signal: RwSignal<ToastState> 参数,心里特别烦躁。这东西明明应该是一个全局函数调用就能搞定的事。
二、初体验:thread_local 扔进去,居然真的能跑
核心想法很简单:WASM 是单线程的,那我能不能用一个进程全局的静态变量来存信号引用?
查了一圈,有两个东西刚好对上:
thread_local!------std 里的宏,声明一个线程局部的静态变量。WASM 里线程=主线程,用起来就是全局的。RwSignal------Leptos 的响应式原语,内部用Rc引用计数。Clone 不复制数据,只加引用计数,多个持有者指向同一个响应式节点。
合起来就是:
rust
thread_local! {
static TOAST_SIGNAL: RefCell<Option<RwSignal<ToastState>>> =
const { RefCell::new(None) };
}
RefCell 在最外层是必需的------thread_local! 里的值要用 with(|v| ...) 访问,拿到的是引用,得通过 RefCell 才能可变借用。好在 WASM 单线程,不存在 borrow_mut 冲突。
Option 是关键设计------组件还没 mount 的时候是 None,mount 之后变成 Some(signal)。不是所有代码路径都在组件之后执行,留一个空状态比直接 unwrap 安全得多。
组件这边做的事就是 mount 时自注册:
rust
#[component]
pub fn Toast() -> impl IntoView {
let toast_state = RwSignal::new(ToastState::default());
// 把自己注册到全局槽
TOAST_SIGNAL.with(|slot| *slot.borrow_mut() = Some(toast_state));
// ... 基于 toast_state 的响应式渲染
}
外部触发只要一行:
rust
TOAST_SIGNAL.with(|slot| {
if let Some(state) = *slot.borrow() {
state.set(ToastState { message: Some("保存成功".into()), ... });
}
});
第一次跑通的时候真的有点激动------你不用传任何东西,随便在哪个文件、哪个函数、嵌套多深,引一下函数就能弹 toast。之前写的那些 fake props 参数全部删掉,爽快。
然后我给它包了四个公开函数,对应四种消息类型:success、error、warning、info。调用方不用关心内部细节,一行搞定。
三、进阶:MessageBox 也搞定了,但 Callback 有点烫手
Toast 是单向的------弹完自己消失。但确认框不一样,用户点了"确认"还是"取消",你得知道。
同一个 thread_local 模式,只是 state 里多了两个回调字段:
rust
#[derive(Debug, Clone)]
pub struct MessageBoxState {
title: String,
message: String,
visible: bool,
message_type: MessageBoxType,
on_confirm: Option<Callback<()>>,
on_cancel: Option<Callback<()>>,
}
这里有一个一开始没想清楚的问题:回调用 Callback 还是 Box<dyn Fn>?
Leptos 的 Callback<()> 不是一般的闭包------它内部处理了 Copy,可以在信号更新时安全地 clone 和移动。如果用 Box<dyn Fn>,在 Leptos 的响应式上下文里会遇到生命周期问题。
最后统一定了接口:
rust
pub fn confirm(title: &str, message: &str,
on_result: impl Fn(bool) + Clone + Send + Sync + 'static) { ... }
pub fn delete_confirm(title: &str, message: &str,
on_result: impl Fn(bool) + Clone + Send + Sync + 'static) { ... }
外面调的时候,on_result(true) 表示确认,false 表示取消。跟 JS 里 Promise 的 resolve 一个思路,但更简单直接。
确认框的点确认后 state.update(|s| s.visible = false) 关掉自己------组件自己负责自己的生命周期,调用者只关心结果。
四、踩坑:组件还没 mount 就去调了,静默失败找了一下午
东西做好之后,我发现一个诡异的问题:登录页报错的时候,toast 有时候弹有时候不弹。
翻了半天代码才发现------登录页加载时有一步认证检查,如果 cookie 过期,在 App() 渲染的早期就会触发 API 调用,然后走 call_api → handle_api_error → toast::error。但此时 <Toast/> 可能还没来得及 mount。
TOAST_SIGNAL 的 Option 还是 None,show_toast 函数很安静地滑进了 else 分支,打一个 eprintln!,页面上什么都不会显示。
这个问题本质上不是 thread_local 方案的问题,是所有"组件自注册"方案的时序问题。 即使换成 context、换成 props,在组件还没渲染的时候去触发,结果也是一样的------页面不会给你任何反馈。
修了三件事:
- 在
show_toast的else分支加eprintln!,至少 console 里能看见 - 把
<Toast/>和<MessageBox/>放在<Router>外面、渲染树的最前面,确保比路由内容更早 mount------这是最关键的 - 登录页做了降级:表单上方额外用一个
<Show>组件显式渲染错误,不依赖 toast
不是什么漂亮方案,但之后没再出过问题。
还有一个我一开始担心但实际没发生过的事:WASM 里虽然是单线程,但 Leptos 的响应式更新是异步批量的,会不会 state.set 的时候冲突? 实测没有。RwSignal 内部做了批量更新队列,同一个 tick 内多次 set 会自动合并,组件看到的是最后一次的值。连续两次 success("a") success("b") 只渲染最终版本,不会闪。
五、实战落地:call_api 统一接入,所有错误自动弹
这套东西真正的威力不在单次调用,而在工具层渗透。
Pico-CRM 有一个 call_api 函数,封装了所有 server function 调用。我在它的错误处理分支里直接接入了 toast::error:
rust
pub async fn call_api<T, F>(server_fn_call: F) -> Result<T, String>
where
F: Future<Output = Result<T, ServerFnError>>,
{
match server_fn_call.await {
Ok(data) => Ok(data),
Err(e) => {
handle_api_error(&e);
Err(format_api_error(&e))
}
}
}
fn handle_api_error(error: &ServerFnError) {
if is_unauthorized_error(error) {
redirect_to_login(); // 401 跳登录
return;
}
let message = get_user_friendly_message(error);
toast::error(message.to_string()); // 其他错误自动弹 toast
}
get_user_friendly_message 把 ServerFnError 的各种变体转成中文文案------"认证失败,请重新登录"、"网络连接失败,请检查网络设置"、"服务器暂时不可用,请稍后重试"。用户看到的不是 MiddlewareError 这种内部术语,而是能看懂的话。
这意味着全站所有通过 call_api 发起的请求,出错自动弹 toast,零额外代码。 大到订单提交失败,小到网络断连,用户都能看到一个红色提示条------而开发者在每个页面一行错误处理都不用写。
最后统计了一下:success() 约 30+ 处调用,error() 约 60+ 处(含 call_api 自动触发的),delete_confirm() 3 处。这些调用点散落在十几个文件里,从登录页到订单管理到后台管理,到处都有。如果没有这套方案,每个地方都要传 props 或取 context。
六、这个方案什么时候该用,什么时候不该用
经验下来,我觉得这套模式适合的场景是:
- 全局单例组件:Toast、MsgBox、全局通知栏,整个应用只需要一个实例
- 调用频率高、位置分散:几十上百个调用点散落在不同页面和组件
- WASM 环境 :单线程,
RefCell不会 panic
不适合的场景也很明显:
- 多线程环境 :
RefCell跨线程 borrow_mut 会 panic,要换Mutex或RwLock - 需要多实例:比如每个 Tab 弹自己独立的 toast,那还是传 props/context
- 需要 SSR 水合 :
thread_local在服务端渲染和客户端水合之间的行为要仔细验证
调试体验 也是个痛点------thread_local 里的值不像 props 那样可以在 React DevTools 里看到传播路径。出了问题,只有一个 eprintln! 兜底。如果团队里不止你一个人用,最好把这段逻辑封在一个模块里,对外只暴露四个函数,减少同事直接碰 TOAST_SIGNAL 的可能。
在 Leptos 社区里,这种 thread_local 的用法不算"官方推荐"。官方更倾向于 context 或 store 模式。但现实是,对于全局弹窗这种天然就应该是 fn() 调用的事,强行套用组件树的传参模型就是在给自己找麻烦。好的抽象是让你感觉不到它的存在,而不是每个地方都要写庙堂级的样板代码。
如果你也在用 Leptos 写 WASM,你是怎么处理全局弹窗的?用 context、传 props,还是也有类似的野路子?评论区聊聊。