前言
在我们使用一个组件库的时候,文档页面是最直接的获取信息的窗口。而文档页一般包含了这些信息:
- 组件的描述
- 组件 Demo 示例的展示、描述和源码
- 组件的参数文档
要是纯组件示例的调试和展示的话,我们当然可以选择像 storybook 这样的工具,不过考虑到美观和按设计图还原的难度,我们还是要考虑自己来写一个可定制性、可拓展性更强的工具。
分析
我们从上面文档页包含的信息来梳理一下我们的需求:
- 最简洁的语法来写页面
- 最简洁的语法来展示 Demo + 源代码 + 示例描述
- 最小成本的维护参数文档
从语法上来说,我们应该首选 markdown 了,语法足够简洁和强大。
展示 Demo 和源码的话,为了能更高效低成本的维护,我们应该把一个示例的 Demo + 源码 + 示例描述 放到一个文件里,尽量多的去复用,减少所需要维护的代码。示例的展示,本质上可以说是跟 markdown 的转译一致,都是 markdown -> html,只是转译的规则我们需要拓展一下。
维护参数文档的话,手动维护会有很多问题:成本大、不容易跟代码同步(每次改动都要手动去改参数文档),所以我们应该考虑自动化的从 ts 声明中去提取信息,形成参数文档。
而 markdown -> html ,我们其实只需要一个 webpack loader 就行了,梳理一下如下的流程:
实现
预处理入口文件
我们的总入口文件是个 markdown 文件,也就是我们生成的页面,整个页面结构如下所示(当然这个结构的顺序是可以调整的):
根据 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,这是我们需要用到的包:
代码的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 形式的组件文档生成,通过这个流程其实解决了我们在写组件文档时的很多痛点:
- 保证参数文档完全跟源代码同步,大大减少了维护成本。
- 官网示例展示、组件调试等一步到位,同时把书写示例的成本降到了最小。只用书写一遍代码,可以同时用于生成官网示例、官网示例源码、快照测试、Github/Gitlab 说明页面。而且这个过程是全自动的,基本可以做到只专注于组件逻辑的书写,很大程度上的减少了开发和维护成本。
- 利用 markdown 文件原生被 gitlab 和 github 解析展示的特点,我们每个组件目录下,相当于都有了一个说明页面,可以看参数和描述等信息。
- 完全可控的官网样式。
如果你也在开发 React 组件库,或者需要展示 React 相关的组件示例,那么本篇文档可能会帮到你。