在上一篇介绍博客搭建的文章里,小结了 Next 的基本项目结构、全局布局,以及怎样读取 MDX 的头数据等。这次继续介绍如何解析 MDX 的内容到博客文章页面,以及代码块如何高亮。以下面的目录结构为起点:
1components/2 |--- Layout.jsx3 |--- NavBar.jsx4 |--- Footer.jsx5lib/6 |--- posts.js7pages/ # 目录中的 js 文件将被解析生成页面8 |--- _app.js # 全局布局、样式在这里设置9 |--- index.js # 通过 http://yoursite/ 来访问10posts/ # 文章目录,放 mdx 文件11 |--- first-post.mdx12public/ # 放图片、图标等静态资源13styles/ # 放 css 文件14next.config.js # Next.js 的配置文件
博客文章页面
第一步:动态路由
在 Next 中,除了pages/index.js
这种生成“固定”链接的页面,还可以生成“动态”链接的页面。我们希望以http://localhost:3000/post/:id
的链接来访问相应的文章,这里的id
就是“动态”的部分,它根据文章的 id(文件名)动态生成。我们的文章以 MDX 文件的形式放在posts/
目录中,可以利用 Next 的动态路由(Dynamic Routes)机制来实现。
我们希望最终的路由用文章的标题来组成,如/post/hello-world
、/post/my-post
等。为此我们在pages/
目录下新建pages/post/[id].js
文件。其中,文件名中的[id]
表示文章对应 MDX 文件的文件名,将用于生成动态路由。关于动态路由的详细用法,可以看 Next 的官方文档,这里不再赘述。
我们的页面是静态生成的,在[id].js
文件中,我们使用getStaticPaths()
方法,来生成相应的动态路由。该方法需要返回一个paths
数组,枚举所有可能的动态路由,对于我们的博客项目来说,就是要返回所有文章的id
。
由于我们只需要返回文章的 id,直接调用上回提到的getAllPosts()
方法即可:
1import { getAllPosts } from "lib/posts";2 3export async function getStaticPaths() {4 const posts = getAllPosts();5 return {6 paths: posts.map((post) => ({7 params: { id: post.id },8 })),9 fallback: false,10 };11}
第二步:读取页面数据
getStaticPaths()
方法还需要搭配getStaticProps()
方法一起使用,后者将返回一个props
对象,在生成静态页面时,作为文章页面组件的参数使用。getStaticProps(context)
方法接受一个context
对象作为参数,其中context.params
属性包含了路由信息,即getStaticPaths()
方法返回的paths
数组中每一个对象中的params
属性。
可能有点绕,举例就是对于[id].js
页面来说,context.params
即{id: ...}
。我们可以通过id
拿到文件名,再去读取对应的 MDX 文件,解析出文章的具体内容。
我们通过mdx-bundler
的bundleMDX()
方法来解析读取到的 MDX 文件数据,该方法会返回解析好的正文内容(code
)和frontmatter
头部数据,我们需要将这些数据以props
对象的形式返回,如下:
1import fs from "fs";2import path from "path";3import { bundleMDX } from "mdx-bundler";4import { postsDirectory } from "lib/posts";5 6export async function getStaticProps({ params }) {7 const filePath = path.join(postsDirectory, `${params.id}.mdx`);8 const fileContent = fs.readFileSync(filePath, "utf-8");9 10 const { code, frontmatter } = await bundleMDX({11 source: fileContent,12 // 插件,后面会有讲到13 });14 15 return { props: { meta: frontmatter, code } };16}
第三步:页面组件
有了解析好的文章数据后,我们就可以来写页面组件了。前面提到的正文数据code
,并不能直接使用,需要用mdx-bundler
的getMDXComponent()
方法再解析一遍,得到一个 React 组件(<Content />
)。你可以直接使用该组件,或者自己写一个<PostLayout />
布局的组件把正文套进去:
1import { getMDXComponent } from "mdx-bundler/client";2import PostLayout from "components/PostLayout";3 4export default function Post({ meta, code }) {5 const Content = getMDXComponent(code);6 return (7 <PostLayout meta={meta}>8 <Content />9 </PostLayout>10 );11}
小结
到此为止,我们的文章页面就写好了。我们来小结一下整个流程。
- 当 Next 生成动态路由页面时,会调用
getStaticPaths()
和getStaticProps()
两个方法。前者用于生成路由,后者给组件提供props
。 - 当 Next 调用
getStaticPaths()
方法时,我们利用getAllPosts()
方法,读取文章目录中所有的 MDX 文件数据,生成paths
数组。数组中的每一项对应一个文章页面的动态路由,即id
,并通过context.params
提供给getStaticProps()
方法使用。 getStaticProps()
方法,通过调用bundleMDX()
方法,找到相应的 MDX 文件进行解析,返回code
与meta
组成的props
给到<Post/>
组件使用。<Post/>
组件,通过调用getMDXComponent()
方法,将props.code
转化成<Content/>
组件,显示文章正文内容。
大致流程如下图所示:
此时[id].js
文件的完整代码如下:
1import fs from "fs";2import path from "path";3import { bundleMDX } from "mdx-bundler";4import { getMDXComponent } from "mdx-bundler/client";5import PostLayout from "components/PostLayout";6import { postsDirectory, getAllPosts } from "lib/posts";7 8export async function getStaticPaths() {9 const posts = getAllPosts();10 return {11 paths: posts.map((post) => ({12 params: { id: post.id },13 })),14 fallback: false,15 };16}17 18export async function getStaticProps({ params }) {19 const filePath = path.join(postsDirectory, `${params.id}.mdx`);20 const fileContent = fs.readFileSync(filePath, "utf-8");21 22 const { code, frontmatter } = await bundleMDX({23 source: fileContent,24 // 插件,后面会有讲到25 });26 27 return { props: { meta: frontmatter, code } };28}29 30export default function Post({ meta, code }) {31 const Content = getMDXComponent(code);32 return (33 <PostLayout meta={meta}>34 <Content />35 </PostLayout>36 );37}
代码高亮
作为一个开发者的博客,文章中必定少不了代码块,而默认情况下代码块的样式非常影响阅读,如下图:
因此我们需要把代码块高亮化处理。可以实现代码高亮的轮子有很多,比较常用的是 Prism.js 和 highlight.js 这两个。我最终选择的是 Prism,用到 prism-react-renderer 这个包。
我们的代码块,通过mdx-bundler
转换后,是放在<pre></pre>
标签中的。为了改变代码块的样式,我们可以自定义转换后的<pre></pre>
标签。
上面讲过,我们读取到的 MDX 文件是通过bundleMDX()
这个方法进行转换后,再通过getMDXComponent()
这个方法转成 React 组件的。转换生成的组件(<Content/>
)有一个components
属性,可以替换生成的 HTML 标签。
第一步:创建代码块组件
我们可以创建一个<CodeBlock/>
组件,用于替换<pre></pre>
标签。在components/
目录下,我们新建CodeBlock.jsx
文件。
1import Highlight, { defaultProps } from "prism-react-renderer";2 3const CodeBlock = ({ children }) => {4 const { className, children: code } = children.props;5 const language = className && className.replace(/language-/, "");6 return (7 <Highlight {...defaultProps} code={code} language={language}>8 {({ className, style, tokens, getLineProps, getTokenProps }) => (9 <div className="code-block-container">10 <pre className={className} style={style}>11 <div className="code-block">12 {tokens.slice(0, -1).map((line, i) => (13 <span key={i} {...getLineProps({ line, key: i })}>14 {line.map((token, key) => (15 <span key={key} {...getTokenProps({ token, key })} />16 ))}17 </span>18 ))}19 </div>20 </pre>21 </div>22 )}23 </Highlight>24 );25};26 27export default CodeBlock;
首先<CodeBlock/>
组件接受一个名叫children
的 props,它的type
属性是'code'
,表示这是一个代码块。除此以外children
还有一个props
属性,包含className
和又一个children
属性。结构大致如下:
1{2 children: {3 type: 'code',4 props: {5 className: 'language-js',6 children: 'console.log("hi")'7 }8 }9}
我们在 Markdonw 中写代码块时,可以表明代码语言,如:
1```js2console.log("hello");3```
其中children.props.className
就是根据```
后面所填写的语言生成的字符串,格式为'language-*'
,通过简单的正则匹配我们就能得到代码块的语言。而children.props.children
则为代码文本。
prism-react-renderer 提供了一个<Highlight/>
组件,用于高亮代码,用法比较简单,将对应的 props 传入即可,不再赘述。
第二步:替换标签
接下来我们要做的就是回到<Post/>
组件中,将CodeBlock
组件传入替换即可:
1import CodeBlock from "components/CodeBlock";2 3// 上略4 5export default function Post({ meta, code }) {6 const Content = getMDXComponent(code);7 return (8 <PostLayout meta={meta}>9 <Content components={pre: CodeBlock} />10 </PostLayout>11 );12}
第三步:修改主题
到此,我们的代码就已经“亮”起来了。默认使用的是 duotoneDark 主题,确实不太好看,没关系,我们可以通过下面的方法进行修改。
方法一
我们可以直接使用 prism-react-renderer 提供的主题,可以在prism-react-renderer/themes/
目录下找到它们,然后直接修改<Highlight/>
组件传入theme
即可:
1import dracula from "prism-react-renderer/themes/dracula";2 3<Highlight theme={dracula} />;
方法二
由于 prism-react-renderer 生成代码块的标签中都添加了相应的 class,我们可以直接使用 Prism 的主题。主题在网上搜一下就能找到,直接把 css 拿过来使用即可。