這個專案是我「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 函式之前,一定要先 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 之間就靠這塊共享記憶體來交換資料:
字串傳遞流程:
- 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/ 目錄起 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 特定技能
- ✅ 理解
cdylibcrate 類型及其作用 - ✅ 使用
#[wasm_bindgen]屬性匯出給 JS - ✅ 管理 Rust/JavaScript 邊界的記憶體
- ✅ 型別轉換(簡單型別 vs. 複雜結構)
- ✅ WASM 模組初始化模式
- ✅ 在瀏覽器 DevTools 中除錯 WASM
- ✅ 套件大小優化技術
- ✅ 建置工具(
wasm-pack、wasm-bindgen)
什麼時候該用 WASM?
說到這裡,我想順便聊聊一個更實際的問題: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、什麼時候用簡單型別,得慢慢摸索
- 非同步初始化:一開始沒搞懂為什麼非得有 init() 不可
驚喜的發現
- 套件大小:比我想像中小多了(222 KB 還含完整解析器耶!)
- 建置速度:增量建置快得很(8 秒搞定)
- 瀏覽器支援:現代瀏覽器全都能跑,連 polyfill 都不用
- 開發體驗:在 DevTools 裡 debug WASM 的體驗其實頗不錯
結論
這個專案總算把我 rust-52-projects 旅程裡那個一直懸著的大缺口給補上了。它也讓我相信:Rust + WASM 真的已經可以拿來做正式環境的互動式 Web 應用了,尤其是像解析這種計算密集型的任務,更是它的拿手好戲。
Rust 的效能和安全性,加上 JavaScript 無所不在的普及性,組合起來實在是一套很有威力的開發模式。而工具鏈(wasm-pack、wasm-bindgen)也成熟到讓人用起來既順手又有效率的程度了。
我想,如果你也在找「第一個 WASM 專案」該寫什麼,這個會是個不錯的選擇——一邊把核心觀念摸熟,一邊還真的做出了個能用的東西,何樂而不為呢。