说明
经过上阶段的配置虽然可以在项目中使用 mdx 语法 来创建页面了,但是我们的组件库有一些定制化的需求:交互式的组件演示、组件 Props 列表展示。这些功能如果可以通过封装来实现,会大大提升开发效率
Props 与 Playground 的对外接口,将会仿照 docz 的 Props Playground
相关文章
1. Props 控件实现
Props 是用来展示组件属性列表的,参考 docz 提供的 api 实现, 我们需要将组件传给 这个控件,让这个控件获得 属性列表后展示
我们的组件是通过 Typescript 开发的,在组件 build 后 TS 相关的类型声明会被剥离,因此,传入 Props 控件中的组件需要是未编译的 ts 文件,这一点可以通过上述 webpack alias 来实现
config.resolve.alias = {
...config.resolve.alias,
"@mjz-test/mjz-ui": path.resolve(__dirname, "../../../mjz-ui/src"), // 配置去获取源文件, 这样可以通过解析拿到组件的 TS 类型
"@mjz-test/icons": path.resolve(__dirname, "../../../icons/src"),
}
虽然使用了 ts 的源文件,但是组件本身的携带的数据中并没有 props 列表,好在社区中有一个插件可以帮助我们将组件的 props 解析出来,并且挂载到组件对象上
/** ----- next.config.js ----- */
module.exports = withMDX({
webpack(config, nextConfig) {
// add less compile
config = injectLessLoader(config, nextConfig);
// add alias
config = injectAlias(config);
// include docgen rule
config = addJsxInclude(config, nextConfig.defaultLoaders);
return config
},
});
/** ----- addJsxInclude.js ----- */
const path = require('path');
const cloneDeep = require('lodash/cloneDeep');
const addJsxInclude = (config, defaultLoaders) => {
// 1. 找到 webpack 配置中处理 ts 的那个 rule
const jsxRuleIdx = config.module.rules.findIndex((rule) => rule.test instanceof RegExp && rule.test.test('xxx.tsx'));
const jsxRule = config.module.rules[jsxRuleIdx];
const rule = cloneDeep(jsxRule);
// 创建一个新的 rule 专门处理 需要显示 props 的组件
rule.test = /\.(tsx|ts)$/;
rule.include = [
path.resolve(__dirname, '../../../next-docs/docs'),
path.resolve(__dirname, '../../../mjz-ui'),
path.resolve(__dirname, '../../../icons')
];
rule.use = [
defaultLoaders.babel, // next 自带的 babel 处理程序
{
loader: 'react-docgen-typescript-loader',
options: {
tsconfigPath: path.resolve(__dirname, "../../tsconfig.json"),
// 这个配置可以使得 enum 类型转为 union 类型输出
shouldExtractLiteralValuesFromEnum: true,
// 排除对原生标签属性的打包,HTML 原生标签属性都是从 @types/react 继承出来的, 通过以下操作排除打包
propFilter: (prop) => {
if (prop.declarations !== undefined && prop.declarations.length > 0) {
const hasPropAdditionalDescription = prop.declarations.find((declaration) => {
return !declaration.fileName.includes("node_modules");
});
return Boolean(hasPropAdditionalDescription);
}
return true;
}
}
}
]
config.module.rules.splice(jsxRuleIdx, 0, rule);
return config;
}
module.exports = addJsxInclude;
经过上边的
react-docgen-typescript-loader
处理后,每个导入的组件就都包含__docgenInfo
属性了,我们预计通过<Props of={Button}/>
来获得组件的props 展示,下边是 Props 控件的实现
import React, { ComponentType } from 'react';
import get from 'lodash/get';
import isObject from 'lodash/isObject';
export type ComponentWithDocGenInfo = ComponentType & {
__docgenInfo: {
description?: string;
props?: Record<string, DocProp>;
};
};
interface PropsProps {
of: ComponentWithDocGenInfo;
}
const hasSymbol = typeof Symbol === 'function' && Symbol.for;
const ReactMemoSymbol = hasSymbol
? Symbol.for('react.memo')
: typeof React.memo === 'function' && React.memo(() => null).$$typeof;
class Props extends React.PureComponent<PropsProps> {
getDocProps() {
const { of } = this.props;
const commonProps = get(of, '__docgenInfo.props');
const memoProps = get(of, 'type.__docgenInfo.props');
// React.memo 包裹的组件有些特殊
if (ReactMemoSymbol && get(of, '$$typeof') === ReactMemoSymbol) {
if (!isObject(memoProps) && !isObject(commonProps)) return [];
return isObject(memoProps) ? Object.values(memoProps) : Object.values(commonProps);
}
return isObject(commonProps) ? Object.values(commonProps) : [];
}
getDocDefaultProps() {
const { of } = this.props;
const commonDef = get(of, 'defaultProps');
const memoDef = get(of, 'type.defaultProps');
if (ReactMemoSymbol && get(of, '$$typeof') === ReactMemoSymbol) {
if (!isObject(memoDef) && !isObject(commonDef)) return {};
return isObject(memoDef) ? memoDef : commonDef;
}
return isObject(commonDef) ? commonDef : {};
}
render(): React.ReactNode {
const docProps = this.getDocProps();
const docDefaultProps = this.getDocDefaultProps();
return (
<div>
{docProps.map((docProp) => {
return (
<div>
<p>{docProp.name}</p>
<p>{docProp.description}</p>
<p>{getTypeStr(docProp.type)}</p>
<p>{getDefaultValue(docProp.defaultValue)}</p>
<p>{docProp.required}</p>
</div>
);
})}
</div>
);
}
}
export default Props;
2. Playground 控件实现
Playground 控件用来提供一个组件的交互式演示区域,这个区域包括:组件展示、源码展示、源码编辑后实时更新;
这个控件我们会借助社区中的
react-live
+ 一些定制来实现,react-live
提供的 LiveProvider 中需要传入两个重要属性 code (源码) scope(源码中的一些上下文)
组件 code 与 scope 的收集比较棘手,我们需要借助 mdx 的插件系统,来开发一个自定义插件,我参考了 docz 的实现(copy),整体的思路是:
- 在 mdx 被编译为 js 的过程中,mdx 文件中 的 Playground 组件会被单独处理
- 传入 Playground 的 children 会被转为字符串后作为 __code 属性传入
- 会在接卸 mdx 的 AST 树中拿到 import 的资源作为 __scope 属性传入
具体插件实现代码
插件如何使用
const rehypePlugin = require('./scripts/rehyoe-plugin/rehype');
const withMDX = require("@next/mdx")({
extension: /\.mdx?$/,
options: {
remarkPlugins: [],
rehypePlugins: [rehypePlugin]
}
});
module.exports = withMDX({/.../});
我们期待 Playground 组件只需要传入 children 即可,其实现如下
import React from 'react';
import { Language, PrismTheme } from 'prism-react-renderer';
import { LiveProvider, LiveError, LiveEditor, LivePreview } from 'react-live';
import { mdx } from '@mdx-js/react';
import { theme } from '../Code';
import PlaygroundLess from './playground.module.less';
interface Props {
hideCode?: boolean;
__code: string;
__scope: Record<string, any>;
__position: number;
language?: Language;
}
const transformCode = (code: string) => {
if (code.startsWith('()') || code.startsWith('class')) return code;
return `<React.Fragment>${code}</React.Fragment>`;
};
class Playground extends React.PureComponent<Props> {
static defaultProps = {
language: 'jsx',
__code: '',
__scope: {},
__position: 0,
};
render(): React.ReactNode {
const { __scope, __code, language } = this.props;
return (
<div className={PlaygroundLess.container}>
<LiveProvider
className={PlaygroundLess.provider}
code={__code}
transformCode={transformCode}
language={language}
theme={theme as PrismTheme}
scope={{ mdx, ...__scope }}
>
<LivePreview />
<LiveEditor />
<LiveError />
</LiveProvider>
</div>
);
}
}
export default Playground;
如果需要对 编辑及展示区域做更多的定制化,可以自由组合
<LivePreview /> <LiveEditor />
,或者可以仿照 其源码,做自己的实现
3. Anchor 页面锚点收集控件实现
一个组件的文档页可能会比较长,一般来讲都会实现一个吸顶的 menu 区域来做锚点跳转
为了自动化的实现这个功能,我们需要如下操作
引入
remark-slug
,他可以作为 mdx 的插件来使用,其作用是将 1~5 级标题,转化为带有 id 的组件
const slug = require('remark-slug');
const withMDX = require("@next/mdx")({
extension: /\.mdx?$/,
options: {
remarkPlugins: [slug],
rehypePlugins: [rehypePlugin]
}
});
module.exports = withMDX({});
转化后的标题怎样在项目中收集呢?前边聊过 mdx 的 MDXProvider 可以通过传入的 components 来映射 markdown 语法对应的组件,这里还包括一个特殊的映射 wrapper
wrapper 是包裹在左右 mdx 解析后组件外层的组件,它接收 children 属性,我们可以通过遍历 children 找出 1~5 级标题的标签(例 child.props.mdxType === ‘h2’)然后将其渲染成一个组件即可。
具体示例查看