一、概述
本文主要通过实际使用案例讨论next-mdx-remote在Nextjs的App Router中的SSR用法,官方教程中没有明确提到在App Router中的用法(功能上支持App Router)。
由于Next.js在处理next-mdx-remote的代码分割client和server代码时容易出现预期之外的情况,从而引发报错。特别是需要Node.js环境下的代码(fs文件模块等)。
二、介绍
next-mdx-remote是一款可以加载远程MDX资源的工具,非常适合加载网络资源,动态资源等。
配合Next的动态路由可以非常方便的实现资源映射。
仓库链接☞ github.com/hashicorp/n…
对于MDX.js,官方介绍如下:
MDX 是一种书写格式,允许你在 Markdown 文档中无缝地插入 JSX 代码。 你还可以导入(import)组件,例如交互式图表或弹框,并将它们嵌入到 你所书写的内容当中。 这让利用组件来编写较长的内容成为了一场革命。 🚀
它的特点在于可以在markdown文件中嵌入JSX组件,并且将md/mdx文档渲染为JSX组件。
具体可以查看☞MDX官方文档
如需在Next.js项目中使用MDX,可参考: ☞ Next.js官方文档中的配置及其方法
三、示例
需求
通过访问 /docs/* 解析动态路由链接获取项目内public/docs目录下的md/mdx资源
思路
通过 fs 文件模块读取映射路由对应的MDX资源
实现
为了减少使用过程中的坑和一些问题,这里从零开始实现此需求。
1. 初始化项目
npx create-next-app@latest remote-mdx-demo
2. 配置mdx
安装必须的mdx依赖
npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
添加mdx解析配置
// next.config.mjs
import createMDX from '@next/mdx'
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['md', 'mdx', 'tsx', 'ts', 'jsx', 'js']
}
const withMDX = createMDX({
extension: /\.mdx?$/,
// Add markdown plugins here, as desired
})
export default withMDX(nextConfig)
添加mdx-components组件(需要在和app文件夹在同一级目录,src/app模式对应src/mdx-components.tsx,app在根目录)
// mdx-components.tsx
import type { MDXComponents } from 'mdx/types'
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
}
}
这里定义了一个react的hooks,只要next.config.mjs配置正确和mdx-components.tsx目录正确,那么项目则会自动使用useMDXComponents hook。
MDX效果预览
移除部分next.js默认的css样式:移除globals.css 文件的以下内容
/* app/globals.css */
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
更改app/page.tsx名称为app/page.mdx,并替换内容为
## 欢迎使用Next.js + MDX.js
运行命令
npm run dev
并在浏览器中打开 localhost:3000 预览mdx功能是否正常
功能正常预览:
3. 配置next-mdx-remote
安装next-mdx-remote依赖
npm install next-mdx-remote
4. 添加动态路由
app目录下创建 docs/[...MDXPath]/page.tsx文件,并添加以下内容
// app/docs/[...MDXPath]/page.tsx
import { MDXRemote } from 'next-mdx-remote/rsc'
import { readMDXFile } from '@/lib/mdx/read'
export default async function Page({ params }: { params: { MDXPath: string[] } }) {
const { source } = await readMDXFile(params.MDXPath)
return <MDXRemote source={source} />
}
这里MDXRemote使用服务器组件(从"next-mdx-remote/rsc"模块导入) MDXRemote组件作为服务器组件,其source属性接受的参数较多,这里是使用fs读取,所以仅展示string用法
这里创建一个readMDXFile方法用于解析动态路由参数以提取对应的MDX文档内容
创建lib/mdx/read.ts文件并添加内容
// lib/mdx/read.ts
"use server"
import fs from "fs"
import path from "path"
import { MDXConfig } from "."
/**
* 根据传入的MDXPath和extensions参数,获取mdx数据
* @param MDXPath 字符串数组或者字符串
* @param extensions 扩展名数组,默认为 MDXConfig.extensions
* @returns mdx原始数据(string) 和 error
*/
export async function readMDXFile(
MDXPath: string[] | string,
extensions: string[] = MDXConfig.extensions,
): Promise<{ source: string | undefined; error: Error | undefined }> {
try {
// 将MDXPath数组转换为完整路径
const fullMDXPath = Array.isArray(MDXPath)
? MDXPath.map((p) => p.trim()).join("/")
: MDXPath.trim()
// 根据MDXPath和extensions,生成路径数组
const paths = extensions.map((extension) =>
path.join(MDXConfig.DOCS_PATH, `${fullMDXPath}.${extension}`),
)
// 读取第一个存在的文件,不存在则抛出异常
const file = await readFirstExistingFileAsync(paths)
return { source: file, error: undefined }
} catch (err: any) {
const error = new Error(
`Failed to load MDX: ${err?.message ? err.message : err}`,
)
return { source: undefined, error }
}
}
/**
* 异步获取列表中第一个存在的文件
*
* @param fileNames 文件名数组
* @returns 第一个存在的文件内容 如果读取文件出错则抛出异常,如果全部无效则返回null
*/
export async function readFirstExistingFileAsync(
fileNames: string[],
): Promise<string | undefined> {
for (const fileName of fileNames) {
try {
// 如果文件不存在,跳过该文件
if (!fs.existsSync(fileName)) continue
// 读取文件内容
const fileContent = await fs.promises.readFile(fileName)
// 返回文件内容
return fileContent.toString("utf-8")
} catch (error: any) {
// 如果错误不是 ENOENT(文件不存在),打印错误信息
if (error?.code !== "ENOENT") {
console.error(`Error reading file "${fileName}":`, error)
}
throw error
}
}
return undefined
}
创建lib/mdx/index.ts文件并添加内容
// lib/mdx/index.ts
export const MDXConfig = {
extensions: ["md", "mdx"],
DOCS_PATH: "public/docs",
MENU_PATH: "public/menu.json",
}
其中MDXConfig具体字段:
| 字段名 | 介绍 |
|---|---|
| DOCS_PATH | 文档资源根目录(用于动态路由映射的资源位置) |
| MENU_PATH | 文档资源结构树存储位置(后续使用) |
这里的配置内容可以根据需求更改,后续示例会以该配置进行讲解
此时你可能注意到readMDXFile可能会返回undefined和error,此时需要处理页面状态边界
// app/docs/[...MDXPath]/page.tsx
// ......
export default async function Page({ params }: { params: { MDXPath: string[] } }) {
const { source, error } = await readMDXFile(params.MDXPath)
if (error) return <div>Load error</div>
if (source === undefined) return <div>file does not unexist or is corrupt</div>
return <MDXRemote source={source} />
}
上面代码为页面添加error边界和undefined边界这样当无效路径和服务器加载出错分别会表现为两种不同状态
5. 添加md/mdx资源
public目录下创建docs目录
新增public/docs/quickstart.mdx文件并添加内容
## 🚀 快速开始
运行命令
```cmd
npm run dev
```
打开浏览器,访问 [http://localhost:3000](http://localhost:3000)
新增public/docs/introduction.md文件并添加内容
## 介绍
这是一个Next.js + MDX.js + next-mdx-remote 的demo项目
预览效果
运行命令
npm run dev
此时在浏览器打开 localhost:3000/docs/quickstart
此时可以看到页面已经动态加载了quickstart.mdx文档的内容,预期效果如下:
此时再尝试加载instruction.md文档,输入链接 localhost:3000/docs/introduction 跳转
预期效果:
🎉🎉至此,需求基本已经达成,mdx和md文件都能够正常进行动态解析
6. 多层嵌套目录
由于app/docs目录下的动态路由是[...MDXPath],默认支持多层路由
同理在readMDXFile方法中参数MDXPath也是支持多层路由
// lib/mdx/read.ts
// ...
export async function readMDXFile(
MDXPath: string[] | string,
extensions: string[] = MDXConfig.extensions,
): Promise<{ source: string | undefined; error: Error | undefined }> {
// ...
}
所以我们仅需要在资源目录下添加多层目录,再通过浏览器访问动态路由即可
多层目录示例
添加public\docs\sub\1.md文件并新增内容
当前路径为/docs/sub/1.md
添加public\docs\sub\sub1\2.md文件并新增内容
当前路径为/docs/sub/sub1/2.md
此时打开浏览器并跳转链接 localhost:3000/docs/sub/1 效果预览
跳转 localhost:3000/docs/sub/sub1/2 效果预览
🎉🎉至此,需求完全已经达成,通过访问 app/docs/* 即可映射访问 public/docs/* 的资源内容
如有兴趣,有继续阅读后续内容
7. 动态路由优化
在实际运行时,可能会发现当浏览器访问不存在的动态路由时(比如/docs/hello),预览如下
虽然我们在代码中处理了不存在此映射资源的情况,但项目依旧会调用readMDXFile尝试读取该路径内容。这样依旧会消耗部分性能。
// app/docs/[...MDXPath]/page.tsx
// ...
export default async function Page({ params }: { params: { MDXPath: string[] } }) {
const { source, error } = await readMDXFile(params.MDXPath)
// ...
if (source === undefined) return <div>file does not unexist or is corrupt</div>
// ...
}
为了让 app/docs动态路由和 public/docs 目录路径保持一致
这里使用Next App Router中的 generateStaticParams方法生成静态路由参数,这样当访问未生成的动态路由时则可以进行限制。
// app/docs/[...MDXPath]/page.tsx
// ...
import { readMDXFile,extractMDXMenuPaths } from '@/lib/mdx/read'
export const dynamicParams = false
export async function generateStaticParams() {
const paths: string[] = await extractMDXMenuPaths()
// 由于MDXPath参数类型为string[],所以这里需要转化路径
const staticPaths = paths.map(path => ({ MDXPath: path.replace(/^\/docs\//, "").split("/") }))
return staticPaths
}
// ...
这里dynamicParams为false代表禁止访问未通过generateStaticParams生成的路由
而extractMDXMenuPaths方法则是遍历public/docs目录并生成一组路径
此时app/docs路由现在和public/docs目录一一对应
extractMDXMenuPaths实现
// lib/mdx/read.ts
"use server"
// ...
/**
* 从MDX menu树形结构中抽取所有叶子节点 可自定义返回内容
*
*@param callback 回调函数,用于处理叶子节点,返回自定义内容
* @returns 所有叶子节点的路径数组
*/
async function extractMDXMenu<T>(callback: (node: TreeNode<NavLink>) => T): Promise<T[]> {
const menu: NavLink = await readMDXMenu()
const results: T[] = []
function traverse(node: TreeNode<NavLink>) {
if (!!node.children?.length) {
for (const child of node.children) {
traverse(child)
}
} else {
const res = callback(node)
results.push(res)
}
}
traverse(menu)
return results
}
/**
* 提取MDX menu中的所有path路径
*
* @returns 路径数组
*/
export async function extractMDXMenuPaths(): Promise<string[]> {
return await extractMDXMenu((node) => node.href)
}
/**
* 从动态生成的MDX menu.json中提取menu数据
*
* @returns menu数据
*/
export async function readMDXMenu(): Promise<NavLink> {
const menuFile = fs.readFileSync(MDXConfig.MENU_PATH)
const menu = JSON.parse(menuFile.toString("utf-8")) as NavLink
return menu
}
这里为了便于管理和使用Menu,可以通过脚本遍历public/docs目录,生成一个node树数据(json),在使用menu时则通过读取这个json数据,而不必每次都遍历目录而带来不必要的性能消耗。
新增lib/mdx/build.ts文件,新增内容
// lib/mdx/build.ts
import path from "path"
import fs from "fs"
import { MDXConfig } from "."
export type TreeNode<T> = T & { children?: TreeNode<T>[] };
/**
* 异步遍历指定目录,构建树形结构,并允许自定义处理逻辑。
*
* @param dir 目标目录的路径
* @param handleItem 处理每个目录项的回调函数,返回处理后的 TreeNode<T>
* @returns 树形结构的数据,类型为 TreeNode<T>
*/
export async function buildMDXMenu<T>(
dir: string,
handleItem: (itemPath: string) => TreeNode<T>
): Promise<TreeNode<T>> {
removeMDXMenu()
const node = handleItem(dir);
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
if (!node.children) {
node.children = [];
}
for (const entry of entries) {
const itemPath = path.join(dir, entry.name);
const childNode = handleItem(itemPath);
if (entry.isDirectory()) {
const subTree = await buildMDXMenu<T>(itemPath, handleItem);
node.children!.push(subTree);
} else {
node.children!.push(childNode);
}
}
return node;
}
export function removeMDXMenu() {
const isExist = fs.existsSync(MDXConfig.MENU_PATH)
if (isExist) {
fs.rmSync(MDXConfig.MENU_PATH)
}
}
这里实现了public/docs目录资源的遍历,但是抽象了每一项生成的内容,可自定义每一个节点的内容
添加生成目录的脚本
新增scripts/mdx.ts文件并新增内容
// scripts/mdx.ts
import { buildMDXMenu, TreeNode } from "@/lib/mdx/build"
import fs from "fs"
import { MDXConfig } from "@/lib/mdx"
import path from 'path'
import { NavLink } from "@/types/link"
export async function generateMDXMenu() {
const { DOCS_PATH, MENU_PATH } = MDXConfig
const handleItemToTreeNode = (itemPath: string): TreeNode<NavLink> => {
const label = path.parse(itemPath).name;
const normalizedPath = itemPath.replace(/\\/g, '/')
const href = normalizedPath.replace(DOCS_PATH.split("/").slice(0, -1).join("/"), '').split(".")[0]
return {
label,
href: href,
children: undefined,
};
};
const tree = await buildMDXMenu<NavLink>(DOCS_PATH, handleItemToTreeNode);
fs.writeFileSync(MENU_PATH, JSON.stringify(tree, null, 2))
}
generateMDXMenu().catch(console.error)
buildMDXMenu接收一个文件夹路径和一个节点处理回调 这里我通过回调将资源路径转化为了可访问的href(例如"public/docs/quickstart.mdx"转化为"/docs/quickstart"),便于导航使用
此处的NavLink类型为
// types/link.d.ts
export interface NavLink {
label: string;
href: string
children?: NavLink[];
}
本地或者全局安装tsx,便于运行ts后缀的文件(ts-node亦可,个人感觉tsx更加稳定)
在package.json中添加script
{
// ...
"scripts": {
"dev": "next dev",
"build": "npm run prepare && next build",
"start": "next start",
"lint": "next lint",
"prepare": "tsx ./scripts/mdx.ts"
},
// ...
}
此时仅在build命令触发时会自动构建public/docs目录的,其他情况需要手动运行prepare命令构建
如果你存在其他需要构建的脚本,可选择合并多条脚本命令或者在统一的文件中调用脚本
例如集中在scripts/prepare.ts中调用需要处理的脚本
移除scripts/mdx.ts中调用
// scripts/mdx.ts
// ...
// 删除以下代码
generateMDXMenu().catch(console.error)
新增scripts/prepare.ts文件或者其他集中脚本调用文件
// scripts/prepare.ts
import { generateMDXMenu } from "./mdx"
// 项目启动前执行内容
async function main(){
// other scripts ...
await generateMDXMenu()
}
main().catch(console.error);
修改package.json中的prepare script命令
{
// ...
"scripts": {
// ...
"prepare": "tsx ./scripts/prepare.ts"
},
// ...
}
运行命令
npm run prepare
此时将会输出docs文档资源结构,具体路径根据lib\mdx\index.ts文件中的MDXConfig.MENU_PATH的值决定
此处为目录路径定义为public/menu.json,找到对应路径,此时文档树结构为
{
"label": "docs",
"href": "/docs",
"children": [
{
"label": "introduction",
"href": "/docs/introduction"
},
{
"label": "quickstart",
"href": "/docs/quickstart"
},
{
"label": "sub",
"href": "/docs/sub",
"children": [
{
"label": "1",
"href": "/docs/sub/1"
},
{
"label": "sub1",
"href": "/docs/sub/sub1",
"children": [
{
"label": "2",
"href": "/docs/sub/sub1/2"
}
]
}
]
}
]
}
如需调整每一项节点的结构,可在scripts\mdx.ts文件内generateMDXMenu方法内部的handleItemToTreeNode自定义结构。
例如根据文件路径读取md/mdx的frontmatter中的字段作为目录节点的字段
这里通过读取md/mdx的frontmatter的icon字段作为示例
// scripts/mdx.ts
// ...
import grayMatter from "gray-matter"
export async function generateMDXMenu() {
// ...
const handleItemToTreeNode = (itemPath: string): TreeNode<NavLink> => {
const label = path.parse(itemPath).name;
const normalizedPath = itemPath.replace(/\\/g, '/')
const href = normalizedPath.replace(DOCS_PATH.split("/").slice(0, -1).join("/"), '').split(".")[0]
const item: NavLink = {
label,
href: href,
children: undefined,
};
const isFile = fs.statSync(itemPath).isFile()
if (isFile) {
const source = fs.readFileSync(itemPath, "utf-8")
if (!source) throw new Error("source load failed")
const { data: frontmatter } = grayMatter(source!)
if (frontmatter?.icon !== undefined) {
item.icon = frontmatter.icon as string
}
}
return item
};
// ...
}
对部分文档新增frontmatter数据测试效果
打开public/docs/quickstart.mdx,新增frontmatter数据
---
title: 快速开始
icon: 🚀
---
// ...
打开public/docs/introduction.md,新增frontmatter数据
---
title: 介绍
icon: 👋
---
// ...
运行命令
npm run prepare
打开menu.json文件查看效果
此时自定义生成目录节点结构也已经符合预期
回到lib\mdx\read.ts文件下
// lib\mdx\read.ts
// ...
export async function readMDXMenu(): Promise<NavLink> {
const menuFile = fs.readFileSync(MDXConfig.MENU_PATH)
const menu = JSON.parse(menuFile.toString("utf-8")) as NavLink
return menu
}
// ..
此函数已在前文声明,它的作用是读取构建的menu.json数据
回到app\docs[...MDXPath]\page.tsx文件中
// app/docs/[...MDXPath]/page.tsx
// ...
export const dynamicParams = false
export async function generateStaticParams() {
const paths: string[] = await extractMDXMenuPaths()
// 由于MDXPath参数类型为string[],所以这里需要转化路径
const staticPaths = paths.map(path => ({ MDXPath: path.replace(/^\/docs\//, "").split("/") }))
return staticPaths
}
// ...
此时就容易理解许多,逻辑就是通过解析json数据来提取path路径来一一映射文档资源
8. 动态导航目录构建
前文中已经实现了遍历public/docs目录构建树形结构,此时通过直接读取json数据即可实现
新增文件components\docs\Aside.tsx
import { cn } from "@/lib/utils"
import { readMDXMenu } from "@/lib/mdx/read"
import { AsideMenuItem } from "./AsideMenuItem"
export async function DocsAside({
className
}: {
className?: string
}) {
const menu = (await readMDXMenu()).children!
return (
<aside className={cn("sticky w-60 h-full px-4 py-2 bg-white rounded-r-md", className)}>
<menu className="w-full space-y-2">
{menu.map((item) => (
<AsideMenuItem key={item.label + item.href} item={item} />
))}
</menu>
</aside>
)
}
这里的readMDXMenu方法在前文中也已经声明,直接使用即可
// lib/mdx/read.ts
// ...
/**
* 从动态生成的MDX menu.json中提取menu数据
*
* @returns menu数据
*/
export async function readMDXMenu(): Promise<NavLink> {
const menuFile = fs.readFileSync(MDXConfig.MENU_PATH)
const menu = JSON.parse(menuFile.toString("utf-8")) as NavLink
return menu
}
// ...
添加AsideMenuItem组件,新增文件components\docs\AsideMenuItem.tsx
// components\docs\AsideMenuItem.tsx
"use client"
import { NavLink } from "@/types/link"
import { useState } from "react"
import { usePathname, useRouter } from "next/navigation"
import { cn } from "@/lib/utils"
import { ChevronDown } from "lucide-react"
export function AsideMenuItem({
item
}: {
item: NavLink
}) {
const pathname = usePathname()
const router = useRouter()
const [open, setOpen] = useState<boolean>(false)
const subMenue = !!item?.children?.length ? (
item.children.map((subItem) => (
<AsideMenuItem key={subItem.label + subItem.href} item={subItem} />
))
) : null
const clickHandler = () => {
if (!!item?.children?.length) {
setOpen((prev) => !prev)
return
}
router.push(item.href)
}
return (
<div className="space-y-2">
<div
className={cn("p-2 rounded active:bg-neutral-200 hover:bg-neutral-200 flex flex-row relative select-none cursor-pointer", { "bg-neutral-200": pathname === item.href })}
onClick={clickHandler}
>
{item.label}
{subMenue && <ChevronDown
className={cn("text-input justify-self-end absolute right-4", {
"-rotate-90": open,
})}
/>}
</div>
{subMenue && (
<div className={cn("pl-4 space-y-2", { "hidden": !open })}>
{subMenue}
</div>
)}
</div>
)
}
这里通过逻辑判断是否跳转,如果当前节点存在子节点说明当前节点是一个目录,点击时则展开/关闭目录,而不是跳转链接
(部分依赖及其方法未提到,需要手动安装,也可查看文末源码)
将侧边栏组件添加到页面
打开并修改app\layout.tsx内容
// app\layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { DocsAside } from "@/components/docs/Aside";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<div className="w-screen h-screen flex">
<DocsAside />
<main className="flex-1">
{children}
</main>
</div>
</body>
</html>
);
}
运行项目
npm run prepare
npm run dev
打开链接 localhost:3000/ 预览效果
使用侧边栏导航
点击文档节点(跳转对应资源)
点击目录节点(打开/关闭子目录)
可以发现md/mdx将frontmatter内容一并放入到了页面内容中,而不是单独解析,可对MDXRemote稍作调整
打开app\docs[...MDXPath]\page.tsx文件,并对MDXRemote添加参数
// app\docs\[...MDXPath]\page.tsx
//..
export default async function Page({ params }: { params: { MDXPath: string[] } }) {
// ...
return <MDXRemote source={source} options={{ parseFrontmatter: true }} />
}
添加options参数,并设置解析frontmatter
回到浏览器,再次查看效果
可以看到,导航和页面资源加载已经符合预期
🎉🎉🎉 至此,本示例教程已经结束,感兴趣的朋友可以参考源码
友情链接: