前端开发

基于 Gatsby 打造我的个人博客系统

03 月 12 日 2025 年

碎碎念

搭建一个自己的博客是无数跻身于 IT 行业开发者的心中最质朴的愿望!

回想笔者的历程,刚上大学的时候就摩拳擦掌想要实现一个博客系统,但在 Github 上创建了仓库以后就开始了无限期的拖延:自己没有任何的开发经验,不知道框架也不知道 UI 组件库,只知道要写 HTML + CSS + JavaScript 代码,面对眼前想要实现的完整系统不知该如何下手。

从零开始实现博客系统的计划虽然搁浅,但是拥有一个自己的博客的愿望没有改变。如果自己还没有能力去开发,那末至少基于现有的博客平台去搭建一个博客吧,毕竟对于博客来说,最重要的还是内容本身。终于在大二上学期的时候收集资料,横向对比,考量不同博客平台的优势与短板,最终选择了基于 Hexo 搭建自己的静态博客系统,能够部署在 Github Pages 上而不需要自己去维护服务器。还根据自己搭建的经验,模仿别人写了一篇搭建 Hexo 博客的简易教程,这篇教程也顺理成章成为了自己的第一篇技术博客。

Hexo 由目录结构驱动,简单好用,作为博主可以全身心投入到博客的撰写中去。Hexo 的生态里已经有着非常多的惹眼且实用的博客主题,下载到到对应目录下再修改全局配置项即可一键启用。笔者在大学生涯相当慢热,直到认为自己应该开始做某件事情时,才不留余力地去 push 自己,可谓是充分享受了青春。因此在拥有了博客系统之后,反而失去了很多原初的动力,又因为自己并没有沉浸于对技术的学习中去,也就没有什么好写下来记成博客的。

直到大三下学期,笔者选定了成为前端开发工程师的道路,在众多面经中看到了博客的重要性,于是开始慢慢写起来。将实习遇到的困难,竞赛项目里的开发经验,以及自学过程中的收获整理为一篇篇博客,上传到自己的仓库里。望着终于能翻页的个人博客,一种纯粹的满足感油然而生。

后来又觉得自己的大学生涯有些太平淡了,于是便挑战向 Github 别人的项目提交 PR,其中提的最多的 PR 是给自己在用的博客主题 —— hexo-theme-archer 的,另外还成功申请成为了它的维护者,自发地帮忙去解决堆积的 Issues。现在想来那时候的自己还非常的稚嫩,Issues 里提什么就做什么,没有认真分析过它是否真的是一个问题。例如有个 Issue 提到希望提供图片左右浮动的功能,自己就写了两个样式类塞到主题里并贴上如何使用,但实际上这样的功能只需要博主在撰写博客的时候使用 <img> 标签的形式再添加行内样式就可以了。不过当然也有自认为做得不错的工作,例如看到有人提了一个 PR 为主题添加了暗色模式支持,一下子激发起了我的兴趣,于是在这个 PR 的基础上花了几天的功夫实现了完全的暗色模式适配,想必为大家的眼睛起到了一些保护的作用。

时间流转,随着前端开发成为了自己的工作,站点开发已然成为烹小鲜般的事情,不再神秘。某天笔者偶然看到了 Thesis Theme Vision,很有笔记软件的感觉,十分符合自己对博客系统的审美喜好,终于耐不住想要去复刻实现的愿望,于是又创建了一个 Github 仓库。这一次,确实从零开始了自己的博客系统搭建工程。

技术选型

笔者最初考虑的是为 Hexo 开发一个主题,但是我实在很难投入到学习其支持的 EJS 或 Pug 这些模板语言中去,思来想去还是继续投资到我擅长的且更有未来的 React 中去吧,遂决定另起炉灶。

接下来无论是选择 Next.js 或 Umi 作为开发框架都没有问题,甚至直接基于 React 手搓也没有问题,但笔者还是对 Gatsby 和 Docusaurus 这类所谓的“静态站点生成器”非常感兴趣。考虑到笔者自身对博客系统高可自定义性的需要,最终选择了 Gatsby 作为框架。核心使用到的库版本如下:

  • gatsby@^5.0.0
  • react@^18.0.0

解析并渲染博客

解析 .mdx 文件为博客

不同于 Hexo 的将文件塞到 source/_posts/ 目录下即识别为一篇博客,Gatsby 要求开发者实现“解析数据源到数据层”和“组件从数据层查询”两个阶段的工作。援引官方文档的数据层介绍图片如下:

data layer

解析数据源到数据层可以通过源插件实现,官方约定源插件以 gatsby-source- 开头。笔者现在想要将本地的 .mdx 文件解析到数据层,可以通过插件 gatsby-source-filesystem 实现。编辑 gatsby-config.ts,添加此插件并配置欲解析的本地目录:

import type { GatsbyConfig } from "gatsby";

const config: GatsbyConfig = {
  plugins: [
    {
      resolve: "gatsby-source-filesystem", // 尽管官方文档在使用字符串时都使用的是反引号 `,但笔者更习惯使用单双引号
      options: {
        name: "posts",
        path: "path/to/posts/",
      },
      __key: "posts",
    },
  ],
};

export default config;

上面的配置项表示插件将自动读取 path/to/posts/ 目录下的文件信息。

但这还不够,源插件 gatsby-source-filesystem 仅允许查询与文件相关的元数据,但是却无法读取文件包含的具体内容。为了读取到文件内容,就要用到转换器插件。对于 .mdx 格式的 File 节点,可以通过插件 gatsby-plugin-mdx 转换为 MDX 节点。官方文档里的这张图能直观描述这个过程:

data layer with nodes

继续配置 gatsby-config.ts 如下:

import remarkGfm from "remark-gfm";

const config: GatsbyConfig = {
  plugins: [
    {
      resolve: "gatsby-source-filesystem",
      // ...
    },
    {
      resolve: "gatsby-plugin-mdx",
      options: {
        gatsbyRemarkPlugins: [
          // 添加代码块高亮
          "gatsby-remark-prismjs",
        ],
        mdxOptions: {
          remarkPlugins: [
            // 添加对 GitHub flavored markdown (GFM) 格式的支持
            remarkGfm,
          ],
        },
      },
    },
  ],
};

值得一提的是,由于 MDX 的语法与 Markdown 不同,它默认仅支持 CommonMark 格式,而某些非标准的 Markdown 功能如表格,需要单独配置插件启用。在上面的配置项里,笔者就引入了 remark-gfm 插件,它使得 gatsby-plugin-mdx 能够解析 GFM 格式的语法。但是由于兼容性问题,经测试,目前的 gatsby-plugin-mdx@5.14.0 只能搭配旧版本的 remark-gfm@^1.0.0 使用,不过也足够了。

官方教程在博客的 Frontmatter 中添加了 slug 属性来方便后续生成路由,但笔者更喜好 Hexo 那种文件路径驱动路由生成的模式。为此,可以在 gatsby-node.ts 中导出 onCreateNode() 方法,对于每一个 MDX 节点,根据文件路径生成 slug 属性:

import { type CreateNodeArgs } from "gatsby";

export const onCreateNode = ({ node, actions }: CreateNodeArgs) => {
  const { createNodeField } = actions;
  if (node.internal.type === "Mdx") {
    createNodeField({
      node,
      name: "slug",
      value: parseFilePathToPostSlug(String(node.internal.contentFilePath)),
    });
  }
};

假设撰写了如下的博客文章:

---
title: 从同步 QQ 空间说说到前端呈现,我都做了些啥
date: 2024-10-17
updated: 2024-10-17
timeliness: false
categories:
  - 全栈开发
tags:
  - React
  - TypeScript
  - Node
  - ffmpeg
---

最近在捣腾我的 Timeline 时间线项目,希望将我在不同平台上的发言和活跃记录同步过来,在独立的站点上按照创建时间倒序呈现。

...

访问本地的 GraphQL IDE 界面,查询到的 MDX 节点结果如下:

query mdx nodes

截止目前,笔者完成了“解析数据源到数据层”这一阶段的工作,接下来就需要在组件读取这些 MDX 节点数据并展示了。

渲染博客

Gatsby 的 GraphQL 数据层是它区别于其它前端框架的重要特性,尽管最终构建的产物仍是静态的前端页面,但在开发的过程中引入了这样的类似数据库的概念,体验仿似自己与自己握手,自己同自己进行数据传递。

如果想在 React 组件里消费数据层里准备好的数据,就需要编写对应的 GraphQL 查询代码:

// src/hooks/useAllMdx.ts
import { graphql, useStaticQuery } from "gatsby";

interface MdxNode {
  excerpt: string;
  fields: {
    slug: string;
  };
  frontmatter: {
    categories: string[];
    tags: string[];
    title: string;
    date: string;
    updated: string;
  };
}

export const useAllMdx = () => {
  const {
    allMdx: { nodes: posts },
  } = useStaticQuery<{
    allMdx: { nodes: MdxNode[] };
  }>(graphql`
    query {
      allMdx(sort: { frontmatter: { date: DESC } }) {
        nodes {
          excerpt
          fields {
            slug
          }
          frontmatter {
            categories
            tags
            title
            date
            updated
          }
        }
      }
    }
  `);

  return posts;
};

export default useAllMdx;

一个 React 组件里最多只能有 useStaticQuery() 方法,如果您想要查询多条不同数据,要么把查询代码写到一起,要么就新建一个钩子方法(推荐)。对于全局都会使用到的数据,可以分门别类放到不同的钩子方法里,例如网站的 Metadata,就可以把查询代码放在 src/hooks/useSiteMetadata.ts 里。

现在,笔者需要根据博客的文件路径,自动生成访问路由,例如对于博客文件 path/to/this-is-a-blog.mdx,可以通过路由 /posts/this-is-a-blog 访问到,该怎么实现呢?官方提供了多种方案,这里笔者选择了更符合自己喜好的,在 gatsby-node.ts 文件里导出 createPages() 方法的方案,它能够以编程的方式自定义生成路由,拥有相当大的自由度:

import { type CreatePagesArgs } from "gatsby";
import path from "path";

const postTemplate = path.resolve("./src/templates/post.tsx");

export const createPages = async function ({
  actions,
  graphql,
}: CreatePagesArgs) {
  const { data } = await graphql<{
    allMdx: {
      nodes: MdxNode[];
    };
  }>(`
    query {
      allMdx(
        sort: { frontmatter: { date: DESC } }
        filter: {
          internal: {
            contentFilePath: { regex: "//blog/posts/|/blog/about-me.mdx/" }
          }
        }
      ) {
        nodes {
          fields {
            slug
          }
          id
          internal {
            contentFilePath
          }
        }
      }
    }
  `);

  data?.allMdx.nodes.forEach((node) => {
    const slug = node.fields.slug;
    const path = slug === "about-me" ? "/about-me" : `/posts/${slug}`;

    actions.createPage({
      path,
      component: `${postTemplate}?__contentFilePath=${node.internal.contentFilePath}`,
      context: {
        id: node.id,
      },
    });
  });
};

笔者就根据博客的路径,单独配置了“关于我”页面的路由。此外,笔者读取了 src/templates/post.tsx 文件作为生成路由的页面模板,并按照 gatsby-plugin-mdx 推荐的方式,将 MDX 节点的文件路径作为了查询参数 __contentFilePath 的值,这样模板文件的 props.children 将自动被替换为 MDX 节点包含内容的解析结果。另外,笔者向组件传递了 MDX 节点 id 作为上下文,为模板文件查询 MDX 节点详细信息提供了参数。

下面,编写模板文件 src/templates/post.tsx 即可实现博客页面的展示了:

import { MDXProvider } from "@mdx-js/react";
import { graphql, type PageProps } from "gatsby";
import * as React from "react";

type PostPageData = {
  mdx: Pick<MdxNode, "frontmatter">;
};

type PostPageContext = Pick<MdxNode, "id">;

const PostTemplate: React.FC<PageProps<PostPageData, PostPageContext>> = ({
  children,
  data,
}) => {
  const {
    mdx: {
      frontmatter: { title, date, updated, categories, tags },
    },
  } = data;

  return (
    <div>
      <h1>{title}</h1>
      <article>
        <MDXProvider>{children}</MDXProvider>
      </article>
    </div>
  );
};

export const query = graphql`
  query ($id: String!) {
    mdx(id: { eq: $id }) {
      frontmatter {
        categories
        tags
        title
        date
        updated
      }
    }
  }
`;

export default PostTemplate;

通过上面导出的 query 静态查询字符串,Gatsby 将查询数据层里 mdx.id === props.pageContext.id 的 MDX 节点的指定属性,并把结果传递给 React 组件的 props.data

这样,“组件从数据层查询”这一阶段的工作也完成了,剩下的就是慢慢优化页面逻辑和展示效果了!

添加自定义组件

MDX 格式的博客相比 MD 格式的最大优势在于能够使用自定义的 React 组件:

import { type MDXProps } from "mdx/types";

import Card from "path/to/components/card";

const components: MDXProps["components"] = {
  Card,
};

const PostTemplate = ({ children }) => {
  return (
    <article>
      <MDXProvider components={components}>{children}</MDXProvider>
    </article>
  );
};

现在,在编写 MDX 博客文件的时候,就可以像使用 JSX 一样使用 <Card> 组件了!

card

另外,还可以自定义现有标签渲染的结果。例如想要为图片文件添加 FancyBox 的支持,可以编写代码如下:

import { Fancybox } from "@fancyapps/ui";

const FancyBoxImage = (props: { alt?: string; src?: string }) => {
  const {
    alt = "The author is too lazy to give an alt",
    src,
    ...restProps
  } = props;
  return (
    <a href={src} data-fancybox="gallery" data-caption={alt}>
      <img src={src} alt={alt} {...restProps} />
    </a>
  );
};

const components: MDXProps["components"] = {
  img: FancyBoxImage,
};

const PostTemplate = ({ children }) => {
  const articleRef = React.useRef<HTMLElement>(null);

  React.useEffect(() => {
    Fancybox.bind("[data-fancybox]");
    return () => Fancybox.unbind("[data-fancybox]");
  }, []);

  return (
    <article ref={articleRef}>
      <MDXProvider components={components}>{children}</MDXProvider>
    </article>
  );
};

MDX 渲染得到的 <img> 组件将自动被替换为 <FancyBoxImage> 组件,随后正确地被 FancyBox 绑定。

静态资源的访问与优化

插件 gatsby-remark-images 将自动压缩博客中使用到的本地图片的体积,减少流量压力;同时生成占位图片,避免博客高度突然变化,提升阅读体验。在 Gatsby 的最佳实践中,应当始终使用类似 gatsby-remark-images 这样的插件来优化图片类型的静态资源。

结合插件 gatsby-plugin-mdx 使用,可以配置如下:

const config: GatsbyConfig = {
  plugins: [
    "gatsby-plugin-sharp",
    {
      resolve: "gatsby-plugin-mdx",
      options: {
        gatsbyRemarkPlugins: [
          {
            resolve: "gatsby-remark-images",
            options: {
              maxWidth: 624,
            },
          },
          "gatsby-remark-copy-linked-files",
        ],
      },
    },
  ],
};

其中,gatsby-plugin-sharp 是插件 gatsby-remark-images 的必要依赖,需要添加到插件列表中。

需要留意的是,插件 gatsby-remark-images 仅支持压缩、处理部分的图片格式如 .jpeg。其它的格式如 .gif 则需要插件 gatsby-remark-copy-linked-files 帮助,原封不动地移动到对应的目录里。协同使用这两个插件即可达成最好的静态资源访问效果。

除此之外,由于 gatsby-remark-images 会相应地封装 MDX 渲染结果里的 <img> 标签,为了兼容 FancyBox 能力,可以更新 useEffect() 钩子方法如下:

const PostTemplate = ({ children }) => {
  const articleRef = React.useRef<HTMLElement>(null);

  React.useEffect(() => {
    const optimizedImageLinks =
      articleRef.current?.querySelectorAll<HTMLLinkElement>(
        "a.gatsby-resp-image-link",
      );
    optimizedImageLinks?.forEach((link) => {
      const image = link.children.item(1) as HTMLImageElement;
      link.setAttribute("data-fancybox", "gallery");
      link.setAttribute("data-caption", image.alt);
    });

    Fancybox.bind("[data-fancybox]");
    return () => Fancybox.unbind("[data-fancybox]");
  }, []);

  return (
    <article ref={articleRef}>
      <MDXProvider components={components}>{children}</MDXProvider>
    </article>
  );
};

博客搜索

笔者基于 Algolia 实现博客搜索的能力,插件 gatsby-plugin-algolia 能帮助项目快速实现索引数据并上传的功能。

在 Algolia 控制台创建索引并获取密钥等的操作不再冗述,将这些值塞到环境变量里即可被 Gatsby 访问:

# 包含 GATSBY_ 前缀的环境变量将暴露到客户端环境中
# 后续将用到下面三个变量实现客户端检索功能
GATSBY_ALGOLIA_APP_ID=YOUR_ALGOLIA_APP_ID
GATSBY_ALGOLIA_API_PUBLIC_KEY=YOUR_ALGOLIA_API_PUBLIC_KEY
GATSBY_ALGOLIA_INDEX_NAME=YOUR_ALGOLIA_INDEX_NAME
# 用于上传数据等的 Algolia 管理秘钥切勿添加 GATSBY_ 前缀
ALGOLIA_API_KEY=YOUR_ALGOLIA_API_PRIVATE_KEY

为了上传用于检索的博客元数据到 Algolia,可以像这么配置 gatsby-config.ts

import dotenv from "dotenv";

dotenv.config({
  path: [".env", `.env.${process.env.NODE_ENV}`],
});

const config: GatsbyConfig = {
  plugins: [
    {
      // ... 其它的插件
    },
    {
      // 应当将 gatsby-plugin-algolia 插件放到列表的最后
      // 确保 GraphQL 查询到的结果是经过其它插件处理后的最终结果
      resolve: "gatsby-plugin-algolia",
      options: {
        appId: process.env.GATSBY_ALGOLIA_APP_ID,
        apiKey: process.env.ALGOLIA_API_KEY,
        indexName: process.env.GATSBY_ALGOLIA_INDEX_NAME,
        queries: [
          {
            query: `
              query {
                allMdx(
                  filter: {
                    internal: {
                      contentFilePath: { regex: "//blog/posts//" }
                    }
                  }
                ) {
                  nodes {
                    excerpt(pruneLength: 200)
                    fields {
                      slug
                    }
                    frontmatter {
                      categories
                      tags
                      title
                      date
                      updated
                      timeliness
                    }
                    id
                    internal {
                      contentDigest
                    }
                  }
                }
              }`,
            transformer: ({
              data,
            }: {
              data: {
                allMdx: {
                  nodes: MdxNode[];
                };
              };
            }) => data.allMdx.nodes,
          },
        ],
      },
    },
  ],
};

插件 gatsby-plugin-algolia 需要一个包含博客元数据的数组以执行上传操作,而 GraphQL 查询的结果是一个形如 { addMdx: { nodes: MdxNode[] }} 的对象,因此要使用插件提供的 transformer() 方法,返回里面的 nodes 数组。

为了确保博客在索引里的唯一性,并确保索引里的数据始终最新,在 GraphQL 查询的时候必须提供 idinternal.contentDigest

这样,当执行构建操作时,插件将自动把查询得到的博客元数据上传到指定的 Algolia 索引中去。登录 Algolia 控制台,即可查看上传的结果啦:

algolia index item

客户端搜索博客的功能可以基于 Algolia 提供的前端组件实现,笔者实现的一个简单的例子是:

import { liteClient as algoliasearch } from "algoliasearch/lite";
import {
  Configure,
  Hits,
  InstantSearch,
  Pagination,
  SearchBox,
} from "react-instantsearch";

import Post from "path/to/post";

const searchClient = algoliasearch(
  String(process.env.GATSBY_ALGOLIA_APP_ID),
  String(process.env.GATSBY_ALGOLIA_API_PUBLIC_KEY),
);

const Hit = ({ hit }) => {
  return <Post post={hit} />;
};

const AlgoliaSearch = () => {
  return (
    <InstantSearch
      insights
      searchClient={searchClient}
      indexName={process.env.GATSBY_ALGOLIA_INDEX_NAME}
    >
      <Configure hitsPerPage={10} />
      <SearchBox />
      <Hits<AlgoliaPostItem> hitComponent={Hit} />
      <Pagination />
    </InstantSearch>
  );
};

export default AlgoliaSearch;

这样,客户端的博客搜索能力就实现了:

algolia search item

更多的功能例如自动生成 RSS 订阅也可以参考上面的处理方案,搜索合适的插件配置实现。

路由切换体验优化

有的时候切换路由可能需要几秒钟时间处理,用户看不到页面切换的结果可能会非常焦虑。为了缓解这种焦虑,在库 nprogress 的帮助下,笔者添加了可视化的进度条。

这一切非常简单,只需要在 gatsby-browser.ts 文件导出两个生命周期钩子方法即可:

import nProgress from "nprogress";

export const onRouteUpdateDelayed = () => {
  nProgress.start();
};

export const onRouteUpdate = () => {
  nProgress.done();
};

上面的代码表示,当路由切换耗时大于 1 秒时,显示进度条。当路由切换完成时,隐藏进度条。

当然,别忘了引入 nprogress 的样式文件,如果希望,还可以修改一下进度条的颜色。

持续部署

笔者基于 Github Workflow 实现项目的持续集成与持续部署,可以编写工作流文件 .github/workflows/deployment.yml 如下:

name: Blog System CI & CD

on:
  push:
    branches:
      - main

jobs:
  pages:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Use Node.js 20.x
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "yarn"

      - name: Cache Yarn dependencies
        uses: actions/cache@v4
        id: yarn-cache
        with:
          path: node_modules
          key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
          restore-keys: |
            ${{ runner.os }}-yarn-

      - name: Install dependencies
        run: yarn install --immutable

      - name: Build Gatsby
        run: yarn build

      - name: Deploy
        uses: peaceiris/actions-gh-pages@v4
        with:
          personal_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} # 笔者将产物推送到了外部仓库,因此这里使用到了 Github 私人密钥
          publish_dir: ./public
          external_repository: LolipopJ/LolipopJ.github.io
          publish_branch: main