MD2PDF
Markdown 编辑渐进式 Web 应用 (PWA),支持通过浏览器渲染引擎直接导出 PDF。
我设计该系统旨在作为嵌入在 Astro 静态环境中的单页应用 (SPA) 运行,提供持久的本地存储、滚动同步、渐进式 Web 应用 (PWA) 支持,以及直接通过浏览器渲染引擎实现的流畅 PDF 导出工作流。
业务上下文与系统实用性
构建此应用时,我的主要目标是解决技术写作与文档发布之间的常见阻力,且不依赖于云端基础设施或第三方数据库。我默认提供私密环境,这确保了敏感数据(如公司内部文档草稿或架构图)孤立保存在用户的本地机器上。
核心工作流 (Workflow) 按以下方式运行:
- 初始化: 加载时,我通过 Dexie.js 从 IndexedDB 检索文档列表和应用状态。如果没有文档,我会用结构化示例初始化数据存储。
- 数据输入与操作: 用户与 Monaco Editor 界面交互,该界面原生拦截键盘事件、格式结构,甚至是剪贴板粘贴的图像。
- 内存优化 (In-Memory): 我在事件传播前拦截粘贴的图像,通过内部
canvas将最大宽度限制为 800 像素进行调整大小,重新编码以提高效率,并使用适当的 MIME 类型(如 WebP 或 JPEG)异步保存到浏览器的存储系统 (IndexedDB) 中。 - 实时渲染: 当文本模型发生变化时,我使用 Zustand(状态管理器)触发文档预览的更新。React-Markdown 处理抽象语法树 (AST) 以渲染数学代码 (KaTeX)、语法高亮 (highlight.js) 和矢量图形 (Mermaid.js)。
- 原生导出: 我没有依赖服务端重型 PDF 渲染引擎,而是显式调整文档 DOM 样式树以适应打印视图,注入隐藏 UI 的 CSS 设置,并发出
window.print()指令,充分利用浏览器原生 PDF 引擎的高保真度。
整体架构分析
我按照高度不对称的混合架构组织了该项目,其中服务端或构建层仅负责提供“外壳”(Shell),将 100% 的业务处理下放给客户端。
architecture-beta
group client(cloud)["客户端 / Web 浏览器"]
service astro(server)["Astro Shell"] in client
service react(server)["React SPA"] in client
service storage(database)["IndexedDB / Dexie"] in client
astro:L -- R:react
react:B -- T:storage
结构模式
- Astro 作为 Shell Host: 我使用 Astro 层 (
src/pages/index.astro及 Layouts) 提供静态入口、PWA 全局配置 (vite-plugin-pwa) 和用于指标的 Vercel Analytics SDK 集成,从而避免在静态启动时水化重型框架。 - 内部 React 微前端: React 根容器 (
src/components/App.tsx) 作为交互核心。 - 单向数据流: 遵循类似于应用于 Frontend 的 Clean Architecture 模式,我使用单一事实来源仓库 (Zustand) 管理状态,视图 (React) 仅反映状态变化,用户事件派发变更(动作),我将其整合并插入到持久化层 (Dexie.js) 中。
数据建模与事件逻辑
我通过 IndexedDB API 实现持久化,通过 Dexie.js 封装库进行编排,以获得可靠的异步 Promise。
erDiagram
DOCUMENT {
number id PK
string title
string content
date updatedAt
}
IMAGE {
number id PK
blob blobData
string mimeType
date createdAt
}
DOCUMENT ||--o{ IMAGE : "通过 markdown 引用 local-image://{id}"
实体与关系流
Document实体: 作为 Markdown 格式纯文本的存储库。Image实体: 作为二进制本地 Blob Store。与其直接在 Markdown 中嵌入 base64(这会严重减慢编辑器的 AST),不如在 Monaco 中拦截二进制数据的粘贴,通过 Canvas API 缩减其尺寸,将其注入 IndexedDB,并生成自定义内部 URI(如local-image://12)。- 视觉生命周期拦截: 包装组件 (
MarkdownImage位于src/components/Preview.tsx) 拦截 Markdown 解析器生成的图像请求,如果前缀对应于local-image://,它会异步向 IndexedDB 请求 Blob,并使用URL.createObjectURL()创建临时 URL。一旦组件卸载,对象即被撤销,从而保护了浏览器的内存管理。
组件同步
sequenceDiagram
participant User as 用户
participant Editor as Monaco Editor
participant Zustand as Store
participant Dexie as IndexedDB
participant Preview as 预览面板
User->>Editor: 输入 Markdown
Editor->>Zustand: updateCurrentDocument(content)
Zustand->>Dexie: put(updatedDoc)
Zustand-->>Preview: React State Change
Preview->>Preview: 解析 Markdown AST
Preview-->>User: 渲染界面
技术栈与工具角色
我对技术环境的选择优先考虑主线程的纯粹性能、客户端隔离和排版标准化。
| 技术 | 类别 | 具体角色 |
|---|---|---|
| Astro | Shell 框架 / PWA | 构建基础网站的静态环境,并通过 Vite 编排本地 PWA 支持。 |
| React 19 | 界面与 UI 层 | 管理编辑器与预览面板的响应性,包括用于虚拟化和 DOM 控制的 ref-hooks。 |
| TypeScript | 主要语言 | 数据库模型与全局状态的接口契约。 |
| Zustand | 状态管理 | 允许组件订阅特定片段的状态以减少重新渲染,并使用 LocalStorage 作为初始 fallback 水化配置。 |
| Dexie.js | 数据持久化 | 为带有关系 Schema 控制的 IndexedDB 提供基于 Promise 的接口。 |
| Monaco Editor | 编辑引擎 | 提取自 VS Code 的文本引擎,支持语法、缩略图和深层事件。 |
| React-Markdown | AST 编译器 | 转换原始文本,支持 remark-gfm(表格)和 remark-math(KaTeX 支持)插件。 |
| Mermaid.js | 代码绘图 | 在预览容器中解析特定语法块并渲染为 SVG 图形,与主题绑定。 |
| Tailwind CSS v4 | 样式引擎 | 管理布局并应用 @tailwindcss/typography 的 .prose,在打印视图中通过 print:!w-full 规则进行条件操纵。 |
| BiomeJS | Toolchain Linter | 在代码分析阶段替代 Prettier/ESLint,确保语法一致性。 |
架构影响与可打印性设计
该架构的核心成功在于其打印模式的设计 (Print Mode Override)。我没有依赖行业标准的依赖包 (react-to-print),而是短暂劫持了 React 的生命周期:
调用导出时,我在 Store 中注入同步的 "light" 状态,强制 React 和 Mermaid.js 在纯对比度下渲染整个 DOM 树,且没有暗色背景。setTimeout 计算 DOM 重绘的精确周期(约 1500ms),我调用 window.print() 对话框(临时停止 JS 引擎),在收到控制权返回时,如果之前启用了暗色主题,则立即恢复暗色主题。这结合通过 @media print 样式表重置边距,允许干净、精确且无双重边框的原生 PDF 导出。
拖动可移动图示。按 + 和 − 缩放,方向键移动,0 重置视图。
一起合作
软件工程师、高级全栈开发工程师,常驻玻利维亚Santa Cruz,远程办公。欢迎新的工作机会。