开源文档怎么写,才能支撑项目增长

0阅读11分钟

文档决定首次体验

我维护过一个内部工具库,代码质量不差,但每次新人接手都要花两天时间搞清楚怎么跑起来。不是代码难,是文档里缺一行关键的安装步骤。后来我补上那行命令,新人上手时间从两天缩短到二十分钟。这件事让我意识到,文档不是锦上添花,它是项目体验的一部分。

开源项目的第一批用户,几乎都从 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-lib
import { 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 的值是「构建目标」,不知道能填什么。是 es2015esnextnode18?翻源码才发现是个枚举值,但文档里没写。再看 plugins,不知道数组元素是什么格式,是字符串还是对象?要不要带参数?

这种文档看似「有内容」,实际上是把理解成本转嫁给了用户。用户不得不去翻源码、看测试、或者在 Issue 里提问——而这些本该是文档完成的工作。

修复

每个字段给出类型、可选值、默认值和用法示例:

# 好例子:类型 + 可选值 + 默认值 + 示例
### `build.target`
 
- **类型**: `string`
- **可选值**: `'es2015'` | `'es2020'` | `'esnext'` | `'node18'`
- **默认值**: `'esnext'`
- **说明**: 控制产物语法降级目标。设为 `esnext` 时不做降级,产物体积最小。
 
```ts
export default &#123;
  build: &#123;
    target: 'es2020', // 兼容 Chrome 80+ / Firefox 78+
  &#125;,
&#125;

### 教训

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(&#123;
  state: &#123; count: 0 &#125;,
  actions: &#123; increment() &#123; this.count++ &#125; &#125;,
&#125;)
 
// v3(新)
const useCounter = defineStore('counter', () => &#123;
  const count = ref(0)
  const increment = () => count.value++
  return &#123; count, increment &#125;
&#125;)

迁移步骤

  1. createStore 改为 defineStore
  2. 第一个参数改为 store 的 ID(字符串)
  3. 第二个参数改为 Setup 函数,返回响应式状态和方法

2. 中间件 API 变更

中间件从数组配置改为链式调用,支持异步。

// v2(旧)
const store = createStore(&#123;
  plugins: [loggerPlugin, persistPlugin(&#123; key: 'app' &#125;)],
&#125;)
 
// v3(新)
const store = defineStore('app', setupFn)
  .use(loggerPlugin)
  .use(persistPlugin(&#123; key: 'app' &#125;))

注意: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

选型对比:文档工具与生成器

文档体系的落地离不开工具。选错工具会让后续的维护成本成倍增加。以下是我调研过的主流方案对比。

文档站点生成器对比

维度DocusaurusVitePressMkDocs + Material
框架React + Node.jsVue + VitePython + 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, HyperlintP0(必做)
代码示例编译示例代码过期CI 中跑 tsc / 构建 examplesP0(必做)
拼写和语法检查文案质量Vale, cspellP1(推荐)
风格一致性术语、语气统一Vale + 自定义规则P2(按需)
可读性分析长句、术语密度Hyperlint readabilityP2(按需)
文档覆盖率检查功能改了但文档没更新Hyperlint source-syncP3(进阶)

文档协作模式对比

模式适用团队规模流程特点典型问题
维护者独写1-2 人维护者自己写全部文档瓶颈明显,文档滞后
开发者写初稿 + 编辑润色5-10 人工程师写初稿,写作者打磨需要写作者资源
社区贡献 + 审核10+ 人任何人可提 PR,维护者审核质量参差,需要风格指南
Docs as Code(全融入)不限文档 PR 和代码 PR 同等流程前期流程建设成本高

代码示例的正确写法

代码示例是文档里最容易出问题的部分。写错了比不写更糟糕——用户会以为那是正确答案。

坏做法 1:省略关键导入

// 坏例子:用户复制粘贴后直接报错
const store = defineStore('counter', () => {
  const count = ref(0)
  return { count }
})

这段代码缺了 refdefineStore 的导入。作者觉得「大家都知道从哪导入」,但新手不知道。

好做法 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 serve

vue 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
}

文档生命周期管理

文档不是写完就结束的。跟代码一样,它有自己的生命周期。一个文档从创建到归档,要经历多次更新和验证。

流程图画布 · 115%
Mermaid 流程图加载中...

关键原则是:文档变更必须跟代码变更绑定。具体来说:

  • 新增功能时,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 参考两个地方做起。这两个是用户最频繁接触的文档,投入产出比最高。等团队习惯了「功能上线 = 文档同步」的节奏,再逐步补全其他象限。

不需要一步到位,但需要从第一步开始。

参考资料

评论 0

0 / 1000