MarkView 是一个纯前端的本地 Markdown 实时预览工具(markview.art):左侧 CodeMirror 6 编辑,右侧实时渲染,所有数据保存在浏览器 IndexedDB 中,无需任何后端。支持暗色/浅色主题、命令面板、URL 分享,并可作为 PWA 离线安装,甚至能注册为系统的 .md 文件处理器------双击本地文件直接用它打开。
本文不逐个罗列功能,而是聊聊它背后的架构取舍:如何在一个没有后端、没有状态管理库的 Vue 3 项目里,把编辑器、渲染、持久化、搜索、同步这些子系统组织得清晰可测。

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