featured.svg

這個專案是我「52 個 Rust 專案」學習計畫的一部分。寫完 16 個專案之後回頭一看,我才發現 WebAssembly 一直是個沒補上的缺口。既然如此,乾脆就用 Rust 來打造一個即時 Markdown 編輯器吧。

專案原始碼:wasm-markdown-editor

為什麼選擇這個專案?

把之前完成的專案攤開來檢視,我發現幾個學習上的空白:

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

那為什麼偏偏挑 Markdown 編輯器呢?理由有幾個:

  1. 填補關鍵缺口 - 之前一個 WASM 專案都沒碰過
  2. 善用既有技能 - 可以把之前在計算機、shell、EBML 等專案裡學到的解析技巧拿出來用
  3. 互動性強 - 成果馬上看得到,滿足感頗高
  4. 實用價值 - 這是真的能用的工具,不只是個 demo 而已
  5. 現代技術堆疊 - 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 模組,完整建置流程可拆解為以下階段:

flowchart TD A["<div style='width:580px; max-width:100%; font-size:15px; font-weight:normal; line-height:1.6; padding:4px 0;'><span style='font-weight:bold;'>Rust 原始碼(Source Files)</span><br/><span style='font-weight:normal;'>包含 lib.rs、parser.rs 與 utils.rs 等模組</span></div>"] B["<div style='width:580px; max-width:100%; font-size:15px; font-weight:normal; line-height:1.6; padding:4px 0;'><span style='font-weight:bold;'>目標編譯(Target Compilation)</span><br/><span style='font-weight:normal;'>呼叫 rustc 指定 --target wasm32-unknown-unknown</span></div>"] C["<div style='width:580px; max-width:100%; font-size:15px; font-weight:normal; line-height:1.6; padding:4px 0;'><span style='font-weight:bold;'>原始二進位檔(Raw WebAssembly Bytecode)</span><br/><span style='font-weight:normal;'>輸出未綁定 JS 介面的純 .wasm 位元組碼</span></div>"] D["<div style='width:580px; max-width:100%; font-size:15px; font-weight:normal; line-height:1.6; padding:4px 0;'><span style='font-weight:bold;'>膠水程式碼生成(Binding Generation)</span><br/><span style='font-weight:normal;'>由 wasm-bindgen 處理記憶體交換與產生 JS 包裝器</span></div>"] E["<div style='width:580px; max-width:100%; font-size:15px; font-weight:normal; line-height:1.6; padding:4px 0;'><span style='font-weight:bold;'>體積最佳化(Binary Optimization)</span><br/><span style='font-weight:normal;'>由 wasm-opt(Binaryen)進行死碼消除與位元組縮減</span></div>"] F["<div style='width:580px; max-width:100%; font-size:15px; font-weight:normal; line-height:1.6; padding:4px 0;'><span style='font-weight:bold;'>最終發布產物(Production Bundle)</span><br/><span style='font-weight:normal;'>產出緊湊的 .wasm、.js 模組與 .d.ts 型別定義檔</span></div>"] A --> B B --> C C --> D D --> E E --> F style A width:580px; style B width:580px; style C width:580px; style D width:580px; style E width:580px; style F width:580px;

記憶體模型

WebAssembly 採用的是由 ArrayBuffer 支撐的線性記憶體(linear memory)。在 wasm-bindgen 的包裝機制下,JavaScript 與 Rust 之間透過這塊共用記憶體進行資料交換:

wasm-memory-model.svg

字串在記憶體間的傳遞與處理流程如下:

  1. JavaScript 將來源字串編碼為 UTF-8 位元組,寫入 WASM 線性記憶體配置的緩衝區
  2. JavaScript 包裝器傳遞緩衝區指標(pointer)與長度(length)呼叫 markdown_to_html
  3. Rust 解析器從切片讀取資料並解析事件串流
  4. Rust 將產出的 HTML 結果寫入 WASM 線性記憶體
  5. Rust 函式回傳結果的記憶體位址與長度 [ret_ptr, ret_len] 給 JavaScript
  6. JavaScript 包裝器從線性記憶體解碼出 UTF-8 結果 HTML 字串
  7. 包裝器在 finally 區塊中呼叫 __wbindgen_free 釋放回傳緩衝區,供後續重複利用
  8. 前端應用程式取得最終 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)
  • 響應式左右分割視窗佈局
 Note

預覽與 HTML 安全性說明 - 目前編輯器是將 pulldown-cmark 的輸出直接賦值給 preview.innerHTML,適合信任的本地內容編輯。在生產環境若需接收不可信來源的 Markdown 輸入,仍應透過 DOMPurify 等工具進行 HTML sanitization,防範 XSS 注入。WASM 的記憶體安全保證並不等同於輸出的 HTML 結構安全。

學習成果與技術選型

WebAssembly 關鍵技能掌握

  • 理解 cdylib crate 類型及其在編譯管線中的定位
  • 熟練使用 #[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 解析函式庫相比維持在合理的網路載入預算內

心得與回顧

順利推進的環節

  1. 工具鏈體驗流暢 - wasm-pack 整合了 cargo、rustc、wasm-bindgen 與 wasm-opt,提供極佳的開箱即用建置流程
  2. 社群文件完整 - The Rust and WebAssembly Book 與官方文件提供清晰的生命週期指引
  3. 編譯時期型別保障 - 在編譯階段即能捕捉記憶體與型別錯誤
  4. 型別定義自動生成 - 自動產生的 TypeScript 定義檔(.d.ts)讓前端呼叫獲得精確的自動補全

克服的技術挑戰

  1. 路徑與模組定位 - 404 錯誤促使我深入理解 ES6 模組載入機制與 WASM 二進位資源的服務路徑
  2. 線性記憶體邊界 - 搞懂 ArrayBuffer、指標位移與字串生命週期的映射關係是跨越 WASM 門檻的關鍵
  3. 型別橋接取捨 - 釐清何時適合直接傳遞純量、何時需借助 JsValue 或 serde-wasm-bindgen 序列化
  4. 非同步生命週期 - 模組非同步載入(await init())與前端事件監聽綁定之間的時序協調

結語

這個專案補齊了我的「52 個 Rust 專案」學習地圖中 WebAssembly 的一塊拼圖。它證實了 Rust + WebAssembly 在現代 Web 應用中具備高度實用性,特別是針對文字解析、計算密集型的任務,能提供極佳的效能與穩健的記憶體安全性。

成熟的工具鏈(wasm-pack、wasm-bindgen)大幅降低了系統程式語言通往瀏覽器環境的摩擦力。如果你也在尋找第一個 WebAssembly 實作題目,從 Markdown 編輯器起步能同時涵蓋字串編碼、記憶體交換與非同步前端整合,是個極佳的練習題材。

相關參考資料