next-mdx-remote
一组轻量级实用工具,允许在 getStaticProps
或 getServerSideProps
中加载 MDX,并在客户端正确地进行水合。
安装
npm install next-mdx-remote
如果与 Turbopack 一起使用,在解决此问题之前,你需要在 next.config.js
中添加以下内容:
const nextConfig = {
+ transpilePackages: ['next-mdx-remote'],
}
示例
import { serialize } from 'next-mdx-remote/serialize'
import { MDXRemote } from 'next-mdx-remote'
import Test from '../components/test'
const components = { Test }
export default function TestPage({ source }) {
return (
<div className="wrapper">
<MDXRemote {...source} components={components} />
</div>
)
}
export async function getStaticProps() {
// MDX 文本 - 可以来自本地文件、数据库或任何地方
const source = 'Some **mdx** text, with a component <Test />'
const mdxSource = await serialize(source)
return { props: { source: mdxSource } }
}
虽然在同一个文件中看到这两个部分可能有些奇怪,但这正是 Next.js 的一个特点 —— getStaticProps
和 TestPage
虽然出现在同一个文件中,但它们在两个不同的地方运行。最终,你的浏览器包不会包含 getStaticProps
,也不会包含它仅在服务器上使用的任何函数,因此 serialize
将完全从浏览器包中移除。
重要提示:请非常谨慎地将任何
next-mdx-remote
代码放入单独的"实用工具"文件中。这样做可能会导致 Next.js 的代码分割功能出现问题 - 它必须能够清晰地确定哪些内容仅在服务器端使用,哪些应该保留在客户端包中。如果你将next-mdx-remote
代码放入外部实用工具文件中并出现问题,请先移除它并从上面的简单示例开始,然后再提交问题。
其他示例
解析前置元数据
Markdown 通常与前置元数据配对使用,这通常意味着需要对 markdown 的处理方式进行一些额外的自定义处理。为了解决这个问题,next-mdx-remote
提供了可选的前置元数据解析功能,可以通过向 serialize
传递 parseFrontmatter: true
来启用。
以下是使用示例:
import { serialize } from 'next-mdx-remote/serialize'
import { MDXRemote } from 'next-mdx-remote'
import Test from '../components/test'
const components = { Test }
export default function TestPage({ mdxSource }) {
return (
<div className="wrapper">
<h1>{mdxSource.frontmatter.title}</h1>
<MDXRemote {...mdxSource} components={components} />
</div>
)
}
export async function getStaticProps() {
// MDX 文本 - 可以来自本地文件、数据库或任何地方
const source = `---
title: Test
---
Some **mdx** text, with a component <Test name={frontmatter.title}/>
`
const mdxSource = await serialize(source, { parseFrontmatter: true })
return { props: { mdxSource } }
}
vfile-matter
用于解析前置元数据。
使用 `scope` 向组件传递自定义数据
<MDXRemote />
接受一个 scope
属性,使所有值都可以在你的 MDX 中使用。
scope
参数中的每个键值对都将作为 JavaScript 变量公开。例如,如果你有一个 scope
像 { foo: 'bar' }
,它将被解释为 const foo = 'bar'
。
这特别意味着你需要确保 scope
参数中的键名是有效的 JavaScript 变量名。例如,传入 { 'my-variable-name': 'bar' }
会产生_错误_,因为键名不是有效的 JavaScript 变量名。
同样重要的是要注意,scope
变量必须作为_组件的参数_使用,不能在文本中间渲染。这在下面的示例中有所展示。
import { serialize } from 'next-mdx-remote/serialize'
import { MDXRemote } from 'next-mdx-remote'
import Test from '../components/test'
const components = { Test }
const data = { product: 'next' }
export default function TestPage({ source }) {
return (
<div className="wrapper">
<MDXRemote {...source} components={components} scope={data} />
</div>
)
}
export async function getStaticProps() {
// MDX 文本 - 可以来自本地文件、数据库或任何地方
const source =
'Some **mdx** text, with a component using a scope variable <Test product={product} />'
const mdxSource = await serialize(source)
return { props: { source: mdxSource } }
}
将 `scope` 传入 `serialize` 函数
你也可以将自定义数据传入 serialize
,它会将值传递并使其在结果中可用。通过将 source
的结果展开到 <MDXRemote />
中,数据将可用。
请注意,传入 serialize
的任何 scope 值都需要是可序列化的,这意味着无法传递函数或组件。此外,scope
参数中的任何键名都必须是有效的 JavaScript 变量名。如果你需要传递不可序列化的自定义 scope,可以直接将 scope
传递给渲染时的 <MDXRemote />
。上一节有一个如何做到这一点的示例。
import { serialize } from 'next-mdx-remote/serialize'
import { MDXRemote } from 'next-mdx-remote'
import Test from '../components/test'
const components = { Test }
const data = { product: 'next' }
export default function TestPage({ source }) {
return (
<div className="wrapper">
<MDXRemote {...source} components={components} />
</div>
)
}
export async function getStaticProps() {
// MDX 文本 - 可以来自本地文件、数据库或任何地方
const source =
'Some **mdx** text, with a component <Test product={product} />'
const mdxSource = await serialize(source, { scope: data })
return { props: { source: mdxSource } }
}
来自 MDXProvider
的自定义组件
如果你想让组件在应用程序中的任何 <MDXRemote />
中可用,可以使用 @mdx-js/react
中的 <MDXProvider />
。
// pages/_app.jsx
import { MDXProvider } from '@mdx-js/react'
import Test from '../components/test'
const components = { Test }
export default function MyApp({ Component, pageProps }) {
return (
<MDXProvider components={components}>
<Component {...pageProps} />
</MDXProvider>
)
}
// pages/test.jsx
import { serialize } from 'next-mdx-remote/serialize'
import { MDXRemote } from 'next-mdx-remote'
export default function TestPage({ source }) {
return (
<div className="wrapper">
<MDXRemote {...source} />
</div>
)
}
export async function getStaticProps() {
// MDX 文本 - 可以来自本地文件、数据库或任何地方
const source = 'Some **mdx** text, with a component <Test />'
const mdxSource = await serialize(source)
return { props: { source: mdxSource } }
}
带点的组件名(例如 motion.div
)
包含点(.
)的组件名,如 framer-motion
中的组件,可以像其他自定义组件一样渲染,只需在组件对象中传递 motion
。
import { motion } from 'framer-motion'
import { MDXProvider } from '@mdx-js/react'
import { serialize } from 'next-mdx-remote/serialize'
import { MDXRemote } from 'next-mdx-remote'
export default function TestPage({ source }) {
return (
<div className="wrapper">
<MDXRemote {...source} components={{ motion }} />
</div>
)
}
export async function getStaticProps() {
// MDX 文本 - 可以来自本地文件、数据库或任何地方
const source = `Some **mdx** text, with a component:
<motion.div animate={{ x: 100 }} />`
const mdxSource = await serialize(source)
return { props: { source: mdxSource } }
}
延迟水合
延迟水合会推迟客户端上组件的水合。这是一种优化技术,可以改善应用程序的初始加载,但可能会导致 MDX 内容中任何动态内容的交互性出现意外延迟。
注意:这将在你渲染的 MDX 周围添加一个额外的包装 div
,这是为了避免渲染过程中的水合不匹配所必需的。
import { serialize } from 'next-mdx-remote/serialize'
import { MDXRemote } from 'next-mdx-remote'
import Test from '../components/test'
const components = { Test }
export default function TestPage({ source }) {
return (
<div className="wrapper">
<MDXRemote {...source} components={components} lazy />
</div>
)
}
export async function getStaticProps() {
// MDX 文本 - 可以来自本地文件、数据库或任何地方
const source = 'Some **mdx** text, with a component <Test />'
const mdxSource = await serialize(source)
return { props: { source: mdxSource } }
}
API
该库暴露了一个函数和一个组件,分别是 serialize
和 <MDXRemote />
。这两者被有意隔离在各自的文件中 —— serialize
旨在在服务器端运行,因此在 getStaticProps
中运行,它在服务器/构建时运行。而 <MDXRemote />
则旨在在客户端,即浏览器中运行。
-
serialize(source: string, { mdxOptions?: object, scope?: object, parseFrontmatter?: boolean })
serialize
接收一个 MDX 字符串。它还可以选择传入选项,这些选项直接传递给 MDX,以及一个可以包含在 MDX 作用域中的 scope 对象。该函数返回一个对象,旨在直接传递给<MDXRemote />
。
serialize(
// 原始MDX内容字符串
'# hello, world',
// 可选参数
{
// 可用于任何自定义MDX组件参数
scope: {},
// MDX的可用选项,更多信息请参见MDX文档
// https://mdxjs.com/packages/mdx/#compilefile-options
mdxOptions: {
remarkPlugins: [],
rehypePlugins: [],
format: 'mdx',
},
// 指示是否从MDX源解析frontmatter
parseFrontmatter: false,
}
)
访问 https://mdxjs.com/packages/mdx/#compilefile-options 获取可用的 mdxOptions
。
-
<MDXRemote compiledSource={string} components?={object} scope?={object} lazy?={boolean} />
<MDXRemote />
使用serialize
的输出以及可选的组件参数。其结果可以直接渲染到您的组件中。要延迟内容的水合并立即提供静态标记,请传递lazy
属性。<MDXRemote {...source} components={components} />
替换默认组件
渲染将在底层使用 MDXProvider
。这意味着您可以用自定义组件替换HTML标签。这些组件列在MDXJS的组件表中。
一个使用案例是使用您偏好的样式库渲染内容。
import { Typography } from "@material-ui/core";
const components = { Test, h2: (props) => <Typography variant="h2" {...props} /> }
...
如果您愿意,也可以用 <MDXProvider />
包裹整个应用,而不是直接将组件传递给 <MDXRemote />
。参见上面的示例。
注意:由于组件名称中的 "/",th/td
将无法工作。
背景与理论
在Next.js应用中加载MDX文件没有一个很好的默认方式。之前,我们编写了 next-mdx-enhanced
以便能够将MDX文件渲染到布局中并导入其frontmatter来创建索引页。
next-mdx-enhanced
的这个工作流程还可以,但引入了一些限制,我们已在 next-mdx-remote
中移除了这些限制:
- 文件内容必须是本地的。 您不能将MDX文件存储在另一个仓库、数据库等中。对于足够大的操作,最终会在创作内容的人和处理内容呈现的人之间产生分歧。在同一个仓库中重叠这两个关注点会使每个人的工作流程更加困难。
- 您受限于基于文件系统的路由。 您的页面是根据其位置生成URL的。或者您可能使用
exportPathMap
重新映射它们,这会给作者带来困惑。无论如何,以任何方式移动页面都会破坏东西 -- 要么是页面的URL,要么是您的exportPathMap
配置。 - 您最终会遇到性能问题。 Webpack是一个JavaScript打包工具,强制它加载数百/数千页的文本内容会导致内存需求激增。Webpack将每个页面存储为具有大量元数据的独特对象。我们的一个实现中,仅有几百个页面就需要超过8GB的内存来编译网站。构建时间超过25分钟。
- 您在构建关系数据的方式上会受到限制。 当您的整个数据结构是解析成JavaScript对象并保存在内存中的frontmatter时,将内容组织成动态的、相关的类别是困难的。
因此,next-mdx-remote
改变了整个模式,使您不是通过导入来加载MDX内容,而是通过 getStaticProps
或 getServerProps
来加载 -- 您知道的,就像加载任何其他数据一样。该库提供了以性能良好的方式序列化和水合MDX内容的工具。这消除了上面列出的所有限制,而且成本显著降低 -- next-mdx-enhanced
是一个非常重的库,有大量自定义逻辑和一些令人烦恼的限制。我们的非正式测试显示构建时间减少了50%或更多。
自本项目最初创建以来,Kent C. Dodds制作了一个类似的项目,mdx-bundler
。该库支持MDX文件中的导入和导出(只要您手动读取每个导入的文件并传递其内容),并自动处理frontmatter。如果您有很多文件都导入和使用不同的组件,您可能会受益于使用 mdx-bundler
,因为 next-mdx-remote
目前只允许在所有页面中导入和使用组件。重要的是要注意,这个功能是有代价的 -- 对于基本的markdown内容,mdx-bundler
的输出至少比 next-mdx-remote
的输出大400%。
我如何用这个构建博客?
数据显示,所有开发者工具用例的99%都是构建不必要的复杂个人博客。开玩笑的。但说真的,如果您试图为个人或小型企业使用构建博客,请考虑只使用普通的HTML和CSS。您绝对不需要使用重量级的全栈JavaScript框架来制作一个简单的博客。当您几年后回来进行更新时,您会感谢自己的,因为届时您所有的依赖项不会有10个破坏性的发布。
如果您真的坚持要这样做,请查看我们官方的Next.js示例实现。💖
注意事项
环境目标
next-mdx-remote
生成的用于实际渲染MDX的代码针对支持模块的浏览器。如果您需要支持较旧的浏览器,请考虑转译 serialize
的 compiledSource
输出。
import
/ export
import
和 export
语句不能在MDX文件内部使用。如果您需要在MDX文件中使用组件,应该将它们作为prop提供给 <MDXRemote />
。
希望这是有意义的,因为为了工作,导入必须相对于文件路径,而这个库允许从任何地方加载内容,而不是只从设定的文件路径加载本地内容。至于导出,MDX内容被视为数据,而不是模块,所以我们无法访问可能从传递给 next-mdx-remote
的MDX中导出的任何值。
安全性
这个库在客户端评估JavaScript字符串,这是它MDXRemotes MDX内容的方式。如果不小心处理,将字符串评估为javascript可能是一种危险的做法,因为它可能启用XSS攻击。重要的是要确保您只将 serialize
函数生成的 mdxSource
输入传递给 <MDXRemote />
,如文档中所指示。不要将用户输入传递给 <MDXRemote />
。
如果您的网站有禁止通过 eval
或 new Function()
进行代码评估的CSP,您需要放宽该限制以使用 next-mdx-remote
,这可以通过使用 unsafe-eval
来完成。
TypeScript
这个项目确实包含TypeScript使用的原生类型。serialize
和 <MDXRemote />
都有正常预期的类型,该库还导出了一个可用于为 getStaticProps
的结果添加类型的类型。
MDXRemoteSerializeResult<TScope = Record<string, unknown>>
:表示serialize
的返回值。可以传递TScope
泛型类型来表示您传入的作用域数据的类型。
以下是TypeScript中简单实现的示例。对于TypeScript的每种配置,您可能不需要完全按照这种方式实现类型 - 这个示例只是演示在需要时可以应用类型的位置。
import type { GetStaticProps } from 'next'
import { serialize } from 'next-mdx-remote/serialize'
import { MDXRemote, type MDXRemoteSerializeResult } from 'next-mdx-remote'
import ExampleComponent from './example'
const components = { ExampleComponent }
interface Props {
mdxSource: MDXRemoteSerializeResult
}
export default function ExamplePage({ mdxSource }: Props) {
return (
<div>
<MDXRemote {...mdxSource} components={components} />
</div>
)
}
export const getStaticProps: GetStaticProps<{
mdxSource: MDXRemoteSerializeResult
}> = async () => {
const mdxSource = await serialize('some *mdx* content: <ExampleComponent />')
return { props: { mdxSource } }
}
React Server Components (RSC) 和 Next.js app
目录支持
在服务器组件中使用 next-mdx-remote
,特别是在Next.js的 app
目录中,可以通过从 next-mdx-remote/rsc
导入来支持。之前,序列化和渲染步骤是分开的,但是从现在开始,RSC使这种分离变得不必要。
一些值得注意的区别:
<MDXRemote />
现在接受一个source
属性,而不是接受来自next-mdx-remote/serialize
的序列化输出- 不能再通过使用
@mdx-js/react
的MDXProvider
上下文来提供自定义组件,因为RSC不支持React上下文 - 要在传递
parseFrontmatter: true
时在MDX外部访问frontmatter,请使用next-mdx-remote/rsc
导出的compileMdx
方法 - 不再支持
lazy
属性,因为渲染发生在服务器上 <MDXRemote />
必须在服务器上渲染,因为它现在是一个异步组件。客户端组件可以作为MDX标记的一部分渲染
有关RSC的更多信息,请查看 Next.js文档。
示例
假设在使用 app
目录的Next.js 13+应用程序中使用。
基本
import { MDXRemote } from 'next-mdx-remote/rsc'
// app/page.js
export default function Home() {
return (
<MDXRemote
source={`# Hello World
This is from Server Components!
`}
/>
)
}
加载状态
import { MDXRemote } from 'next-mdx-remote/rsc'
// app/page.js
export default function Home() {
return (
// 理想情况下,这个加载旋转器应确保没有布局偏移,
// 这是如何提供这样一个加载旋转器的示例。
// 在Next.js中,您也可以使用 `loading.js` 来实现这一点。
<Suspense fallback={<>Loading...</>}>
<MDXRemote
source={`# Hello World
This is from Server Components!
`}
/>
</Suspense>
)
}
自定义组件
// components/mdx-remote.js
import { MDXRemote } from 'next-mdx-remote/rsc'
const components = {
h1: (props) => (
<h1 {...props} className="large-text">
{props.children}
</h1>
),
}
export function CustomMDX(props) {
return (
<MDXRemote
{...props}
components={{ ...components, ...(props.components || {}) }}
/>
)
}
// app/page.js
import { CustomMDX } from '../components/mdx-remote'
export default function Home() {
return (
<CustomMDX
// h1 现在使用 `large-text` 类名渲染
source={`# 你好世界
这是来自服务器组件的内容!
`}
/>
)
}
在 MDX 外部访问前置元数据
// app/page.js
import { compileMDX } from 'next-mdx-remote/rsc'
export default async function Home() {
// 可选择为前置元数据对象提供类型
const { content, frontmatter } = await compileMDX<{ title: string }>({
source: `---
title: RSC 前置元数据示例
---
# 你好世界
这是来自服务器组件的内容!
`,
options: { parseFrontmatter: true },
})
return (
<>
<h1>{frontmatter.title}</h1>
{content}
</>
)
}
替代方案
next-mdx-remote
对其支持的功能有明确的观点。如果你需要 next-mdx-remote
未提供的额外功能,以下是一些可以考虑的替代方案:
你可能不需要 next-mdx-remote
如果你正在使用 React 服务器组件,并且只是想使用带有自定义组件的基本 MDX,你只需要核心的 MDX 库,不需要其他东西。
import { compile, run } from '@mdx-js/mdx'
import * as runtime from 'react/jsx-runtime'
import ClientComponent from './components/client'
// MDX 可以从任何地方获取,比如文件或数据库。
const mdxSource = `# 你好,世界!
<ClientComponent />
`
export default async function Page() {
// 将 MDX 源代码编译为函数体
const code = String(
await compile(mdxSource, { outputFormat: 'function-body' })
)
// 然后你可以在服务器上运行代码,生成一个服务器
// 组件,或者你可以将字符串传递给客户端组件进行
// 最终渲染。
// 使用运行时运行编译后的代码并获取默认导出
const { default: MDXContent } = await run(code, {
...runtime,
baseUrl: import.meta.url,
})
// 渲染 MDX 内容,提供 ClientComponent 作为组件
return <MDXContent components={{ ClientComponent }} />
}
如果你不打算将编译后的字符串传递给数据库或客户端组件,你也可以使用 evaluate
来简化这个过程,它可以在一次调用中编译和运行代码。
import { evaluate } from '@mdx-js/mdx'
import * as runtime from 'react/jsx-runtime'
import ClientComponent from './components/client'
// MDX 可以从任何地方获取,比如文件或数据库。
const mdxSource = `
export const title = "MDX 导出演示";
# 你好,世界!
<ClientComponent />
export function MDXDefinedComponent() {
return <p>MDX 定义的组件</p>;
}
`
export default async function Page() {
// 运行编译后的代码
const {
default: MDXContent,
MDXDefinedComponent,
...rest
} = await evaluate(mdxSource, runtime)
console.log(rest) // 输出 { title: 'MDX 导出演示' }
// 渲染 MDX 内容,提供 ClientComponent 作为组件,以及
// 导出的 MDXDefinedComponent。
return (
<>
<MDXContent components={{ ClientComponent }} />
<MDXDefinedComponent />
</>
)
}