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 之間就靠這塊共享記憶體來交換資料:

wasm-memory-model.svg

字串傳遞流程:

  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-pack、wasm-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-pack、wasm-bindgen)也成熟到讓人用起來既順手又有效率的程度了。

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

參考資源