md-preview 源码解析 — Rust 写的超轻量 Markdown 预览器
> 一句话版本:一个用 Rust 写的 Markdown 预览工具,整个核心逻辑只有 ~400 行代码,编译出 ~1MB 二进制,用系统原生 WebView 渲染,不捆绑 Chromium。是一个"Rust + wry 构建桌面应用"的教科书级示例。
| 项目 | 信息 | |
|---|---|---|
| 来源 | https://github.com/vorojar/md-preview | |
| 作者 | vorojar | |
| 创建时间 | 2026-04-11 | |
| 语言 | Rust | |
| Stars | 19 | Forks 2 |
| 许可证 | MIT | |
| 核心代码 | 单文件 `src/main.rs`,~400 行 |
项目结构
md-preview/
├── src/main.rs ← 核心逻辑(唯一 Rust 源文件)
├── Cargo.toml ← 6 个依赖
├── assets/
│ ├── hljs/highlight.min.js ← 语法高亮(编译进二进制)
│ ├── hljs/github.min.css ← 亮色主题
│ └── hljs/github-dark.min.css ← 暗色主题
├── docs/index.html ← 落地页(SEO 优化,中英双语)
├── bundle.sh ← macOS .app 打包脚本
├── install.sh ← 安装 + 注册为 .md 默认程序
└── screenshots/welcome.png
技术栈与依赖
[dependencies]
wry = "0.50" # WebView(核心:用系统浏览器引擎渲染 HTML)
tao = "0.33" # 窗口管理(跨平台窗口、事件循环)
pulldown-cmark = "0.12" # Markdown 解析(CommonMark + GFM)
rfd = "0.15" # 原生文件对话框
notify = "7" # 文件监听(热重载)
open = "5" # 打开 URL/文件(macOS 用)
6 个依赖,极简。对比 Electron 需要 Node.js + Chromium,这里零运行时依赖。
核心架构(main.rs 源码解析)
1. Markdown → HTML 转换
fn md_to_html(md: &str) -> String {
let opts = Options::ENABLE_TABLES
| Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_TASKLISTS
| Options::ENABLE_HEADING_ATTRIBUTES;
let parser = Parser::new_ext(md, opts);
let mut html_out = String::new();
html::push_html(&mut html_out, parser);
html_out
}
使用 pulldown-cmark,支持 GFM 扩展(表格、任务列表、删除线、标题锚点)。纯 Rust 实现,无外部依赖。
2. HTML 页面生成(CSS 全内联)
fn build_page(body: &str) -> String {
format!(r#"<!DOCTYPE html><html><head>...
<style>/* 完整 CSS 样式 */</style>
<script>{hljs_js}</script>
<style id="hljs-light">{css_light}</style>
<style id="hljs-dark" media="not all">{css_dark}</style>
</head><body>{body}</body>
<script>hljs.highlightAll();</script></html>"#,
css_light = HLJS_LIGHT,
css_dark = HLJS_DARK,
hljs_js = HLJS_JS,
body = body
)
}
关键设计:
- CSS 全部内联,无外部 CSS 文件依赖
- highlight.js 通过
include_str!编译进二进制(const HLJS_JS: &str = include_str!("../assets/hljs/highlight.min.js")),完全离线 - 暗色模式通过
prefers-color-scheme媒体查询 + JS 动态切换两个标签的media属性实现 - 样式模仿 GitHub Markdown 渲染风格(代码块、表格、引用等)
3. 窗口 + WebView 初始化
let window = WindowBuilder::new()
.with_title(&title)
.with_inner_size(tao::dpi::LogicalSize::new(900.0, 700.0))
.build(&event_loop);
let webview = WebViewBuilder::new()
.with_html(&initial_page)
.with_ipc_handler(move |msg| { /* 处理 "open" 消息 → 文件对话框 */ })
.with_drag_drop_handler(move |event| { /* 拖拽文件处理 */ })
.with_initialization_script(r#"Cmd/Ctrl+O 监听 → IPC 发送 "open" "#)
.build(&window);
三层交互:
- IPC Handler:WebView 内 JS 通过
window.ipc.postMessage('open')发消息给 Rust,Rust 打开文件对话框 - Drag & Drop Handler:系统级拖拽事件,过滤 .md/.txt 文件
- Initialization Script:注入 JS 监听键盘快捷键
4. 文件监听 + 热重载
notify::recommended_watcher(move |res| {
if let Ok(ev) = res {
if matches!(ev.kind, EventKind::Modify(_) | EventKind::Create(_)) {
let _ = proxy.send_event(UserEvent::FileChanged);
}
}
})
- 用
notifycrate 监听文件变化 - 文件修改/创建时发送自定义事件
UserEvent::FileChanged - 事件循环收到后重新读取文件 → 转 HTML → 注入 WebView
热重载保持滚动位置:
let js = format!(r#"
var s = document.documentElement.scrollTop;
document.documentElement.innerHTML = '{}';
requestAnimationFrame(function(){{
document.documentElement.scrollTop = s;
hljs.highlightAll();
}});
"#, escape_js(&page));
webview.evaluate_script(&js);
先记录滚动位置,替换内容后恢复。用户体验细节到位。
5. macOS 默认程序注册
#[cfg(target_os = "macos")]
fn register_as_default() {
let _ = Command::new("swift").arg("-").stdin(Stdio::piped())
.spawn().and_then(|mut child| {
child.stdin.write_all(b"
import Foundation; import CoreServices;
let _ = LSSetDefaultRoleHandlerForContentType(
\"net.daringfireball.markdown\" as NSString,
.viewer,
\"com.mdpreview.app\" as NSString);
");
child.wait()
});
}
首次启动时调用 Swift 代码注册为 .md 文件默认打开程序。用标记文件(.md-preview-registered)避免重复注册。
6. 事件循环
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Wait; // 不忙等,省 CPU
match event {
UserEvent(FileChanged) => { /* 热重载 */ }
Opened { urls } => { /* macOS 双击 .md 文件打开 */ }
WindowEvent(CloseRequested) => { ControlFlow::Exit }
_ => {}
}
});
ControlFlow::Wait 而非 Poll——不忙等,CPU 占用接近零。
编译优化
[profile.release]
opt-level = "z" # 优化体积(而非速度)
lto = true # 链接时优化
codegen-units = 1 # 单编译单元(更好的优化)
strip = true # 去掉符号表
panic = "abort" # 不展开 panic(减小体积)
这 5 行配置把二进制从几十 MB 压缩到 ~1MB。
macOS 打包
bundle.sh 做了三件事:
1. Universal Binary:编译 arm64 + x86_64,lipo 合并
2. .app Bundle:创建标准 macOS .app 目录结构(Contents/MacOS + Contents/Resources + Info.plist)
3. 文件类型注册:Info.plist 声明支持 .md/.markdown/.mdown/.mkd,UTI 为 net.daringfireball.markdown
install.sh 做了三件事:
1. 复制到 /Applications
2. lsregister 注册
3. Swift 调用 LSSetDefaultRoleHandlerForContentType 设为默认
落地页(docs/index.html)
独立的 SEO 优化落地页,中英双语自动切换(检测 navigator.language),结构化数据(Schema.org SoftwareApplication),GitHub star 数动态获取。
分析
优势:
- 源码级教学价值:~400 行代码展示完整的 Rust 桌面应用开发范式
- 架构清晰:每个函数职责单一,注释充分
- 用户体验细节:保持滚动位置、暗色模式跟随系统、多种打开方式
- 极致轻量:6 个依赖、~1MB 二进制、~15MB 内存
- 编译优化教科书:5 行 release profile 压缩到 1MB
风险:
- 功能单一(只预览,不能编辑)
- 19 stars,个人项目
- 不支持 Mermaid 图表、数学公式(KaTeX)等扩展
- 没有搜索、跳转、目录导航
学习价值:
- wry + tao 的最佳实践:如何用 Rust 构建跨平台桌面应用
- 编译时嵌入资源:
include_str!把 JS/CSS 编译进二进制 - 热重载实现:notify + IPC + evaluate_script 的完整链路
- macOS 集成:默认程序注册、.app 打包、Universal Binary
- release 优化:如何把 Rust 二进制压到最小
与 Jay 的关联:
- Jay 的小虾 Agent v0.3 考虑用 Wails(Go + WebView),md-preview 展示了 wry(Rust + WebView)的方案,技术栈不同但思路相同
- 编译优化配置可以直接参考(Go 的
-ldflags -s -w对比 Rust 的 strip + lto) - macOS 打包脚本可以作为参考
评分
| 维度 | 评分 (1-10) | 说明 |
|---|---|---|
| 创新性 | 5 | 常规 Markdown 预览器,无新意 |
| 代码质量 | 9 | ~400 行,架构清晰,每个细节都到位 |
| 教学价值 | 9 | Rust 桌面应用开发的教科书 |
| 实用性 | 6 | 功能单一,但确实轻量好用 |
| 文档 | 7 | README + SEO 落地页,中英双语 |
| 生态 | 3 | 19 stars,个人项目 |
| **总分** | **6.5** | 功能普通,但源码是 Rust 桌面开发的优秀学习材料 |