第 2 章 / 共 10 章
一份能用的规格长什么样
2.1 从「导出订阅列表」这个需求说起
先看反面。下面这段是很多人心目中的「我已经写清楚了」:
在设置页加一个导出按钮,点击后把用户的订阅列表导出成 OPML 文件供其他阅读器导入。要处理好异常情况,代码风格和现有代码保持一致。
比开头那句好,但它仍然留着大量缺口:OPML 的哪个版本?文件名叫什么?导出的字段包含分组吗?「异常情况」指哪些?导出成功后界面要有反馈吗?允许改动哪些文件?最要命的是最后一句——「处理好异常情况」是不可验收的,实现完成后你无法判断它到底处理了没有。
2.2 规格的五层
一份能用的规格只需要五层。每一层对应一类猜测。

| 层 | 回答的问题 | 消灭的猜测 |
|---|---|---|
| 目标 | 为谁解决什么问题 | 做错方向 |
| 范围与非目标 | 这次做什么、明确不做什么 | 越界改动、顺手重构 |
| 接口与约束 | 数据长什么样、依赖什么、不能碰什么 | 格式、命名、依赖选型 |
| 验收标准 | 满足哪些条件才算完成 | 「看起来做完了」 |
| 验证方式 | 用什么命令或操作能证明 | 无法自证的交付 |
Claude Code 的官方最佳实践对好规格的描述几乎是同一件事:最有用的规格是自足的——它点名涉及的文件与接口,声明什么不在范围内,并以一个端到端的验证步骤收尾。这三点分别落在第三层、第二层和第五层。
2.3 非目标为什么比目标更省事
五层里最容易被跳过、性价比却最高的是「非目标」。
原因很简单:目标是有限的,非目标是无限的。你不写非目标,agent 就要在无限的可能里自己划线。而它划线的倾向是做多不做少——顺手把相邻的旧代码重构了、顺手加了个它认为该有的配置项、顺手把类型定义抽成了公共模块。每一处单独看都「不算错」,合在一起就是一个你不敢合并的 PR。
写非目标几乎不费力,一行一条:
## 非目标
- 不做导入功能,本次只做导出
- 不改动订阅数据层(src/store/subscriptions.ts)的任何接口
- 不引入新的第三方依赖
- 不做导出进度条,订阅量级在千条以内,同步生成即可
这四行消灭的猜测,比前面那段散文多得多。
2.4 最小规格模板
下面这份模板可以直接复制。它刻意保持短——目标是十五分钟能填完,不是写一份交付文档。
# 规格:订阅列表导出为 OPML
## 目标
让用户能把当前的订阅列表导出为标准 OPML 文件,以便导入到其他 RSS 阅读器。
## 范围
- 设置页新增「导出订阅」按钮
- 新增导出模块,把订阅数据序列化为 OPML 2.0
- 通过浏览器下载交付文件
## 非目标
- 不做导入
- 不改动 src/store/subscriptions.ts 的接口
- 不引入新的第三方依赖
- 不做导出进度提示
## 接口与约束
- 输入:store 中的 Subscription[],字段为 { id, title, xmlUrl, htmlUrl, folder }
- 输出:OPML 2.0 文档,folder 映射为嵌套的 outline 节点
- 文件名:subscriptions-YYYY-MM-DD.opml
- 涉及文件:src/pages/Settings.tsx(新增按钮)、src/lib/opml.ts(新增)
- 编码 UTF-8,标题中的 & < > 需转义
## 验收标准
1. 当用户点击「导出订阅」按钮时,系统应生成一个 OPML 文件并触发浏览器下载。
2. 当订阅列表包含分组时,系统应在 OPML 中生成对应的嵌套 outline 结构。
3. 若订阅列表为空,则系统应提示「暂无订阅可导出」并且不触发下载。
4. 当订阅标题包含 XML 特殊字符时,系统应对其进行转义,导出的文件应能被 XML 解析器正确解析。
## 验证方式
- 单元测试:npm test src/lib/opml.test.ts,覆盖空列表、含分组、含特殊字符三种输入
- 端到端人工验证:导出的文件能被 Feedly / Inoreader 的导入功能接受
这份规格大约十分钟能写完,但它把开头那句话里的十几个决策点全部固定住了。
为什么验收标准写成「当……系统应……」这种句式? 那是第3章的主题。