最近在用 Rust 和 Leptos 0.8 重構一款 Wasm 網頁端打字機/輸入法應用。隨着內置的練習題(如宮保拼音、注音字根等打字範文)越來越多,我意識到:有些練習題打過一次就不常用了,一直硬編碼(Hardcode)塞在 Rust 原代碼裏,會讓編譯出來的 Wasm 執行檔無限膨脹。

於是我動手進行了一場「Wasm 瘦身計劃」:將這些長文本全數抽離成獨立的 .txt 文件,通過 Trunk 構建到靜態目錄,並在客戶端使用 gloo-net 進行異步(Async)加載。

結果,喜聞樂見的「翻車」了。

當我滿心歡喜地看着代碼裏少了幾百行的純文本,一跑編譯,Wasm 文件體積竟然不減反增,整整胖了 100 多 KB! (而且我已經加滿了 opt-level = 'z'lto = true 等終極優化參數)。

冷靜下來分析,這其實是 Wasm 前端開發必經的「基建稅」:原本的純文本在編譯後只佔用極小的只讀數據段(.rodata),但我爲此引入了 gloo-net(包含大量 Web API 的 Wasm 綁定),以及 Rust 爲了 async/await 所生成的一大堆龐大的狀態機(State Machine)代碼。

好在部署到 Cloudflare 後加載速度依然飛快。更重要的是,這 100KB 的「基建首付」換來了零邊際成本的無限擴展性:從今以後,就算往服務器塞 100MB 的練習題,Wasm 文件也不會再增加半個 Byte。

除了體積上的黑色幽默,這次從「純同步」遷移到「異步加載」的過程,還讓我迎頭撞上了幾個 Wasm 單線程環境下的深坑,總結了幾條血淚經驗:

💥 踩坑一:Wasm 異步鎖死(Async Lock Poisoning)

當我把加載邏輯改成異步後,程序在運行時突然觸發了 unreachable 崩潰。錯誤堆棧直指 Leptos 底層的 RwLock::write

兇手是這段直覺但致命的代碼:

let 目標作業內容 = LocalResource::new(move || {
    let 作業 = 當前作業.read(); // 🔴 取得讀寫鎖 (RwLockReadGuard)

    async move { // 🔴 讀取鎖被 move 進了異步代碼塊!
        match 作業.題號 {
            Some() => fetch_text(...).await, // 鎖在這裏被掛起了!
            None => ...
        }
    }
});

原因分析: 在 Wasm 單線程環境下,當把 當前作業.read() 產生的「讀取守衛(Read Guard)」帶入 async move 區塊並經歷了 .await 時,這個鎖就被掛起在背景了。如果用戶此時點擊 UI 觸發狀態更新(Write),系統發現讀取鎖還沒釋放,且單線程無法真正等待,就會直接 Panic 崩潰。

✅ 解決方案:將「取值」與「異步」物理隔離 在進入異步區塊前,先深層複製出純數據,瞬間釋放信號鎖:

let 目標作業內容 = LocalResource::new(move || {
    // 🟢 同步獲取並複製數據,立刻釋放 Leptos 信號的讀取鎖!
    let 獨立作業 = 當前作業.get(); 

    async move {
        // 在異步塊內部只使用沒有生命週期綁定的純數據
        match 獨立作業.題號 { ... }
    }
});

🔄 踩坑二:路由同步的死循環與 read_untracked

應用中需要讓 URL 的 ?drill=題號 參數與內部的 當前作業 狀態雙向同步。如果沒寫好,極易引發「URL 改變 -> 狀態更新 -> URL 又被更新」的死循環。

在 Leptos 0.8 中,我們利用全新的 read_untracked() API,結合 Rust 的衛語句(Guard Clause),寫出了性能與可讀性兼具的防禦性代碼:

Effect::new(move || {
    // 1. 取得 URL 參數,若無則提早結束,展平代碼層級
    let Some(目標題號) = state.drill.get() else { return };

    // 2. 使用 read_untracked() 實現零拷貝讀取,且不創建多餘的依賴追蹤
    // 3. 加上 != 判斷,阻斷死循環
    if 當前作業.read_untracked().題號 != Some(目標題號) {
        佈置作業(作業::練習題(現行方案.get(), 目標題號));
    }
});

注:Leptos 0.8 的 read_untracked() 會返回一個短暫的 Read Guard,拿完 .題號 後瞬間銷燬,全程零內存複製,性能拉滿。

✨ 意外的驚喜:渾然天成的「優雅降級」

引入異步加載後,我本來有些擔心:在網絡請求文本的那幾十毫秒空窗期內,用戶如果敲擊鍵盤,系統會不會報錯?需不需要加上 if loading { return } 把鍵盤鎖死?

結果發現,完全不需要!得益於將「輸入微觀引擎」與「作業邏輯」徹底解耦的架構:

  1. 引擎隨時待命:輸入法的連擊/並擊狀態機與查詞邏輯是常駐內存的同步操作,完全不依賴外部作業。
  2. 自動降級爲「自由打字」:當練習題正在加載時,對應的 目標輸入碼片段None,引擎內部的核對機制自動判定爲 false,進度不會推進。
  3. 無縫銜接:用戶在等待期間依然可以流暢地敲擊鍵盤(當作熱身)。一旦網絡請求完成,信號切換爲 Some,跟打覈對機制瞬間無縫介入。

總結

雖然這次重構讓我體會到了 Wasm 體積膨脹的「黑色幽默」,但在 Rust 強大的編譯器與 Leptos 0.8 精細粒度響應式的配合下,我們成功用 100KB 的代價換來了架構的極大自由。它強迫你正視許多在傳統 JS 框架中被掩蓋的狀態時序問題,一旦跨過了這道坎,換來的將是極致的運行性能和重構時滿滿的安全感。

觀看視頻版本: 📺 嗶站