
在 Next.js 13 的 App Router 架构中,构建一个从本地文件(如 Markdown 文章)生成静态页面的博客系统是常见需求。然而,当需要为这些文章列表添加客户端交互功能(如搜索、过滤)时,会遇到一些挑战:
这些限制使得在客户端组件中直接访问本地 Markdown 文件变得复杂,而将 fs 操作与客户端交互结合是核心问题。
解决此问题的关键在于充分利用 Next.js 13 App Router 的服务器组件和客户端组件分离的架构。基本思路是:
这种模式确保了 fs 操作只在服务器端进行,同时允许客户端组件获得所需数据并提供丰富的用户体验。
我们将通过一个博客文章列表的例子来演示这一解决方案。
首先,创建一个包含 fs 操作的工具文件。这些函数将在服务器组件中被调用。
// lib/posts.ts
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { remark } from 'remark';
import html from 'remark-html';
export interface BlogPost {
id: string;
title: string;
date: string;
tags?: string[];
}
export interface BlogPostWithHTML extends BlogPost {
contentHtml: string;
}
const postsDirectory = path.join(process.cwd(), 'public/posts'); // 假设 Markdown 文件存放在 public/posts
/**
* 获取所有文章的ID
* @returns 包含所有文章ID的数组
*/
export function getAllPostIds() {
const fileNames = fs.readdirSync(postsDirectory);
return fileNames.map((fileName) => {
return {
params: {
id: fileName.replace(/\.md$/, ''),
},
};
});
}
/**
* 根据ID获取单篇文章数据(包含HTML内容)
* @param id 文章ID
* @returns 包含文章元数据和HTML内容的BlogPostWithHTML对象
*/
export async function getPostData(id: string): Promise<BlogPostWithHTML> {
const fullPath = path.join(postsDirectory, `${id}.md`);
const fileContents = fs.readFileSync(fullPath, 'utf8');
// 使用 gray-matter 解析文章元数据
const matterResult = matter(fileContents);
// 使用 remark 将 Markdown 内容转换为 HTML
const processedContent = await remark().use(html).process(matterResult.content);
const contentHtml = processedContent.toString();
const blogPostWithHTML: BlogPostWithHTML = {
id,
title: matterResult.data.title,
date: matterResult.data.date,
tags: matterResult.data.tags || [],
contentHtml,
};
return blogPostWithHTML;
}
/**
* 获取所有排序后的文章摘要数据(不包含完整HTML内容,除非列表需要)
* @returns 包含所有文章元数据的数组,按日期降序排列
*/
export async function getSortedPostsData(): Promise<BlogPost[]> {
const fileNames = fs.readdirSync(postsDirectory);
const allPostsData = await Promise.all(
fileNames.map(async (fileName) => {
const id = fileName.replace(/\.md$/, '');
const fullPath = path.join(postsDirectory, fileName);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const matterResult = matter(fileContents);
return {
id,
title: matterResult.data.title,
date: matterResult.data.date,
tags: matterResult.data.tags || [],
};
})
);
// 按日期降序排序
return allPostsData.sort((a, b) => {
if (a.date < b.date) {
return 1;
} else {
return -1;
}
});
}这个服务器组件负责调用 lib/posts.ts 中的函数,获取所有文章数据,然后将数据传递给客户端组件。
// app/blog/page.tsx (这是一个服务器组件)
import { getSortedPostsData, BlogPost } from '@/lib/posts'; // 确保路径正确
import BlogListClient from './components/BlogListClient'; // 客户端组件
export default async function BlogPage() {
const allPostsData: BlogPost[] = await getSortedPostsData();
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">博客文章</h1>
{/* 将所有文章数据作为 props 传递给客户端组件 */}
<BlogListClient posts={allPostsData} />
</div>
);
}这个客户端组件接收服务器组件传递的数据,并实现搜索和过滤的交互逻辑。
// app/blog/components/BlogListClient.tsx
'use client'; // 标记为客户端组件
import React, { useState, useMemo } from 'react';
import Link from 'next/link';
import { BlogPost } from '@/lib/posts'; // 确保路径正确
interface BlogListClientProps {
posts: BlogPost[];
}
export default function BlogListClient({ posts }: BlogListClientProps) {
const [searchTerm, setSearchTerm] = useState('');
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const filteredPosts = useMemo(() => {
let currentPosts = posts;
// 按标签过滤
if (selectedTag) {
currentPosts = currentPosts.filter(post => post.tags?.includes(selectedTag));
}
// 按搜索词过滤
if (searchTerm) {
const lowerCaseSearchTerm = searchTerm.toLowerCase();
currentPosts = currentPosts.filter(
(post) =>
post.title.toLowerCase().includes(lowerCaseSearchTerm) ||
post.id.toLowerCase().includes(lowerCaseSearchTerm)
);
}
return currentPosts;
}, [posts, searchTerm, selectedTag]);
// 获取所有不重复的标签
const allTags = useMemo(() => {
const tags = new Set<string>();
posts.forEach(post => post.tags?.forEach(tag => tags.add(tag)));
return Array.from(tags);
}, [posts]);
return (
<div>
<div className="mb-4 flex flex-wrap gap-2 items-center">
<input
type="text"
placeholder="搜索文章..."
className="p-2 border rounded shadow-sm flex-grow"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<select
className="p-2 border rounded shadow-sm"
value={selectedTag || ''}
onChange={(e) => setSelectedTag(e.target.value || null)}
>
<option value="">所有标签</option>
{allTags.map(tag => (
<option key={tag} value={tag}>{tag}</option>
))}
</select>
</div>
<ul className="space-y-4">
{filteredPosts.length > 0 ? (
filteredPosts.map(({ id, title, date, tags }) => (
<li key={id} className="p-4 border rounded shadow-sm bg-white">
<Link href={`/blog/${id}`} className="text-xl font-semibold text-blue-600 hover:underline">
{title}
</Link>
<p className="text-gray-500 text-sm mt-1">{date}</p>
{tags && tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{tags.map(tag => (
<span key={tag} className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
{tag}
</span>
))}
</div>
)}
</li>
))
) : (
<p className="text-gray-600">没有找到匹配的文章。</p>
)}
</ul>
</div>
);
}对于单个文章页面,仍然可以通过服务器组件直接读取和渲染,无需客户端组件参与 fs 操作。
// app/blog/[id]/page.tsx (这是一个服务器组件)
import { getPostData, getAllPostIds, BlogPostWithHTML } from '@/lib/posts';
import Link from 'next/link';
// generateStaticParams 用于在构建时生成所有可能的 [id] 路径
export async function generateStaticParams() {
const paths = getAllPostIds();
return paths;
}
export default async function PostPage({ params }: { params: { id: string } }) {
const postData: BlogPostWithHTML = await getPostData(params.id);
return (
<article className="container mx-auto p-4 prose lg:prose-xl">
<h1 className="text-4xl font-bold mb-4">{postData.title}</h1>
<p className="text-gray-500 text-sm mb-6">发布日期: {postData.date}</p>
{postData.tags && postData.tags.length > 0 && (
<div className="mb-6 flex flex-wrap gap-2">
{postData.tags.map(tag => (
<span key={tag} className="px-3 py-1 bg-green-100 text-green-800 text-sm rounded-full">
{tag}
</span>
))}
</div>
)}
<div dangerouslySetInnerHTML={{ __html: postData.contentHtml }} />
<div className="mt-8">
<Link href="/blog" className="text-blue-600 hover:underline">
← 返回博客列表
</Link>
</div>
</article>
);
}Next.js 13 App Router 为处理本地数据和客户端交互提供了强大的能力。通过将文件系统操作隔离到服务器组件中,并在构建时预处理所有必要数据,我们可以有效地将这些数据传递给客户端组件,从而实现静态页面上的复杂客户端交互功能,如搜索和过滤。这种模式既保证了构建时的性能和安全性,又提供了灵活的客户端用户体验,是构建高性能、可扩展静态站点的理想选择。
以上就是如何在 Next.js 13 中为带客户端交互的静态页面读取本地数据的详细内容,更多请关注php中文网其它相关文章!
每个人都需要一台速度更快、更稳定的 PC。随着时间的推移,垃圾文件、旧注册表数据和不必要的后台进程会占用资源并降低性能。幸运的是,许多工具可以让 Windows 保持平稳运行。
Copyright 2014-2025 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号