使用 Next.js 构建 Tailwind 博客

日期

我们团队相信,我们制作的一切都应该以博客文章的形式发布。强迫自己为我们参与的每个项目撰写简短的公告文章,这是一种内置的质量检查,确保我们永远不会将项目称为“完成”,除非我们觉得可以告诉全世界它已经发布了。

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

选择平台

我们是一支开发人员团队,所以自然地,我们无法说服自己使用现成的工具,而是选择使用Next.js构建一些简单且自定义的东西。

Next.js 有很多优点,但我们决定使用它的主要原因是它对MDX有很好的支持,而 MDX 正是我们想要用来编写文章的格式。

# My first MDX post

MDX is a really cool authoring format because it lets
you embed React components right in your markdown:

<MyComponent myProp={5} />

How cool is that?

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.js
next.config.js
package.json
postcss.config.js
README.md
tailwind.config.js

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

元数据

我们在每个 MDX 文件顶部导出的 meta 对象中存储有关每篇文章的元数据。

import { bradlc } from '@/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--> 标签中来定义每篇博文的摘要,从而使我们能够编写与博文内容完全独立的摘要。

const meta = {
  // ...
}

This is the beginning of the post, and what we'd like to
show on the homepage.

<!--​more-->

Anything after that is not included in the bundle unless
you are actually viewing that post.

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

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

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

核心是,我们需要做的是加载所有帖子(理想情况下在构建时),在该列表中找到当前帖子,然后获取之前和之后的帖子,以便我们可以将它们作为 props 传递给页面组件。

结果比我们预期的要难,因为事实证明 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, ''))
    }),
  ],
}

我们还需要使用这个自定义加载器将这些静态 props 传递给我们的 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 的下一个版本。

如果你有兴趣查看这个博客的代码库,甚至提交一个 pull request 来简化我上面提到的任何内容,请 查看 GitHub 上的仓库

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