這個專案是我「52 個 Rust 專案」學習計畫的一部分。寫完 16 個專案之後回頭一看,我才發現 WebAssembly 一直是個沒補上的缺口。既然如此,乾脆就用 Rust 來打造一個即時 Markdown 編輯器吧。
專案原始碼:wasm-markdown-editor
為什麼選擇這個專案?
把之前完成的專案攤開來檢視,我發現幾個學習上的空白:
- 已掌握的領域 - async/await、網路程式設計(TCP / UDP / HTTP / WebSockets)、解析器、CLI 工具、錯誤處理
- 待加強的領域 - 資料庫 ORM、程序巨集(procedural macros)、FFI、WebAssembly、進階測試、GUI / 圖形
那為什麼偏偏挑 Markdown 編輯器呢?理由有幾個:
- 填補關鍵缺口 - 之前一個 WASM 專案都沒碰過
- 善用既有技能 - 可以把之前在計算機、shell、EBML 等專案裡學到的解析技巧拿出來用
- 互動性強 - 成果馬上看得到,滿足感頗高
- 實用價值 - 這是真的能用的工具,不只是個 demo 而已
- 現代技術堆疊 - WASM 在 Rust 生態系裡的份量越來越重了
技術架構
Rust 依賴項
專案在 Cargo.toml 中配置了以下核心依賴:
[dependencies]
wasm-bindgen = "0.2" # JavaScript 互操作層
pulldown-cmark = "0.12" # 高效的 Markdown 解析器
web-sys = { version = "0.3", features = ["console"] } # Web API 綁定
serde = { version = "1.0", features = ["derive"] }
serde-wasm-bindgen = "0.6" # Rust / JS 之間的資料序列化
console_error_panic_hook = { version = "0.1", optional = true } # 瀏覽器中更好的除錯 panic 訊息專案結構
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
在 src/lib.rs 中,透過 wasm-bindgen 屬性將 Rust 函式提供給前端呼叫:
// src/lib.rs
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 解析
解析核心採用 pulldown-cmark,透過拉取式事件驅動解析器(pull parser)產生解析事件串流(event stream),再直接串流寫入 HTML 輸出:
// src/parser.rs
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);
options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
let parser = Parser::new_ext(markdown, options);
let mut html_output = String::new();
html::push_html(&mut html_output, parser);
html_output
}啟用的解析功能包括:刪除線、表格、註腳、任務清單與標題屬性。
統計資訊計算
為了在編輯時即時回饋文件統計資訊,實作了 Statistics 結構體計算字數、行數與預估閱讀時間:
// src/parser.rs
#[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 characters = text.chars().count();
let characters_no_spaces = text.chars().filter(|c| !c.is_whitespace()).count();
let words = count_words(text);
let lines = text.lines().count();
let paragraphs = text
.split("\n\n")
.filter(|s| !s.trim().is_empty())
.count();
// 平均閱讀速度:每分鐘 200 個以空白分隔的詞
let reading_time_minutes = if words > 0 {
(words as f64 / 200.0).ceil()
} else {
0.0
};
Statistics {
characters,
characters_no_spaces,
words,
lines,
paragraphs,
reading_time_minutes,
}
}需要說明的是,目前的 count_words() 實作採用 split_whitespace() 計算空白分隔的詞彙數,適用於西文字詞;針對中文或日文等不以空白分詞的文字,此處計算結果為連續字塊數量,平均閱讀時間亦是以西文每分鐘約 200 詞的模型作為估算基準。
JavaScript 整合
前端透過 ES6 模組載入 WASM 套件,並引入防抖動(debounce)機制降低頻繁鍵入時的重繪負擔:
// 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);
}
// 效能最佳化:防抖動(debounce)
let debounceTimer = null;
const DEBOUNCE_DELAY = 300;
function handleInput() {
if (debounceTimer) clearTimeout(debounceTimer);
updateStatistics(); // 立即更新(計算輕量)
debounceTimer = setTimeout(() => {
updatePreview(); // 防抖動(DOM 重繪較重)
saveToStorage();
}, DEBOUNCE_DELAY);
}這段整合有幾個關鍵細節:
- 使用 ES6 模組標準匯入 WASM
- 呼叫 Rust 匯出函式前,必須先
await非同步的init()完成模組實例化 - 防抖動避免每鍵入一個字元就觸發完整的 DOM
innerHTML替換 - 文字統計計算輕量,因此隨輸入立即更新;Markdown 解析與 HTML 渲染相對較重,交由防抖動延遲觸發
WASM 編譯與記憶體模型
編譯流程
從 Rust 原始碼到瀏覽器中可執行的 WebAssembly 模組,完整建置流程可拆解為以下階段:
記憶體模型
WebAssembly 採用的是由 ArrayBuffer 支撐的線性記憶體(linear memory)。在 wasm-bindgen 的包裝機制下,JavaScript 與 Rust 之間透過這塊共用記憶體進行資料交換:
字串在記憶體間的傳遞與處理流程如下:
- JavaScript 將來源字串編碼為 UTF-8 位元組,寫入 WASM 線性記憶體配置的緩衝區
- JavaScript 包裝器傳遞緩衝區指標(pointer)與長度(length)呼叫
markdown_to_html - Rust 解析器從切片讀取資料並解析事件串流
- Rust 將產出的 HTML 結果寫入 WASM 線性記憶體
- Rust 函式回傳結果的記憶體位址與長度
[ret_ptr, ret_len]給 JavaScript - JavaScript 包裝器從線性記憶體解碼出 UTF-8 結果 HTML 字串
- 包裝器在
finally區塊中呼叫__wbindgen_free釋放回傳緩衝區,供後續重複利用 - 前端應用程式取得最終 HTML 字串,更新至 DOM 容器(
preview.innerHTML)
需要說明的是,WASM 線性記憶體並非不可變的靜態空間,當資料需求增加時,核心可透過 WebAssembly.Memory.grow() 動態擴展頁面(每頁 64 KiB),產生的 JavaScript 膠水程式碼亦會在存取時檢查並更新失效的記憶體視圖(memory views)。
型別轉換對照表
| Rust 型別 | WASM 型別 | JavaScript 型別 | 轉換機制 |
|---|---|---|---|
&str, String |
i32 (ptr) + i32 (len) |
string |
線性記憶體 UTF-8 編解碼 |
i32, u32 |
i32 |
number |
原生數值映射 |
f64 |
f64 |
number |
原生浮點數映射 |
bool |
i32 (0 或 1) |
boolean |
整數轉換 |
JsValue |
externref |
any |
JavaScript 參照封裝 |
| 可序列化 struct | externref |
object |
serde-wasm-bindgen 序列化 |
建置與效能設定
建置指令
透過 wasm-pack 即可一鍵驅動編譯、最佳化與型別定義生成:
# 安裝 wasm-pack(只需一次)
cargo install wasm-pack
# 建置發布版 WASM 模組
wasm-pack build --target web
# 執行 Rust 單元測試
cargo test
# 在瀏覽器中執行無頭 WASM 測試
wasm-pack test --headless --firefox建置輸出規格
直接檢視 pkg/ 產出的核心模組(不包含外部 HTML、CSS 與應用 JS):
- WASM 二進位檔 (
wasm_markdown_editor_bg.wasm) - 227,030 bytes(約 221.7 KiB) - JS 膠水模組 (
wasm_markdown_editor.js) - 13,757 bytes(約 13.4 KiB) - 核心模組未壓縮合計 - 240,787 bytes(約 235.1 KiB)
- 傳輸壓縮評估 -
- gzip (level 9):約 98.4 KiB(100,775 bytes)
- Brotli (quality 11):約 81.2 KiB(83,123 bytes)
- 建置耗時 - 依硬體規格與快取狀態不同,乾淨建置約 10–15 秒,增量編譯通常在數秒內完成
Cargo.toml 關鍵最佳化設定
[lib]
crate-type = ["cdylib", "rlib"] # 本專案以 cdylib 產出供 wasm-bindgen 整合的 WASM 模組;rlib 則保留供 Rust 函式庫與內部測試
[profile.release]
opt-level = "s" # 優先縮減二進位體積(二進位尺寸導向)
lto = true # 連結時期最佳化(Link-Time Optimization),消除跨 crate 死碼這兩項設定在 WebAssembly 開發中的具體效益:
cdylib產生供wasm-bindgen處理的 C 動態函式庫規格,排除不必要的 Rust 內部符號opt-level = "s"針對程式碼尺寸進行壓縮,比起以最大純執行速度為導向的"3"能有效收斂二進位大小lto = true在連結階段進行跨 crate 全域死碼消除(dead code elimination),大幅剔除未使用的依賴函式
遇到的問題與解決方案
開發伺服器靜態資源 404
一開始嘗試直接將靜態網頁伺服器的根目錄指派在 www/ 目錄:
http://localhost:8080/ → www/index.html
http://localhost:8080/pkg/... → 404 Not Found(超出提供服務的根目錄範圍)
由於建置產物輸出於專案根目錄下的 pkg/,瀏覽器端透過 ../pkg/wasm_markdown_editor.js 引用時,存取路徑超出了 www/ 根目錄,導致 HTTP 404。
解決方案 - 在專案根目錄建立主 index.html,並將靜態伺服器起在專案根目錄下:
# 從專案根目錄提供服務
cd wasm-markdown-editor
python -m http.server 8080
# 瀏覽器存取 http://localhost:8080實作功能清單
- 帶防抖動的即時 Markdown 預覽
- 即時文件統計(字數、字元數、預估閱讀時間)
localStorage本地自動儲存- 帶內嵌 CSS 樣式的 HTML 匯出
- 預設範例 Markdown 載入器
- 鍵盤快速鍵支援(Ctrl+S、Ctrl+K)
- 響應式左右分割視窗佈局
預覽與 HTML 安全性說明 - 目前編輯器是將 pulldown-cmark 的輸出直接賦值給 preview.innerHTML,適合信任的本地內容編輯。在生產環境若需接收不可信來源的 Markdown 輸入,仍應透過 DOMPurify 等工具進行 HTML sanitization,防範 XSS 注入。WASM 的記憶體安全保證並不等同於輸出的 HTML 結構安全。
學習成果與技術選型
WebAssembly 關鍵技能掌握
- 理解
cdylibcrate 類型及其在編譯管線中的定位 - 熟練使用
#[wasm_bindgen]屬性定義 JS 呼叫介面 - 掌握 Rust 與 JavaScript 跨語言邊界的線性記憶體管理
- 型別轉換機制(純量型別、指標長度傳遞與複雜結構序列化)
- 非同步 WASM 模組載入與實例化生命週期
- 運用瀏覽器 DevTools 與
console_error_panic_hook進行 WASM 堆疊追蹤 - 透過
wasm-opt與 release profile 進行檔案體積最佳化
何時適合採用 WebAssembly?
WASM 並非適用於所有 Web 場景的銀彈,評估技術選型時應依任務性質區分邊界:
- 適合採用的場景 - 適合用來探索文字解析與既有 Rust 函式庫整合、音視訊處理,或系統中具有延遲要求的效能關鍵路徑,且套件體積在網路載入預算可接受的範圍內
- 不建議採用的場景 - 頻繁進行細粒度 DOM 操作的介面互動(跨邊界呼叫開銷高,直接使用 JavaScript 更佳)、微小的簡單工具函式(模組初始化與載入開銷不符效益)、簡單的表單驗證或 CRUD 資料呈現,以及極度受限於初始頁面下載體積的極簡網頁
與純 JavaScript 方案的客觀比較
效能與架構取捨
- 解析機制 - 在中長篇 Markdown 文件的批次解析情境下,基於 Rust 狀態機的
pulldown-cmark具備高效的解析吞吐;然而在瀏覽器端,每次呼叫都需要跨越 WASM / JS 邊界進行字串 UTF-8 編解碼與指標傳遞,實際端到端延遲需視輸入文字規模與呼叫頻率而定 - 記憶體與 GC 行為 - Rust 解析器內部的臨時配置完全由 WASM 內建的分配器(allocator)管理,不依賴 JavaScript 垃圾回收機制;但在前端,輸入字串建立、
get_statistics()產生的 JS 物件,以及將 HTML 指派給innerHTML所引發的 DOM 樹更新,依然會在瀏覽器引擎中產生 JavaScript 物件與垃圾回收負擔
套件體積
- 核心解析模組體積 - 完整的 CommonMark 解析器與統計核心模組約 235 KiB(經 Brotli 壓縮後約 81 KiB),與主流 JavaScript Markdown 解析函式庫相比維持在合理的網路載入預算內
心得與回顧
順利推進的環節
- 工具鏈體驗流暢 -
wasm-pack整合了cargo、rustc、wasm-bindgen與wasm-opt,提供極佳的開箱即用建置流程 - 社群文件完整 - The Rust and WebAssembly Book 與官方文件提供清晰的生命週期指引
- 編譯時期型別保障 - 在編譯階段即能捕捉記憶體與型別錯誤
- 型別定義自動生成 - 自動產生的 TypeScript 定義檔(
.d.ts)讓前端呼叫獲得精確的自動補全
克服的技術挑戰
- 路徑與模組定位 - 404 錯誤促使我深入理解 ES6 模組載入機制與 WASM 二進位資源的服務路徑
- 線性記憶體邊界 - 搞懂
ArrayBuffer、指標位移與字串生命週期的映射關係是跨越 WASM 門檻的關鍵 - 型別橋接取捨 - 釐清何時適合直接傳遞純量、何時需借助
JsValue或serde-wasm-bindgen序列化 - 非同步生命週期 - 模組非同步載入(
await init())與前端事件監聽綁定之間的時序協調
結語
這個專案補齊了我的「52 個 Rust 專案」學習地圖中 WebAssembly 的一塊拼圖。它證實了 Rust + WebAssembly 在現代 Web 應用中具備高度實用性,特別是針對文字解析、計算密集型的任務,能提供極佳的效能與穩健的記憶體安全性。
成熟的工具鏈(wasm-pack、wasm-bindgen)大幅降低了系統程式語言通往瀏覽器環境的摩擦力。如果你也在尋找第一個 WebAssembly 實作題目,從 Markdown 編輯器起步能同時涵蓋字串編碼、記憶體交換與非同步前端整合,是個極佳的練習題材。
相關參考資料
- 專案原始碼 -
github.com/p47t/rust-52-projects/tree/master/wasm-markdown-editor - Rust and WebAssembly 官方指南 - The Rust and WebAssembly Book
- wasm-bindgen 官方文件 - wasm-bindgen Guide
- wasm-pack 官方文件 - wasm-pack Documentation
- pulldown-cmark 文件 - pulldown-cmark on Docs.rs
- serde-wasm-bindgen 文件 - serde-wasm-bindgen on Docs.rs
- Binaryen 與 wasm-opt 專案 - WebAssembly/binaryen on GitHub