组件库文档自动生成工具

avatar
FE @字节跳动

前言

在我们使用一个组件库的时候,文档页面是最直接的获取信息的窗口。而文档页一般包含了这些信息:

  1. 组件的描述
  2. 组件 Demo 示例的展示、描述和源码
  3. 组件的参数文档

要是纯组件示例的调试和展示的话,我们当然可以选择像 storybook 这样的工具,不过考虑到美观和按设计图还原的难度,我们还是要考虑自己来写一个可定制性、可拓展性更强的工具。

分析

我们从上面文档页包含的信息来梳理一下我们的需求:

  1. 最简洁的语法来写页面
  2. 最简洁的语法来展示 Demo + 源代码 + 示例描述
  3. 最小成本的维护参数文档

从语法上来说,我们应该首选 markdown 了,语法足够简洁和强大。

展示 Demo 和源码的话,为了能更高效低成本的维护,我们应该把一个示例的 Demo + 源码 + 示例描述 放到一个文件里,尽量多的去复用,减少所需要维护的代码。示例的展示,本质上可以说是跟 markdown 的转译一致,都是 markdown -> html,只是转译的规则我们需要拓展一下。

维护参数文档的话,手动维护会有很多问题:成本大、不容易跟代码同步(每次改动都要手动去改参数文档),所以我们应该考虑自动化的从 ts 声明中去提取信息,形成参数文档。

markdown -> html ,我们其实只需要一个 webpack loader 就行了,梳理一下如下的流程:

image.png

实现

预处理入口文件

我们的总入口文件是个 markdown 文件,也就是我们生成的页面,整个页面结构如下所示(当然这个结构的顺序是可以调整的):

image.png

根据 TypeScript 声明提取参数名描述类型默认值,我们可以使用 react-docgen-typescript 这个工具,我们做下小修饰。

定制编译器,过滤 react 本身不需要展示的参数

const parse = require('react-docgen-typescript').withDefaultConfig({
  propFilter: (prop) => {
    if (prop.parent == null) {
      return true;
    }
    return prop.parent.fileName.indexOf('node_modules/@types/react') < 0;
  },
}).parse;

根据组件 ts 得到所需信息

const params = parse(filePath);
const info = params[0];

上面我们获取到了 info,包含了组件的 参数名描述类型默认值 等信息,我们只要把这些信息转化成 markdown table 写法然后插入到总入口 markdown 文件指定位置即可。

Webpack loader

Webpack loader 本身就是处理指定的文件类型,输出实际打包的 js 代码,我们现在需要把 markdown 转换成实际运行的 react jsx 代码。

上面一步我们拿到了包含参数信息的总入口 markdown 文件,这个 markdown 我们预留了一个插槽 %%Content%%(当然我们可以随意指定),用于之后的插入 Demo 示例。

为了方便维护,我们把每个组件的 Demo 示例都放到相应的组件目录下,存放到一个名为 demo 的文件夹中。

每个 Demo 示例即一个 markdown 文件,markdown 文件的内容如下所示:

---
order: 0
title: 不同类型的按钮
---

按钮分为 默认按钮,主要按钮,危险按钮,高危按钮,虚线按钮,文本按钮六种。

import { Button } from '@bytedesign/web-react';

ReactDOM.render(
  <Button type="primary">
    Primary
  </Button>,
  CONTAINER
);

文件中包含着标题、展示顺序、描述、示例源码等信息。

我们使用正则或者直接使用 front-matter,可以取到标题和展示顺序等配置信息,通过正则可以拿到描述信息和示例的源代码,该有的信息都有了,接下来我们需要把这些信息拼接成我们想要的页面。

AST 树处理

处理 AST 树,我们选择用 babel,这是我们需要用到的包:

  1. @babel/core
  2. @babel/parser
  3. @babel/template
  4. @babel/traverse
  5. @babel/generator
  6. @babel/types

代码的AST树是一个非常复杂的树形结构,我们可以通过 astexplorer.net/ 这个网站来协助生成和查看AST树

对于 markdown 的内容,我们要先用 marked 将之转换成 html 代码。当然被转后的 html 是字符串,我们用 babel 无法生成 AST 树,我们需要先把 html 字符串转换成 jsx。如下:

function htmlToJsx(html) {
  return `import React from 'react';
  export default function() {
    return (
      <span>${html
        .replace(/class=/g, 'className=')
        .replace(/{/g, '{"{"{')
        .replace(/}/g, '{"}"}')
        .replace(/{"{"{/g, '{"{"}')}
      </span>
    );
  };`;
}

得到 jsx 代码,我们就能愉快地生成 AST 树:

const parser = require('@babel/parser');
function parse(codeBlock) {
  return parser.parse(codeBlock, {
    sourceType: 'module',
    plugins: ['jsx', 'classProperties'],
  });
}
const ast = parse(htmlToJsx(marked(markdown)));

构建 demo 示例的 AST

通过正则获取到示例源代码的AST树:

// @arco-design/arco-components 为抽离的用于展示示例和源码的组件
const ast = parse(`
   import { CodeBlockWrapper, CellCode, CellDemo, CellDescription, Browser } from "@arco-design/arco-components";
   ${code}
`);

遍历AST树,将获取到的示例AST、描述AST、源代码AST统统插入到 CodeBlockWrapper 组件中:

const traverse = require('@babel/traverse').default;
const t = require('@babel/types');

traverse(ast, {
    CallExpression(_path) {
      if (
        _path.node.callee.object &&
        _path.node.callee.object.name === 'ReactDOM' &&
        _path.node.callee.property.name === 'render'
      ) {
        const demoCellElement = t.jsxElement(
          t.jsxOpeningElement(t.JSXIdentifier('CellDemo'), []),
          t.jsxClosingElement(t.JSXIdentifier('CellDemo')),
          [_path.node.arguments[0]]
        );

        const codeCellElement = t.jsxElement(
          t.jsxOpeningElement(t.JSXIdentifier('CellCode'), codeAttrs),
          t.jsxClosingElement(t.JSXIdentifier('CellCode')),
          [codePreviewBlockAst]
        );

        const descriptionCellElement = t.jsxElement(
          t.jsxOpeningElement(t.JSXIdentifier('CellDescription'), []),
          t.jsxClosingElement(t.JSXIdentifier('CellDescription')),
          [descriptionAst]
        );

        const codeBlockElement = t.jsxElement(
          t.jsxOpeningElement(t.JSXIdentifier('CodeBlockWrapper'), []),
          t.jsxClosingElement(t.JSXIdentifier('CodeBlockWrapper')),
          [descriptionCellElement, demoCellElement, codeCellElement]
        );

        const app = t.VariableDeclaration('const', [
          t.VariableDeclarator(t.Identifier('__export'), codeBlockElement),
        ]);

        _path.insertBefore(app);
        _path.remove();
      }
    },
  });

获取如上转换得到的代码:

const babel = require('@babel/core');
const { code } = babel.transformFromAstSync(ast, null, babelConfig);

输出的代码是如下格式:

const __export = <CodeBlockWrapper>
    <CellDescription>...<CellDescription>
    <CellDemo>...</CellDemo>
    <CellCode>...<CellCode>
</CodeBlockWrapper>

我们的 demo 文件夹下会有多个示例 markdown,每个示例我们都生成一个函数组件,放到一个数组里:

const generate = require('@babel/generator').default;
const template = require('@babel/template').default;

const buildRequire = template(`
    function NAME() {
      AST
      return __export;
    }
  `);

  const finnalAst = buildRequire({
    NAME: `Demo${index}`,
    AST: code,
  });

  demoList.push(generate(finnalAst).code);

现在我们的 demoList 其实是包含组件所有示例组件的一个数组,我们把这些示例放到一个真实用于展示的组件内:

const buildRequire = template(`
    CODE
    class Component extends React.Component {
      render() {
        return React.createElement('span', { className: 'arco-components-wrapper' }, ${demoList
          .map((_, index) => `React.createElement(Demo${index}, { key: ${index} })`)
          .join(',')});
      }
    }
  `);

  const finnalAst = buildRequire({
    CODE: demoList.join('\n'),
  });

OK,finnalAst 即为我们最终插入到入口 Markdown 生成的 AST 中的 AST。

替换占位符

还记得我们上面留下了一个 %%Content%% 的占位符,我们现在需要把处理好的示例替换到占位符所在的位置。

traverse(contentAst, {
  JSXElement: (_path) => {
    if (
      _path.node.openingElement.name.name === 'p' &&
      _path.node.children[0].value === '%%Content%%'
    ) {
      const expresstion = t.jsxExpressionContainer(
        t.jsxElement(
          t.jsxOpeningElement(t.JSXIdentifier('Component'), [], true),
          null,
          [],
          true
        )
      );
      _path.replaceWith(expresstion);
      _path.stop();
    }
  },
});

// 把我们处理的示例ast放到函数声明前

traverse(contentAst, {
  FunctionDeclaration: (_path) => {
    _path.insertBefore(finnalAst);
    _path.stop();
  },
});

Webpack loader 最终处理返回的代码:

return generate(contentAst).code; 

使用

根据上面的流程,我们已经成功搭建了一个处理 markdown 文件,并且会处理 demo 示例的一个 markdown loader,使用这个插件之后,我们就可以像如下去使用:

import ButtonPage from 'components/Button/README.md';    
function Page() {    
    return <ButtonPage />;  
} 

总结

上面我们通过一个 webpack loader,实现了基于 markdown 形式的组件文档生成,通过这个流程其实解决了我们在写组件文档时的很多痛点:

  1. 保证参数文档完全跟源代码同步,大大减少了维护成本。
  2. 官网示例展示、组件调试等一步到位,同时把书写示例的成本降到了最小。只用书写一遍代码,可以同时用于生成官网示例官网示例源码快照测试、Github/Gitlab 说明页面。而且这个过程是全自动的,基本可以做到只专注于组件逻辑的书写,很大程度上的减少了开发和维护成本。
  3. 利用 markdown 文件原生被 gitlab 和 github 解析展示的特点,我们每个组件目录下,相当于都有了一个说明页面,可以看参数和描述等信息。
  4. 完全可控的官网样式。

如果你也在开发 React 组件库,或者需要展示 React 相关的组件示例,那么本篇文档可能会帮到你。