"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.js 和 MDX 来搭。过程中遇到了无数个坑,总结得到了这篇文章。
之所以选择 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:
1yarn create next-app my-blog
进入项目根目录并运行yarn dev
后访问http://localhost:3000
,如果能看到下面的页面,就证明项目已经顺利跑起来了。
此时项目的目录结构是这样的:
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
,并在里面导出一个组件,如:
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
文件中修改配置来支持更多文件类型:
1module.exports = {2 pageExtensions: ["js", "jsx", "md", "mdx"],3};
除此之外,并不是所有在pages/
目录下的文件都会被解析生成页面,例外的有api/
目录及_app.js
。其中api/
目录主要用于 severless 函数,暂时用不上,可以整个删掉完全忽略。而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
组件。
此时,项目的目录结构调整为:
1pages/ # 目录中的 js 文件将被解析生成页面2|--- _app.js # 全局布局、样式在这里设置3|--- index.js # 通过 http://yoursite/ 来访问4posts/ # 文章目录,放 mdx 文件5public/ # 放图片、图标等静态资源6styles/ # 放 css 文件7next.config.js # Next.js 的配置文件
第二步:写个布局
我们可以利用pages/_app.js
来给所有页面增加所有页面的全局配置,如页面头的信息,布局等。首先,在根目录下新建components/
目录,把自定义的组件都丢进去方便管理。创建几个页面布局用的组件,像NavBar
、Footer
这些。然后创一个Layout
组件,将这些用于布局的组件像上面一样都引进来。再利用 Next.js 自带的Head
组件,添加页面标题,icon,各种 SEO 的标签等。代码如下:
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 />
,就可以实现全局的布局了。
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 的首页比较简单,就是一个文章的列表,包括文章的标题、发表日期、简介等。这些数据,会在 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
组件。
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/
这个目录下,如下:
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 来存储标题、发表日期、简介这些信息,格式如下:
1---2title: "使用 Next.js 与 MDX 搭建一个静态博客"3date: "OCT 22, 2021"4desc: "花了几天时间,重新搭了一个 blog。"5type: "horse-sense"6---7 8文章正文部分
使用 gray-matter 这个包,可以将这些元数据提取出来,具体方法为,先安装依赖包:
1yarn add gray-matter -D
在根目录新建lib/posts.js
文件,实现getAllPosts()
函数。大致过程为:
- 先遍历
posts/
目录,拿到所有文件名,并过滤掉只要 MDX 文件 - 读取每个文件,通过
gray-matter
处理 - 将提取到的数据与文件名(id)组合
- 按发表日期排序后返回
具体代码如下:
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
组件,用法可以看官方文档,代码如下:
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{}
对象中。如此这番折腾之后,我们的首页终于完成了!
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}
鉴于篇幅有点过长,关于文章页面的生成,代码高亮的配置等,放在第二篇文章里介绍。