Next.js 博客开发
创建于:2025-09-28 11:27:20
|
更新于:2025-11-12 15:59:45

技术选型

本文将详细介绍一个基于 Next.js 15 构建的现代化博客系统的技术实现,主要涵盖以下几个核心功能:

  • 代码高亮系统 - 基于 Shiki 的语法高亮
  • 主题切换功能 - 支持明暗主题无缝切换
  • 文章索引缓存 - 基于 JSON 的文章元数据缓存
  • MDX 渲染引擎 - 支持 React 组件的 Markdown 渲染
  • Markdown 样式 - 基于 Tailwind CSS Typography 的样式系统

目录结构

blog/
├── src/
│   ├── app/                   # Next.js App Router
│   │   ├── posts/[slug]/      # 动态路由
│   │   └── components/        # 应用级组件
│   ├── components/            # 可复用组件
│   ├── contents/              # MDX 文章
│   ├── lib/                   # 工具函数
│   └── hooks/                 # 自定义 Hooks
├── scripts/                   # 构建脚本
├── public/                    # 静态资源
└── mdx-components.tsx         # MDX 组件配置

代码高亮

项目采用 Shiki 作为代码高亮引擎,通过 rehype-pretty-code 插件集成到 MDX 处理流程中。

{
  "dependencies": {
    "rehype-pretty-code": "^0.14.1",
    "shiki": "^3.6.0"
  }
}
next.config.mjs
import nextMDX from '@next/mdx';
import rehypePrettyCode from 'rehype-pretty-code';
import rehypeSlug from 'rehype-slug';
import remarkFrontmatter from 'remark-frontmatter';
import remarkGfm from 'remark-gfm';
 
/** @type {import('rehype-pretty-code').Options} */
const options = {
  /* 是否保留背景色 */
  keepBackground: false,
  defaultLang: 'typescript',
  theme: {
    light: 'github-light',
    dark: 'github-dark',
  },
};
 
const withMDX = nextMDX({
  extension: /\.(md|mdx)$/,
  options: {
    remarkPlugins: [remarkFrontmatter, remarkGfm],
    rehypePlugins: [[rehypePrettyCode, options], [rehypeSlug]],
  },
});
 
export default withMDX(nextConfig);

通过 CSS 变量实现主题适配的代码高亮:

/* globals.css */
html {
  &.light {
    .shiki,
    .shiki span {
      color: var(--shiki-light) !important;
      background-color: var(--shiki-light-bg) !important;
    }
  }
 
  &.dark {
    .shiki,
    .shiki span {
      color: var(--shiki-dark) !important;
      background-color: var(--shiki-dark-bg) !important;
    }
  }
}

复制功能

code.tsx
'use client';
 
import { Check, Copy } from 'lucide-react';
import { useRef, useState } from 'react';
 
interface CodeProps {
  children: string;
  className?: string;
  [key: string]: any;
}
 
const Code = ({ children, className, ...props }: CodeProps) => {
  const [copied, setCopied] = useState(false);
  const preRef = useRef<HTMLPreElement>(null);
 
  const handleCopy = async () => {
    try {
      await navigator.clipboard.writeText(preRef.current?.innerText || '');
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    } catch (err) {
      console.error('Failed to copy text: ', err);
    }
  };
 
  return (
    <div className="group relative">
      <pre ref={preRef} className={className} {...props}>
        {children}
      </pre>
      <button
        onClick={handleCopy}
        className="bg-fore/20 hover:bg-fore/10 absolute top-2 right-2 rounded-xl p-2 text-xs opacity-0 transition-opacity duration-200 group-hover:opacity-100"
        aria-label="Copy code"
      >
        {copied ? <Check size={16} /> : <Copy size={16} />}
      </button>
    </div>
  );
};
 
export default Code;

通过 mdx-components.tsx 将自定义组件映射到 MDX:

import type { MDXComponents } from 'mdx/types';
import Code from './src/components/code';
 
export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {
    ...components,
    pre: Code, // 将 pre 标签替换为自定义组件
  };
}

Dark Theme

使用 next-themes 库实现主题管理,结合 View Transition API 提供流畅的切换动画。

theme-provider.tsx
layout.tsx
theme-toggle.tsx
'use client';
 
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import type { ThemeProviderProps } from 'next-themes';
 
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

CSS Variables

使用 Tailwind CSS 4 的新特性实现主题变量:

@import 'tailwindcss';
@plugin "@tailwindcss/typography";
 
/* 声明暗黑模式为 class 模式 */
@custom-variant dark (&:where(.dark, .dark *));
 
@layer base {
  html {
    &.light {
      --bg-main: #ffffff;
      --bg-fore: #09090b;
      --bg-card: #fafafa;
    }
 
    &.dark {
      --bg-main: #09090b;
      --bg-fore: #ffffff;
      --bg-card: #18181b;
    }
  }
}
 
/* 将 CSS 变量映射到 Tailwind 类名 */
@theme inline {
  --color-main: var(--bg-main);
  --color-fore: var(--bg-fore);
  --color-card: var(--bg-card);
}

View Transition 动画

/* 主题切换动画 */
::view-transition-group(root) {
  animation-timing-function: var(--expo-out);
}
 
::view-transition-new(root) {
  mask: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs><circle cx="0" cy="0" r="18" fill="white" filter="url(%23blur)"/></svg>')
    top left / 0 no-repeat;
  mask-origin: content-box;
  animation: scale 1s forwards;
  transform-origin: top left;
}
 
::view-transition-old(root) {
  animation: scale 1s;
  transform-origin: top left;
  z-index: -1;
}
 
@keyframes scale {
  to {
    mask-size: 350vmax;
  }
}

文章索引缓存

为了提升性能,博客系统采用预构建的 JSON 索引文件来存储所有文章的元数据,避免运行时频繁的文件系统操作。

gen-posts-index.js
posts.ts
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
 
const POSTS_DIR = path.join(process.cwd(), 'src/contents');
const OUTPUT_FILE = path.join(process.cwd(), 'public/posts-index.json');
 
/**
 * 处理单个文章文件
 */
function processPostFile(file) {
  try {
    const filePath = path.join(POSTS_DIR, file);
    const content = fs.readFileSync(filePath, 'utf-8');
    const { data } = matter(content);
 
    // 验证必要字段
    if (!data.slug || !data.title || !data.date) {
      console.warn(`⚠️  Missing required fields in ${file}`);
      return null;
    }
 
    return {
      slug: data.slug,
      title: data.title,
      date: data.date.toLocaleDateString('zh-CN', {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
      }),
      summary: data.summary || '',
      category: data.category || 'uncategorized',
    };
  } catch (error) {
    console.error(`❌ Error processing file ${file}:`, error.message);
    return null;
  }
}
 
/**
 * 生成文章索引
 */
function generatePostsIndex() {
  try {
    console.log('🚀 Starting posts index generation...');
    
    const files = fs.readdirSync(POSTS_DIR).filter((file) => file.endsWith('.mdx'));
    console.log(`📂 Found ${files.length} MDX files`);
 
    const posts = files
      .map((file) => processPostFile(file))
      .filter(Boolean)
      .sort((a, b) => new Date(b.date) - new Date(a.date));
 
    fs.writeFileSync(OUTPUT_FILE, JSON.stringify(posts, null, 2), 'utf-8');
    console.log(`✅ Generated index with ${posts.length} posts`);
  } catch (error) {
    console.error('❌ Error generating posts index:', error.message);
    process.exit(1);
  }
}
 
generatePostsIndex();

package.json 中集成索引生成:

{
  "scripts": {
    "gen-posts-index": "node scripts/gen-posts-index.js",
    "dev": "npm run gen-posts-index && next dev",
    "build": "npm run gen-posts-index && next build"
  }
}

MDX 渲染引擎

项目使用 Next.js 官方的 MDX 解决方案:

{
  "dependencies": {
    "@mdx-js/loader": "^3.1.0",
    "@mdx-js/react": "^3.1.0",
    "@next/mdx": "^15.3.3",
    "@types/mdx": "^2.0.13"
  }
}

插件配置

MDX 处理流程中集成了多个插件来增强功能:

  • remark-frontmatter - 解析 YAML 前置元数据
  • remark-gfm - 支持 GitHub Flavored Markdown
  • rehype-pretty-code - 语法高亮
  • rehype-slug - 自动生成标题锚点

动态路由实现

[slug]/page.tsx
import { getAllPosts, getPostBySlug } from '@/lib/posts';
import { notFound } from 'next/navigation';
import { TableOfContents } from '@/components/table-of-contents';
 
export interface Params {
  slug: string;
}
 
interface Props {
  params: Promise<Params>;
}
 
// 静态生成所有文章路径
export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}
 
export const revalidate = 3600; // ISR 重新验证间隔
export const dynamicParams = true;
 
export default async function Page({ params }: Props) {
  const { slug } = await params;
 
  let Post, matter;
 
  try {
    // 获取文章元数据
    matter = await getPostBySlug(slug);
    // 动态导入 MDX 组件
    const mod = await import(`@/contents/${slug}.mdx`);
    Post = mod.default;
  } catch (e) {
    return notFound();
  }
 
  return (
    <>
      {/* 文章头部信息 */}
      <div className="my-10">
        <div className="text-fore mb-4 text-center text-3xl font-bold underline">
          {matter?.title}
        </div>
        <div className="text-fore/50 mb-4 text-center text-sm">{matter?.date}</div>
        <div className="flex justify-center gap-1">
          {matter?.category.map((item) => (
            <Link
              key={item}
              className="text-fore/60 bg-fore/10 rounded-xl px-2 py-1 text-xs"
              href={`/categories/${item}`}
            >
              #{item}
            </Link>
          ))}
        </div>
      </div>
 
      {/* 文章内容和目录 */}
      <div className="relative">
        <article className="prose-custom shiki">
          <Post />
        </article>
        <TableOfContents
          className="fixed top-40 right-10 hidden max-h-[60vh] w-64 overflow-auto xl:block"
          maxLevel={4}
        />
      </div>
    </>
  );
}

Markdown 样式系统

Tailwind CSS Typography

项目使用 @tailwindcss/typography 插件提供基础的 Markdown 样式:

@plugin "@tailwindcss/typography";

自定义样式类

prose-custom
.prose-custom {
  @apply prose prose-gray dark:prose-invert max-w-none;
  @apply prose-headings:text-fore;
  @apply prose-h1:text-3xl prose-h1:font-bold prose-h1:mb-6;
  @apply prose-h2:text-2xl prose-h2:font-semibold prose-h2:mt-8 prose-h2:mb-4;
  @apply prose-h3:text-xl prose-h3:font-medium prose-h3:mt-6 prose-h3:mb-3;
  @apply prose-h4:text-lg prose-h4:font-medium prose-h4:mt-4 prose-h4:mb-2;
  @apply prose-p:text-fore prose-p:leading-relaxed;
  @apply prose-a:text-blue-400 prose-a:no-underline prose-a:hover:underline;
  @apply prose-strong:text-fore prose-strong:font-semibold;
  @apply prose-ul:text-fore prose-ol:text-fore;
  @apply prose-li:my-1;
  @apply prose-blockquote:border-l-blue-400 prose-blockquote:text-fore;
  @apply mx-auto max-w-4xl px-6 py-8;
 
  /* 图片样式 */
  img {
    @apply border-main/10 rounded-xl border object-cover dark:invert;
  }
 
  /* 代码块样式 */
  pre {
    @apply bg-fore/5 dark:bg-fore/5 overflow-x-auto rounded-xl p-4;
    @apply border border-gray-200 dark:border-gray-700;
    font-family: inherit;
  }
 
  /* 内联代码样式 */
  :not(pre) > code {
    @apply text-fore rounded-xl bg-gray-100 px-1.5 py-0.5 text-sm dark:bg-gray-800;
    @apply border border-gray-200 dark:border-gray-700;
    font-family: inherit;
 
    &::before,
    &::after {
      content: none; /* 移除默认的引号 */
    }
  }
}

Toc

Toc 即 Table of Contents,文章的目录。

直接 document.querySelectorAll 获取所有标题(h1-h4),然后根据标题的 level 和 id 生成目录。

跟随滚动高亮当前标题则是利用 IntersectionObserver 来监听标题的可见性,然后设置 activeId。这里做过特殊优化,在滚动停止后,触发更新 activeId,避免滚动过程中频繁触发。

table-of-contents.tsx
use-table-of-contents-highlight.ts
'use client';
 
import { useTableOfContentsHighlight } from '@/hooks/use-table-of-contents-highlight';
import { cn } from '@/lib/utils';
import { motion } from 'motion/react';
import { useEffect, useState, useRef } from 'react';
 
/**
 * Heading structure for the table of contents
 */
export interface TocHeading {
  id: string;
  text: string;
  level: number;
}
 
/**
 * Props for the TableOfContents component
 */
export interface TableOfContentsProps {
  /**
   * Custom class name for the container
   */
  className?: string;
  /**
   * Whether to show the table of contents (useful for responsive design)
   * @default true
   */
  show?: boolean;
  /**
   * Maximum heading level to include in TOC
   * @default 4
   */
  maxLevel?: number;
}
 
/**
 * Extract headings from the DOM and create TOC structure
 */
const extractHeadings = (maxLevel: number): TocHeading[] => {
  const headingSelector = Array.from({ length: maxLevel }, (_, i) => `h${i + 1}`).join(', ');
  const headingElements = document.querySelectorAll(headingSelector);
 
  return Array.from(headingElements)
    .filter((heading) => heading.id && heading.textContent)
    .map((heading) => ({
      id: heading.id,
      text: heading.textContent?.trim() || '',
      level: parseInt(heading.tagName.substring(1), 10),
    }));
};
 
/**
 * Table of Contents component that highlights the currently visible section
 *
 * @param props - Component props
 * @returns JSX element for the table of contents
 */
export const TableOfContents = ({
  className = '',
  show = true,
  maxLevel = 4,
}: TableOfContentsProps) => {
  const [headings, setHeadings] = useState<TocHeading[]>([]);
  const { activeId, scrollToHeading } = useTableOfContentsHighlight();
  const activeItemRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    // Extract headings after component mounts
    const tocHeadings = extractHeadings(maxLevel);
    setHeadings(tocHeadings);
  }, [maxLevel]);
 
  useEffect(() => {
    if (activeId && activeItemRef.current) {
      const activeElement = activeItemRef.current;
 
      activeElement.scrollIntoView({
        behavior: 'smooth',
        block: 'center',
      });
    }
  }, [activeId]);
 
  if (!show || headings.length === 0) {
    return null;
  }
 
  return (
    <nav
      className={`toc border-fore/10 border-l px-4 py-2 ${className}`}
      aria-label="Table of contents"
    >
      <div className="text-fore/70 mb-3 text-sm font-semibold tracking-wide uppercase">目录</div>
      <div className="space-y-1">
        {headings.map((heading) => {
          const isActive = activeId === heading.id;
          const indent = (heading.level - 1) * 12; // 12px per level
 
          return (
            <div
              key={heading.id}
              style={{ paddingLeft: `${indent}px` }}
              className="relative"
              ref={isActive ? activeItemRef : null}
            >
              <div
                onClick={(e) => {
                  e.preventDefault();
                  scrollToHeading(heading.id);
                }}
                className={cn(
                  'toc-link hover:text-fore text-fore/60 block w-full cursor-pointer text-left text-sm transition-all duration-200',
                  isActive && 'text-fore',
                )}
              >
                {heading.text}
              </div>
              {isActive && (
                <motion.div
                  className="bg-fore absolute top-0 -left-4 h-full w-0.5"
                  layoutId="toc-active-indicator"
                ></motion.div>
              )}
            </div>
          );
        })}
      </div>
    </nav>
  );
};
我也是有底线的 🫠