MDX 文件高级渲染
- W_Z_C
- 共 2263 字,阅读约 6 分钟
这个章节本来不准备写了,毕竟在前面的文章里已经详细的介绍了 Markdown 的渲染流程,但是想了想,还是把 Markdown 文件显示的细节说一说,其实内容并不复杂,只是 Markdown 文件渲染的细节很多,特别是围绕着 Markdown 可以添加很多渲染插件,在渲染的每一步其实都可以自行扩展,主要还是看个人需要,下面就简单的介绍下一目前我博客中的渲染设置,仅供参考。
目前项目中渲染 Markdown 文件的是 mdx-bundler 库,在前面的文章中我只提到 Markdown 文件,但实际上 mdx-bundler 最强大的是后面的 “X”。MDX 是 Markdown 的超集,“X” 代表自定义组件,使用 mdx-bundler 可以直接将包含组件代码的 Markdown 文件渲染成最终的 HTML 代码。
按照 mdx-bundler 库渲染 Markdown 的流程顺序,有几个地方可以修改最终的显示效果:
- remark 插件。mdx-bundler 可以绑定 remark 插件,这些插件可以操作 Markdown 的语法树。
- rehype 插件。mdx-bundler 可以绑定 rehype 插件,这些插件可以操作 HTML 的语法树。
- components 参数。该参数可以通过 MDX 的能力替换现有的组件内容。
- typography 设置。这个设置不是 mdx-bundler 的功能,而是 Tailwind CSS 的设置,它可以设置最终文本的显示效果,等价于修改 CSS 代码。
上述步骤中的前两步发生在后端,后两步发生在前端。
1. remark 插件
当前系统中一共使用了两个 remark 插件。一个是 remark-unwrap-images
,主要是修改默认 img 标签。MDX 文件在编译的时候,会将下面的图片显示语法:

转换为这样的 HTML 代码:
<>
<p><img src="/logo.png" alt="Alt text" title="title" /></p>
</>
直接使用上面的 HTML 代码可能会导致 img 图片受到 p 标签的样式影响,使用该插件可以移除外面包裹的 p 标签。
另一个使用的插件是 remark-gfm
,该插件主要是扩展 Markdown 的语法。GFM 是 GitHub Flavored Markdown 的简称,表示在 Github 上额外增强的几种常用语法,包括自动链接(www.xxx.com
)、页脚([^1]
)、删除线(~~stuff~~
)、表格(| cell |...
)以及任务列表(* [x]
)。通过这个插件可以将 Markdown 中这几种语法转换为对应的 HTML 代码。
加载使用 remark 插件的方法很简单:
async function getMDXList() {
//... for (let i = 0; i < files.length; i++) { const fp = path.join(files[i].dir, files[i].name)
const { code, frontmatter } = await bundleMDX({ mdxOptions: opts => { opts.remarkPlugins = [...(opts.remarkPlugins ?? []), remarkUnwrapImages, [remarkGfm, {singleTilde: false}]] return opts }, file: fp, cwd: COMPONENTROOT }) }
//...}
2. rehype 插件
系统中目前使用了四个 rehype 插件,都是我个人写的,主要是为了实现博客的一些扩展功能,稳定性值得怀疑,不过等发现具体问题再修复就好。下面简单的介绍一下:
rehypeLinks 插件。 这个插件主要是为了检测当前 Markdown 文档中的链接,如果是外部链接,则加入 _blank
属性。
const rehypeLinks = () => {
return (tree) => {
function visitor(node) {
const { tagName, properties: { href } } = node
if (tagName !== 'a' || typeof href !== 'string' || href.length < 1) {
return
}
const scrUrl = new URI(href);
if (scrUrl.is('absolute')) {
node.properties.target = '_blank'
node.properties.rel = 'nofollow noopener'
}
}
visit(tree, ['element'], visitor)
}
}
rehypeImgSize 插件。 这个插件主要是为了解决图片大小问题,因为 Nextjs 中的 next/image 组件在使用的时候必须传入图片的宽高。所以这个组件主要是为了给图片增加宽高属性,额外的还检测了图片是否存在,不存在则给一个默认的 404 图片替代。
rehypeImgSize 插件代码
const rehypeImgSize = () => {
const PUBLIC_IMAGE_DIR = path.join(process.cwd(), '/public')
return async (tree) => {
const tasks = []
function visitor(node) {
const { tagName, properties: { src } } = node
if (tagName !== 'img' || typeof src !== 'string' || src.length < 1) {
return
}
const scrUrl = new URI(src);
if (scrUrl.is('absolute')) {
const task = probe(decodeURIComponent(scrUrl), { rejectUnauthorized: false }).then((res) => {
if (res && res.width && res.height) {
node.properties.width = res.width
node.properties.height = res.height
}
})
tasks.push(task)
} else {
let fp = path.join(PUBLIC_IMAGE_DIR, src)
try {
//文件是否存在
if (!fs.existsSync(fp)) {
console.error(fp)
node.properties.src = '/assert/images/404.jpg'
fp = path.join(PUBLIC_IMAGE_DIR, node.properties.src)
}
} catch (e) { }
const task = probe(fs.createReadStream(fp)).then((res) => {
if (res && res.width && res.height) {
node.properties.width = res.width
node.properties.height = res.height
}
})
tasks.push(task)
}
}
visit(tree, ['element'], visitor)
await Promise.all(tasks)
}
}
rehypeToc 插件。 主要是为了提取文章大纲。该插件会递归的提取文章标题,并给文章标题的节点增加 id 和 depth 属性,便于前端大纲的渲染。
rehypeToc 插件代码
const rehypeToc = ({ cb, headings = ['h2', 'h3', 'h4'] }) => {
const DEPTHS = { h1: 1, h2: 2, h3: 3, h4: 4, h5: 5 }
const slugger = new GithubSlugger()
//判断是否为标题节点
function isHeadingNode(node) {
if (node && node.type === 'element' && typeof node.tagName === 'string' && typeof node.properties === 'object') {
if (!headings.includes(node.tagName)) {
return false
}
return true
}
return false
}
//递归查找标题
function findHeadingsRecursive(node, headingNodes) {
if (isHeadingNode(node)) {
headingNodes.push(node)
}
if (node.children) {
let parent = node
for (let i = 0; i < parent.children.length; i++) {
findHeadingsRecursive(parent.children[i], headingNodes)
}
}
}
//查找标题
function findHeadings(tree) {
let headingNodes = []
findHeadingsRecursive(tree, headingNodes)
return headingNodes
}
//获取标题文本
function getInnerText(node) {
let text = ''
if (node.type === 'text') {
text += node.value || ''
}
if (node.children) {
let parent = node
for (let i = 0; i < parent.children.length; i++) {
text += getInnerText(parent.children[i])
}
}
return text
}
//返回标题文本
function getTitleText(val) {
return val.replace(/<(\/)?[^>]+>/g, '').replace(/\s{2,}/g, ' ')
}
return (tree) => {
let headings = findHeadings(tree)
for (let i = 0; i < headings.length; i++) {
const titleText = getTitleText(getInnerText(headings[i]))
const pinyinText = toPinyin(titleText)
const slugText = slugger.slug(pinyinText)
const dp = DEPTHS[headings[i].tagName] || 0
if(slugText && slugText.length > 0) {
headings[i].properties['id'] = slugText
if (cb) {
cb({
value: titleText,
depth: dp,
id: slugText,
})
}
}
}
}
}
这个插件会通过回调的方式返回提取的大纲,最后这个大纲的结果会保存到数据库中,前端加载页面的时候可以直接查询到。
rehypeSyntaxHighlighting 插件。 主要是为了代码高亮使用。该插件可以提取 Markdown 中的代码,并将临近的代码片段提取为一组,最终配合前端显示为类似 Tabs 的效果,实现可以参考 Tailwind CSS 的官网,我也是直接从它们的官网裁剪的代码,具体的实现过程现在已经有点看不太懂了……
使用这些插件的方法和 remark 插件类似:
async function getMDXList() {
//... for (let i = 0; i < files.length; i++) { const fp = path.join(files[i].dir, files[i].name)
let headings = [] const { code, frontmatter } = await bundleMDX({ mdxOptions: opts => { opts.remarkPlugins = [...(opts.remarkPlugins ?? []), remarkUnwrapImages, [remarkGfm, {singleTilde: false}]] opts.rehypePlugins = [...(opts.rehypePlugins ?? []), rehypeLinks, rehypeImgSize, [rehypeToc, {cb: h => {headings.push(h)}}], rehypeSyntaxHighlighting] return opts }, file: fp, cwd: COMPONENTROOT }) }
//...}
代码中的 headings 在每次解析完 Markdown 文档后,都会保存当前 Markdown 文档的大纲,不要忘记将该数组保存到数据库中便于前端直接加载。
3. components 参数
上面两处改变 Markdown 文档的方法都是在后端进行的,到目前为止,所有的 Markdown 文档都已就存储到数据库中,接下来显示 Markdown 文档的内容都是直接从数据库中加载的。对于 mdx-bundler 这个第三方库,该库提供了在前端渲染的具体方法:
const MDXContent = React.useMemo(() => getMDXComponent(code), [code])
<MDXContent />
这也是目前项目中使用的渲染方式,为了配合后台的数据显示,可能需要替换一些现有的标签组件,例如 img 元素,改为惰性加载组件、a 链接,使用 Nextjs 提供的 Link 组件、pre 元素,增加 notranslate 类名称,防止 google 翻译的时候自动翻译代码中的文字,除此之外还包含标题元素等等。
具体的实现每个人可能都有所不同,纯粹是前端的样式设置,这里拿 img 元素为例,让其用惰性加载的组件进行替换:
<MDXContent components={{
img: (props) => {
return (
<div className="text-center">
<LazyLoadImage className="mx-auto" {...props} />
</div>
)
}
}} />
代码中直接修改 components 对象,对象的 key 表示要替换的元素,对象的 value 表示要替换的组件,具体需要替换哪些完全由你自己决定,推荐至少替换 a 元素和 img 元素,因为 Nextjs 官方提供了类似组件,只有替换这些元素才能使用 Nextjs 提供的额外功能。
4. typography 设置
typography 相关设置本质上就是修改 CSS 样式,目前使用的是 @tailwindcss/typography 组件。可以在 tailwind.config.js 配置文件中修改它的默认样式。在前面的文章中,已经提供了一份默认的参考样式,具体可以要查看 Tailwind CSS 官方文档自行修改。