被Leptos弹窗逼疯后,我搞了一套零Props方案

Pico-CRM 是一个用 Rust 全栈写的家政行业 CRM,前端 Leptos + WASM。如果你写过 WASM 前端,应该知道:在 Leptos 里做跨组件通信,正统路子是传 props 或用 context。

但 CRM 这东西吧,十几页,每页几十个操作------新建客户弹一句"保存成功",编辑订单弹一句"已更新",删错了弹一个确认框。如果每个页面都把 toast 信号一层层传下去,光样板代码就够喝一壶的。

写到第三页我就放弃了。

后来搞出来一套方案:组件 mount 时把自己注册到 thread_local,暴露几个裸函数,任何地方一行调用,零 props 零 context。 全站 90+ 个调用点,开发体验直接从传参地狱拉到一次 import 搞定。

下面按我搞这套东西的时间线来讲------有兴致勃勃的正向设计,也有找了一下午才发现组件加载时序不对的蠢事。


一、缘起:十几页 CRM,弹窗传到怀疑人生

Leptos 的跨组件通信,文档里基本就两条路:

  1. Props drilling :父组件持有 RwSignal,一层层往下传。一个十几层的组件树,每层都要加一个跟自己毫无关系的参数。
  2. 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 参数全部删掉,爽快。

然后我给它包了四个公开函数,对应四种消息类型:successerrorwarninginfo。调用方不用关心内部细节,一行搞定。

三、进阶: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_apihandle_api_errortoast::error。但此时 <Toast/> 可能还没来得及 mount。

TOAST_SIGNALOption 还是 Noneshow_toast 函数很安静地滑进了 else 分支,打一个 eprintln!,页面上什么都不会显示。

这个问题本质上不是 thread_local 方案的问题,是所有"组件自注册"方案的时序问题。 即使换成 context、换成 props,在组件还没渲染的时候去触发,结果也是一样的------页面不会给你任何反馈。

修了三件事:

  1. show_toastelse 分支加 eprintln!,至少 console 里能看见
  2. <Toast/><MessageBox/> 放在 <Router> 外面、渲染树的最前面,确保比路由内容更早 mount------这是最关键的
  3. 登录页做了降级:表单上方额外用一个 <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_messageServerFnError 的各种变体转成中文文案------"认证失败,请重新登录"、"网络连接失败,请检查网络设置"、"服务器暂时不可用,请稍后重试"。用户看到的不是 MiddlewareError 这种内部术语,而是能看懂的话。

这意味着全站所有通过 call_api 发起的请求,出错自动弹 toast,零额外代码。 大到订单提交失败,小到网络断连,用户都能看到一个红色提示条------而开发者在每个页面一行错误处理都不用写。

最后统计了一下:success() 约 30+ 处调用,error() 约 60+ 处(含 call_api 自动触发的),delete_confirm() 3 处。这些调用点散落在十几个文件里,从登录页到订单管理到后台管理,到处都有。如果没有这套方案,每个地方都要传 props 或取 context。

六、这个方案什么时候该用,什么时候不该用

经验下来,我觉得这套模式适合的场景是:

  • 全局单例组件:Toast、MsgBox、全局通知栏,整个应用只需要一个实例
  • 调用频率高、位置分散:几十上百个调用点散落在不同页面和组件
  • WASM 环境 :单线程,RefCell 不会 panic

不适合的场景也很明显:

  • 多线程环境RefCell 跨线程 borrow_mut 会 panic,要换 MutexRwLock
  • 需要多实例:比如每个 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,还是也有类似的野路子?评论区聊聊。

相关推荐
不是山谷.:.1 小时前
Axios的【接口防抖 + 请求失败重试 + 弱网提示】三合一高阶版封装
前端·javascript·vue.js·笔记·elementui·typescript
超绝大帅哥1 小时前
babel降级|>, Object.groupBy
前端·javascript
23朵毒蘑菇1 小时前
前端自定义滚动条新星库出现了,看它亮还是不亮
前端·javascript
子兮曰1 小时前
GEO 生成式引擎优化完全指南:让你的内容成为 AI 的默认答案
前端·后端·seo
Cache技术分享1 小时前
412. Java 文件操作基础 - 用装饰者模式定制 BufferedReader 实现结构化文本读取
前端·后端
w_t_y_y1 小时前
VUE3(一)VUE3语法
前端·javascript·vue.js
builderwfy1 小时前
VUE子页面调用父页面实现方式
前端·javascript·vue.js
喵桑丶1 小时前
Skills
前端