featured.svg

這個專案是我「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 函式之前,一定要先 await 那個非同步的 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/ 目錄起 server 的時候,瀏覽器根本碰不到 ../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?

說到這裡,我想順便聊聊一個更實際的問題: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、什麼時候用簡單型別,得慢慢摸索
  4. 非同步初始化:一開始沒搞懂為什麼非得有 init() 不可

驚喜的發現

  1. 套件大小:比我想像中小多了(222 KB 還含完整解析器耶!)
  2. 建置速度:增量建置快得很(8 秒搞定)
  3. 瀏覽器支援:現代瀏覽器全都能跑,連 polyfill 都不用
  4. 開發體驗:在 DevTools 裡 debug WASM 的體驗其實頗不錯

結論

這個專案總算把我 rust-52-projects 旅程裡那個一直懸著的大缺口給補上了。它也讓我相信:Rust + WASM 真的已經可以拿來做正式環境的互動式 Web 應用了,尤其是像解析這種計算密集型的任務,更是它的拿手好戲。

Rust 的效能和安全性,加上 JavaScript 無所不在的普及性,組合起來實在是一套很有威力的開發模式。而工具鏈(wasm-packwasm-bindgen)也成熟到讓人用起來既順手又有效率的程度了。

我想,如果你也在找「第一個 WASM 專案」該寫什麼,這個會是個不錯的選擇——一邊把核心觀念摸熟,一邊還真的做出了個能用的東西,何樂而不為呢。

參考資源