基于 next.js + mdx 搭建组件库文档项目(二) -- mdx 控件封装实现组件的演示与 Props 列表

说明

经过上阶段的配置虽然可以在项目中使用 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’)然后将其渲染成一个组件即可。

具体示例查看

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值