技术选型
本文将详细介绍一个基于 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>
);
};