"In some ways, programming is like painting. You start with a blank canvas and certain basic raw materials. You use a combination of science, art, and craft to determine what to do with them."

-- Andrew Hunt

Why?

花了几天时间,重新搭了一个 blog。考虑到之前一直在写 Vue,这次想换 React 练练手,调研了一堆轮子后,花了九牛二虎之力才最终定下来,用 Next.jsMDX 来搭。过程中遇到了无数个坑,总结得到了这篇文章。

之所以选择 Next.js 加 MDX,主要是考虑以下几个需求:

  • 基于 React,方便练手
  • 基于 Markdown,不用解释了吧
  • 页面、主题能随便改,最好能从零开始
  • 最终打包成静态页面,方便直接丢到 Github Pages 上
  • 有脚手架,项目配置简单

上述几个需求,Next.js 都能完美满足,整个开发体验也是相当优秀,跟着文档走一遍基本就能搭出来。剩下的就是找各种优化的插件,以及无休止地修改页面样式。

Next Player: X

项目最开始搭建的时候,本来打算主要写写文章,所以用的是纯 MD 来写。后来发现了这个网站,里头的这篇文章有介绍作者是怎样搭建他的 blog 的,用到的是 MDX。其实 MDX 就是能在 MD 文件里插入 React 的组件,是 MD 的拓展。当时我想,如果在文章里加一些自定义的组件,用来做演示,效果应该挺好的,就像上面的TicTacToe组件。

Next.js 集成 MDX 有相当多方案,并且每个都有坑。这篇文章有简单的对比,感兴趣的可以了解一下。一开始我尝试用mdx-bundler来解释 MDX 文件,第一版出来的效果还行,只是卡在了生成目录上。后面发现 React 的文档居然更新了,同样是基于 Next.js 和 MDX。于是我赶紧 clone 下来,发现他们用的是@mdx-js/loader。折腾了一番之后,我最终还是换回了mdx-bundler

How?

第一步:先跑起来再说

其实 Next.js 官方有一个比较完整的新手教程,跟着它走完,就可以把一个基于 markdown 的 blog 跑起来。或者通过这个模板也可以达到同样的效果。然而,作为练手项目,当然得从零开始,重新造个轮子。

先通过下面的命令,得到一个“纯净版”的 next app:

code
1yarn create next-app my-blog

进入项目根目录并运行yarn dev后访问http://localhost:3000,如果能看到下面的页面,就证明项目已经顺利跑起来了。

脚手架首页

此时项目的目录结构是这样的:

code
1pages/ # 目录中的 js 文件将被解析生成页面2|--- _app.js # 全局布局、样式在这里设置3|--- index.js # 通过 http://yoursite/ 来访问4|--- api/ # API 路由,可以用来实现 severless 函数5 |--- hello.js # 通过 http://yoursite/api/hello 来访问,用不到,可忽略6public/ # 放图片、图标等静态资源7styles/ # 放 css 文件8next.config.js # Next.js 的配置文件

上面看到的首页,其实就是pages/index.js里的东西。Next.js 开箱即支持页面路由,不需要繁琐地配置 React Router,仅需要在pages/目录下新增一个文件,比如pages/about.js,并在里面导出一个组件,如:

pages/about.js
1export default function About() {2 return <h1>Hello World!</h1>;3}

然后就可以通过http://localhost:3000/about来访问这个页面了,简单到令人发指!

整个pages/目录就相当于一个路由表,当需要增加页面路由时,在相应的目录下新建文件即可。我们的文章以 MDX 文件的形式放在posts/目录中。通过“动态”的方式生成,页面的解析逻辑写在pages/post/[id].js文件中,通过链接http://localhost:3000/post/:id来访问相应的文章,具体配置后面会说。

要注意的是,默认情况下,Next.js 只会解析 JS 文件,需要在next.config.js文件中修改配置来支持更多文件类型:

next.config.js
1module.exports = {2 pageExtensions: ["js", "jsx", "md", "mdx"],3};

除此之外,并不是所有在pages/目录下的文件都会被解析生成页面,例外的有api/目录及_app.js。其中api/目录主要用于 severless 函数,暂时用不上,可以整个删掉完全忽略。而pages/_app.js这个文件,可以用来进行一些全局的配置。默认生成的pages/_app.js文件内容如下:

pages/_app.js
1function MyApp({ Component, pageProps }) {2 return <Component {...pageProps} />;3}4 5export default MyApp;

类比 React Router,MyApp相当于那个被BrowserRouter包裹的“全局”组件。传给它的Component组件,则对应了pages/目录下每一个将要被解析的文件中默认导出的组件,相当于每个Route。如解析pages/about.js这个文件时,Component = Aboubt/about这个路由就指向了About组件。

此时,项目的目录结构调整为:

code
1pages/ # 目录中的 js 文件将被解析生成页面2|--- _app.js # 全局布局、样式在这里设置3|--- index.js # 通过 http://yoursite/ 来访问4posts/ # 文章目录,放 mdx 文件5public/ # 放图片、图标等静态资源6styles/ # 放 css 文件7next.config.js # Next.js 的配置文件

第二步:写个布局

我们可以利用pages/_app.js来给所有页面增加所有页面的全局配置,如页面头的信息,布局等。首先,在根目录下新建components/目录,把自定义的组件都丢进去方便管理。创建几个页面布局用的组件,像NavBarFooter这些。然后创一个Layout组件,将这些用于布局的组件像上面一样都引进来。再利用 Next.js 自带的Head组件,添加页面标题,icon,各种 SEO 的标签等。代码如下:

components/Layout.jsx
1import Head from "next/head";2import NavBar from "./Navbar";3import Footer from "./Footer";4 5const PAGE_TITLE = "Kelvin's Blog";6const PAGE_DESCRIPTION = "Kelvin's blog";7 8export default function Layout({ children }) {9 return (10 <>11 // 将被添加到所有页面头12 <Head>13 <meta name="viewport" content="width=device-width, initial-scale=1" />14 <meta charSet="utf-8" />15 <meta name="description" content={PAGE_DESCRIPTION} />16 <link rel="icon" href="/favicon.ico" />17 <title>{PAGE_TITLE}</title>18 </Head>19 // 所有页面通用的布局20 <div className="container">21 <NavBar />22 <main className="main">{children}</main>23 <Footer />24 </div>25 </>26 );27}

Layout组件引入到pages/_app.js中,像下面这样包住<Component />,就可以实现全局的布局了。

pages/_app.js
1import Layout from "components/Layout";2import "styles/main.scss";3 4function MyApp({ Component, pageProps }) {5 return (6 <Layout>7 <Component {...pageProps} />8 </Layout>9 );10}11 12export default MyApp;

第三步:写个首页

Blog 首页

写完基本的布局,是时候来写首页了。Blog 的首页比较简单,就是一个文章的列表,包括文章的标题、发表日期、简介等。这些数据,会在 Next.js 打包时,传给首页组件。

Next.js 支持 SSG 和 SSR 的方式去生成页面,我们是纯静态页面,不考虑 SSR。在 SSG 的过程中,Next.js 提供了getStaticProps()钩子,可以在生成页面的时候去做一些额外的处理,比如从数据库中获取数据,压缩图片等。这些处理过的数据,可以通过getStaticProps()返回的对象(props)传递给页面组件。

假设我们有一个获取文章数据的getAllPosts()函数。我们可以在pages/index.js中导出一个异步函数getStaticProps,在这个函数中调用getAllPosts,将获取到的文章数据postsData包裹在一个对象中返回出来(作为props属性的值)。Next.js 就会在 SSG 的过程中,以props的形式将这个返回值传递给Home组件。

pages/index.js
1function getAllPosts() {2 // 获取文章数据3}4 5// 在打包时调用,返回 props 给 Home 组件生成首页6export async function getStaticProps() {7 const postsData = getAllPosts();8 return {9 props: {10 postsData,11 },12 };13}14 15export default function Home({ postsData }) {16 // 首页17}

那么问题来了,这个getAllPosts()函数,该怎么弄呢?前面讲过,最终我们是要用 MDX 文件来写文章,放在posts/这个目录下,如下:

code
1posts/2|--- first-post.mdx # => http://localhost:3000/post/first-post3|--- second-post.mdx # => http://localhost:3000/post/second-post

那么有没有可能,在这些文件中记录标题、发表日期、简介、分类等信息,然后遍历这个目录下的所有文件,取出这些数据呢?有,我们需要用到 frontmatter

Frontmatter 是 markdown 的插件,通过在 MD 文件头插入键值对的方式,存储有关文件的元数据。我们可以通过 frontmatter 来存储标题、发表日期、简介这些信息,格式如下:

code
1---2title: "使用 Next.js 与 MDX 搭建一个静态博客"3date: "OCT 22, 2021"4desc: "花了几天时间,重新搭了一个 blog。"5type: "horse-sense"6---7 8文章正文部分

使用 gray-matter 这个包,可以将这些元数据提取出来,具体方法为,先安装依赖包:

code
1yarn add gray-matter -D

在根目录新建lib/posts.js文件,实现getAllPosts()函数。大致过程为:

  1. 先遍历posts/目录,拿到所有文件名,并过滤掉只要 MDX 文件
  2. 读取每个文件,通过gray-matter处理
  3. 将提取到的数据与文件名(id)组合
  4. 按发表日期排序后返回

具体代码如下:

lib/posts.js
1import fs from "fs";2import path from "path";3import matter from "gray-matter";4 5export const postsDirectory = path.join(process.cwd(), "posts");6// 获取 /posts 目录下的文件名,过滤只要 MDX 文件7const fileNames = fs8 .readdirSync(postsDirectory)9 .filter((fileName) => fileName.match(/\.mdx$/));10 11export function getAllPosts(type) {12 let postsData = fileNames.map((fileName) => {13 // 去除后缀名,剩下的文件名为 id14 const id = fileName.replace(/\.mdx$/, "");15 16 // MDX 文件路径17 const filePath = path.join(postsDirectory, fileName);18 const fileContent = fs.readFileSync(filePath, "utf-8");19 20 // 读取 MDX 文件头数据21 const { data } = matter(fileContent);22 23 return {24 id,25 ...data,26 };27 });28 // 返回指定类型,且按时间排序的结果29 if (type) postsData = postsData.filter((post) => post.type === type);30 31 return postsData.sort(({ date: a }, { date: b }) => {32 a = new Date(a);33 b = new Date(b);34 if (a < b) {35 return 1;36 } else if (a > b) {37 return -1;38 } else {39 return 0;40 }41 });42}

这里getAllPosts(type)方法还引入了一个type参数,方便获取特定分类的文章数据。

然后在components/目录中新建一个PostList组件,方便在别的页面中复用。组件用到了 Next.js 自带的Link组件,用法可以看官方文档,代码如下:

components/PostList.jsx
1import Link from "next/link";2 3// postData 包含了所有文章的 id、标题、发表日期、简介4export default function PostList({ postsData }) {5 return (6 <ul className="main-posts-list">7 {postsData.map(({ id, title, date, desc }) => (8 <li key={id}>9 <article>10 <div className="posts-list-date">{date}</div>11 <Link href={`/post/${id}`}>12 <a>13 <h1>{title}</h1>14 </a>15 </Link>16 <p>{desc}</p>17 </article>18 </li>19 ))}20 </ul>21 );22}

最后在pages/index.js文件中调用getAllPosts并传给PostList。注意,最后return出来的数据,一定要包在props{}对象中。如此这番折腾之后,我们的首页终于完成了!

pages/index.js
1import PostList from "components/PostList";2import { getAllPosts } from "lib/posts";3 4export async function getStaticProps() {5 const postsData = getAllPosts();6 return {7 props: {8 postsData,9 },10 };11}12 13export default function Home({ postsData }) {14 return <PostList postsData={postsData} />;15}

鉴于篇幅有点过长,关于文章页面的生成,代码高亮的配置等,放在第二篇文章里介绍。