Files.md如何手写Markdown解析器:放弃AST后代码量减少3倍的实战
【免费下载链接】files.md🌱 Private, quiet space for thinking. Simple app for .md files.项目地址: https://gitcode.com/GitHub_Trending/fi/files.md
Files.md 是一个本地优先、纯.md文件驱动的笔记应用(私有、安静的思考空间)。本文分享它在手写 Markdown 解析器上的实战经验:当年放弃通用的 AST(抽象语法树)方案后,解析代码量直接减少 3 倍,理解和维护的心智负担也随之大幅下降。这是一个"少即是多"的典型工程决策。
📌 为什么放弃 AST:边界情况太多,认知负荷太重
很多开发者处理 Markdown 时的第一反应是引入成熟解析库:把文本解析成 AST,再遍历节点渲染成 HTML。这条路在 Files.md 上也走过,但很快被放弃,原因写在了项目的架构决策记录(ADR)里:
"走 AST 时遇到太多边界情况,代码也愈发复杂。Markdown 并不那么难解析,老老实实写直白的代码就好。最终代码量降到原来的 1/3,理解起来也轻松多了。"
—— README.md
核心矛盾在于:你只需要支持自己业务用到的那一点点语法子集(加粗、斜体、代码块、链接、清单),却要承担整套 AST 机制带来的复杂度。对一个人(或一个 LLM)就能装进脑子里的小项目来说,这是典型的过度设计。
✂️ 手写解析的核心思路:只做"字符串到字符串"的直白转换
Files.md 的服务端用 Go 编写,Markdown 处理全部集中在 server/pkg/txt/md.go 中。它的哲学可以概括为一句话:不建树,直接变换字符串。
以 server/pkg/txt/md.go 中的MarkdownToHTML为例,它要把用户的 Markdown 转成 Telegram 支持的那一小撮 HTML 标签。整个流程只有四步,全部是正则 + 字符串替换:
- 先转义 HTML,避免用户的
<>破坏输出; - 用占位符把代码块(
```)和行内代码临时替换掉(占位符写成c0debl0ck、inl1ne这种不会自然出现的字符串),保护它们不被后续转换误伤; - 按空行(
\n{2,})切段,对每段跑一个轻量级的手写解析器,处理加粗、斜体; - 恢复占位符,再用正则把代码块、标题补上
<pre>、<code>、<b>标签。
代码注释里写得很直白:"We don't need to implement full-blown AST parser because TG only supports a few HTML tags."(我们不需要实现完整的 AST 解析器,因为 Telegram 只支持少数 HTML 标签。)—— server/pkg/txt/md.go
这就是手写解析器最重要的原则:按需求的天花板来设计。你不需要解析完整 CommonMark,你只需要解析"业务真正用到的那一小部分"。
🧩 轻量手写解析器:用"解析器组合子"拼出语法
server/pkg/txt/md.go 里有一个不到百行的迷你解析器,没有 AST、没有节点对象,核心类型只是一个函数:
type parser func(input string) []result每个解析器吃进一段字符串,吐出"已消费的部分 + 剩余的部分"。在此之上只用三个组合子拼出全部语法:
and(a, b, c):按顺序依次匹配;or(a, b):任一匹配即可;some(p):重复匹配。
于是加粗、斜体的语法树(其实是函数嵌套)就是几行声明式的拼装,比如"加粗 =**+ 若干(文本或斜体) +**"。项目明确只支持一层嵌套(见 server/pkg/txt/md.go 注释),因为笔记场景里两层以上的嵌套加粗几乎没有价值——主动砍掉语法,代码自然简单。
🔁 反向转换也手写:Telegram 实体 → Markdown
解析器是双向的。用户在 Telegram Bot 里发的加粗、斜体消息,需要还原成 Markdown 存进文件。这个逆向转换在 server/pkg/txt/tgtxt.go 中完成:遍历 Telegram 的 message entities,计算 UTF-16 偏移,把**、*、`等标记精确地插回文本里。同样是逐字符的直白逻辑,没有引入任何第三方 Markdown 库。
🖥 前端同款思路:逐行处理,不引入任何构建
浏览器端同样贯彻"手写"路线。web/lib/md.js 顶部的注释写着"Various string functions, ported from Golang bot"——前端逻辑就是从服务端逐字移植过去的。比如 web/lib/md.js 的extractHeaderAndBody:取第一行做标题、截断过长标题、去重标题,全靠split('\n')+ 前缀判断,十几行解决"从一段文本里提取标题"这种 AST 方案里需要好几个节点遍历器才能做的事。
更关键的是,这种手写代码是双向可维护的:改服务端的清单逻辑,前端的移植版本几乎一比一对应;新人读代码时,"所见即所得",没有任何抽象层。
📊 实战收益:代码量减 3 倍,理解成本大幅下降
这次重构带来的直接收益,项目 ADR 总结得很清楚:
| 维度 | AST 方案 | 手写方案 |
|---|---|---|
| 代码量 | 基线 | 约 1/3 |
| 边界情况 | 节点遍历的组合爆炸 | 正则+前缀匹配,一眼看穿 |
| 依赖 | 引入成熟大库 | 零依赖,纯正则与字符串 |
| 扩展方式 | 写节点访问器 | 加一个组合子或一条正则 |
配套的原则也写进了 ADR("Tolerant Reader"):遇到乱码就跳过,遇到有效标志(如###)但数据无效则明确报错。容错策略同样简单直接,例如 server/habits/habits.go 解析习惯文件时逐行校验月标题,失败就返回带上下文的错误。
🎯 小结:什么时候该手写解析器?
Files.md 的经验可以浓缩成三条判断标准:
- 你的语法子集足够小——只处理加粗、斜体、代码块、清单,就不需要通用 AST;
- 你要双向转换——Markdown ↔ 目标格式(HTML、Telegram 实体)都能用"字符串进、字符串出"的函数表达;
- 维护者是"一个人 + LLM"——直白的代码能被完整装进工作记忆,改一行不会牵动整个解析树。
如果你的场景是渲染任意用户提交的复杂文档(完整表格、嵌套列表、脚注),成熟解析库仍是正解;但如果是 Files.md 这种"自己掌控输入格式"的本地优先应用,手写那个"刚刚好"的解析器,往往比引入大轮子更省钱、更耐用。
📎 延伸阅读:docs/sync-flow.md 了解这些.md文件如何在设备间同步,web/lib/md.js 可直接对照服务端 server/pkg/txt/md.go 阅读。
【免费下载链接】files.md🌱 Private, quiet space for thinking. Simple app for .md files.项目地址: https://gitcode.com/GitHub_Trending/fi/files.md
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考