一个小到可笑的需求
先说清楚我到底想要什么,因为这决定了后面所有的取舍:
我只是想在 macOS 上随手打开一个 Markdown 文件看一眼,如果能顺便改两笔就更好——仅此而已。
我不需要笔记库(vault)、不需要双向链接、不需要标签系统、不需要文件树、不需要发布到博客的一键流程、不需要 30 个主题。这些功能不是不好,而是它们都在解决"管理大量笔记"的问题,而我的问题是"此刻打开这一个文件"。
这个需求小到,理论上应该有一大把现成软件能完美满足。但真当我一个个试过去,发现没有一个让我舒服的。
我试过的那些编辑器,以及为什么都不行
把候选者摊开,按我最在意的几个维度排一张表:
| 编辑器 | 实现方式 | 价格 | 维护状态 | 我的槽点 |
|---|---|---|---|---|
| Typora | Electron / CEF | 付费 | 活跃 | 体验确实是标杆,但收费 + Electron 套壳,进程动辄几百 MB |
| MacDown | 原生 WebView | 免费开源 | 年久失修 | 老牌良心货,但更新近乎停滞,对新语法支持滞后 |
| MacDown3000 | MacDown 衍生 | 免费 | 不稳定 | 每次跟着更新就容易"罢工",可靠性堪忧 |
| Mark Text | Electron | 免费开源 | 基本停更 | 曾经很惊艳,如今 issue 堆积、长期没有新版 |
| Obsidian | Electron | 个人免费 | 活跃 | 强大但"重",是知识库工具,为"打开一个文件"杀鸡用牛刀 |
| iA Writer | 原生 | 付费 | 活跃 | 原生、极简、很优雅,但要付费,且偏纯写作 |
| Marked 2 | 原生 | 付费 | 活跃 | 只读预览器,不能编辑,定位和我的需求错位 |
| VS Code | Electron | 免费 | 活跃 | 它是 IDE,为看一个 md 启动整个编辑器,心理负担太大 |
把这张表看下来,会发现它们恰好踩满了我的四条雷区:
- 要么收费——Typora、iA Writer、Marked 2,体验好但要掏钱,而我的需求廉价到不值得为它付费;
- 要么是 Electron 套壳——Mark Text、Obsidian、VS Code,随便开一个进程就吃掉几百 MB 内存,启动还有可感知的延迟,跟"随手看一眼"的轻快感背道而驰;
- 要么年久失修——MacDown 这样的老牌开源货,代码还在,但维护近乎停摆,新语法跟不上;
- 要么不稳定——MacDown 衍生出来的 MacDown3000,我用得最闹心,常常一更新就不能好好工作。
最让我难受的是第 2 条。Markdown 这种纯文本格式,本应是最轻量的东西,结果主流方案却普遍建立在一整个浏览器内核之上。为了渲染几段文字和代码块,背一个 Chromium,怎么想都不对劲。
那就自己写一个吧
既然挑不到,那就自己造。正好赶上 AI 辅助编程(大家戏称 "vibe coding")已经足够顺手,我决定亲自下场,做一个完全贴合自己需求的编辑器。给它起名 Markdown2——"Markdown Editor Too",一个不太正经的双关:既是"另一个 Markdown 编辑器",也是"我也想要一个 Markdown 编辑器"。
立项时我给自己定了几条死规矩,全部是对前面四条雷区的正面回应:
- 原生,不用 Electron——用 SwiftUI + AppKit,做成真正的 macOS app,启动快、内存小;
- 轻量,专注阅读/编辑——单窗口,只有"编写"和"阅读"两种模式,不做文件管理、不做笔记库;
- 离线,不依赖网络——公式、图表的渲染资源全部内置打包,断网照样工作;
- 开源免费——MIT 协议,自己用得放心,也省得有人重蹈我"挑不到"的覆辙。
技术栈选 Swift 而不是继续套 Electron,理由很直接:我想要的就是"原生的轻"。文本编辑这件事,AppKit 的 NSTextView 经过几十年打磨,底子比任何 Web 富文本都扎实;外层用 SwiftUI 搭界面和状态管理,写起来又足够现代。两者配合,正好是"原生底座 + 现代外壳"。
整个项目用 Swift Package Manager 组织,拆成三层:
MD2Core——纯逻辑层,Markdown 解析、渲染成 HTML、大纲提取、语法高亮、文档统计,不碰 UI,方便写单元测试;MD2AppSupport——承载启动激活这类与 AppKit 生命周期强相关的辅助逻辑;MD2App——SwiftUI/AppKit 的应用层,窗口、编辑器、预览、设置都在这。
逻辑和界面分层之后,核心的解析渲染能力是可以脱离 GUI 单独测试的,这让"vibe coding"产出的代码不至于变成一团没法验证的浆糊。
途中那些有意思的坑
vibe coding 不是把需求丢给 AI 就万事大吉,真正花时间的,永远是那些"看起来该工作却不工作"的细节。挑几个印象最深的讲讲——前两个是早期的小坑,后两个则是真正需要先想清楚、再让 AI 动手的复杂需求。
坑一:swift run 启动后,窗口躲到了别人后面
开发阶段我习惯用 swift run Markdown2 直接跑。结果发现一个诡异现象:app 进程确实起来了,但窗口总是藏在当前 app 的后面,前台焦点还赖在终端/编辑器上,等于这 app 没法正常用。
排查下来,根因有点意思:SwiftPM 跑出来的是一个裸的 Mach-O 可执行文件,而不是被 LaunchServices 管理的 .app bundle。macOS 对"裸进程"和"正经 app"的窗口前台化待遇是不一样的——从裸进程里直接调 AppKit 的激活 API,并不足以可靠地把窗口抢到最前面。
解决办法是加了一段"直接启动引导"(direct-launch bootstrap):当 Markdown2 发现自己不是在 .app 里运行时,就在 .build 目录下临时合成一个 Markdown2.app,再通过 NSWorkspace 带激活地把这个 bundle 打开,然后原始的裸进程退出。被 bundle 拉起来的那个进程会跳过引导逻辑,正常进入主流程。后来又发现 SwiftUI 的 WindowGroup 在这条特殊启动路径下,偶尔会进了 applicationDidFinishLaunching 却没真正建出可用的初始窗口,于是干脆在 AppDelegate 里显式创建主 NSWindow,把 SwiftUI 的 ContentView 塞进去托管,再加上几次延迟激活重试兜底。
这个坑我专门写了回归测试,验证激活策略提升、窗口层级排序、以及对"迟到窗口"的延迟重试。它很典型地说明了一件事:AI 能飞快写出主干,但操作系统层面的边角行为,还是得人去抠。
坑二:公式和图表,到底要不要联网
很多 Markdown 编辑器渲染数学公式和图表时,是去 CDN 拉 KaTeX/Mermaid 这些前端库的。但这跟我"断网也能看"的原则冲突——很多时候我打开一个 md,恰恰是在没网或不想联网的场景。
于是我把渲染资源全部内置打包进 app:
- 数学公式用内置的 KaTeX,行内
$...$和独占一行的$$...$$都支持,还带上了 mhchem 扩展,能写化学式\ce{H2SO4}; - 图表支持
mermaid、flow(flowchart.js)和sequence(js-sequence-diagrams),引擎全部离线内置。
代价是 app 体积大了一点,但换来"任何时候、任何网络环境下打开都一致",我觉得非常值。顺带一提,这两块功能我是用 spec-driven 的方式做的——先写 proposal/design/spec,再让 AI 照着实现,archive 里留着 add-math-support 和 add-diagram-rendering 两个变更记录。给 vibe coding 套上一层规格约束,产出的可控性明显高很多。后面两个更复杂的需求,更是全靠这套流程才没翻车。
坑三:模式切换后,怎么还"停在原地"
这是个用着用着就越来越忍不了的体验问题:在编写/阅读两种模式之间切换时,视口总是弹回文档顶部。文档但凡比一屏长,每切一次就得重新滚回刚才看的地方,读改循环被打断得很烦。
听起来像"记一下滚动位置"这么简单,真做起来才发现卡在一个根本性的错位上:编辑器和预览是两套完全不同的坐标系。编辑器认的是「源码第几行」,预览是一个 WKWebView,认的是「DOM 元素的像素滚动偏移」。我"刚才在第 180 行",翻译成预览里该滚到哪个像素,并没有现成的换算。
破局点是文档大纲:每个 Heading 节点同时带着源码 line 和 HTML 元素 id——它天然就是横跨两套坐标系的那座桥。于是策略定为:切换时锚定到「光标/视口上方最近的那个标题」,按章节粒度对齐,不追求像素级或光标级精确——目标是"落回文档的同一部分",而不是分毫不差。没有标题的文档、或标题之前的内容,则退化成按滚动比例(scroll-fraction)兜底,绝不静默跳回顶部。
真正棘手的是异步:WKWebView 的滚动位置只能通过 evaluateJavaScript 回调异步读取,而 SwiftUI 的模式切换是同步发生的、旧视图瞬间就被销毁。所以根本不能"在切换那一刻才去问预览滚到哪了"——得在用户滚动时就持续把「当前视口顶部是哪个标题」缓存下来,等切换真正发生时直接拿现成的值用。我把纯粹的「行号↔标题」解析逻辑(比如"找出 line 不超过某行的最后一个标题")抽进 MD2Core 做成纯函数,这样不依赖跑起来的 WebView 就能单测。
这种需求,是没法一句话丢给 AI 的。得先把"用什么做锚、锚在什么粒度、异步怎么规避、没锚点时怎么兜底"这几件事一条条想清楚、写进 spec,AI 才可能写对——否则它大概率给你一个"在简单情况下能跑、一遇到长文档和异步就乱套"的版本。
坑四:藏不住的图表"闪一下"
这个 bug 是做坑三时顺手揪出来的:调试滚动位置时我注意到,预览加载完之后大约一秒,Mermaid 图表会肉眼可见地"闪"一下——先显示一段原始代码文本,然后才被引擎替换成渲染好的 SVG。
根因有两层。其一,图表引擎是异步渲染的,在它 resolve 之前,DOM 里就摆着原始源码当占位文本给读者看到了;其二,前面提过预览的 WKWebView 每次切换模式都会重建,所以这个闪烁不只第一次出现,而是每切一次复发一次。
修法是给占位状态做文章:源码仍然留在 DOM 里供引擎读取,但用 CSS 不再把它当正常文本显示;等引擎渲染完成,再加一个约 120ms 的淡入,把生硬的"代码→图"硬切变成柔和过渡;同时给占位块预留一点 min-height,减少 SVG 撑开时的布局跳动。这里最容易踩的雷是别把错误兜底搞丢了——解析失败时仍然要把原始源码显示出来,绝不能让一个写错的图表变成一片空白。所以"渲染成功才隐藏、失败要回显"这条岔路必须在 spec 里写死,测试也要专门覆盖错误路径仍然可见。
一个需求的调试过程牵出另一个隐藏问题,再各自立项、写 spec、补回归测试——这才是真实软件演进的样子,也是为什么我越来越离不开这套"先规格、后实现"的节奏。
现在的 Markdown2 长什么样
迭代到现在,它已经能覆盖我日常的全部需求:
- 单窗口、双模式:一个主界面,
Esc从编辑切到预览,预览里Cmd+双击切回编辑,手不离键,且切换时停留在同一章节、不会弹回顶部; - 多窗口:每个文档独立开窗,从 Finder 双击打开时会复用那些还没动过的空白窗口,不会越开越乱;
- 大纲侧栏:按标题自动生成,长文档导航很顺;
- 自动保存 + 关闭确认:已保存的文档防抖自动保存,有未保存改动时关窗/退出会提示;
- 状态栏:字数、字符数、行数、预计阅读时间一眼可见;
- 够用的 Markdown 渲染:标题、强调、链接、图片、引用、各类列表、GFM 表格、带语法高亮的围栏代码(Python/Go/Rust/Swift 等十来种语言)、YAML front matter、
[TOC],外加前面说的离线公式与图表; - 文件类型关联:打包后把
.md/.markdown注册给自己,双击直达。
它不做的事同样清晰:脚注、PDF/DOCX 导出、图片上传拖拽、打字机模式、主题管理、笔记库——这些都不在"随手打开看一眼"的射程内,我刻意没碰。约束自己不做什么,往往比堆功能更难,但这恰恰是我对那些"功能太复杂"的编辑器最大的不满,不能自己又掉进同一个坑。
一点心路体会
回头看这趟,从"挑不到一个趁手的 Markdown 编辑器"到"两天攒出一个自己用着舒服的原生 app",最大的感受有两条。
一是 AI 把"自己造一个轮子"的门槛拉得很低了。放在以前,为了这么个小需求去啃 AppKit、SwiftUI、KaTeX 内嵌,光是查文档就能劝退我;现在 vibe coding 让我能把精力集中在"我到底想要什么、哪里还不对"上,而不是卡在 API 细节里。
二是 AI 替代不了"品味"和"较真"。它能秒出一个能跑的版本,但"窗口为什么躲到后面"这种坑、"要不要联网渲染"这种取舍、"克制住不加哪些功能"这种判断,依然得人来定。工具越强,"想清楚自己要什么"反而越值钱。
代码已经开源在 GitHub(stutiredboy/Markdown2),MIT 协议。如果你也和我一样,只是想随手打开一个 Markdown 文件看一眼,欢迎试试,也欢迎拍砖。
评论
评论使用 GitHub Discussions 承载;留言需要 GitHub 账号。