开源文档怎么写,才能支撑项目增长
文档决定首次体验
我维护过一个内部工具库,代码质量不差,但每次新人接手都要花两天时间搞清楚怎么跑起来。不是代码难,是文档里缺一行关键的安装步骤。后来我补上那行命令,新人上手时间从两天缩短到二十分钟。这件事让我意识到,文档不是锦上添花,它是项目体验的一部分。
开源项目的第一批用户,几乎都从 README 和快速开始来判断是否继续试用。如果前五分钟跑不通,大部分人会直接关掉标签页,不会去看你精心设计的 API。文档质量直接决定了项目的采纳率。
问题是,大多数开源项目的文档都是在「赶功能」的过程中随手写出来的。等到用户量上来,文档就成了一个谁都不想碰、碰了也修不全的历史包袱。这篇文章想聊的是:怎么从一开始就把文档体系搭对,让它能跟着项目一起长。
理论基础:Docs as Code 和 Diataxis 框架
Docs as Code:文档就是代码
「Docs as Code」这个概念并不新,Write the Docs 社区在十年前就在推这件事。核心观点很直白——用写代码的方式写文档:用纯文本格式(Markdown、reStructuredText)、用 Git 做版本控制、用 PR Review 做质量把关、用 CI/CD 做自动发布。
Cloudflare 的工程博客详细记录过他们的实践:文档存在 GitHub 公共仓库,每个 PR 自动触发构建预览,CI 流程里内置了链接检查和构建验证,审核通过后一键部署到生产环境。整个过程跟代码发布没有区别。这种方式的好处是,开发者对文档的参与门槛降到最低——你会提 PR,就会改文档。
Diataxis 框架:四种文档,四种用途
如果说 Docs as Code 解决的是「怎么管文档」,那 Diataxis 框架解决的就是「怎么写文档」。
Diataxis 由 Daniele Procida 提出,Canonical(Ubuntu 背后的公司)已经把这套框架作为官方文档的组织原则。它把技术文档分成四个象限:
- Tutorial(教程):面向新手,手把手带着做,目标是「学会」。允许省略背景知识,专注操作步骤。
- How-to Guide(操作指南):面向有经验的用户,解决一个具体任务。不解释原理,只给路径。
- Reference(参考):面向查阅场景,比如 API 文档、配置项列表。结构化、完整、准确,不要求线性阅读。
- Explanation(解释):面向理解需求,解释架构决策、设计动机、概念关系。允许展开讨论。
Quobyte 的工程师 Emmanuel Bernard 在实践中总结过一个关键洞察:Diataxis 最大的价值不是告诉你「该写什么」,而是告诉你「不该混什么」。很多文档读起来费劲,就是因为把教程和参考混在一起——你只想查一个参数含义,却要翻过三段概念铺垫。
案例一:快速开始里的「消失的依赖」
场景
一个 React 组件库,GitHub star 数 2k+,README 里的快速开始只有三行:
# 坏例子:看似完整,实际跑不通
npm install my-component-libimport { Button } from 'my-component-lib'
export default () => <Button variant="primary">Click me</Button>问题
用户照着做,报错 Module not found: Can't resolve 'my-component-lib/styles.css'。原来这个库依赖一个全局样式文件,但 README 没提。翻到 examples/ 目录才发现还要引入 CSS。更糟的是,这个库用了 React 18 的 createRoot,但示例代码还在用 React 17 的 ReactDOM.render。
用户的第一反应不会是「哦,原来是版本问题」,而是「这库是不是没人维护了」。
修复
补全前置条件、安装命令、样式引入和版本说明,并且把示例放进 CI 做编译验证:
# 好例子:前置条件 + 完整安装 + 样式引入
# 要求 Node.js >= 18, React >= 18
npm install my-component-lib @peer-dep/react@^18// 好例子:完整可运行示例,包含样式引入
import { createRoot } from 'react-dom/client'
import { Button } from 'my-component-lib'
import 'my-component-lib/styles.css' // 不要漏掉这行
const root = createRoot(document.getElementById('root')!)
root.render(<Button variant="primary">Click me</Button>)# CI 验证示例代码能否编译通过
name: verify-examples
on: [push, pull_request]
jobs:
build-examples:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx tsc --noEmit examples/quick-start.tsx教训
快速开始是用户看到的第一个文档,它需要做到「复制粘贴就能跑」。任何隐藏的前置条件都是定时炸弹。把示例放进 CI 编译验证,比人工检查靠谱得多。
案例二:API 文档的「信息黑洞」
场景
一个 CLI 工具的配置文件文档,把所有配置项列在一个表格里,每个字段只有一句话描述:
# 坏例子:信息密度低,缺少上下文
| 字段 | 类型 | 说明 |
|------|------|------|
| `build.target` | string | 构建目标 |
| `build.outDir` | string | 输出目录 |
| `plugins` | array | 插件列表 |问题
用户看到 build.target 的值是「构建目标」,不知道能填什么。是 es2015?esnext?node18?翻源码才发现是个枚举值,但文档里没写。再看 plugins,不知道数组元素是什么格式,是字符串还是对象?要不要带参数?
这种文档看似「有内容」,实际上是把理解成本转嫁给了用户。用户不得不去翻源码、看测试、或者在 Issue 里提问——而这些本该是文档完成的工作。
修复
每个字段给出类型、可选值、默认值和用法示例:
# 好例子:类型 + 可选值 + 默认值 + 示例
### `build.target`
- **类型**: `string`
- **可选值**: `'es2015'` | `'es2020'` | `'esnext'` | `'node18'`
- **默认值**: `'esnext'`
- **说明**: 控制产物语法降级目标。设为 `esnext` 时不做降级,产物体积最小。
```ts
export default {
build: {
target: 'es2020', // 兼容 Chrome 80+ / Firefox 78+
},
}
### 教训
API 文档的价值不在于「列出来」,而在于让用户不需要第二次查找。好的参考文档应该像字典——你查一个词,它的释义、用法、例句都在同一页。
## 案例三:版本升级的「断崖式迁移」
### 场景
一个状态管理库从 v2 升级到 v3,Breaking Change 不少,但迁移文档只有一段话:
```markdown
# 坏例子:含糊的迁移说明
v3 有一些不兼容变更,请参考 changelog 了解详情。
主要变化包括新的 store 创建方式和中间件 API 调整。
问题
用户看完这段话,依然不知道自己的代码要怎么改。changelog 里有 40 条提交记录,用户得自己判断哪些跟自己的用法相关。中间件 API 「调整」了——是改了参数顺序?还是换了命名?还是整个模型都变了?
结果就是用户卡在 v2 不敢升级,或者升级之后花一天时间踩坑。有些用户干脆不升级,项目慢慢被新方案替代。
修复
按变更类型分组,每条给出 Before/After 代码对比:
# 好例子:Before/After 对比,逐条说明
## 从 v2 迁移到 v3
### 1. Store 创建方式变更
`createStore()` 替换为 `defineStore()`,返回值从对象变为函数。
```ts
// v2(旧)
const store = createStore({
state: { count: 0 },
actions: { increment() { this.count++ } },
})
// v3(新)
const useCounter = defineStore('counter', () => {
const count = ref(0)
const increment = () => count.value++
return { count, increment }
})迁移步骤:
- 将
createStore改为defineStore - 第一个参数改为 store 的 ID(字符串)
- 第二个参数改为 Setup 函数,返回响应式状态和方法
2. 中间件 API 变更
中间件从数组配置改为链式调用,支持异步。
// v2(旧)
const store = createStore({
plugins: [loggerPlugin, persistPlugin({ key: 'app' })],
})
// v3(新)
const store = defineStore('app', setupFn)
.use(loggerPlugin)
.use(persistPlugin({ key: 'app' }))注意:v3 的中间件签名从 (store) => void 变为 (context) => void | Promise<void>,如果你的自定义中间件有异步操作,需要加 async 关键字。
### 教训
迁移文档的核心不是列出所有变更,而是告诉用户「你现在的代码长什么样,要改成什么样」。Before/After 对比是最有效的迁移文档形式。如果变更量大,可以按影响范围分组,先写最常见的场景。
## 文档体系的组织结构
一个能跟着项目增长的文档体系,需要在信息架构层面就做对。下面的流程图展示了一个用户从接触到贡献的完整路径,以及每个阶段需要的文档类型:
```mermaid
graph TD
A[用户首次访问项目] --> B{README 快速开始}
B -->|跑通了| C[浏览概念说明]
B -->|报错了| D[故障排查文档]
C --> E[按需查阅 API 参考]
C --> F[按任务查阅 How-to]
D -->|问题解决| C
D -->|解决不了| G[提 Issue / 社区讨论]
E --> H[决定深度使用]
F --> H
H --> I[版本升级时查阅迁移指南]
I --> J[成为贡献者 查阅贡献指南]
G -->|被邀请| J
对应到这个结构,一个典型的文档目录如下:
docs/
├── getting-started/ # Tutorial:快速开始
│ ├── installation.md
│ └── quick-start.md
├── concepts/ # Explanation:概念解释
│ ├── architecture.md
│ └── core-models.md
├── guides/ # How-to:操作指南
│ ├── deployment.md
│ └── customization.md
├── reference/ # Reference:API 参考
│ ├── api.md
│ └── configuration.md
├── migration/ # 迁移指南(独立分类)
│ ├── v2-to-v3.md
│ └── v3-to-v4.md
├── troubleshooting/ # 故障排查
│ └── common-errors.md
└── contributing/ # 贡献指南
└── README.md
选型对比:文档工具与生成器
文档体系的落地离不开工具。选错工具会让后续的维护成本成倍增加。以下是我调研过的主流方案对比。
文档站点生成器对比
| 维度 | Docusaurus | VitePress | MkDocs + Material |
|---|---|---|---|
| 框架 | React + Node.js | Vue + Vite | Python + Jinja2 |
| 构建速度 | 中等(项目大时变慢) | 快(Vite HMR 毫秒级) | 中等 |
| 插件生态 | 丰富(官方插件 + 社区) | 较少,靠 Vue 插件补充 | 丰富(Python 生态) |
| 搜索 | Algolia DocSearch 集成 | 内置本地搜索 + Algolia | 内置本地搜索 + Algolia |
| 版本管理 | 内置 docs 命令管理多版本 | 需手动配置或社区插件 | 需社区插件 |
| 国际化 | 内置 i18n 支持 | 需社区插件 | 需社区插件 |
| Bundle 大小(gzip) | ~174 KB | ~807 KB | 取决于主题 |
| 适合场景 | 大型开源项目、需要多版本和 i18n | 中小型项目、追求开发体验 | Python 生态项目 |
文档类型与写作策略对比
| 文档类型 | 目标读者 | 写作重点 | 更新频率 | 维护责任人 |
|---|---|---|---|---|
| Tutorial(教程) | 新手用户 | 步骤完整、可跟做、允许省略背景 | 低(稳定后很少改) | 技术写作者 / 核心维护者 |
| How-to(操作指南) | 有经验的用户 | 聚焦单一任务、不解释原理 | 中(随功能增加) | 功能模块负责人 |
| Reference(参考) | 所有用户 | 结构化、完整、准确 | 高(每次 API 变更) | 开发者(配合 CI 自动生成) |
| Explanation(解释) | 深度用户 / 贡献者 | 架构决策、设计动机、权衡 | 低(架构稳定后) | 架构师 / 核心维护者 |
文档质量保障手段对比
| 手段 | 解决什么问题 | 工具示例 | 实施成本 | 推荐优先级 |
|---|---|---|---|---|
| 链接检查 | 死链、断链 | markdown-link-check, Hyperlint | 低 | P0(必做) |
| 代码示例编译 | 示例代码过期 | CI 中跑 tsc / 构建 examples | 中 | P0(必做) |
| 拼写和语法检查 | 文案质量 | Vale, cspell | 低 | P1(推荐) |
| 风格一致性 | 术语、语气统一 | Vale + 自定义规则 | 中 | P2(按需) |
| 可读性分析 | 长句、术语密度 | Hyperlint readability | 低 | P2(按需) |
| 文档覆盖率检查 | 功能改了但文档没更新 | Hyperlint source-sync | 高 | P3(进阶) |
文档协作模式对比
| 模式 | 适用团队规模 | 流程特点 | 典型问题 |
|---|---|---|---|
| 维护者独写 | 1-2 人 | 维护者自己写全部文档 | 瓶颈明显,文档滞后 |
| 开发者写初稿 + 编辑润色 | 5-10 人 | 工程师写初稿,写作者打磨 | 需要写作者资源 |
| 社区贡献 + 审核 | 10+ 人 | 任何人可提 PR,维护者审核 | 质量参差,需要风格指南 |
| Docs as Code(全融入) | 不限 | 文档 PR 和代码 PR 同等流程 | 前期流程建设成本高 |
代码示例的正确写法
代码示例是文档里最容易出问题的部分。写错了比不写更糟糕——用户会以为那是正确答案。
坏做法 1:省略关键导入
// 坏例子:用户复制粘贴后直接报错
const store = defineStore('counter', () => {
const count = ref(0)
return { count }
})这段代码缺了 ref 和 defineStore 的导入。作者觉得「大家都知道从哪导入」,但新手不知道。
好做法 1:补全所有导入
// 好例子:所有依赖显式导入
import { ref } from 'vue'
import { defineStore } from 'pinia'
const useCounter = defineStore('counter', () => {
const count = ref(0)
return { count }
})坏做法 2:使用已废弃 API 不标注
# 坏例子:命令本身没错,但已经被废弃
vue create my-project
npm run servevue create 在 Vue 3.3+ 已经被 create-vue 取代,npm run serve 也变成了 npm run dev。文档里不标注版本,用户照着做会遇到一堆过时提示。
好做法 2:标注版本和替代方案
# 好例子:标注最低版本,使用当前推荐命令
# Vue 3.3+ 推荐使用 create-vue
npm create vue@latest my-project
cd my-project
npm install
npm run dev # 注意:不再是 npm run serve坏做法 3:配置文件没有完整上下文
// 坏例子:片段式配置,不知道放在文件哪个位置
{
"experimental": {
"serverActions": true
}
}用户不知道这个配置要放在 next.config.js 的哪个层级,也不知道需不需要跟已有配置合并。
好做法 3:给出完整文件上下文
// 好例子:展示完整配置文件和注释
// next.config.js
const nextConfig = {
// ...其他已有配置保持不变
// 启用 Server Actions(Next.js 14+ 默认开启,13.x 需手动开启)
experimental: {
serverActions: true,
},
}
module.exports = nextConfig坏做法 4:错误处理只写正常路径
// 坏例子:只展示 happy path
async function fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`)
return res.json()
}真实场景里这个请求可能 404、可能网络超时、可能返回非 JSON 响应。文档里不提这些,用户上线后就会在生产环境里遇到。
好做法 4:覆盖关键错误路径
// 好例子:展示正常路径和关键错误处理
async function fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) {
// 404 说明用户不存在,抛业务异常让上层决定怎么处理
if (res.status === 404) {
throw new UserNotFoundError(id)
}
throw new Error(`请求失败: ${res.status} ${res.statusText}`)
}
// 不要信任 API 响应格式,做基本校验
const data = await res.json()
if (!data.id || !data.name) {
throw new Error('响应格式异常: 缺少必要字段')
}
return data as User
}文档生命周期管理
文档不是写完就结束的。跟代码一样,它有自己的生命周期。一个文档从创建到归档,要经历多次更新和验证。
关键原则是:文档变更必须跟代码变更绑定。具体来说:
- 新增功能时,PR 描述里加一个 checklist 项:「是否需要更新文档」。
- 修改 API 时,CI 跑一个脚本检测文档中是否还有旧 API 的引用。
- 发布新版本时,自动生成 changelog 并提醒维护者检查迁移文档。
Cloudflare 的做法值得参考:他们的 CI 管道会自动检查每个 PR 中的文档链接是否有效,构建是否成功。如果文档构建失败,整个 PR 无法合并。这意味着开发者不可能在不更新文档的情况下改变 API 行为——流程本身就是一道防线。
上线前检查清单
文档发布前的检查工作,可以按阶段分成三组。这个清单覆盖了我在实际项目里踩过的坑。
发布前检查(P0 — 必须通过)
- 快速开始的所有命令在全新环境中可以复制粘贴运行
- 代码示例已在 CI 中通过编译验证(至少 tsc --noEmit)
- 所有内部链接和外部链接有效(markdown-link-check 通过)
- API 参考文档与当前代码版本一致(无已删除的字段、参数)
- 版本号在所有文档页面中已更新
- 迁移指南覆盖所有 Breaking Change,每条有 Before/After 示例
质量检查(P1 — 强烈建议)
- 术语表一致(同一概念全文只用一个名称)
- 代码示例包含必要的 import 语句和依赖声明
- 配置文件示例给出完整上下文,不是片段
- 故障排查文档覆盖最近的 5 个高频 Issue
- 文档目录结构与 Diataxis 四象限对应(教程、操作指南、参考、解释不混杂)
持续维护(P2 — 定期执行)
- 每季度跑一次全量链接检查,修复或替换失效链接
- 每次 minor 版本发布后,检查是否需要补充 How-to 或更新概念说明
- 收集社区高频问题,转化为故障排查或 How-to 文档
- 文档 CI 覆盖率报告:哪些新增代码模块还没有对应文档
写在最后
文档这件事,最难的不是文笔,而是意识到它跟代码一样需要体系化的工程方法。Docs as Code 给了流程框架,Diataxis 给了内容组织原则,工具链解决了自动化验证。三者加在一起,文档才能从一个「写完就忘」的附属品,变成一个能跟项目一起生长的活资产。
我的经验是,先从快速开始和 API 参考两个地方做起。这两个是用户最频繁接触的文档,投入产出比最高。等团队习惯了「功能上线 = 文档同步」的节奏,再逐步补全其他象限。
不需要一步到位,但需要从第一步开始。
参考资料
- Docs as Code - Write the Docs — Docs as Code 概念的基础定义和实践指南
- Diátaxis 官方文档 — Daniele Procida 提出的技术文档四象限框架
- Cloudflare: Our Docs-as-Code Approach — Cloudflare 工程博客关于文档工程化实践的详细记录
- Exploring Diataxis - Emmanuel Bernard — Diataxis 框架在实际项目中的应用经验和常见误区
- 5 Critical Documentation Best Practices for Docs-as-Code - Hyperlint — Docs as Code 的五个关键实践和工具建议
- Docusaurus 官方文档 — React 生态文档站点生成器的功能和配置参考
- VitePress 官方文档 — Vite + Vue 驱动的轻量文档站点生成器
- awesome-developer-experience — 开发者体验(DX)相关资源的策展列表