使用 Next.js 构建 Tailwind 博客

Adam Wathan

作为一个团队,我们相信我们制作的每一件事都应该以一篇博客文章来封印。强迫自己为每个项目写一篇简短的发布文章作为内置的质量检查,确保在我们觉得舒服地向世界宣布它之前,永远不会称一个项目"完成"。

问题是直到今天,我们实际上还没有地方发布这些文章!

选择平台

我们是一个开发团队,所以很自然地我们无法说服自己使用现成的解决方案,而是选择用 Next.js 构建一些简单和定制的东西。

Next.js 有很多值得喜欢的地方,但我们决定使用它的主要原因是它对 MDX 有很好的支持,这是我们想要用来撰写文章的格式。

# 我的第一篇 MDX 文章MDX 是一个非常酷的撰写格式,因为它让你可以直接在 markdown 中嵌入 React 组件:<MyComponent myProp={5} />这是不是很酷?

MDX 很有趣,因为与普通的 Markdown 不同,你可以直接在内容中嵌入活动的 React 组件。这很令人兴奋,因为它为你在写作中交流想法的方式打开了许多机会。不仅仅依靠图片、视频或代码块,你还可以构建交互式演示并直接放在两段内容之间,而不会失去使用 Markdown 编写的人体工程学。

我们计划在今年晚些时候重新设计和重建 Tailwind CSS 文档网站,能够嵌入交互式组件对我们教授框架的工作原理有很大帮助,所以使用我们的小博客网站作为测试项目是很有意义的。

组织我们的内容

我们开始时将文章写成简单的 MDX 文档,直接放在 pages 目录中。不过最终我们意识到几乎每篇文章都会有相关的资产,例如至少会有一个 Open Graph 图片。

将这些存储在另一个文件夹中感觉有点草率,所以我们决定给 pages 目录中的每篇文章一个单独的文件夹,并将文章内容放在 index.mdx 文件中。

public/src/├── components/├── css/├── img/└── pages/    ├── building-the-tailwindcss-blog/    │   ├── index.mdx    │   └── card.jpeg    ├── introducing-linting-for-tailwindcss-intellisense/    │   ├── index.mdx    │   ├── css.png    │   ├── html.png    │   └── card.jpeg    ├── _app.js    ├── _document.js    └── index.jsnext.config.jspackage.jsonpostcss.config.jsREADME.mdtailwind.config.js

这让我们可以将该文章的任何资产放在同一个文件夹中,并利用 webpack 的 file-loader 直接将这些资产导入文章中。

元数据

我们在每个 MDX 文件的顶部导出一个 meta 对象来存储每篇文章的元数据:

import { bradlc } from "@/app/blog/authors";import openGraphImage from "./card.jpeg";export const meta = {  title: "Introducing linting for Tailwind CSS IntelliSense",  description: `Today we’re releasing a new version of the Tailwind CSS IntelliSense extension for Visual Studio Code that adds Tailwind-specific linting to both your CSS and your markup.`,  date: "2020-06-23T18:52:03Z",  authors: [bradlc],  image: openGraphImage,  discussion: "https://github.com/tailwindcss/tailwindcss/discussions/1956",};// Post content goes here

这是我们定义文章标题(用于文章页面的实际 h1 和页面标题)、描述(用于 Open Graph 预览)、发布日期、作者、Open Graph 图片以及文章的 GitHub Discussions 线程链接的地方。

我们将所有作者数据存储在一个单独的文件中,该文件仅包含每个团队成员的姓名、Twitter 账号和头像。

import adamwathanAvatar from "./img/adamwathan.jpg";import bradlcAvatar from "./img/bradlc.jpg";import steveschogerAvatar from "./img/steveschoger.jpg";export const adamwathan = {  name: "Adam Wathan",  twitter: "@adamwathan",  avatar: adamwathanAvatar,};export const bradlc = {  name: "Brad Cornes",  twitter: "@bradlc",  avatar: bradlcAvatar,};export const steveschoger = {  name: "Steve Schoger",  twitter: "@steveschoger",  avatar: steveschogerAvatar,};

实际上将作者对象导入文章而不是通过某种标识符连接它的好处是,如果我们想要,可以很容易地内联添加一个作者:

export const meta = {  title: "An example of a guest post by someone not on the team",  authors: [    {      name: "Simon Vrachliotis",      twitter: "@simonswiss",      avatar: "https://pbs.twimg.com/profile_images/1160929863/n510426211_274341_6220_400x400.jpg",    },  ],  // ...};

这使我们可以通过提供一个中央的真实来源来保持作者信息同步,但不会放弃任何灵活性。

显示文章预览

我们希望在主页上显示每篇文章的预览,这证明是一个出乎意料的挑战问题。

基本上我们想要做的是使用 Next.js 的 getStaticProps 功能在构建时获取所有文章的列表,提取我们需要的信息,并将其传递给实际的页面组件进行渲染。

挑战在于我们希望在不实际导入每个页面的情况下做到这一点,因为这意味着我们的主页包将包含整个站点的每篇博客文章,导致比必要的包大得多。现在我们只有几篇文章时这可能不是大问题,但一旦你有几十或几百篇文章,那就是很多浪费的字节。

我们尝试了几种不同的方法,但最终我们选择了使用 webpack 的 resourceQuery 功能结合几个自定义加载器,使其可以以两种格式加载每篇博客文章:

  1. 整篇文章,用于文章页面。
  2. 文章预览,我们加载主页所需的最小数据。

我们设置的方式是,每次我们在单个文章的导入末尾添加 ?preview 查询时,我们会得到该文章的一个小得多的版本,只包含元数据和预览摘录,而不是整个文章内容。

这是该自定义加载器的一个片段:

{  resourceQuery: /preview/,  use: [    ...mdx,    createLoader(function (src) {      if (src.includes('<!--​more​-->')) {        const [preview] = src.split('<!--​more​-->')        return this.callback(null, preview)      }      const [preview] = src.split('<!--​/excerpt​-->')      return this.callback(null, preview.replace('<!--​excerpt​-->', ''))    }),  ],}

它让我们可以通过在介绍段落后面添加 <!--​more--> 或者将摘录包裹在一对 <!--​excerpt--><!--​/excerpt--> 标签中来定义每篇文章的摘录,使我们可以编写一个完全独立于文章内容的摘录。

export const meta = {  // ...}这是文章的开头,也是我们希望在主页上显示的内容。<!--​more-->之后的任何内容都不会包含在包中,除非你实际在查看该文章。

以优雅的方式解决这个问题非常具有挑战性,但最终能够找到一个解决方案,让我们将所有内容保存在一个文件中,而不是为预览和实际文章内容使用单独的文件,这很酷。

生成下一篇/上一篇文章链接

构建这个简单网站时我们遇到的最后一个挑战是能够在查看单个文章时包含指向下一篇和上一篇文章的链接。

其核心是,我们需要在构建时加载所有文章,找到列表中的当前文章,然后抓取前一篇和后一篇文章,以便将它们作为道具传递给页面组件。

这比我们预期的要难,因为事实证明 MDX 目前不支持你通常使用的 getStaticProps。你不能直接从 MDX 文件中导出它,而是必须将代码存储在单独的文件中并从那里重新导出。

我们不希望在主页上仅导入我们的文章 预览 时加载这段额外的代码,也不希望在每篇文章中重复这段代码,所以我们决定使用另一个自定义加载器将这个导出添加到每篇文章的开头:

{  use: [    ...mdx,    createLoader(function (src) {      const content = [        'import Post from "@/components/Post"',        'export { getStaticProps } from "@/getStaticProps"',        src,        'export default (props) => <Post meta={meta} {...props} />',      ].join('\n')      if (content.includes('<!--​more-->')) {        return this.callback(null, content.split('<!--​more-->').join('\n'))      }      return this.callback(null, content.replace(/<!--​excerpt-->.*<!--\/excerpt-->/s, ''))    }),  ],}

我们还需要使用这个自定义加载器将这些静态道具传递给我们的 Post 组件,所以我们也附加了上面看到的额外导出。

但这不是唯一的问题。事实证明 getStaticProps 不会给你任何关于当前正在渲染的页面的信息,所以我们在尝试确定下一篇和上一篇文章时无法知道我们正在查看的是什么文章。我怀疑这是可以解决的,但由于时间限制,我们选择在客户端上做更多的工作,而不是在构建时做更多的工作,这样我们在尝试确定需要哪些链接时可以实际看到当前的路由。

我们在 getStaticProps 中加载所有文章,并将它们映射到仅包含文章 URL 和文章标题的非常轻量级的对象:

import getAllPostPreviews from "@/getAllPostPreviews";export async function getStaticProps() {  return {    props: {      posts: getAllPostPreviews().map((post) => ({        title: post.module.meta.title,        link: post.link.substr(1),      })),    },  };}

然后在我们实际的 Post 布局组件中,我们使用当前路由来确定下一篇和上一篇文章:

export default function Post({ meta, children, posts }) {  const router = useRouter();  const postIndex = posts.findIndex((post) => post.link === router.pathname);  const previous = posts[postIndex + 1];  const next = posts[postIndex - 1];  // ...}

目前这工作得很好,但从长远来看,我希望找到一个更简单的解决方案,让我们在 getStaticProps 中仅加载下一篇和上一篇文章,而不是整个内容。

Hashicorp 有一个有趣的库,旨在使将 MDX 文件视为数据源成为可能,称为 Next MDX Remote,我们可能会在未来探索。它应该让我们切换到基于动态 slug 的路由,这将使我们在 getStaticProps 中访问当前路径名,并赋予我们更多的权力。

总结

总的来说,用 Next.js 构建这个小网站是一个有趣的学习体验。我总是对这些工具中看似简单的事情变得如此复杂感到惊讶,但我对 Next.js 的未来非常看好,并期待在未来几个月内用它构建 tailwindcss.com 的下一个版本。

如果你有兴趣查看这个博客的代码库,甚至提交一个拉取请求来简化我上面提到的任何事情,请查看 GitHub 上的存储库

想讨论这篇文章吗?在 GitHub 上讨论 →

Get all of our updates directly to your inbox.
Sign up for our newsletter.