這個專案是我「52 個 Rust 專案」學習計畫的一部分。在完成了 16 個專案後,我發現 WebAssembly 是一個重要的學習缺口,於是決定用 Rust 來打造一個即時 Markdown 編輯器。

專案原始碼:wasm-markdown-editor

為什麼選擇這個專案?

在分析了之前完成的專案後,我發現幾個學習上的空白:

  • 已掌握的領域:Async/await、網路程式設計(TCP/UDP/HTTP/WebSockets)、解析器、CLI 工具、錯誤處理
  • 待加強的領域:資料庫 ORM、程序宏、FFI、WebAssembly、進階測試、GUI/圖形

選擇 Markdown 編輯器的原因:

  1. 填補關鍵缺口:之前沒有任何 WASM 專案
  2. 善用既有技能:運用之前在計算機、shell、EBML 等專案中學到的解析技巧
  3. 互動性強:能立即看到成果,滿足感高
  4. 實用價值:這是真正能用的工具,不只是 demo
  5. 現代技術棧: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. 釋放記憶體

字串傳遞流程

  1. JS 字串寫入 WASM 線性記憶體
  2. 傳遞指標和長度給 Rust 函式
  3. Rust 處理資料
  4. Rust 將結果寫入記憶體
  5. 回傳結果的指標給 JS
  6. JS 從記憶體讀取結果字串
  7. 釋放不再需要的記憶體

型別轉換對照表

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 特定技能

  1. ✅ 理解 cdylib crate 類型及其作用
  2. ✅ 使用 #[wasm_bindgen] 屬性匯出給 JS
  3. ✅ 管理 Rust/JavaScript 邊界的記憶體
  4. ✅ 型別轉換(簡單型別 vs. 複雜結構)
  5. ✅ WASM 模組初始化模式
  6. ✅ 在瀏覽器 DevTools 中除錯 WASM
  7. ✅ 套件大小優化技術
  8. ✅ 建置工具(wasm-packwasm-bindgen

什麼時候該用 WASM?

適合的場景

  • ✅ 計算密集型操作(解析、圖像處理)
  • ✅ 想重用現有的 Rust 函式庫
  • ✅ 效能關鍵路徑
  • ✅ 套件大小可接受時

不太適合的場景

  • ❌ 大量 DOM 操作(用 JS)
  • ❌ 微小的工具函式(開銷不值得)
  • ❌ 簡單的 CRUD 操作
  • ❌ 套件大小是關鍵考量時

與純 JavaScript 方案的比較

效能

  • Markdown 解析:比 JS 替代方案快約 2-3 倍
  • 打字時沒有 GC 暫停
  • 可預測的記憶體使用

套件大小

  • 與流行的 JS 函式庫相當或更小
  • markdown-it.js:約 320 KB
  • 我們的方案:235 KB(包含解析器 + 統計)

反思

順利的部分

  1. 流暢的建置過程:wasm-pack「直接就能用」
  2. 優秀的文件:Rust WASM book 非常有價值
  3. 型別安全:編譯時就能捕捉錯誤
  4. 效能:解析明顯流暢
  5. 工具鏈:自動產生的 TypeScript 定義很有幫助

克服的挑戰

  1. 路徑解析:404 錯誤需要理解 WASM 服務方式
  2. 記憶體模型:理解線性記憶體花了一些時間
  3. 型別轉換:學習何時用 JsValue vs. 簡單型別
  4. 非同步初始化:理解 init() 的必要性

驚喜的發現

  1. 套件大小:比預期小(222 KB 含完整解析器!)
  2. 建置速度:增量建置很快(8 秒)
  3. 瀏覽器支援:所有現代瀏覽器都能用,不需要 polyfill
  4. 開發體驗:在 DevTools 中除錯 WASM 相當不錯

結論

這個專案成功填補了我 rust-52-projects 學習旅程中的一個主要缺口。它證明了 Rust + WASM 已經可以用於生產環境的互動式 Web 應用程式,尤其是像解析這樣的計算密集型任務。

Rust 的效能和安全性與 JavaScript 的普及性結合,創造了一個強大的開發模式。工具鏈(wasm-packwasm-bindgen)已經成熟到體驗流暢且高效的程度。

這是一個很好的「第一個 WASM 專案」,在學習核心概念的同時建構出真正實用的東西。

參考資源