likes
comments
collection
share

react-docgen-typescript 生成组件文档

作者站长头像
站长
· 阅读数 28

前言

最近一直想尝试写一个React的移动端组件库来巩固学习一下,不然很多东西过段时间就很容易忘记,写组件库肯定需要组件文档。遂调研了市面上的一些文档生成器,比如dumi,react-styleguidist,storybook,觉得非常🐂,于是想自己来实现下类似的效果,顺便学习一下怎么实现的,又是一顿🔍查找资料,最后找到了github.com/zhuangtiany… 实现了类似的效果。

效果

react-docgen-typescript 生成组件文档

主要实现以下3个功能:

  1. 根据组件 interface 自动生成 API 文档
  2. 生成 demo 代码,并可展开收起
  3. 可复制 demo 代码

目录

# 运行 tree 命令生成 文件目录树
tree --gitignore > 1.txt
.
├── 1.txt
├── CHANGELOG.md
├── README.md
├── babel.config.json
├── commitlint.config.js
├── config
│   ├── doc-webpack-plugin.ts                         // 生成文档 webpack 插件
│   ├── doc.json
│   ├── docgen.ts
│   ├── webpack.base.config.ts
│   ├── webpack.dev.config.ts
│   └── webpack.prod.config.ts
├── index.html
├── package.json
├── react-app-env.d.ts
├── site                                              // 网站相关
│   ├── components
│   │   ├── Api
│   │   │   ├── index.tsx
│   │   │   └── style.scss
│   │   ├── Code
│   │   │   └── index.tsx
│   │   ├── Demo
│   │   │   ├── index.tsx
│   │   │   └── style.scss
│   │   ├── Index.tsx
│   │   └── Mobile.tsx
│   ├── index.tsx
│   └── mobile.tsx
├── src                                              // 组件源码相关
│   ├── components
│   │   ├── Button
│   │   │   ├── Item.tsx
│   │   │   ├── button.scss
│   │   │   ├── demo
│   │   │   │   ├── demo-1.tsx
│   │   │   │   ├── demo-2.tsx
│   │   │   │   └── index.tsx
│   │   │   └── index.tsx
│   │   ├── Icon
│   │   │   ├── icon.scss
│   │   │   └── index.tsx
│   │   └── index.ts
│   ├── index.tsx
│   └── utils.ts
├── tsconfig.json
└── webpack.config.js

11 directories, 36 files

约定 demo 与组件源码 components 放在一起 先来分析下我们需要怎样的数据结构才能实现上面的效果 肯定需要知道是什么组件Button,然后这个组件上面有什么属性props,这个组件相关的demo,拿到了数据渲染页面就简单了。

{
  "Button": {
    "props": {
      "props1": "xxx",
      "props2": "xxx"
    },
    "demos": {
      "demo-1": "xxx",
      "demo-2": "xxx"
    }
  }
}

那我们要怎么来生成数据了?

我们想要生成组件的 props interfaceapi 文档可以借助 react-docgen-typescript 这个包来实现,那我们想要实时更新类似于webpack-dev-server的效果,我们可以用webpack 插件 watchRun hooks 来实现这个效果生成对应的 doc.json 这样就生成了数据

插件

生成文档的核心逻辑主要是在这个 webpack 插件上

  1. react-docgen-typescript 生成api
  2. fs 读取 demo 文件夹下的代码
  3. fs 写入数据到 doc.json 供渲染器使用
// config/doc-webpack-plugin.ts
// 根据 components 下面组件的源码和 demo 生成对应api和 demo 代码
import * as fs from 'fs';
import * as path from 'path';
import glob from 'glob';

import { withCustomConfig } from 'react-docgen-typescript';

const join = (p) => path.join(__dirname, '../', p);

const OUTPUT_DIR = join('config/doc.json');

const tsconfigPath = path.join(process.cwd(), 'tsconfig.json');
const parser = withCustomConfig(tsconfigPath, {});

const getComponentsDirName = (paths) => {
    let dir: string[] = [];
    const componentsPath = path.resolve('src/components/');
    if (!paths || !paths.length) {
        dir = glob.sync(join('src/components/*/'));
    } else {
        dir = paths.filter((item) => item.startsWith(componentsPath));
    }

    const names = dir.map(
        (item) => item.replace(componentsPath, '').split('/')[1]
    );
    return names;
};

const getComponentInterfaceProps = (component) => {
    const filePath = join(`src/components/${component}/index.tsx`);
    const content = parser.parse(filePath);
    const item = content[0];

    return item.props;
};

const getComponentDemoCodes = (component) => {
    const demosPath = glob.sync(
        join(`src/components/${component}/demo/demo-*.tsx`)
    );

    return demosPath.reduce((acc, cur) => {
        const key = cur.replace(/.+\/(demo-\d)\.tsx$/, (_, a) => a);
        acc[key] = fs.readFileSync(cur).toString();
        return acc;
    }, {});
};

const getComponentData = (component) => {
    const props = getComponentInterfaceProps(component);
    const demos = getComponentDemoCodes(component);
    return { props, demos };
};

const getComponentsData = (components) => {
    components = getComponentsDirName(components);
    if (!components?.length) return;

    const data = components.reduce((acc, component) => {
        acc[component] = getComponentData(component);
        return acc;
    }, {});

    console.log('---- update docs.json ----', components);
    fs.writeFileSync(OUTPUT_DIR, JSON.stringify(data, null, 2));
};

class DocWebpackPlugin {
    apply(compiler) {
        compiler.hooks.entryOption.tap('DocWebpackPlugin', () => {
            console.log('---- DocWebpackPlugin entryOption ----');
            getComponentsData(null);
        });

        compiler.hooks.watchRun.tap('DocWebpackPlugin', (compiler) => {
            console.log('----  DocWebpackPlugin watchRun ----', compiler.modifiedFiles);
            const modifiedFiles = compiler.modifiedFiles;
            if (modifiedFiles && modifiedFiles.has(OUTPUT_DIR)) {
                return;
            }

            modifiedFiles && getComponentsData([...modifiedFiles]);
        });
    }
}

export default DocWebpackPlugin;

使用该插件

// webpack.base.config.ts
import DocWebpackPlugin from './doc-webpack-plugin';

plugins: [
    new DocWebpackPlugin(),
    // other webpack plugin
    ...
]

最终生成的doc.json格式大概如下

// config/doc.json
{
  "Button": {
    "props": {
      "size": {
        "defaultValue": null,
        "description": "尺寸大小",
        "name": "size",
        "parent": {
          "fileName": "react-mobile-ui/src/components/Button/index.tsx",
          "name": "IButtonProps"
        },
        "declarations": [
          {
            "fileName": "react-mobile-ui/src/components/Button/index.tsx",
            "name": "IButtonProps"
          }
        ],
        "required": true,
        "type": {
          "name": "\"small\" | \"medium\" | \"large\" | \"xxx\""
        }
      }
    },
    "demos": {
      "demo-1": "import Button from '..';\nimport React from 'react';",
      "demo-2": "import React, { memo } from 'react';"
    }
  }
}

API

生成 api 表格

// Api.tsx
import React, { memo } from 'react';
import doc from '@config/doc.json';

import './style.scss';

interface IApiProps {
  componentName: string;
  [key: string]: any;
}

const columns = ['name', 'type', 'defaultValue', 'description'];

/**
 * Api Component discription
 */
const Api: React.FC<IApiProps> = (props: IApiProps) => {
    const { componentName } = props;

    const data = Object.values(doc[componentName].props).map(
        ({ type, ...rest }: any) => ({
            ...rest,
            type: type.name,
        })
    );

    return (
        <section className="api">
            <h2>API</h2>
            <table>
                <thead>
                    <tr>
                        {columns.map((item) => (
                            <th key={item}>{item}</th>
                        ))}
                    </tr>
            </thead>
            <tbody>
                    {data.map((item: any) => {
                        return (
                            <tr key={item.name}>
                                {columns.map((col) => (
                                    <td key={item[col]}>{item[col]}</td>
                                ))}
                            </tr>
                        );
                    })}
                </tbody>
            </table>
        </section>
    );
};

export default memo(Api);

Code

通过 react-syntax-highlighter 生成高亮代码块

// Code.tsx
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
import React, { memo } from 'react';

interface ICodeProps {
  code: string;
  [key: string]: any;
}

/**
 * Code Component discription
 */
const Code: React.FC<ICodeProps> = (props: ICodeProps) => {
    const { code } = props;

    return (
        <SyntaxHighlighter language="typescript" style={oneLight}>
            {code}
        </SyntaxHighlighter>
    );
};

export default memo(Code);

Demo

生成 demo

// Demo.tsx
import { Tooltip } from 'demo';
import React, { memo, useLayoutEffect, useRef, useState } from 'react';
import cls from 'classnames';
import doc from '@config/doc.json';

import Code from '../Code';

import './style.scss';

interface IDemoProps {
  title: React.ReactNode;
  componentName: string;
  demoName: string;
  children: React.ReactNode;
  [key: string]: any;
}

/**
 * Demo Component discription
 */
const Demo: React.FC<IDemoProps> = (props: IDemoProps) => {
    const { children, componentName, demoName, title = 'demo__title' } = props;
    const code = doc?.[componentName]?.demos?.[demoName];

    const codeRef = useRef<HTMLDivElement>(null);
    const [collapsed, setCollapsed] = useState<boolean>(false);

    const codeClassNames = cls('demo__code', { collapsed });

    const handleCodeClick = () => {
        setCollapsed((prev) => !prev);
    };

    const handleCodeCopy = async () => {
        try {
            await navigator.clipboard.writeText(code);
            console.log('---- copy successed  ----');
        } catch (error) {
            console.log('---- copy failed  ----');
        }
    };

    useLayoutEffect(() => {
        const el = codeRef.current;
        if (!el) return;
        const height = el.scrollHeight + 'px';
        el.style.setProperty('--code-max-height', height);
    }, []);

    return (
        <section className="demo">
            <div className="demo__title">
                <h3>{title}</h3>
                <div className="demo__operator">
                    <Tooltip title="展开">
                        <a href="javascript:;" onClick={handleCodeClick}>
                            <svg
                                xmlns="http://www.w3.org/2000/svg"
                                width="24"
                                height="24"
                                viewBox="0 0 24 24"
                                fill="none"
                            >
                                <rect
                                    x="0.5"
                                    y="0.5"
                                    width="23"
                                    height="23"
                                    rx="3.5"
                                    stroke="#00142A"
                                    strokeOpacity="0.12"
                                />
                                <path d="M10 8L6 12L10 16" stroke="black" />
                                <path d="M14 8L18 12L14 16" stroke="black" />
                            </svg>
                        </a>
                    </Tooltip>

                    <Tooltip title="复制代码">
                        <a href="javascript:;" onClick={handleCodeCopy}>
                            <svg
                                xmlns="http://www.w3.org/2000/svg"
                                width="24"
                                height="24"
                                viewBox="0 0 24 24"
                                fill="none"
                            >
                                <rect
                                    x="0.5"
                                    y="0.5"
                                    width="23"
                                    height="23"
                                    rx="3.5"
                                    stroke="#00142A"
                                    strokeOpacity="0.12"
                                />
                                <path
                                    fillRule="evenodd"
                                    clipRule="evenodd"
                                    d="M18 14V15H15V16H14V11C14 10.4477 13.5523 10 13 10H8C7.44772 10 7 10.4477 7 11V16C7 16.5523 7.44772 17 8 17H13H15V18H6V9H9V6H18V13H17V8C17 7.44772 16.5523 7 16 7H11C10.4477 7 10 7.44772 10 8V9H15V14H16H18Z"
                                    fill="#00142A"
                                />
                            </svg>
                        </a>
                    </Tooltip>
                </div>
            </div>
            <div className="demo__component">{children}</div>
            <div className={codeClassNames} ref={codeRef}>
                <Code code={code} />
            </div>
        </section>
    );
};

export default memo(Demo);

其中代码的展开收起使用的 max-height 属性实现 css

 demo__code {
    max-height: 0;
    overflow: hidden;
    transition: 0.3s all ease;

    &.collapsed {
      max-height: var(--code-max-height);
    }
  }

js

useLayoutEffect(() => {
    const el = codeRef.current;
    if (!el) return;
    const height = el.scrollHeight + 'px';
    el.style.setProperty('--code-max-height', height);
}, []);

生成对应组件文档

// components/Button/demo/index.tsx
import Api from '@site/components/Api';
import Demo from '@site/components/Demo';

import Demo1 from './demo-1';
import Demo2 from './demo-2';
import React, { memo } from 'react';

interface IButtonDemoProps {
  [key: string]: any;
}

/**
 * ButtonDemo Component discription
 */
const ButtonDemo: React.FC<IButtonDemoProps> = () => {
    return (
        <div>
            <Demo componentName="Button" demoName="demo-1" title="Demo-1">
                <Demo1 />
            </Demo>
            <Demo componentName="Button" demoName="demo-2" title="Demo-2">
                <Demo2 />
            </Demo>
            <Api componentName="Button" />
        </div>
    );
};

export default memo(ButtonDemo);

至此,一个简单的组件文档就生成了

结语

从零到一搭建一个组件库还是需要很多的技能知识储备的,在这过程中会遇到很多的问题,刚好可以查漏补缺,有时间的话尽量尝试一下。工程化这块尤其重要,一个简单的文档生成器,我们就可以学到很多东西,比如怎么书写一个webpack插件自动生成文档,代码高亮等等。