這個專案是我「52 個 Rust 專案」學習計畫的一部分。在完成了 16 個專案後,我發現 WebAssembly 是一個重要的學習缺口,於是決定用 Rust 來打造一個即時 Markdown 編輯器。
專案原始碼:wasm-markdown-editor
為什麼選擇這個專案?
在分析了之前完成的專案後,我發現幾個學習上的空白:
- 已掌握的領域:Async/await、網路程式設計(TCP/UDP/HTTP/WebSockets)、解析器、CLI 工具、錯誤處理
- 待加強的領域:資料庫 ORM、程序宏、FFI、WebAssembly、進階測試、GUI/圖形
選擇 Markdown 編輯器的原因:
- 填補關鍵缺口:之前沒有任何 WASM 專案
- 善用既有技能:運用之前在計算機、shell、EBML 等專案中學到的解析技巧
- 互動性強:能立即看到成果,滿足感高
- 實用價值:這是真正能用的工具,不只是 demo
- 現代技術棧:WASM 在 Rust 生態系中越來越重要
技術架構
Rust 依賴項
[dependencies]
wasm-bindgen = "0.2" # JavaScript 互操作層
pulldown-cmark = "0.12" # 經過實戰驗證的 Markdown 解析器
web-sys = "0.3" # Web API 綁定
serde = { version = "1.0", features = ["derive"] }
serde-wasm-bindgen = "0.6" # Rust/JS 之間的資料序列化
console_error_panic_hook = "0.1" # 瀏覽器中更好的錯誤訊息專案結構
wasm-markdown-editor/
├── Cargo.toml # Rust 專案設定
├── index.html # 主進入點
├── src/
│ ├── lib.rs # WASM 進入點
│ ├── parser.rs # Markdown 解析 + 統計邏輯
│ └── utils.rs # Panic hook 和工具函式
├── www/
│ ├── index.js # JavaScript 應用邏輯
│ └── styles.css # 樣式
└── pkg/ # 建置輸出
├── wasm_markdown_editor.js
└── wasm_markdown_editor_bg.wasm
核心實作
將函式匯出到 JavaScript
use wasm_bindgen::prelude::*;
// WASM 模組載入時自動執行
#[wasm_bindgen(start)]
pub fn init() {
utils::set_panic_hook(); // 更好的錯誤訊息
}
// 簡單的字串轉換
#[wasm_bindgen]
pub fn markdown_to_html(markdown: &str) -> String {
parser::parse_markdown(markdown)
}
// 複雜結構 → JavaScript 物件
#[wasm_bindgen]
pub fn get_statistics(text: &str) -> JsValue {
let stats = parser::calculate_stats(text);
serde_wasm_bindgen::to_value(&stats).unwrap()
}關鍵模式:
#[wasm_bindgen]標記要匯出給 JS 的函式#[wasm_bindgen(start)]在模組初始化時自動執行- 簡單型別(str、數字、布林)自動轉換
- 複雜型別需要
serde-wasm-bindgen進行序列化
Markdown 解析
use pulldown_cmark::{html, Options, Parser};
pub fn parse_markdown(markdown: &str) -> String {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_TASKLISTS);
let parser = Parser::new_ext(markdown, options);
let mut html_output = String::new();
html::push_html(&mut html_output, parser);
html_output
}啟用的功能:刪除線、表格、註腳、任務清單、標題屬性。
統計資訊計算
#[derive(Serialize)]
pub struct Statistics {
pub characters: usize,
pub characters_no_spaces: usize,
pub words: usize,
pub lines: usize,
pub paragraphs: usize,
pub reading_time_minutes: f64,
}
pub fn calculate_stats(text: &str) -> Statistics {
let words = text.split_whitespace().count();
let paragraphs = text
.split("\n\n")
.filter(|s| !s.trim().is_empty())
.count();
// 平均閱讀速度:每分鐘 200 字
let reading_time_minutes = (words as f64 / 200.0).ceil();
// ...
}JavaScript 整合
import init, {
markdown_to_html,
get_statistics
} from '../pkg/wasm_markdown_editor.js';
async function run() {
// 初始化 WASM 模組
await init();
// 現在可以使用 Rust 函式了
const html = markdown_to_html(markdown);
const stats = get_statistics(text);
}
// 效能優化:防抖動
let debounceTimer = null;
const DEBOUNCE_DELAY = 300;
function handleInput() {
if (debounceTimer) clearTimeout(debounceTimer);
updateStatistics(); // 立即更新(快速)
debounceTimer = setTimeout(() => {
updatePreview(); // 防抖動(較重)
saveToStorage();
}, DEBOUNCE_DELAY);
}關鍵重點:
- ES6 模組匯入 WASM
- 呼叫 Rust 函式前必須先執行非同步的
init() - 防抖動避免過度渲染
- 統計立即更新(便宜),預覽防抖動(昂貴)
WASM 編譯深入解析
編譯流程
1. Rust 原始碼 (lib.rs, parser.rs, utils.rs)
↓
2. rustc --target wasm32-unknown-unknown
↓
3. 原始 .wasm 二進位檔(WebAssembly 位元組碼)
↓
4. wasm-bindgen(產生 JS 膠水程式碼)
↓
5. wasm-opt(Binaryen 優化)
↓
6. 最終輸出:.wasm + .js + .d.ts
記憶體模型
WASM 使用線性記憶體(單一連續區塊),JavaScript 和 Rust 透過這塊共享記憶體交換資料:
sequenceDiagram
participant JS as JavaScript
participant Mem as WASM 線性記憶體
participant Rust as Rust
JS->>Mem: 1. 寫入字串
JS->>Rust: 2. 傳遞指標 + 長度
Rust->>Rust: 3. 處理資料
Rust->>Mem: 4. 寫入結果
Rust->>JS: 5. 回傳結果指標
JS->>Mem: 6. 讀取結果
JS->>Mem: 7. 釋放記憶體
字串傳遞流程:
- JS 字串寫入 WASM 線性記憶體
- 傳遞指標和長度給 Rust 函式
- Rust 處理資料
- Rust 將結果寫入記憶體
- 回傳結果的指標給 JS
- JS 從記憶體讀取結果字串
- 釋放不再需要的記憶體
型別轉換對照表
| Rust 型別 | WASM 型別 | JavaScript 型別 |
|---|---|---|
&str, String |
i32 (ptr) + i32 (len) | string |
i32, u32 |
i32 | number |
f64 |
f64 | number |
bool |
i32 (0 或 1) | boolean |
JsValue |
externref | any |
| 可序列化 struct | externref | object |
建置與效能
建置指令
# 安裝 wasm-pack(只需一次)
cargo install wasm-pack
# 建置 WASM 模組
wasm-pack build --target web
# 執行 Rust 單元測試
cargo test
# 在瀏覽器中執行 WASM 測試
wasm-pack test --headless --firefox建置輸出
- WASM 套件:222 KB(已優化)
- JS 膠水程式碼:13 KB
- 總下載量:235 KB
- 建置時間:約 8 秒(增量)、約 13 秒(完整)
Cargo.toml 關鍵設定
[lib]
crate-type = ["cdylib", "rlib"] # WASM 編譯必需
[profile.release]
opt-level = "s" # 優化檔案大小(而非速度)
lto = true # 連結時優化,產生更小的套件為什麼這些設定很重要:
cdylib= C 動態函式庫類型,WASM 必需opt-level = "s"產生的二進位檔比 “3” 小約 30%- LTO 消除整個依賴樹中的死碼
遇到的問題與解決方案
問題:404 錯誤
從 www/ 目錄提供服務時,瀏覽器無法存取 ../pkg/(在提供的目錄之外)。
初始嘗試:從 www/ 目錄提供服務
http://localhost:8080/ → www/index.html
http://localhost:8080/pkg/... → 404 錯誤
解決方案:在專案根目錄建立 index.html
# 從專案根目錄提供服務,而非 www/
cd wasm-markdown-editor
python -m http.server 8080
# 存取 http://localhost:8080實作的功能
- ✅ 帶防抖動的即時預覽
- ✅ 即時統計(字數、字元數、閱讀時間)
- ✅ LocalStorage 自動儲存
- ✅ 帶內嵌 CSS 的 HTML 匯出
- ✅ 範例 Markdown 載入器
- ✅ 鍵盤快捷鍵(Ctrl+S、Ctrl+K)
- ✅ 響應式分割視窗佈局
學習成果
WASM 特定技能
- ✅ 理解
cdylibcrate 類型及其作用 - ✅ 使用
#[wasm_bindgen]屬性匯出給 JS - ✅ 管理 Rust/JavaScript 邊界的記憶體
- ✅ 型別轉換(簡單型別 vs. 複雜結構)
- ✅ WASM 模組初始化模式
- ✅ 在瀏覽器 DevTools 中除錯 WASM
- ✅ 套件大小優化技術
- ✅ 建置工具(
wasm-pack、wasm-bindgen)
什麼時候該用 WASM?
適合的場景:
- ✅ 計算密集型操作(解析、圖像處理)
- ✅ 想重用現有的 Rust 函式庫
- ✅ 效能關鍵路徑
- ✅ 套件大小可接受時
不太適合的場景:
- ❌ 大量 DOM 操作(用 JS)
- ❌ 微小的工具函式(開銷不值得)
- ❌ 簡單的 CRUD 操作
- ❌ 套件大小是關鍵考量時
與純 JavaScript 方案的比較
效能:
- Markdown 解析:比 JS 替代方案快約 2-3 倍
- 打字時沒有 GC 暫停
- 可預測的記憶體使用
套件大小:
- 與流行的 JS 函式庫相當或更小
- markdown-it.js:約 320 KB
- 我們的方案:235 KB(包含解析器 + 統計)
反思
順利的部分
- 流暢的建置過程:wasm-pack「直接就能用」
- 優秀的文件:Rust WASM book 非常有價值
- 型別安全:編譯時就能捕捉錯誤
- 效能:解析明顯流暢
- 工具鏈:自動產生的 TypeScript 定義很有幫助
克服的挑戰
- 路徑解析:404 錯誤需要理解 WASM 服務方式
- 記憶體模型:理解線性記憶體花了一些時間
- 型別轉換:學習何時用 JsValue vs. 簡單型別
- 非同步初始化:理解 init() 的必要性
驚喜的發現
- 套件大小:比預期小(222 KB 含完整解析器!)
- 建置速度:增量建置很快(8 秒)
- 瀏覽器支援:所有現代瀏覽器都能用,不需要 polyfill
- 開發體驗:在 DevTools 中除錯 WASM 相當不錯
結論
這個專案成功填補了我 rust-52-projects 學習旅程中的一個主要缺口。它證明了 Rust + WASM 已經可以用於生產環境的互動式 Web 應用程式,尤其是像解析這樣的計算密集型任務。
Rust 的效能和安全性與 JavaScript 的普及性結合,創造了一個強大的開發模式。工具鏈(wasm-pack、wasm-bindgen)已經成熟到體驗流暢且高效的程度。
這是一個很好的「第一個 WASM 專案」,在學習核心概念的同時建構出真正實用的東西。