MarkView:纯前端 Markdown 实时预览工具的架构设计 MarkView 是一个纯前端的本地 Markdown 实时预览工具https://markview.art左侧 CodeMirror 6 编辑右侧实时渲染所有数据保存在浏览器 IndexedDB 中无需任何后端。支持暗色/浅色主题、命令面板、URL 分享并可作为 PWA 离线安装甚至能注册为系统的.md文件处理器——双击本地文件直接用它打开。本文不逐个罗列功能而是聊聊它背后的架构取舍如何在一个没有后端、没有状态管理库的 Vue 3 项目里把编辑器、渲染、持久化、搜索、同步这些子系统组织得清晰可测。一、为什么是「纯前端」先说定位。市面上 Markdown 工具很多MarkView 选择了一条约束最强的路线零后端没有服务器、没有账号体系文档全部存在浏览器 IndexedDB零上传分享靠把文档压缩进 URL hash链接自包含离线可用PWA 预缓存构建产物断网也能正常编辑预览。这个约束反过来塑造了很多技术决策。比如图片没有后端就没有图床于是粘贴/拖拽的图片被压缩缩放 WebP 编码成 base64 直接内联进文档——图片随文档一起存本地分享和导出天然自包含。再比如 PDF 导出不引 html2pdf 这类庞大依赖直接调浏览器原生打印得到文字可选、体积小的矢量输出版式交给一份media print样式表。二、CodeMirror-First 分层架构项目最核心的设计是一套严格单向依赖的分层components哑组件inject 工作区直接消费 ↓ workspace响应式状态 横切编排 ↓ controllersEditorController / PreviewController命令式门面 ↓ core纯逻辑领域层零 Vue、零 DOMVitest 直测core零 Vue、零 DOM 的领域层最底层的core/是纯函数世界文档模型、IndexedDB 存储、保存状态机、Markdown 渲染与源行标注、跨文档搜索、滚动锚点表、分享链接编解码……全部不依赖 Vue 和 DOM。好处非常直接这一层可以被 Vitest 直测不需要挂载组件、不需要模拟浏览器环境。测试文件与被测模块同目录放置比如保存状态机的防抖、竞态、串行化逻辑都是纯逻辑单测// core/documents/persistenceScheduler.js// 保存状态机防抖调度 revision 防竞态 Promise 链串行化写入。// 存储实现save与快照来源getSnapshot以参数注入不依赖 Vue 与 DOM。exportconstcreatePersistenceScheduler({getSnapshot,save,canPersist,onStatus,}){// ...};依赖注入贯穿整层存储怎么写、快照怎么取都是参数。测试时换成内存实现即可。controllers命令式能力的唯一入口CodeMirror 6 和预览区 DOM 本质上是命令式的设光标、滚动到某行、高亮某段。这些能力被收进两个门面——EditorController与PreviewController它们是组件之下操作编辑器/预览的唯一入口。EditorController还承担多文档的EditorState缓存每个文档拥有独立的撤销历史切换文档时换 state 而不是重建编辑器。workspace响应式状态与横切编排workspace/持有全部响应式状态按职责拆成会话文档会话多文档 CRUD 持久化、搜索会话、布局会话分栏宽度/折叠/目录宽度、预览渲染会话等。跨子系统的流程——比如「搜索命中后定位到行并双侧高亮」「折叠编辑区时把按钮移入预览区头部」——集中在orchestrations.js不散落在组件里。createWorkspace.js负责把这一切组装起来通过provide下发。components只管 UI 的哑组件最上层的组件通过inject拿到工作区直接消费自身不含业务逻辑。工具栏、命令面板、目录、搜索面板……都只是状态的投影和动作的转发。这套分层的收益是变更有明确的落点。加一个「导出 HTML」功能纯逻辑进services/exporter.js动作挂到 workspace组件加个按钮三层各改一处互不渗透。三、几个关键子系统的取舍渲染进 Web WorkerMarkdown 渲染marked 解析 代码高亮 源行标注对长文档来说不便宜跑在主线程会卡输入。MarkView 把渲染整体搬进了 Web Worker// workspace/markdownRenderer.js// Markdown 渲染调度优先用 Web Worker渲染在后台线程主线程不卡顿// Worker 不可用时动态 import 同步渲染降级保持主 bundle 精简仅降级时才加载。// 用递增序号丢弃过期结果避免快速输入时旧结果覆盖新结果。三个细节值得注意降级路径Worker 不可用时动态import同步渲染模块——降级代码不进主 bundle只有真正降级时才加载序号防乱序快速输入时会有多个渲染请求在途用递增序号丢弃过期结果保证旧结果不会覆盖新结果渲染是纯函数renderMarkdown.js本身在 core 层Worker 只是它的一个宿主。滚动同步锚点表 单调插值编辑区和预览区的双向滚动同步是 Markdown 工具的经典难题——两侧内容高度不成线性比例一行 Markdown 可能渲染成一张大图。MarkView 的做法是渲染时给块级元素标注源码行号再据此构建锚点表// core/sync/anchorMap.js// 构建锚点表pairs 是若干对已对齐的滚动偏移 { editor, preview }。// 两端各加一个哨兵锚点把文档顶部与底部钉在一起使两侧能同时到达 0 和最大值。// 只保留严格落在两侧滚动范围内、且在两个轴上都严格递增的锚点// 以保证每一段插值都是单调的。锚点之间线性插值配合「滚动主控方」标记防止两侧互相触发形成回环。这套逻辑同样是纯函数边界情况锚点乱序、超出滚动范围全部有单测覆盖。URL 分享文档压缩进 hash没有后端分享怎么做答案是 lz-string// core/share/shareLink.jsexportconstencodeSharePayload({name,content,anchor}){constpayloadJSON.stringify({n:name||,c:content||,...(anchor?{a:anchor}:{}),});returncompressToEncodedURIComponent(payload);};文档名和内容 JSON 序列化后用 lz-string 的 URI 变体压缩直接放进 URL hash。链接自包含——打开即还原文档还可以携带标题锚点直达某一节。hash 不会发往服务器天然私密。PWA不止离线还是文件处理器除了常规的预缓存离线MarkView 在 manifest 里注册了file_handlersfile_handlers:[{action:base,accept:{text/markdown:[.md,.markdown]}}],launch_handler:{client_mode:[focus-existing,auto]}安装为 PWA 后它成为系统里.md/.markdown文件的处理器——双击文件操作系统唤起 MarkView 应用侧由launchQueue消费者接收文件句柄并导入为新文档。launch_handler设为优先复用已有窗口避免每次双击都新开实例。一个 Web 应用就这样长出了一点「桌面应用」的质感。四、工程化闭环最后是保证质量的一揽子配置一条命令跑完全部校验pnpmcheck# lint 格式检查 测试 构建ESLint--max-warnings0警告即失败Prettier 统一格式Vitest 直测 core 层与 workspace 编排逻辑最后跑一次生产构建确保产物健康。结语MarkView 的经验可以浓缩成三句话约束是设计的朋友——「零后端」逼出了图片内联、URL 分享、原生打印这些干净的方案纯逻辑下沉是可测试性的根基——core 层零 Vue 零 DOM测试成本低到没有借口不写命令式的世界要有门面——CodeMirror 和 DOM 的复杂性被 controller 挡住上层始终面对声明式接口。关于多标签页打开时文档状态如何实时同步、编辑中的内容如何保证不被覆盖展开来又是一套有意思的机制我写在了另一篇多标签页文档同步。