关于组件文档从编写到生成的那些事

1,553 阅读11分钟

前言

说到前端领域的组件,Vue 技术体系下有 Element UI,React 技术体系下有 Ant Design,这些都是当前的前端攻城狮们都免不了要实际使用到的基础组件库。而在实际工作中,我们也总免不了要根据自己的工作内容,整理一些适合自己业务风格的一套组件库,基础组件部分可以基于上面开源的组件库以及 less 框架等多主题样式方案做自己的定制,但更多的是一些基于这些基础组件整理出适合自己业务产品的一套业务组件库。

而说到开发组件库,我们或选择 Monorepo 单仓库多包的形式(参考网文 segmentfault.com/a/119000001… 等)或其他 Git 多仓库单包的形式来维护组件代码,最终都免不了要将组件真正落到一个文档中,提供给其他同事去参考使用。

本篇文章就产出组件文档这件事,聊聊我在产出文档过程中的一系列思考过程,解决组件开发这「最后一公里」中的体验问题。

组件文档的编写

规范与搭建的调研

组件文档是要有一定的规范的,规范是任何软件工程阶段的第一步。对于组件来说,文档透出的内容都应包含哪些内容,决定着组件开发者和使用者双方的所有的体验。确定这些规范并不难,参考开源的组件库的文档,我们会有一些初步的印象。

因为我们团队使用的是 React 技术栈,这里我们参考 Ant Design 组件库。

比如这个最基本的 Button 组件,官方文档从上至下的内容结构是这样:

  1. 显示组件标题,下跟组件的简单描述。
  2. 列出组件的使用场景。
  3. 不同使用场景下的效果演示、代码案例。可外跳 CodeSandbox、CodePen 等在线源码编辑器网站编辑实时查看效果。
  4. 列出组件可配置的属性、接口方法列表。列表中包含属性/方法名、字段类型、默认值、使用描述等。
  5. 常见问题的 FAQ。
  6. 面向设计师的一些 Case 说明链接。

这些文档内容很丰富,作为一个开放的组件库,几乎考虑到的从设计到开发视角的方方面面,使用体验特别好。而在好奇心驱使下,我去查看了官网源码方库,比如 Button 组件:github.com/ant-design/…。在源码库下,放置了和组件入口文件同名的分别以 .zh-CN.md.en-US.md 后缀命名的 Markdown 文件,而在这些 Markdown 文件中,便是我们看到的官网文档内容...咦?不对,好像缺少了什么,案例演示和示例代码呢?

难道 AntD 官网文档是另外自己手动开发维护的?这么大的一个项目肯定不至于,根据其官网类似 docs/react/introduce-cn 这种访问路径在源码库中有对应的 Markdown 文件来看,官网的文档肯定是官方仓库根据一种规范来生成的。那么是怎么生成的呢?第一次做组件文档规范的我被挑起了兴趣。

而作为一个前端工程老手,我很熟练地打开了其 package.json 文件,通过查看其中的 scripts 命令,轻易便发现了其下的 site 命令(源码仓库 package.json):

npm run site:theme && cross-env NODE_ICU_DATA=node_modules/full-icu ESBUILD=1 bisheng build --ssr -c ./site/bisheng.config.js

原来如此,网站的构建使用了 bisheng。通过查阅了解 bisheng 这个工具库,发现它确实是一个文档系统的自动生成工具,其下有一个插件 bisheng-plugin-react,可以将 Markdown 文档中的 JSX 源码块转换成可以运行演示的示例。而每个组件自身的示例代码文档,则在每个组件路径下的 demo 目录下维护。

Emmm,bisheng 确实是很好很强大,还能支持多语言,结合一定的文档规范约束下,能够快速搭建一个文档的主站。但在深入了解 bisheng 的过程中,发现其文档相对来说比较缺乏,包装的东西又那么多,使用过程中黑盒感严重,而我们团队的组件库其实要求很简单,一是能做到方便流通,而是只在内部流通使用,不会开源。那么,有没有更简单的搭建文档的方式呢?

更多的文档工具库的调研

在谷歌搜索中敲入如 React Components Documentation 等关键字,我们很快便能搜索出很多与 React 组件文档相关的工具库,这里我看到了如下这些:DoczStoryBookReact Styleguidist 、UMI 构建体系下的 dumi 等等。

这些工具库都支持解析 Markdown 文档,其中 DoczStoryBook 还支持使用 mdx 格式(Markdown 和 JSX 的混合写法),且在文档内容格式都能支持到组件属性列表、示例代码演示等功能。

接下来,我们分别简单看下这些工具库对于组件文档的支持情况。

Docz

在了解过程中,发现 Docz 其实是一个比较老牌的文档系统搭建工具了。它本身便主推 MDX 格式的文档,基本不需要什么配置便能跑起来。支持本地调试和构建生成可发布产物,支持多包仓库、TypeScript 环境、CSS 处理器、插件机制等,完全满足功能需要。

只是 Docz 貌似只支持 React 组件(当然对于我们来说够用),且看其 NPM 包最近更新已经是两年之前。另外 MDX 格式的文档虽然理解成本很少但对于使用不多的同事来说还是有一定的接受和熟练上手的成本。暂时备选。

StoryBook

在初次了解到 StoryBook 时便被其 66.7K 的 Star 量惊到了(Docz 是 22K),相对 Docz 来说,StoryBook 相关的社区内容非常丰富,它不依赖组件的技术栈体系,现在已经支持 React、Vue、Angular、Web Components 等数十个技术栈。

StoryBook 搭建文档系统的方式不是去自动解析 Markdown 文件,而是暴露一系列搭建文档的接口,让开发者自己为组件手动编写一个个的 stories 文件,StoryBook 会自动解析这些 stories 文件来生成文档内容。这种方式会带来一定的学习和理解接口的成本,但同时也基于这种方式实现了支持跨组件技术栈的效果,并让社区显得更为丰富。

官方示例:github.com/storybookjs…

StoryBook 的强大毋庸置疑,但对于我们团队的情况来说还是有些杀鸡用牛刀了。另外,其需要额外理解接口功能并编写组件的 stories 文件在团队内很难推动起来:大家都很忙,组件开发分布在团队几十号人,情况比较复杂,将文档整理约束到一个人身上又不现实。继续调研。

React Styleguidist

React Styleguidist 的 Star 量没有 StoryBook 那么耀眼(10K+),但包体的下载量也比较大,且近期的提交也是相当活跃。由名字可知,它支持的是 React 组件的环境。它是通过自动解析 Mardown 文件的形式来生成文档的,实现方式是自动解析文档中 JSX 声明代码块,按照名称一一对应的规则查找到组件源码,然后将声明的代码块通过 Webpack 打包产生出对应的演示示例。

而在继续试用了 React Styleguidist 的一些基础案例后,它的一个功能让我眼前一亮:它会自动解析组件的属性,并解析出其类型、默认值、注释描述等内容,然后将解析到的内容自动生成属性表格放置在演示示例的上方。这就有点 JSDOC 的意思了,对于一个组件开发者来说,TA 确实需要关心组件属性的透出、注释以及文档案例的编写,但编写完也就够了,不用去考虑怎么适应搭建出一个文档系统。

另外, React Styleguidist 解析组件属性是基于解析 AST 以及配合工具 react-docgen 来实现的,并且还支持配合 react-docgen-typescript 来实现解析 TypeScript 环境下的组件,另外还能很多配置项支持更改文档站点相关的各个部分的展示样式、内容格式等,配置自定义支持相当灵活。

当然,它也有一些缺点,比如内嵌 Webpack,对于已经将编译组件库的构建工具换为 Rollup.js 的情况是一个额外的配置负担。

总的来说,React Styleguidist 在我看来是一个小而美的工具库,很适合我们团队协作参与人多、且大都日常开发工作繁重的情况。暂时备选。

dumi

了解到 dumi 是因为我们团队内已经有部分组件文档站点是基于它来搭建的了。dumi 一样是通过自动解析 Markdown 文档的方式来实现搭建文档系统,同样基本零配置,也有很多灵活的配置支持更改文档站点一些部分的显示内容、(主题)样式等,整体秉承了 UMI 体系的风格:开箱即用,封装极好。它能单独使用,也能结合 UMI 框架一起配置使用。

只是相比于上面已经了解到的 React Styleguidist 来说,并未看到有其他明显的优势,且貌似没有看到有自动解析组件属性部分的功能,对于我来说没有 React Styleguidist 下得一些亮点。可以参考,不再考虑。

组件文档的生成

在多方对比了多个文档搭建的工具库后,我最终还是选用了 React Styleguidist。在我看来,自然是其基于 react-docgen 来实现解析组件属性、类型、注释描述等的功能吸引到了我,这个功能一方面能在较少的额外时间付出下规范团队同事开发组件过程中一系列规范,另一方面其 API 接口的接入形式能够通过统一构建配置而统一产出文档内容格式和样式,方便各业务接入使用。

决定了技术方案后,便是如何具体实现基于其封装一个工具,便于各业务仓库接入了。

我们团队有自己统一的 CLI 构建工具,再多一个 React Styleguidist 的 CLI 配置会在理解上有一定的熟悉成本,但我可以基于 React Styleguidist 的 Node API 接入形式,将 React Styleguidist 的功能分别融入我们自身 CLI 的 devbuild 命令。

首先,基于 React Styleguidist API 的形式,统一一套配置,将生成 React Styleguidist 示例的代码抽象出来:

// 定义一套统一的配置,生成 react-styleguidist 实例
import styleguidist from 'react-styleguidist/lib/scripts/index.esm';
import * as docgen from 'react-docgen';
import * as docgenTS from 'react-docgen-typescript';

import type * as RDocgen from 'react-docgen';

export type DocStyleguideOptions = {
  cwd?: string;
  rootDir: string;
  workDir: string;
  customConfig?: object;
};

const DOC_STYLEGUIDE_DEFAULTS = {
  cwd: process.cwd(),
  rootDir: process.cwd(),
  workDir: process.cwd(),
  customConfig: {},
};

export const createDocStyleguide = (
  env: 'development' | 'production',
  options: DocStyleguideOptions = DOC_STYLEGUIDE_DEFAULTS,
) => {
  // 0. 处理配置项
  const opts = { ...DOC_STYLEGUIDE_DEFAULTS, ...options };
  const {
    cwd: cwdPath = DOC_STYLEGUIDE_DEFAULTS.cwd,
    rootDir,
    workDir,
    customConfig,
  } = opts;

  // 标记:是否正在调试所有包
  let isDevAllPackages = true;

  // 解析工程根目录包信息
  const pkgRootJson = Utils.parsePackageSync(rootDir);

  // 1. 解析指定要调试的包下的组件
  let componentsPattern: (() => string[]) | string | string[] = [];
  if (path.relative(rootDir, workDir).length <= 0) {
    // 选择调试所有包时,则读取根路径下 packages 字段定义的所有包下的组件
    const { packages = [] } = pkgRootJson;
    componentsPattern = packages.map(packagePattern => (
      path.relative(cwdPath, path.join(rootDir, packagePattern, 'src/**/[A-Z]*.{js,jsx,ts,tsx}'))
    ));
  } else {
    // 选择调试某个包时,则定位至选择的具体包下的组件
    componentsPattern = path.join(workDir, 'src/**/[A-Z]*.{js,jsx,ts,tsx}');
    isDevAllPackages = false;
  }

  // 2. 获取默认的 webpack 配置
  const webpackConfig = getWebpackConfig(env);

  // 3. 生成 styleguidist 配置实例
  const styleguide = styleguidist({
    title: `${pkgRootJson.name}`,
    // 要解析的所有组件
    components: componentsPattern,
    // 属性解析设置
    propsParser: (filePath, code, resolver, handlers) => {
      if (/\.tsx?/.test(filePath)) {
        // ts 文件,使用 typescript docgen 解析器
        const pkgRootDir = findPackageRootDir(path.dirname(filePath));
        const tsConfigParser = docgenTS.withCustomConfig(
          path.resolve(pkgRootDir, 'tsconfig.json'),
          {},
        );
        const parseResults = tsConfigParser.parse(filePath);
        const parseResult = parseResults[0];
        return (parseResult as any) as RDocgen.DocumentationObject;
      }
      // 其他使用默认的 react-docgen 解析器
      const parseResults = docgen.parse(code, resolver, handlers);
      if (Array.isArray(parseResults)) {
        return parseResults[0];
      }
      return parseResults;
    },
    // webpack 配置
    webpackConfig: { ...webpackConfig },
    // 初始是否展开代码样例
    // expand: 展开 | collapse: 折叠 | hide: 不显示;
    exampleMode: 'expand',
    // 组件 path 展示内容
    getComponentPathLine: (componentPath) => {
      const pkgRootDir = findPackageRootDir(path.dirname(componentPath));
      try {
        const pkgJson = Utils.parsePackageSync(pkgRootDir);
        const name = path.basename(componentPath, path.extname(componentPath));
        return `import ${name} from '${pkgJson.name}';`;
      } catch (error) {
        return componentPath;
      }
    },
    // 非调试所有包时,不显示 sidebar
    showSidebar: isDevAllPackages,
    // 日志配置
    logger: {
      // One of: info, debug, warn
      info: message => Utils.log('info', message),
      warn: message => Utils.log('warning', message),
      debug: message => console.debug(message),
    },
    // 覆盖自定义配置
    ...customConfig,
  });

  return styleguide;
};

这样,在 devbuild 命令下可以分别调用实例的 server 接口方法和 build 接口方法来实现调试和构建产出文档静态资源。

// dev 命令下启动调试
// 0. 初始化配置
const HOST = process.env.HOST || customConfig.serverHost || '0.0.0.0';
const PORT = process.env.PORT || customConfig.serverPort || '6060';

// 1. 生成 styleguide 实例
const styleguide = createDocStyleguide(
  'development',
  {
	cwd: cwdPath,
	rootDir: pkgRootPath,
	workDir: workPath,
	customConfig: {
	  ...customConfig,
	  // dev server host
	  serverHost: HOST,
	  // dev server port
	  serverPort: PORT,
	},
  },
);

// 2. 调用 server 接口方法启动调试
const { compiler } = styleguide.server((err, config) => {
  if (err) {
	console.error(err);
  } else {
	const url = `http://${config.serverHost}:${config.serverPort}`;
	Utils.log('info', `Listening at ${url}`);
  }
});
compiler.hooks.done.tap('done', (stats: any) => {
  const timeStr = stats.toString({
	all: false,
	timings: true,
  });

  const statStr = stats.toString({
	all: false,
	warnings: true,
	errors: true,
  });

  console.log(timeStr);

  if (stats.hasErrors()) {
	console.log(statStr);
	return;
  }
});
// build 命令下执行构建

// 生成 styleguide 实例
const styleguide = MonorepoDev.createDocStyleguide('production', {
  cwd,
  rootDir,
  workDir,
  customConfig: {
	styleguideDir: path.join(pkgDocsDir, 'dist'),
  },
});
// 构建文档内容
await new Promise<void>((resolve, reject) => {
  styleguide.build(
	(err, config, stats) => {
	  if (err) {
		reject(err);
	  } else {
		if (stats != null) {
		  const statStr = stats.toString({
			all: false,
			warnings: true,
			errors: true,
		  });
		  console.log(statStr);
		  if (stats.hasErrors()) {
			reject(new Error('Docs build failed!'));
			return;
		  }
		  console.log('\n');
		  Utils.log('success', `Docs published to ${path.relative(workDir, config.styleguideDir)}`);
		}
		resolve();
	  }
	},
);

最后,在组件多包仓库的每个包下的 package.json 中,分别配置 devbuild 命令即可。实现了支持无感启动调试和构建产出文档资源。

小结

本文主要介绍了我在调研实现组件文档规范和搭建过程中的一个思考过程,诚如文中介绍其他文档系统搭建工具时所说,有很多优秀的开源工具能够支持实现我们想要的效果,这是前端攻城狮们的幸运,也是不幸:我们可以站在前人的肩膀上,但要在这么多优秀库中选择一个适合自己的,更需要多做一些了解和收益点的权衡。一句老话经久不衰:适合自己的才是最好的。

希望这篇文章对看到这里的你能有所帮助。

作者:ES2049 / 靳志凯

文章可随意转载,但请保留此原文链接。

非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 caijun.hcj@alibaba-inc.com