好多年前我带过一个实习生,他把一个 200 行的脚本重构成 2000 行的工程,然后跑过来问我:哥,这样是不是更正规?我当时没说话。直到最近动手做caveman这个项目,我才真正想明白该怎么回答他:工具的价值不在工程化,在于解决问题。caveman是我刚写完的一个零依赖静态站点生成器,全部源码不到 300 行,运行只需要 Python 标准库,不装任何第三方包。它的用途很朴素——把一批 Markdown 文件变成可以发布的 HTML 页面,仅此而已。但正是在"刻意削减功能"的过程中,我重新理解了简化、抽象和取舍,也踩了几个很有意思的坑。这篇文章就是这次实验的完整记录,适合被依赖地狱和过度设计折磨过、或是正在犹豫"要不要也做一个极简工具"的开发者。
1. 从"工具焦虑"到"caveman":一个极简项目是怎么立项的
1.1 压垮我的那根"构建链稻草"
事情发生在一个普通的工作日。我需要给内部一个文档目录加个网页入口,本来想着十分钟搞定,结果发现当前环境里没有现成的文档生成工具,于是开始装。先装 Node.js 的包管理器,再拉一个静态站点框架,然后它告诉我需要额外的插件来支持目录导航,装完插件后又要求指定主题版本。等我终于把配置文件调通,并且看到一条新的依赖安全告警时,已经过去了将近四十分钟,而我还没写出任何一行跟业务有关的东西。
那种感觉很荒诞:我只是想让几个 Markdown 文件能被浏览器打开,却引入了一整套需要长期维护的构建生态系统。晚上回家我盯着窗外想了一会儿,冒出一个念头:如果是一个原始人碰到这个问题,他会怎么做?原始人不会先搭建"火种管理系统",他会直接钻木取火。所谓caveman这个名字,就是在这一刻定下来的——做一个"原始人级别"的工具,能用最笨的办法解决当下的问题,绝不提前为不存在的需求买单。
1.2 立项之前,我先给自己列了四条"原始约束"
为了避免做的时候又忍不住"顺手加个功能",我把需求边界写在纸上,反复删改后只保留四条:
- 零第三方依赖。所有功能只使用编程语言自带的标准库,这是硬性要求,不接受例外。
- 单一职责。只做"Markdown 转 HTML 并生成站点目录",不做搜索、不做分页、不做主题系统。
- 源码可全读。任何一个有基本编程经验的人,打开代码仓库后半小时内能理解全部逻辑。
- 行为可预测。同样的输入永远产生同样的输出,不做缓存、不做增量构建、不做花哨的动态优化。
这四条约束现在看很平常,但真正执行起来会发现它们会逼你不断做减法。比如"不做增量构建"意味着每次运行都全量生成,当你的内容条目少时这完全没问题,可一旦内容多到上千篇,单线程全量写文件就会慢得让人怀疑人生。我在第 4 章会专门讲这个取舍带来的问题。
1.3 为什么偏偏选"静态站点生成器"当实验品
选这个方向是因为它刚好是"复杂度重灾区"。市面上的主流静态站点生成器,动辄几百上千个依赖包,安装体积以百兆计,而它解决的核心问题——把文本变成页面——本质上只需要三步:读文件、做转换、写文件。这就像两个人去同一个目的地,一个人开着改装越野车绕山路,另一个人骑自行车走直线,最终都能到,但后者对路况的敏感度完全不同。
我想证明的其实不是"极简更好",而是"很多复杂度到底是需求本身需要,还是工具链自己制造出来的"。静态站点生成器是一个理想样本:它的输入是纯文本,输出是纯静态文件,中间没有任何必须依赖复杂生态的理由。做完之后我意识到,这个实验真正的价值不在工具本身,而在它让我看清了"默认复杂"思维里那些被大家习惯性接受的额外成本。
2. 三条铁律:我是怎么把复杂度锁在项目门外的
2.1 铁律一:零依赖不是矫情,是可控性的护城河
很多人听到"零依赖"的第一反应是:这有什么难的,不用第三方库而已。但你只要真的开始做,就会发现它带来的约束比你想象的更深刻。首先是功能层面,你只能用标准库,那就意味着很多现成的东西要自己写——HTML 转义、路径整理、目录遍历,全都得用最原始的方式实现。其次是心理层面,每当你动念头"如果有个库能干这个就好了",你就必须停下来问自己:这个功能真的需要吗?还是我可以换一种更简单的输出形式绕过去?
举个例子,我最初想支持 Markdown 里的表格语法,但标准库没有现成解析器,自己写一个完整支持 GFM 表格的解析器至少要二三百行。按照"零依赖"原则,我面临两个选择:要么写解析器,要么放弃表格。我最终选择放弃表格,改为建议用户在文档里用列表和代码块表达结构化信息。这个决定让代码量砍了一半,而面向的读者几乎没有感知——因为我的目标用户就是我自己和几个同事,我们完全可以用更简单的语法写作。
依赖带来的另一个问题是被动升级。主流框架每半年发一个大版本,接口变化、配置格式调整、插件兼容性问题,这些都是你无法控制的。零依赖意味着这个工具在未来五年、十年后打开仍然能跑,只要 Python 还在。这种"时间上的确定性",在我看来比任何功能都值钱。
2.2 铁律二:标准库能搞定的,绝不自己重复造轮子
这里有个容易走向极端的误区:为了显示"极简",连标准库都刻意回避。我见过有人不用标准库的glob模块,而是自己手写通配符匹配,理由是"这样更纯粹"。这完全是本末倒置。极简的目的是降低总成本,不是表演行为艺术。
我的原则是反向的:只要标准库提供了成熟实现,就毫不犹豫地用。目录遍历用os.walk,文件路径拼接用pathlib.Path,甚至 HTML 转义都可以用html.escape。这些模块经过十几年大规模使用,稳定性远超我自己写的替代品。真正需要自己动手的是那些"标准库没有、但需求又很核心"的部分,比如把 Markdown 的行级语法转成 HTML 段落,这才是我用几十行代码解决的地方。
这条铁律帮我划清了一个重要边界:不依赖外部,但信任系统自带的能力。这就像原始人虽然不会冶炼金属,但知道用石块和木棍,大自然给什么就用什么,而不是非要去火星挖矿。
2.3 铁律三:特性支持宁缺毋滥,每一项功能都有维护成本
功能的维护成本分三层:第一层是写代码的时间,第二层是测试和修 bug 的时间,第三层是用户(包括未来的自己)理解你的时间。第三层最容易被忽略——每多一个特性,使用文档就要多一段,别人阅读源码就要多花几秒去理解。而真实情况是,绝大多数用户只用到工具功能的百分之二十。
caveman最终支持的 Markdown 语法被我砍到只剩三种:标题、段落、代码块。注意,连列表语法我都没做。为什么?因为我发现把列表手写成- 项目再通过正则转换成<ul><li>,本质上只是换个格式,内容表达并没有得到增强,而多一个列表语法就意味着多一套嵌套和转义规则。我的解法是让用户直接在 Markdown 里写原始 HTML 列表,反正 HTML 本身也是文本,转换器对已含 HTML 标签的行原样透传。
这个取舍让我想起一个很妙的类比:瑞士军刀功能多,但真正在野外求生时,一把砍刀往往比一把全是小零件的军刀更可靠。工具数量的增多会稀释你对每一个工具的熟悉程度,而极简工具的每一个功能,用户都清清楚楚知道它在什么时候触发、什么时候不触发。
3. 关键实现拆解:caveman的代码骨架与核心流程
3.1 目录扫描:先用os.walk把文件讲清楚
工具的第一步是找到所有 Markdown 文件。这里的核心问题是:目录结构应该怎么映射到输出站点的路径结构?我采用的策略是最直观的一一映射——输入目录content/docs/下的intro.md,对应输出目录site/docs/intro.html,子目录自动创建。
用os.walk遍历目录,把.md结尾的文件收集起来,这个逻辑简单到不需要解释。真正需要想清楚的是排序规则。你不可能指望操作系统返回的文件顺序就是你想在导航中展示的顺序,所以我额外约定了一个"同目录下按文件名排序"的规则,并在命名时就让它体现顺序,比如01-intro.md、02-setup.md。这是一个很"原始人"的约定,但效果出奇地稳定,完全不需要配置 TOML 或 YAML 导航文件。
from pathlib import Path def collect_markdown_files(content_dir: Path) -> list[Path]: files = [] for root, dirs, filenames in os.walk(content_dir): for name in filenames: if name.endswith(".md"): full = Path(root) / name files.append(full) files.sort(key=lambda p: str(p).lower()) return files这段代码我在实际写的时候只花了几分钟,但它保证了后续所有逻辑都能专注于"处理文件"而不是"找文件"。
3.2 Markdown 转 HTML:只做三种结构,但做扎实
核心转换逻辑是最值得细看的部分。我没有引入任何现成的 Markdown 解析器,因为如果只支持标题、段落、代码块三种结构,手写一个行级解析器其实非常可控。处理流程是按行读取,核心状态只有一个"是否在代码块内"的布尔变量。
逻辑要点如下:遇到以#开头的行,根据#数量生成对应的<h1>到<h3>;遇到空行视为段落分隔;遇到```切换代码块状态,代码块内的所有行原样保留并做 HTML 转义;其余非空行作为普通段落包进<p>标签。这里最关键的一步是转义——正文里如果出现<script>或者<img onerror>之类的字符串,不转义就会变成活性内容,轻则样式错乱,重则有安全风险。标准库的html.escape在这里发挥了大作用。
import html import re def md_to_html(md_text: str) -> str: lines = md_text.splitlines() out = [] in_code = False for line in lines: stripped = line.strip() if stripped.startswith("```"): if in_code: out.append("</code></pre>") in_code = False else: out.append("<pre><code>") in_code = True continue if in_code: out.append(html.escape(line)) continue m = re.match(r"^(#{1,3})\s+(.*)", line) if m: level = len(m.group(1)) out.append(f"<h{level}>{html.escape(m.group(2))}</h{level}>") continue if stripped: out.append(f"<p>{html.escape(stripped)}</p>") else: out.append("") return "\n".join(out) + "\n"这段代码只有二十行,但我花了大半个晚上调试边界情况:行内代码标记code没有处理、图片语法![]()没有处理、引用块>没有处理。每次遇到一种"常见语法",它带来的不是便利,而是一连串新的转义和嵌套问题。最后我狠下心说:只要内容表达不依赖这些语法,就不支持。事实证明,用纯文本 + 少量 HTML 写文档,反而让我更注意内容本身的结构。
3.3 模板与站点骨架:字符串替换就足够了
有了 HTML 片段,还需要把它放进一个完整的页面模板里:带<html>、<head>、<body>结构,还要有一个简单的导航栏。这一步我同样选择了最原始的方法——预定义模板字符串,用.replace()做占位符替换。
模板中我设计了三个占位符:<!-- TITLE -->放页面标题、<!-- NAV -->放导航列表、<!-- CONTENT -->放正文 HTML。生成导航的方式是遍历前面的文件列表,为每个文件生成一个<a>链接,当前页面加一个class="active"。这种模板方案在今天看来确实很"史前",但它有一个明显的优点:没有任何隐藏行为,一个模板变量对应一个替换点,想加变量就先加占位符,逻辑一眼看穿。
这里我补充一个实际遇到的细节:链接路径统一用正斜杠/,即使你在 Windows 上运行,输出到浏览器里也必须用正斜杠。pathlib.Path在 Windows 上会给出反斜杠,直接拼到 HTML 里虽然浏览器通常能容错,但碰到某些严格场景就会出现资源加载失败。我在代码里统一做了str(rel_path).replace(os.sep, "/")处理,这个细节是实测时被坑过之后才补上的。
3.4 监听文件变化:没有 watchdog,轮询也是一种美德
写完转换逻辑后,我发现开发时每次手动跑命令太麻烦,于是想加一个"文件变化自动重建"的功能。常规做法是装一个watchdog库,监听文件系统事件。但在零依赖的约束下,我只能用最古老的轮询方案:每隔一秒扫描一次所有文件的修改时间,发现变化就重新生成。
这个方案的实现非常简单,用一个dict记录每个文件的mtime,每次扫描对比新旧值,变了就触发重建。缺点也很明显:对于上千个文件的目录,每秒全量扫描一次会带来额外的磁盘 IO;好处是无法更简单了,只有十几行代码,而且跨平台稳定,不需要依赖操作系统特有的文件事件机制。
import time def watch_and_build(content_dir: Path, build, interval=1.0): snapshot = {} while True: for f in collect_markdown_files(content_dir): mtime = f.stat().st_mtime if snapshot.get(f) != mtime: snapshot[f] = mtime build() break time.sleep(interval)这段轮询代码让我想到一个道理:很多"高级需求"其实都有低级但够用的解法。文件监听用轮询,虽然理论上延迟一秒,但对编辑文档这种场景完全无感。工具的价值是解决问题,不是使用最先进的技术方案。
4. 实测中的意外与边界情况:简洁不等于没有坑
4.1 第一个坑:Windows 记事本存出的 UTF-8 带 BOM,转换直接乱套
我拿到第一批真实文档时,有几篇是从 Windows 机器上拷贝过来的。转换后的页面在标题处莫名多了一个\ufeff字符,浏览器里显示成一个不可见的零宽空格,但 HTML 校验总是报错。排查了半天才发现是 BOM 头。
BOM 是 Unicode 编码规范里用来标记字节序的字符,UTF-8 本身不需要它,但 Windows 记事本默认会加。去掉它的方式很简单:读取文本后检查第一个字符是否为\ufeff,如果是就切片去除。这个坑很小,但如果不处理,后续所有基于字符串开头的判断(比如正则匹配#标题)都会失效,标题永远不会被识别成标题,而是被包进一个段落里。我把这个处理放在了文件读取函数里,保证进入解析器之前文本已经是干净的。
def read_text(path: Path) -> str: raw = path.read_text(encoding="utf-8") if raw.startswith("\ufeff"): raw = raw[1:] return raw4.2 第二个坑:中文和空格文件名在链接里必须处理
这个项目的内容有不少中文文件名,比如开发笔记.md。生成链接时直接拼<a href="开发笔记.html">,在本地文件协议下浏览器通常能打开,可一旦部署到 Nginx 或托管到对象存储,中间经过一次 URL 解析,未编码的中文和空格就可能触发 404。标准做法是把链接里的非 ASCII 字符做百分号编码。
最初的代码里我没有做编码,导致部署后一串链接全部打不开。排查思路是先在浏览器开发者工具里看实际请求的 URL,再对比服务器上真实的文件路径,才发现是编码问题。解法是用urllib.parse.quote对相对路径做编码,同时保留/分隔符。
from urllib.parse import quote def href_for(rel_path: str) -> str: return quote(rel_path.replace(os.sep, "/"))4.3 第三个坑:用户总会把<和>直接写进正文
站点的读者不仅有开发者,还有产品经理和运营同学,他们不会记得 Markdown 里需要转义。第一次真实使用时就有人写了一行"当 x < 10 时需要告警",结果你会发现这个<被子段<p>的判断逻辑吞掉了,浏览器把它当成一个未闭合标签的开始,后续整个页面的结构全部错乱。
传统解析器会在这里有自己的纠正逻辑,但我的极简解析器直接撞上了。解决方式有两个层次:第一层是上面提到的html.escape——这能保证任何裸的<和>都被转义成实体;第二层是约定——在面向非技术用户的使用说明里明确写"不要直接使用<或>,用中文的『小于』『大于』"。两层同时上,才把这类问题压到零。
4.4 第四个坑:目录越深,os.walk的返回顺序越"任性"
在导航排序上,我一开始天真地以为os.walk会按字典序返回子目录。实测发现目录较浅时确实基本有序,但当嵌套到三层以上,顺序开始变得不稳定——这跟底层文件系统的遍历顺序有关。导航里原来靠前的内容目录跑到中间,视觉上很错乱。
排查过程比我想象的复杂:我先是怀疑排序逻辑写错了,检查半天没发现问题;然后写了个脚本打印所有目录和文件的遍历顺序,对比多次运行结果,发现顺序本身就有随机性。最终确认是文件系统遍历顺序的问题,解决方案是不要在遍历过程中依赖顺序,而是在收集完所有文件后,把每个文件的相对路径拆分后逐级排序,保证导航树按照"层级 + 文件名"双键稳定排列。这个修复让我意识到,极简工具虽然逻辑简单,但它的输入环境(文件系统、编码、平台)并不简单,该防御的地方一个都不能少。
5. Caveman 式开发思维的适用边界与我的最终取舍
5.1 什么时候我推荐你"像原始人一样做工具"
经过这个项目,我总结出了几个适合采用极简方案的判断标准:
- 使用者和开发者高度重合。如果这个工具只有你自己和身边两三个同事用,那么任何"为了工程化而工程化"的设计都是负债。
- 需求边界清晰且短期内不会膨胀。文档转 HTML 这类需求,未来无非是改改样式、加点导航,不太会出现需要插件生态的爆发式增长。
- 运行环境受控。
caveman只需要 Python,而你的机器上反正有 Python,零依赖就等于零安装成本。 - 对长期可维护性有要求。一个十年后还能原样运行的工具,远比一个每年要跟随上游升级的工具更省心。
我还发现一个有趣的副产品:极简工具逼着用户简化自己的工作流。以前我用文档工具时会去搜各种插件、主题、短代码,现在因为caveman不支持,我反而养成了用朴素 Markdown 直接表达的习惯,写文档的速度变快了,因为不需要记二十种语法规则。
5.2 什么时候千万别学caveman,那是自找麻烦
极简不是万能的。以下场景我会明确反对"原始人方案":
- 团队协作且人员流动频繁。别人不熟悉你的约定和限制,每换一个人都要重新解释一遍"我们不支持什么",沟通成本极高。
- 需求真的需要复杂生态。比如你需要全文搜索、多语言国际化、动态路由、权限系统,这些不是"简单"能替代的,硬砍功能只会让产品残缺。
- 安全敏感场景。自己写的解析器往往没有经过大规模安全测试,XSS 防护、路径穿越、恶意输入这些内容,成熟框架的积累远比自己硬写的几十行代码可靠。
caveman目前只适配我信任的内容来源,绝不建议直接暴露给不可控的互联网输入。
这个边界很重要。极简是一种价值观,不是一个可以到处套用的万能答案。它适合解决的问题类型是"工具",而不是"平台"。
5.3 项目收尾后,我给自己留了三条经验
做完caveman已经有几周了,它现在稳定地帮我生成内部文档站点。回看整个过程,有三条经验我会长期保留:
第一条,任何功能在加进去之前,先写一段"如果删掉它会发生什么"。大部分时候你会发现删掉之后什么都没发生,不,更准确地说,是你节省了后续所有维护它的时间。
第二条,限制条件越硬,创造力越强。这听起来像鸡汤,但当代码里只剩下标准库时,你会本能地寻找更简单、更本质的解法,而不是第一时间去搜索现成库。这种锻炼对编程能力本身是一种很好的训练。
第三条,工具的"使用体验"不只是界面和速度,还包括"我完全理解你在干什么"的安全感。caveman的源码我随时可以打开,每个人都能从头读到尾,这种透明感是所有黑盒依赖给不了的。
最后分享一个小技巧:如果你也想做类似的实验,不要一上来就写通用框架,先拿自己手头一个真实的小需求练手,把功能砍到不能再砍,用零依赖实现一遍。做完你大概率会发现,那些"必须用重型工具才能解决"的借口,有一大半其实是幻觉。