使用 React Router v7 重构博客

最近我使用 React Router v7 重构了原本使用 Next.js 搭建的个人博客。这篇文章记录了重构的动机、技术选型和实现细节。

1. 为什么要重构?

Next.js 是一个优秀的全栈框架,在 React 基础上预置了 SSR(服务端渲染)和 RSC(React Server Components)等能力。

然而,对于一个简单的静态博客来说,这些能力反而带来了不必要的心智负担:

  • 复杂的约定:需要理解 Next.js 的文件路由约定、特殊生命周期函数,还要时刻考虑代码究竟在 Server 端还是 Client 端执行。
  • 指令限制"use server""use client" 文件在 Hook 使用上有不同的限制。
  • 组件依赖规则:Server Components 可以依赖 Client Components,但反过来不行。

这些约定对于大型团队项目来说,有助于统一架构风格;但对于个人博客,只会增加不必要的复杂度。

于是,我决定换一个更轻量的方案。

2. 为什么选择 React Router v7?

2.1. 技术尝鲜

React Router v7 (RR7) 发布后不久,我就想体验一下。作为个人项目,稳定性不是首要考虑——好玩才是。

2.2. 原生 SSG 支持

RR7 内置支持 SSG(静态站点生成)。配置非常简单,只需在 react-router.config.ts 中设置:

export default {
  prerender: true,  // 开启预渲染
  ssr: false,       // 禁用 SSR,使用纯静态
  appDirectory: "src",
} satisfies Config;

构建时生成的静态页面和资源,可以直接托管到 Vercel、Cloudflare Pages 等平台,无需自己维护服务器。

3. 博客核心功能

3.1. 自定义嵌套文件路由

使用 SSG 的博客通常采用「文件路由」(File Based Route)——通过目录结构组织 Markdown 文件,运行时按相同层级访问页面。

React Router 通过 @react-router/fs-routes 提供了文件路由支持,但它对嵌套路由的处理方式不太理想。例如:

  • posts/index.mdx/posts
  • posts/hello-world.mdx/posts/hello-world

官方方案要求将文件放在同一级目录,使用 . 分隔层级:

  • posts._index.mdx/posts
  • posts.hello-world.mdx/posts/hello-world

这种扁平结构不便于管理,也不够直观。

解决方案:React Router 支持自定义 RouteConfig。我在 routes.ts 中编写了一个递归扫描函数,构建时读取 pages 目录结构,自动生成符合要求的路由配置:

const scanDirectory = (dir: string, basePath: string = "") => {
  const items = readdirSync(dir, { withFileTypes: true });

  for (const item of items) {
    const fullPath = path.join(dir, item.name);

    if (item.isDirectory()) {
      // 递归扫描子目录
      scanDirectory(fullPath, path.join(basePath, item.name));
    } else if (item.isFile()) {
      const ext = path.extname(item.name);
      if (fileExtensions.includes(ext)) {
        const fileName = path.basename(item.name, ext);
        // 构建路由路径
        let routePath = fileName === "index"
          ? `/${basePath}`
          : `/${basePath}/${fileName}`;
        routes.push(route(routePath, fullPath));
      }
    }
  }
};

这样就实现了真正的嵌套文件路由,目录结构与 URL 路径一一对应。

3.2. 项目架构

静态网站通常由两部分组成:内容(Markdown 文件)和生成器(解析、渲染逻辑)。

我的博客也遵循这个分离原则:

├── pages/           # 内容层:Markdown/MDX 文件
│   ├── index.mdx    # 首页
│   └── posts/       # 文章目录
│       ├── index.mdx
│       └── hello-world.mdx
└── src/             # 生成层:React 组件和业务逻辑
    ├── components/
    ├── logic/
    └── routes.ts

pages 文件夹相当于博客的「数据库」。这种按变更频率拆分的方式,有利于项目的稳定迭代——写新文章只需在 pages 目录下添加 MDX 文件,无需修改核心代码。

3.3. Content Collections 管理文章元数据

使用 @content-collections/core 来管理文章的 Frontmatter 元数据。这个库可以:

  • 自动解析 MDX 文件的 frontmatter
  • 提供类型安全的数据访问
  • 在构建时生成文章列表

配置示例:

const posts = defineCollection({
  name: "posts",
  directory: "pages/posts",
  include: "**/*.mdx",
  schema,  // Zod schema 定义 frontmatter 结构
  transform: (data, context) => ({
    ...data,
    // 自动从 # 标题提取 title
    title: data.title ?? getTitleFromContent(data.content),
    _meta: {
      ...data._meta,
      path: getPathnameFromContext(context.collection.directory, data._meta.path),
    },
  }),
});

这样,在组件中就可以直接导入类型安全的文章列表:

import { allPosts } from "content-collections/generated";

3.4. 自定义 MDX 组件

通过 MDXProvider 可以为 MDX 内容注入自定义组件。例如:

  • PostList:渲染文章列表
  • PostWrapper:为每篇文章添加 SEO 元数据
  • 自定义 <a> 标签:统一处理外链
export default {
  PostList,
  wrapper: (props) => <PostWrapper {...props} />,
  a: Anchor,
} satisfies MDXComponents;

3.5. View Transition 主题切换动画

为了让深色/浅色模式切换更加优雅,我使用了 View Transition API 实现了一个圆形扩散的过渡动画。

核心实现思路:

  1. 点击切换按钮时,记录点击坐标
  2. 使用 document.startViewTransition() 启动过渡
  3. 通过 CSS clip-path 实现圆形扩散效果
const x = trigger?.clientX ?? window.innerWidth / 2;
const y = trigger?.clientY ?? window.innerHeight / 2;
const endRadius = Math.hypot(
  Math.max(x, window.innerWidth - x),
  Math.max(y, window.innerHeight - y)
);

document.startViewTransition(() => {
  document.documentElement.classList.toggle("dark", nextIsDark);
});

CSS 动画部分:

:root[data-theme-transition="to-dark"]::view-transition-old(theme) {
  clip-path: circle(var(--vt-radius) at var(--vt-x) var(--vt-y));
  animation: theme-wipe-out 420ms ease-in forwards;
}

@keyframes theme-wipe-out {
  to {
    clip-path: circle(0 at var(--vt-x) var(--vt-y));
  }
}

动画会从点击位置开始,以圆形扩散至全屏,视觉效果非常流畅。

4. 部署

博客通过 Vite 进行构建,最终生成纯静态文件。部署策略如下:

  1. 静态资源:上传至阿里云 OSS,通过 CDN 加速
  2. HTML 页面:部署到自有服务器,配置 Nginx 反向代理

构建命令:

pnpm build  # 生成静态文件
# postbuild 脚本自动上传资源到 OSS

5. 总结

这次重构让我的博客变得更加轻量、灵活。主要收获:

  • 更轻的心智负担:不再需要考虑 Server/Client 边界
  • 更灵活的架构:自定义路由、自定义组件,一切尽在掌控
  • 更好的开发体验:Vite 的 HMR 速度很快,写作体验流畅
  • 有趣的技术探索:View Transition API 等新特性的实践

如果你也在考虑搭建个人博客,React Router v7 + MDX + Content Collections 是一个值得尝试的技术栈组合。

💡 本博客源码开源在 GitHub: suenyiyang/suenyiyang.com