导读
本文所述依赖如下的库包及其版本
包名 | 版本号 |
---|---|
next | 14.2.15 |
react | 18.2.0 |
react-dom | 18.2.0 |
@mdx-js/lodaer | 3.1.0 |
@mdx-js/react | 3.1.0 |
@next/mdx | 15.0.2 |
@types/mdx | 2.0.13 |
remark-gfm | 4 |
remark-frontmatter | 5.0.0 |
rehype-highlight | 7.0.1 |
next-mdx-remote | 5.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的模式

于是,可在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
中配置的remarkPlugins
和rehypePlugins
没有生效。因为它们只处理本地加载的.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>
这里使用的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的美化和代码高亮。