作为团队,我们相信的一件事是,我们所做的每一件事都应该用一篇博客文章来“封存”。强迫自己为每个项目撰写一篇简短的公告文章就像是一种内置的质量检查,确保我们在感觉可以告诉世界它已经发布之前,绝不称一个项目为“完成”。
🌐 One of the things we believe as a team is that everything we make should be sealed with a blog post. Forcing ourselves to write up a short announcement post for every project we work on acts as a built-in quality check, making sure that we never call a project "done" until we feel comfortable telling the world it's out there.
问题是,直到今天,我们实际上还没有地方发布这些文章!
🌐 The problem was that up until today, we didn't actually have anywhere to publish those posts!
选择平台(Choosing a platform)
我们是一支开发者团队,所以自然无法说服自己去使用现成的东西,而是选择用 Next.js 构建一些简单且定制的东西。
🌐 We're a team of developers so naturally there was no way we could convince ourselves to use something off-the-shelf, and opted to build something simple and custom with Next.js.
Next.js 有很多值得喜欢的地方,但我们决定使用它的主要原因是它对 MDX 有很好的支持,而这正是我们想用来撰写文章的格式。
🌐 There are a lot of things to like about Next.js, but the primary reason we decided to use it is that it has great support for MDX, which is the format we wanted to use to author our posts.
# My first MDX postMDX is a really cool authoring format because it letsyou embed React components right in your markdown:<MyComponent myProp={5} />How cool is that?MDX 非常有趣,因为与普通 Markdown 不同,你可以在内容中直接嵌入实时的 React 组件。这很令人兴奋,因为它为你在写作中传达想法的方式打开了许多可能性。你不仅仅依赖图片、视频或代码块,还可以构建互动演示,并将它们直接放在两段内容之间,而无需放弃 Markdown 的创作便利性。
🌐 MDX is really interesting because unlike regular Markdown, you can embed live React components directly in your content. This is exciting because it unlocks a lot of opportunities in how you communicate ideas in your writing. Instead of relying only on images, videos, or code blocks, you can build interactive demos and stick them directly between two paragraphs of content, without throwing away the ergonomics of authoring in Markdown.
我们计划在今年晚些时候重新设计和重建 Tailwind CSS 文档网站,能够嵌入交互式组件对我们讲解框架工作原理的能力至关重要,因此将我们的小博客网站作为测试项目非常有意义。
🌐 We're planning to do a redesign and rebuild of the Tailwind CSS documentation site later this year and being able to embed interactive components makes a huge difference in our ability to teach how the framework works, so using our little blog site as a test project made a lot of sense.
内容组织(Organizing our content)
我们一开始是将文章作为简单的 MDX 文档直接存放在 pages 目录中。然而,最终我们意识到几乎每篇文章都会有相关的资源,例如至少需要一张 Open Graph 图片。
🌐 We started by writing posts as simple MDX documents that lived directly in the pages directory. Eventually though we realized that just about every post would also have associated assets, for example an Open Graph image at the bare minimum.
必须将这些内容存放在另一个文件夹里感觉有点凌乱,所以我们决定改为在 pages 目录下为每个帖子创建自己的文件夹,并将帖子内容放在一个 index.mdx 文件中。
🌐 Having to store those in another folder felt a bit sloppy, so we decided instead to give every post its own folder in the pages directory, and put the post content in an index.mdx file.
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 将这些资源直接导入到文章中。
🌐 This let us co-locate any assets for that post in the same folder, and leverage webpack's file-loader to import those assets directly into the post.
元数据(Metadata)
我们在 meta 对象中存储每篇文章的元数据,并在每个 MDX 文件的顶部导出它:
🌐 We store metadata about each post in a meta object that we export at the top of each MDX file:
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 讨论线程链接。
🌐 This is where we define the post title (used for the actual h1 on the post page and the page title), the description (for Open Graph previews), the publish date, the authors, the Open Graph image, and a link to the GitHub Discussions thread for the post.
我们将所有作者数据存储在一个单独的文件中,该文件仅包含每个团队成员的名称、Twitter 用户名和头像。
🌐 We store all of our authors data in a separate file that just contains each team member's name, Twitter handle, and avatar.
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,};将作者对象实际导入到帖子中而不是通过某种标识符连接它的好处是,我们可以轻松地在需要时内联添加作者:
🌐 The nice thing about actually importing the author object into a post instead of connecting it through some sort of identifier is that we can easily add an author inline if we wanted to:
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", }, ], // ...};这使我们能够通过为作者信息提供一个集中的数据源来轻松地保持同步,同时又不放弃任何灵活性。
🌐 This makes it easy for us to keep author information in sync by giving it a central source of truth, but doesn't give up any flexibility.
显示帖子预览(Displaying post previews)
我们希望在主页上显示每篇文章的预览,而这却出乎意料地具有挑战性。
🌐 We wanted to display previews for each post on the homepage, and this turned out to be a surprisingly challenging problem.
本质上,我们想要做到的是使用 Next.js 的 getStaticProps 功能在构建时获取所有文章的列表,提取我们需要的信息,并将其传递给实际的页面组件进行渲染。
🌐 Essentially what we wanted to be able to do was use the getStaticProps feature of Next.js to get a list of all the posts at build-time, extract the information we need, and pass that in to the actual page component to render.
问题在于,我们希望在不实际导入每一页的情况下完成这件事,因为那样的话,我们主页的打包文件就会包含整个网站的每一篇博客文章,从而导致打包文件比必要的要大得多。现在只有几篇文章时可能没什么大问题,但一旦文章数量达到几十或几百篇,那将会浪费大量字节。
🌐 The challenge is that we wanted to do this without actually importing every single page, because that would mean that our bundle for the homepage would contain every single blog post for the entire site, leading to a much bigger bundle than necessary. Maybe not a big deal right now when we only have a couple of posts, but once you're up to dozens or hundreds of posts that's a lot of wasted bytes.
我们尝试了几种不同的方法,但最终我们采用的是使用 webpack 的 resourceQuery 功能,结合几个自定义加载器,使得每篇博客文章可以以两种格式加载:
🌐 We tried a few different approaches but the one we settled on was using webpack's resourceQuery feature combined with a couple of custom loaders to make it possible to load each blog post in two formats:
- 整篇文章,用于文章页面。
- 文章预览,我们会在其中加载首页所需的最少数据。
我们设置的方式是,每当我们在导入单个帖子的末尾添加 ?preview 查询时,我们会得到该帖子的一个更小版本,只包含元数据和预览摘要,而不是整个帖子内容。
🌐 The way we set it up, any time we add a ?preview query to the end of an import for an individual post, we get back a much smaller version of that post that just includes the metadata and the preview excerpt, rather than the entire post content.
以下是自定义加载器的部分代码片段:
🌐 Here's a snippet of what that custom loader looks like:
{ 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--> 标签中,为每篇文章定义摘要,从而使我们能够撰写与文章内容完全独立的摘要。
🌐 It lets us define the excerpt for each post either by sticking <!--more--> after the intro paragraph, or by wrapping the excerpt in a pair of <!--excerpt--> and <!--/excerpt--> tags, allowing us to write an excerpt that's completely independent from the post content.
export const meta = { // ...}This is the beginning of the post, and what we'd like toshow on the homepage.<!--more-->Anything after that is not included in the bundle unlessyou are actually viewing that post.以优雅的方式解决这个问题颇具挑战性,但最终我们找到了一个很酷的解决方案,让我们可以将所有内容保存在一个文件中,而不是分别使用单独的文件来保存预览和实际的帖子内容。
🌐 Solving this problem in an elegant way was pretty challenging, but ultimately it was cool to come up with a solution that let us keep everything in one file instead of using a separate file for the preview and the actual post content.
生成下一篇/上一篇帖子链接(Generating next/previous post links)
我们在构建这个简单网站时遇到的最后一个挑战是,当你查看单个帖子时,能够包含指向下一篇和上一篇帖子的链接。
🌐 The last challenge we had when building this simple site was being able to include links to the next and previous post whenever you're viewing an individual post.
本质上,我们需要做的是加载所有文章(最好是在构建时),在列表中找到当前文章,然后获取之前和之后的文章,以便将它们作为 props 传递给页面组件。
🌐 At its core, what we needed to do was load up all of the posts (ideally at build-time), find the current post in that list, then grab the post that came before and the post that came after so we could pass those through to the page component as props.
这比我们预期的要难,因为事实证明 MDX 目前并不支持像你通常使用 getStaticProps 那样使用它。你实际上无法直接从 MDX 文件中导出它,而是必须将代码存储在单独的文件中,然后从那里重新导出。
🌐 This ended up being harder than we expected, because it turns out that MDX doesn't currently support getStaticProps the way you'd normally use it. You can't actually export it directly from your MDX files, instead you have to store your code in a separate file and re-export it from there.
我们不想在仅在主页上导入我们的文章 预览 时加载这些额外的代码,同时也不想在每篇文章中重复这段代码,所以我们决定使用另一个自定义加载器将这个导出添加到每篇文章的开头:
🌐 We didn't want to load this extra code when just importing our post previews on the homepage, and we also didn't want to have to repeat this code in every single post, so we decided to prepend this export to the beginning of each post using another custom loader:
{ 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 组件,因此我们还添加了上面看到的那个额外的导出。
🌐 We also needed to use this custom loader to actually pass those static props to our Post component, so we appended that extra export you see above as well.
不过,这并不是唯一的问题。事实证明,getStaticProps 并不会提供任何关于当前正在渲染页面的信息,因此在尝试确定上下篇文章时,我们无法知道自己正在查看哪一篇文章。我怀疑这是可以解决的,但由于时间有限,我们选择将更多的工作放在客户端完成,而在构建时减少处理,这样我们在尝试确定需要哪些链接时就能实际看到当前的路由。
🌐 This wasn't the only issue though. It turns out getStaticProps doesn't give you any information about the current page being rendered, so we had no way of knowing what post we were looking at when trying to determine the next and previous posts. I suspect this is solvable, but due to time constraints we opted to do more of that work on the client and less at build time, so we could actually see what the current route was when trying to figure out which links we needed.
我们加载 getStaticProps 中的所有帖子,并将它们映射到非常轻量的对象中,这些对象只包含帖子的 URL 和帖子标题:
🌐 We load up all of the posts in getStaticProps, and map them to very lightweight objects that just contain the URL for the post, and the post title:
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 布局组件中,我们使用当前路由来确定上一篇和下一篇文章:
🌐 Then in our actual Post layout component, we use the current route to determine the next and previous posts:
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 中的上一条和下一条帖子,而不是全部加载。
🌐 This works well enough for now, but again long-term I'd like to figure out a simpler solution that lets us load only the next and previous posts in getStaticProps instead of the entire thing.
Hashicorp 有一个有趣的库,名为 Next MDX Remote,旨在使我们能够像对待数据源一样处理 MDX 文件,我们可能会在未来探索它。它应该让我们切换到基于动态 slug 的路由,这将使我们能够在 getStaticProps 中访问当前的路径名,并赋予我们更多的功能。
🌐 There's an interesting library by Hashicorp designed to make it possible to treat MDX files like a data source called Next MDX Remote that we will probably explore in the future. It should let us switch to dynamic slug-based routing which would give us access to the current pathname in getStaticProps and give us a lot more power.
总结(Wrapping up)
总的来说,使用 Next.js 构建这个小网站是一次有趣的学习体验。我总是对许多工具中看似简单的事情最终会变得如此复杂感到惊讶,但我对 Next.js 的未来非常看好,并期待在接下来的几个月里用它构建 tailwindcss.com 的下一版本。
🌐 Overall, building this little site with Next.js was a fun learning experience. I'm always surprised at how complicated seemingly simple things end up being with a lot of these tools, but I'm very bullish on the future of Next.js and looking forward to building the next iteration of tailwindcss.com with it in the months to come.
如果你有兴趣查看这个博客的代码库,或者甚至提交一个拉取请求来简化我上面提到的任何内容,请在 GitHub 上查看该仓库。
🌐 If you're interested in checking out the codebase for this blog or even submitting a pull request to simplify any of the things I mentioned above, check out the repository on GitHub.
想讨论这篇文章吗? 在 GitHub 上讨论 →