nextjs构建自己的react组件库(二)- 文档的核心:markdown的渲染支持

385 阅读9分钟

导读

本文所述依赖如下的库包及其版本

包名版本号
next14.2.15
react18.2.0
react-dom18.2.0
@mdx-js/lodaer3.1.0
@mdx-js/react3.1.0
@next/mdx15.0.2
@types/mdx2.0.13
remark-gfm4
remark-frontmatter5.0.0
rehype-highlight7.0.1
next-mdx-remote5.0.0

本文的开发环境基于 Macbook Pro M1 MacOS 14.6.1。

本地渲染支持

由于我们的文档除了从packages/**加载的动态文档,还有next.js内部固定的文档。让我们先实现next.js内部的markdown解析和mdx的支持。

安装依赖与本地配置

参考官方网站的配置

pnpm add @next/mdx @mdx-js/loader @mdx-js/react @types/mdx

配置文件参考:


    import createMDX from '@next/mdx'/** @type {import('next').NextConfig} */
    const nextConfig = {
        reactStrictMode:true,
        output:'standalone',
        images:{
            //github pages 无法对图像优化
            unoptimized:true
        },
        //都是对应仓库名<reposity-name>
        // basePath:"/react-components",
        // assetPrefix:"/react-components",
        //支持这些后缀作为文件名
        pageExtensions:["js","jsx","ts","tsx","md","mdx"]
    };
    ​
    const withMDX = createMDX({
        extension: /.mdx?$/,
        // Add markdown plugins here, as desired
    })
    ​
    export default withMDX(nextConfig);

注意如上的配置中的extension: /.mdx?$/ ,其表示以.md.mdx为后缀的页面会被next.js解析。

在项目的根目录下添加文件: mdx-components.tsx

import type { MDXComponents } from 'mdx/types'

export function useMDXComponents(components: MDXComponents): MDXComponents {
   return {
     ...components,
   }
}

接着,添加 app/docs/page.md页面,输入一些内容:

 # 导读

本文基于以下包和版本配置:

|              包名               | 版本号  |
| :-----------------------------: | :-----: |
|              next               | 14.2.15 |
|              react              | 18.2.0  |
|            react-dom            | 18.2.0  |
|           tailwindcss           |  3.4.1  |
|         @changesets/cli         | 2.27.9  |
|         @commitlint/cli         | 19.5.0  |
| @commitlint/config-conventional | 19.5.0  |
|              husky              |  9.1.6  |
|           typescript            |  5.4.4  |
本文介绍的开发环境是**Macbook Pro M1 MacOS 14.6.1**# 项目启动与打包验证

## 创建项目

创建项目,使用next 14.2.15

```bash
npx reate-next-app@14.2.15
```
使用app router的模式

![](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/59fc7fb6d4574b68a6dd585dcd4a5028~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgS2Fpc2VyS1g=:q75.awebp?rk3s=f64ab15b&x-expires=1752056490&x-signature=SDbc%2F4SBoiaRRMdzFufuLScv20Q%3D)

于是,可在http://lcoalhost:3000/docs路径下看到

如果把上面next.config.js配置文件中的extension: /.mdx?$/干掉,你就会得到一个next.js提示的编译错误:

插件的使用

注意到:上方的文档样式着实太丑了,且断行不对、代码没有格式化🤢;因此我们可以选择使用一些插件,本部分参考官方文档。 remark用来处理markdown文档,用来进行ast解析等,可以在github中找到有趣的插件。 rehype用来处理html,可在github中找到有趣的插件。 本节使用的插件配置如下:

import createMDX from '@next/mdx'
import remarkGfm from 'remark-gfm'
import remarkFrontmatter from 'remark-frontmatter'
import rehypeHighlight from 'rehype-highlight'
...
const withMDX = createMDX({
    extension: /.mdx?$/,
    options:{
        //处理md象github那样,出来formatter语法
        remarkPlugins:[remarkGfm,remarkFrontmatter],
        rehypePlugins:[rehypeHighlight]
     }
    // Add markdown plugins here, as desired
})

**src/layout.tsx**文件中新增highlight的样式文件

import "highlight.js/styles/lightfair.css"

重新访问 http://lcoalhost:3000/docs

看起来确实美观得多了🎆。

按照官网的教程,这里的page.md还可以写成page.mdx,这里就不再赘述,请自行查阅官方文档。

加载其它库包下的文档

前置知识串讲

上文的内容几乎都是官方文档中的内容,而我们真正要做的,是加载来自**packages/**/docs/index.md**这个路径下的文档。以项目为例,需要加载packages/image-gallery/docs/index.md,并显示在页面上,

目标是访问/docs/image-gallery时能加载这个markdown文件,也就是packages/image-gallery/docs/index.md

这里要重点说明:由于组件库包都是已知的,对应的文档就是已知的,加上我们的页面还是部署在github pages上,且应该是个静态的页面,每次更新文档或者组件库都会重新构建。这类构建方式是SSG,而非SSR服务端渲染。

如果你不了解什么是SSR,什么是SSG,傻傻分不清楚,请看看这篇文章《一文搞懂:什么是SSR、SSG、CSR?前端渲染技术全解析》

为了将我们的应用以SSG的方式构建,我们需要在next.config.js将output设置为 export

接着,要构建SSG,我们肯定要告知next.js 当前存在哪些页面,也就是明确哪些组件库是有文档的。

由于我们最终通过/docs/[slug]路径访问,这个[slug]可能是image-gallery也可能是color-pciker,因此[slug]是个动态路径,于是我们的next.js也需要使用动态路由的方式构建我们的文档(PS:如果你对nextjs的路由不够了解,可以参考@神说要有光 大佬《Next.js 的路由为什么这么奇怪?》)。

于是新建一个src/app/docs/[slug]/page.tsx页面文件,其除了页面渲染函数外,还有一个用于SSG指定构建路径的函数 generateStaticParams(),该函数在next.js以SSG构建时执行。详情参考官方文档

一开始,如果没有指定generateStaticParams()函数,启动页面就会报错:

加上这个generateParams()函数,但是只返回空数组:

又是不同的报错信息:

给定一个默认的对象,并在页面正常时显示 slug的匹配值:

此时页面正常显示:

但是!访问/docs/image-gallery 还是不行的。

除非我们也把它加上我们的generateStaticParams()函数的返回值:

这下页面也正常了!

到了这一步,你可能对generateStaticParams()和动态路由[slug]有了一定的了解,其底层其实是一种**results.include([slug])**匹配机制。而且,假若我们需要对packages/**下的每个组件库更新文档,每次都要来到这个src/app/docs/[slug]/page.tsx文件下,修改一下generateStaticParams()函数的返回值,你为了简化操作可能会有:

但是这样操作显然不太友好,这样的命令式更新实在是蛋疼(看着就头疼)!

而大多数组件库,如果你更新文档都是去仓库编辑对应的.md文件就好了,并不会要求开发者做复杂的额外配置。因此,我们需要约定一种方式,实现声明式地更新文档。

动态读取可用路径并加载.md文档的内容

其实,上文中,已经对这个声明式做了说明 packages/[slug]/docs/index.md,我们构建时自动识别这样的路径,取出[slug]的值并以合法的形式作为generateStaticParams()的返回值。

首先,读取packages路径下所有的路径,如果子路径存在/docs/index.md 这样的文件,我们就过滤为一个数组,并以合法的格式返回。

详细的代码为:

import fs from "fs"
import path from "path"
export async function generateStaticParams(props){
  const files =fs.readdirSync(path.join(process.cwd(),'packages'))         .filter(file=>fs.existsSync(path.join(process.cwd(),'packages',file,'docs','index.md')))
   return files.map(file => ({ slug: file } ))
}

export default function Page(props){
    const {params} = props
    return <div>slug:{params.slug}</div>
  }

接着我们的页面就是能正常访问的了。

接着,在Page()页面渲染函数,我们确保了已经拿到合法的路径,到这里,我们可以直接读取这份.md文件。

Page()函数代码如下:

export default async function Page({params}:{params:{slug:string}}){
   const slug = params.slug;
   const content = await fs.promises.readFile(path.join(process.cwd(),'packages',slug,'docs','index.md'), 'utf-8');
   return <>
            <div>slug:{params.slug}</div>
            <div>{content}</div>
     </>
}

到这里,我们已经打通了只要每新增组件,并按照要求放置.md文档(packages/**/docs/index.md),就可以实现动态访问了。

但是目前,我们加载的是原生的markdown文件内容,并没有处理为正确的html并支持组件化渲染,要实现这一点,这是下一节中的内容。

安装next-mdx-remote

上一节实现了加载packages/**/docs/index.md这个路径下的文档,本文将继续探索完成html部分的渲染。

第一步,参考官方的教程,我们使用next-mdx-remote

安装下载它:

pnpm add next-mdx-remote -w

之后在src/app/docs/[slug]/page.tsx页面中使用:

import {MDXRemote} from "next-mdx-remote/rsc"
...
export default async function Page({params}:{params:{slug:string}}){
   const slug = params.slug;
   const content = await fs.promises.readFile(path.join(process.cwd(),'packages',slug,'docs','index.md'), 'utf-8');
   return <>
            <div>slug:{params.slug}</div>
            {/*<div>{content}</div>*/}
            <MDXRemote source={content} />
      </>
}

打开浏览器,访问/docs/color-picker,看到渲染成功了

必须要强调的是:import 是从next-mdx-remote/rsc导入的MDXRemote,而不是直接从next-mdx-remote导入。虽然next-mdx-remote也可以导出MDXRemote组件,但是用法完全不同。具体异同笔者也不是很清楚,可以参考官方文档自行研究

远程加载后的文档美化

接着,笔者又发现一新的问题,在上述部分(本地渲染支持)中,笔者在next.config.js中配置的remarkPluginsrehypePlugins没有生效。因为它们只处理本地加载的.md或者.mdx文档,不处理“远程”加载的文件内容,于是,该项目也需要配置这些插件。

参考代码如下:

    import fs from "fs"
    import path from "path"
    import {MDXRemote} from "next-mdx-remote/rsc"
    import remarkGfm from "remark-gfm";
    import remarkFrontmatter from "remark-frontmatter";
    import rehypeHighlight from "rehype-highlight";

    export async function generateStaticParams(props){
        const files =fs.readdirSync(path.join(process.cwd(),'packages'))
            .filter(file=>fs.existsSync(path.join(process.cwd(),'packages',file,'docs','index.md')))
        return files.map(file => ({ slug: file } ))
    }


export default async function Page({params}:{params:{slug:string}}){
    const slug = params.slug;
    const content = await fs.promises.readFile(path.join(process.cwd(),'packages',slug,'docs','index.md'), 'utf-8');
    return <>
            <div>slug:{params.slug}</div>
            {/*<div>{content}</div>*/}
            <MDXRemote source={content} options={{
                mdxOptions:{
                    remarkPlugins:[remarkGfm,remarkFrontmatter],
                    rehypePlugins:[rehypeHighlight]
                }
            }}/>
        </>
}

可以看到,MDXRemote里的配置和next.config.js完全一致,继续刷新浏览器,可以看到上线了正确的断行还有highlight代码块解析。

为了后续方便,笔者将MDXRemote的使用封装为组件RemoteConntent,并放置到/src/components/marddown/RemoteContent.tsx

import remarkGfm from "remark-gfm";
import remarkFrontmatter from "remark-frontmatter";
import rehypeHighlight from "rehype-highlight";
import {MDXRemote} from "next-mdx-remote/rsc";
const RemoteContent:React.FC<{source:string}>=({source})=>{
     return <MDXRemote source={source} options={{
         mdxOptions:{
             remarkPlugins:[remarkGfm,remarkFrontmatter],
             rehypePlugins:[rehypeHighlight]
        }
        }}/>
    }
export default RemoteContent

具体使用,在/src/app/docs/[slug].page.tsx中,使用RemoteContent替换MDXRemote

import RemoteContent from "@/components/markdown/RemoteContent"
...
export default async function Page({params}:{params:{slug:string}}){
     const slug = params.slug;
     const content = await fs.promises
     .readFile(path.join(process.cwd(),'packages',slug,'docs','index.md'), 'utf-8');
     return <>
          <div>slug:{params.slug}</div>
            {/*<div>{content}</div>*/}
          <RemoteContent source={content} />
     </>
}

在这需要强调的是,使用rehypeHighlight只是对代码块转换后打上类似hljs-XX的类名标记,并不引入css样式文件,因此你需要额外的引入代码高亮的样式文件。有两种引入方式: 第一种是直接下载highlight.js这个库后引入响应的样式:

import "highlight.js/styles/atom-one-light.css"

第二种也可以通过CDN直接导入相应的样式文件: 在对应页面最近的layoyt.tsx文件中导入

<Link ref="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/styles/atom-one-light.css"></Link>

点击此处可以查看更多的css文件

这里使用的jsdelivr如果国内访问存在问题,也可以使用字节的CDN有关的样式文件

至此,markdown解析的集成算是搞定了,按上一篇的打包说明,尝试看看打包后是正常的不。

执行 pnpm build命令后,得到了一个报错:

这是我们的项目中ts不允许使用any,在项目根目录下的tsconfig.json

文件中新增 noImplicitAny:false即可

可以看到,打包是成功了的。

接着,我们cd 到打包生成的out目录,它就是我们使用SSG模式打包后输出的所有静态资源。

允许serve命令,可以通过npm i -g serve安装,它将帮助我们在此目录下生成一个web服务器,就好像配置了nginx一样,接着就可以在浏览器中验证我们的功能了!

在浏览器中访问 http://localhost:3000/docs/image-gallery,显示是正常的。

说明引入的markdown功能开发和部署下都是没问题的,就可以放心地把代码推送到仓库了。

可通过github查看相关的代码,可回退到本次提交记录。(git reset --hard 3c0361a)

本文小结

本文介绍了next.js 如何加载next.js项目中的.md的markdown文档和从外部加载markdown字符串并解析。

接着,声明式指定加载packages/**/docs/index.md路径的markdown字符串,并使用remark.js和rehype.js插件来实现markdown的美化和代码高亮。

参考文档

  1. 一文搞懂:什么是SSR、SSG、CSR?前端渲染技术全解析
  2. Next.js 的路由为什么这么奇怪?
  3. 使用 Next.js 搭建 Monorepo 组件库文档