本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。
这是全栈博客系统实战的下篇 。在上篇《全栈博客系统架构与核心功能》中,我们完成了数据库设计、认证系统、Server Actions 等后端核心功能。本篇将聚焦前端页面开发、用户体验优化和生产部署,带你完成从代码到上线的完整流程。
一、📖 前置准备
在开始之前,请确保你已经:
- ✅ 完成了上篇的所有内容
- ✅ 数据库已初始化并运行
- ✅ Auth.js 配置完成
- ✅ Server Actions 可以正常调用
如果还没有,建议先回顾上篇内容《博客系统架构与核心功能》。
二、🎨 Markdown 渲染与代码高亮
1. 为什么选择 MDX?
传统 Markdown 的局限性:
- ❌ 无法使用 React 组件
- ❌ 交互功能受限
- ❌ 动态内容难以集成
MDX (Markdown + JSX) 的优势:
- ✅ 在 Markdown 中嵌入 React 组件
- ✅ 支持自定义渲染逻辑
- ✅ 完美的 TypeScript 类型支持
例如,你可以在文章中这样写:
md
这是一段普通文本。
<Callout type="info">
这是一个提示框组件!
</Callout>
```javascript
console.log('代码块自动高亮');
```
2. 安装依赖
bash
npm install next-mdx-remote shiki rehype-autolink-headings rehype-slug
依赖说明:
next-mdx-remote: 在服务端安全地渲染 MDXshiki: VS Code 同款语法高亮引擎(比 Prism.js 更准确)rehype-autolink-headings: 自动为标题添加锚点链接rehype-slug: 为标题生成 ID
3. 创建 MDX 渲染器组件
创建 components/MDXRenderer.tsx:
ts
// components/MDXRenderer.tsx
import { MDXRemote } from 'next-mdx-remote/rsc';
import { serialize } from 'next-mdx-remote/serialize';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypeSlug from 'rehype-slug';
import { CodeBlock } from './CodeBlock';
import { Callout } from './Callout';
interface MDXRendererProps {
content: string;
}
/**
* MDX 内容渲染器
*
* 工作流程:
* 1. serialize: 将 Markdown 字符串编译为 MDX AST
* 2. MDXRemote: 在服务端渲染为 HTML
* 3. components: 自定义组件映射表
*
* @param content - Markdown 内容
*/
export async function MDXRenderer({ content }: MDXRendererProps) {
// 序列化 MDX 内容
const mdxSource = await serialize(content, {
mdxOptions: {
rehypePlugins: [
rehypeSlug, // 先生成 slug
[rehypeAutolinkHeadings, {
behavior: 'wrap', // 将整个标题包装为链接
properties: {
className: ['anchor-link'],
},
}],
],
},
});
return (
<article className="prose prose-lg max-w-none dark:prose-invert prose-headings:relative">
<MDXRemote
{...mdxSource}
components={{
// 自定义代码块渲染
pre: CodeBlock,
// 自定义提示框
Callout,
// 可以添加更多自定义组件
img: CustomImage,
a: CustomLink,
}}
/>
</article>
);
}
/**
* 自定义图片组件(懒加载)
*/
function CustomImage(props: React.ImgHTMLAttributes<HTMLImageElement>) {
return (
<img
{...props}
loading="lazy" // 懒加载
className="rounded-lg shadow-md"
/>
);
}
/**
* 自定义链接组件(外部链接新窗口打开)
*/
function CustomLink(props: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
const href = props.href;
const isExternal = href?.startsWith('http');
return (
<a
{...props}
{...(isExternal && {
target: '_blank',
rel: 'noopener noreferrer',
})}
className="text-blue-600 hover:underline"
/>
);
}
🔍 代码解析:
(1) 为什么使用 next-mdx-remote/rsc?
typescript
import { MDXRemote } from 'next-mdx-remote/rsc'; // RSC 版本
- RSC 版本: 在服务端渲染,性能更好
- 客户端版本 :
next-mdx-remote/client,用于交互式 MDX
(2) Rehype 插件的作用
typescript
rehypePlugins: [
rehypeSlug, // 为 h1-h6 添加 id 属性
[rehypeAutolinkHeadings, { behavior: 'wrap' }], // 将标题变为可点击链接
]
执行顺序很重要:
rehypeSlug先执行,生成id="introduction"rehypeAutolinkHeadings后执行,包裹为<a href="#introduction"><h2>...</h2></a>
(3) Components 映射表
typescript
components={{
pre: CodeBlock, // 替换所有 <pre> 标签
Callout, // 支持自定义 <Callout> 组件
}}
当 MDX 中出现 <pre> 时,会自动使用 CodeBlock 组件渲染。
4. 代码高亮组件
创建 components/CodeBlock.tsx:
ts
// components/CodeBlock.tsx
import { codeToHtml } from 'shiki';
interface CodeBlockProps {
children: React.ReactNode;
className?: string;
}
/**
* 代码块组件(带语法高亮)
*
* Shiki 优势:
* - 使用 TextMate grammar,与 VS Code 一致
* - 支持主题切换
* - 输出静态 HTML,无运行时 JS
*/
export async function CodeBlock({ children, className }: CodeBlockProps) {
// 提取语言信息(如 language-jsx)
const match = /language-(\w+)/.exec(className || '');
const lang = match ? match[1] : 'text';
// 获取代码内容
const code = String(children).replace(/\n$/, '');
// 使用 Shiki 生成高亮 HTML
const html = await codeToHtml(code, {
lang,
theme: 'github-dark', // 可切换主题
});
return (
<div className="relative my-6 rounded-lg overflow-hidden">
{/* 语言标签 */}
<div className="absolute top-2 right-2 px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded">
{lang}
</div>
{/* 高亮代码 */}
<div
dangerouslySetInnerHTML={{ __html: html }}
className="overflow-x-auto"
/>
</div>
);
}
⚡ 性能优化:
Shiki 是异步的,所以组件必须是 async:
tsx
export async function CodeBlock({ ... }) {
const html = await codeToHtml(code, { ... });
// ...
}
Next.js 会在服务端等待异步操作完成,然后缓存结果。
5. 提示框组件
创建 components/Callout.tsx:
ts
// components/Callout.tsx
interface CalloutProps {
type?: 'info' | 'warning' | 'error' | 'success';
children: React.ReactNode;
}
const icons = {
info: '💡',
warning: '⚠️',
error: '❌',
success: '✅',
};
const styles = {
info: 'bg-blue-50 border-blue-200 text-blue-800',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
error: 'bg-red-50 border-red-200 text-red-800',
success: 'bg-green-50 border-green-200 text-green-800',
};
/**
* 提示框组件
*
* 使用示例:
* <Callout type="warning">
* 这是一个警告提示
* </Callout>
*/
export function Callout({ type = 'info', children }: CalloutProps) {
return (
<div className={`p-4 my-4 border-l-4 rounded ${styles[type]}`}>
<div className="flex items-start gap-3">
<span className="text-xl">{icons[type]}</span>
<div className="flex-1">{children}</div>
</div>
</div>
);
}
三、🏠 首页文章列表
1. 页面结构
创建 app/page.tsx:
ts
// app/page.tsx
import { getPosts } from '@/lib/posts';
import Link from 'next/link';
import Image from 'next/image';
import { formatDate } from '@/lib/utils';
// ==================== 元数据 ====================
export const metadata = {
title: '全栈博客 - 分享技术与思考',
description: '专注于 Next.js、React、TypeScript 等现代 Web 开发技术',
};
// ==================== 缓存策略 ====================
/**
* 每小时重新验证一次
*
* 为什么不是静态生成?
* - 文章可能频繁更新
* - 需要显示最新评论数、点赞数
* - revalidate 平衡了性能和时效性
*/
export const revalidate = 3600;
// ==================== 页面组件 ====================
export default async function HomePage() {
// 获取第一页的 10 篇文章
const { posts, pagination } = await getPosts({
page: 1,
pageSize: 10
});
return (
<div className="container mx-auto px-4 py-8">
{/* Hero 区域 */}
<section className="mb-12 text-center">
<h1 className="text-5xl font-bold mb-4 bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
全栈博客
</h1>
<p className="text-xl text-gray-600">
分享 Next.js、React、TypeScript 等现代 Web 开发技术
</p>
</section>
{/* 文章列表 */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
{/* 分页 */}
{pagination.totalPages > 1 && (
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
/>
)}
{/* 空状态 */}
{posts.length === 0 && (
<EmptyState />
)}
</div>
);
}
/**
* 文章卡片组件
*/
function PostCard({ post }: { post: any }) {
return (
<article className="group border rounded-lg overflow-hidden hover:shadow-lg transition-all duration-300">
{/* 封面图 */}
{post.coverImage && (
<Link href={`/blog/${post.slug}`}>
<Image
src={post.coverImage}
alt={post.title}
width={400}
height={200}
className="w-full h-48 object-cover group-hover:scale-105 transition-transform"
/>
</Link>
)}
<div className="p-4">
{/* 标题 */}
<h2 className="text-xl font-semibold mb-2 line-clamp-2">
<Link
href={`/blog/${post.slug}`}
className="hover:text-blue-600 transition-colors"
>
{post.title}
</Link>
</h2>
{/* 摘要 */}
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
{post.excerpt}
</p>
{/* 元信息 */}
<div className="flex items-center justify-between text-sm text-gray-500">
<div className="flex items-center gap-2">
{post.author.image && (
<Image
src={post.author.image}
alt={post.author.name || ''}
width={24}
height={24}
className="rounded-full"
/>
)}
<span>{post.author.name}</span>
</div>
<div className="flex gap-3">
<span title="浏览量">👁 {post.viewCount}</span>
<span title="评论数">💬 {post._count.comments}</span>
<span title="点赞数">❤️ {post._count.likes}</span>
</div>
</div>
{/* 日期和阅读时间 */}
<div className="mt-3 flex items-center gap-3 text-xs text-gray-400">
<time dateTime={post.publishedAt?.toISOString()}>
{formatDate(post.publishedAt || post.createdAt)}
</time>
{post.readingTime && (
<>
<span>•</span>
<span>{post.readingTime} 分钟阅读</span>
</>
)}
</div>
{/* 标签 */}
{post.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{post.tags.slice(0, 3).map(({ tag }) => (
<Link
key={tag.id}
href={`/tags/${tag.slug}`}
className="px-2 py-1 text-xs rounded-full hover:opacity-80 transition-opacity"
style={{
backgroundColor: `${tag.color}20`,
color: tag.color
}}
>
{tag.name}
</Link>
))}
</div>
)}
</div>
</article>
);
}
/**
* 分页组件
*/
function Pagination({
currentPage,
totalPages
}: {
currentPage: number;
totalPages: number;
}) {
return (
<nav className="flex justify-center gap-2 mt-8">
{Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
<Link
key={page}
href={`/?page=${page}`}
className={`px-4 py-2 rounded ${
page === currentPage
? 'bg-blue-600 text-white'
: 'bg-gray-100 hover:bg-gray-200'
}`}
>
{page}
</Link>
))}
</nav>
);
}
/**
* 空状态组件
*/
function EmptyState() {
return (
<div className="text-center py-12">
<div className="text-6xl mb-4">📝</div>
<h3 className="text-xl font-semibold mb-2">暂无文章</h3>
<p className="text-gray-600">
博主正在努力创作中,敬请期待...
</p>
</div>
);
}
📖 设计要点解析:
(1) 渐进增强原则
ts
<Link href={`/blog/${post.slug}`}>
<Image src={post.coverImage} alt={post.title} />
</Link>
即使 JavaScript 未加载,用户仍可点击链接跳转,保证基本可用性。
(2) 图片优化
ts
<Image
src={post.coverImage}
width={400}
height={200}
className="group-hover:scale-105 transition-transform"
/>
next/image 自动:
- ✅ 生成多种尺寸的图片
- ✅ 转换为现代格式(WebP/AVIF)
- ✅ 懒加载(非首屏图片)
- ✅ 防止布局偏移(CLS)
(3) 文本截断
ts
className="line-clamp-2" // 最多显示 2 行
Tailwind CSS 的实用类,优雅地处理长文本。
四、📄 文章详情页
1. 动态路由页面
创建 app/blog/[slug]/page.tsx:
ts
// app/blog/[slug]/page.tsx
import { getPostBySlug } from '@/lib/posts';
import { notFound } from 'next/navigation';
import { MDXRenderer } from '@/components/MDXRenderer';
import { CommentSection } from '@/components/CommentSection';
import { LikeButton } from '@/components/LikeButton';
import { BookmarkButton } from '@/components/BookmarkButton';
import { auth } from '@/auth';
import Image from 'next/image';
interface BlogPostPageProps {
params: Promise<{ slug: string }>;
}
// ==================== 元数据生成 ====================
export async function generateMetadata({ params }: BlogPostPageProps) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) {
return {};
}
return {
title: post.title,
description: post.excerpt || post.content.substring(0, 160),
openGraph: {
title: post.title,
description: post.excerpt,
images: post.coverImage ? [{ url: post.coverImage }] : [],
type: 'article',
publishedTime: post.publishedAt?.toISOString(),
authors: [post.author.name].filter(Boolean),
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: post.coverImage ? [post.coverImage] : [],
},
};
}
// ==================== 静态参数生成(可选优化) ====================
/**
* 预生成热门文章的静态页面
*
* 适用场景:
* - 访问量高的文章
* - 不经常更新的内容
*
* 注意:如果文章很多,不要全部预生成,会导致构建缓慢
*/
export async function generateStaticParams() {
// 只预生成最近 10 篇文章
const { posts } = await getPosts({
page: 1,
pageSize: 10,
published: true
});
return posts.map(post => ({
slug: post.slug,
}));
}
// ==================== 页面组件 ====================
export default async function BlogPostPage({ params }: BlogPostPageProps) {
const { slug } = await params;
// 并行获取文章和当前用户
const [post, session] = await Promise.all([
getPostBySlug(slug),
auth(),
]);
if (!post) {
notFound();
}
return (
<article className="container mx-auto px-4 py-8 max-w-4xl">
{/* 文章头部 */}
<PostHeader post={post} />
{/* 互动按钮 */}
<InteractionBar
postId={post.id}
initialLiked={false}
initialBookmarked={false}
user={session?.user || null}
/>
{/* 文章内容 */}
<MDXRenderer content={post.content} />
{/* 标签 */}
<PostTags tags={post.tags} />
{/* 作者信息 */}
<AuthorCard author={post.author} />
{/* 评论区 */}
<CommentSection
postId={post.id}
comments={post.comments}
currentUser={session?.user || null}
/>
</article>
);
}
/**
* 文章头部组件
*/
function PostHeader({ post }: { post: any }) {
return (
<header className="mb-8 pb-8 border-b">
{/* 标题 */}
<h1 className="text-4xl md:text-5xl font-bold mb-6">
{post.title}
</h1>
{/* 作者和日期 */}
<div className="flex flex-wrap items-center gap-4 text-gray-600">
{post.author.image && (
<Image
src={post.author.image}
alt={post.author.name || ''}
width={40}
height={40}
className="rounded-full"
/>
)}
<span className="font-medium">{post.author.name}</span>
<span>•</span>
<time dateTime={post.publishedAt?.toISOString()}>
{new Date(post.publishedAt || post.createdAt).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<span>•</span>
<span>{post.readingTime} 分钟阅读</span>
<span>•</span>
<span>👁 {post.viewCount} 次阅读</span>
</div>
{/* 封面图 */}
{post.coverImage && (
<div className="mt-6 rounded-lg overflow-hidden">
<Image
src={post.coverImage}
alt={post.title}
width={1200}
height={600}
priority // 首屏图片,优先加载
className="w-full h-auto"
/>
</div>
)}
</header>
);
}
/**
* 互动按钮栏
*/
function InteractionBar({
postId,
initialLiked,
initialBookmarked,
user
}: {
postId: string;
initialLiked: boolean;
initialBookmarked: boolean;
user: any;
}) {
return (
<div className="flex gap-4 mb-8 pb-8 border-b">
<LikeButton
postId={postId}
initialLiked={initialLiked}
isAuthenticated={!!user}
/>
<BookmarkButton
postId={postId}
initialBookmarked={initialBookmarked}
isAuthenticated={!!user}
/>
</div>
);
}
/**
* 标签组件
*/
function PostTags({ tags }: { tags: any[] }) {
if (tags.length === 0) return null;
return (
<div className="flex flex-wrap gap-2 my-8">
{tags.map(({ tag }) => (
<Link
key={tag.id}
href={`/tags/${tag.slug}`}
className="px-3 py-1 text-sm rounded-full transition-opacity hover:opacity-80"
style={{
backgroundColor: `${tag.color}20`,
color: tag.color
}}
>
#{tag.name}
</Link>
))}
</div>
);
}
/**
* 作者卡片
*/
function AuthorCard({ author }: { author: any }) {
return (
<div className="my-12 p-6 bg-gray-50 rounded-lg">
<div className="flex items-center gap-4">
{author.image && (
<Image
src={author.image}
alt={author.name || ''}
width={60}
height={60}
className="rounded-full"
/>
)}
<div>
<h3 className="font-semibold text-lg">{author.name}</h3>
{author.bio && (
<p className="text-gray-600 text-sm mt-1">{author.bio}</p>
)}
</div>
</div>
</div>
);
}
🎯 关键知识点:
(1)Metadata API
typescript
export async function generateMetadata({ params }) {
return {
title: post.title,
openGraph: { /* Facebook/Twitter 预览 */ },
twitter: { /* Twitter Card */ },
};
}
SEO 最佳实践:
- ✅
title: 控制在 60 字符以内 - ✅
description: 150-160 字符,包含关键词 - ✅
openGraph.images: 至少 1200x630 像素 - ✅
twitter.card: 使用summary_large_image获得大卡片
(2) generateStaticParams
typescript
export async function generateStaticParams() {
const { posts } = await getPosts({ page: 1, pageSize: 10 });
return posts.map(post => ({ slug: post.slug }));
}
何时使用?
- ✅ 访问量高的页面(首页、热门文章)
- ✅ 内容不频繁变化
- ❌ 文章数量巨大(会导致构建缓慢)
效果:
- 这些页面在构建时生成静态 HTML
- 访问时无需服务端渲染,速度极快
(3)并行数据获取
typescript
const [post, session] = await Promise.all([
getPostBySlug(slug),
auth(),
]);
而不是串行:
typescript
// ❌ 慢
const post = await getPostBySlug(slug);
const session = await auth();
五、💬 评论组件实现
1. 评论列表
创建 components/CommentSection.tsx:
ts
// components/CommentSection.tsx
'use client';
import { useState } from 'react';
import { createComment } from '@/app/actions/comment';
import Image from 'next/image';
import { formatDate } from '@/lib/utils';
interface CommentSectionProps {
postId: string;
comments: any[];
currentUser: any;
}
/**
* 评论区组件
*
* 功能:
* - 显示评论列表(支持嵌套)
* - 发表评论
* - 回复评论
* - Optimistic UI(乐观更新)
*/
export function CommentSection({
postId,
comments,
currentUser
}: CommentSectionProps) {
const [commentList, setCommentList] = useState(comments);
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [content, setContent] = useState('');
const [loading, setLoading] = useState(false);
/**
* 提交评论
*/
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim()) return;
if (!currentUser) {
alert('请先登录');
return;
}
setLoading(true);
try {
const result = await createComment({
postId,
content,
parentId: replyingTo || undefined,
});
if (result.success && result.comment) {
// Optimistic Update: 立即更新 UI
if (replyingTo) {
// 添加到回复列表
setCommentList(prev =>
prev.map(comment =>
comment.id === replyingTo
? {
...comment,
replies: [...(comment.replies || []), result.comment],
}
: comment
)
);
} else {
// 添加到顶级评论
setCommentList(prev => [...prev, result.comment]);
}
// 清空表单
setContent('');
setReplyingTo(null);
} else {
alert(result.error || '评论失败');
}
} catch (error) {
console.error(error);
alert('评论失败,请稍后重试');
} finally {
setLoading(false);
}
};
return (
<section className="mt-12 pt-8 border-t">
<h2 className="text-2xl font-bold mb-6">
评论 ({commentList.length})
</h2>
{/* 评论表单 */}
<CommentForm
content={content}
onChange={setContent}
onSubmit={handleSubmit}
loading={loading}
placeholder={
replyingTo ? '撰写回复...' : '写下你的评论...'
}
onCancel={() => setReplyingTo(null)}
isReply={!!replyingTo}
/>
{/* 评论列表 */}
<div className="space-y-6 mt-8">
{commentList.map(comment => (
<CommentItem
key={comment.id}
comment={comment}
currentUser={currentUser}
onReply={(commentId) => setReplyingTo(commentId)}
replyingTo={replyingTo}
/>
))}
{commentList.length === 0 && (
<p className="text-center text-gray-500 py-8">
暂无评论,来发表第一条评论吧!
</p>
)}
</div>
</section>
);
}
/**
* 评论表单组件
*/
function CommentForm({
content,
onChange,
onSubmit,
loading,
placeholder,
onCancel,
isReply,
}: any) {
return (
<form onSubmit={onSubmit} className="mb-8">
<textarea
value={content}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={4}
className="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
required
/>
<div className="flex justify-end gap-2 mt-3">
{isReply && (
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
>
取消
</button>
)}
<button
type="submit"
disabled={loading || !content.trim()}
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '提交中...' : '发表评论'}
</button>
</div>
</form>
);
}
/**
* 单条评论组件
*/
function CommentItem({
comment,
currentUser,
onReply,
replyingTo
}: any) {
const isReplying = replyingTo === comment.id;
return (
<div className="flex gap-4">
{/* 头像 */}
{comment.author.image && (
<Image
src={comment.author.image}
alt={comment.author.name || ''}
width={40}
height={40}
className="rounded-full flex-shrink-0"
/>
)}
<div className="flex-1">
{/* 评论头部 */}
<div className="flex items-center gap-2 mb-2">
<span className="font-medium">{comment.author.name}</span>
<time
className="text-sm text-gray-500"
dateTime={comment.createdAt}
>
{formatDate(comment.createdAt)}
</time>
</div>
{/* 评论内容 */}
<p className="text-gray-700 mb-3 whitespace-pre-wrap">
{comment.content}
</p>
{/* 回复按钮 */}
{currentUser && !isReplying && (
<button
onClick={() => onReply(comment.id)}
className="text-sm text-blue-600 hover:underline"
>
回复
</button>
)}
{/* 回复表单 */}
{isReplying && (
<div className="mt-4 ml-8">
<CommentForm
content=""
onChange={() => {}}
onSubmit={async (e: any) => {
e.preventDefault();
// 实际应由父组件处理
}}
loading={false}
placeholder="撰写回复..."
onCancel={() => onReply(null)}
isReply={true}
/>
</div>
)}
{/* 回复列表 */}
{comment.replies?.length > 0 && (
<div className="mt-4 space-y-4 ml-8">
{comment.replies.map((reply: any) => (
<CommentItem
key={reply.id}
comment={reply}
currentUser={currentUser}
onReply={onReply}
replyingTo={replyingTo}
/>
))}
</div>
)}
</div>
</div>
);
}
💡 Optimistic UI 原理:
提交评论的时候采用了乐观更新的方式:
typescript
// 1. 立即更新 UI(假设成功)
setCommentList(prev => [...prev, newComment]);
// 2. 发送请求
const result = await createComment(data);
// 3. 如果失败,回滚
if (!result.success) {
setCommentList(prev => prev.filter(c => c.id !== newComment.id));
}
优势:
- ✅ 用户体验极佳,无需等待服务器响应
- ✅ 减少感知延迟
风险:
- ⚠️ 需要处理失败情况
- ⚠️ 不适合关键操作(如支付)
后续的点赞收藏功能也采用乐观更新。
六、❤️ 点赞与收藏按钮
1. 点赞按钮
创建 components/LikeButton.tsx:
ts
// components/LikeButton.tsx
'use client';
import { useState } from 'react';
import { toggleLike } from '@/app/actions/interaction';
interface LikeButtonProps {
postId: string;
initialLiked: boolean;
isAuthenticated: boolean;
}
/**
* 点赞按钮(Optimistic UI)
*
* 交互流程:
* 1. 用户点击
* 2. 立即切换 UI 状态
* 3. 后台发送请求
* 4. 如果失败,回滚状态
*/
export function LikeButton({
postId,
initialLiked,
isAuthenticated
}: LikeButtonProps) {
const [liked, setLiked] = useState(initialLiked);
const [loading, setLoading] = useState(false);
const handleClick = async () => {
if (!isAuthenticated) {
alert('请先登录');
return;
}
// Optimistic Update
const previousState = liked;
setLiked(!previousState);
setLoading(true);
try {
const result = await toggleLike(postId);
if (!result.success) {
// 回滚
setLiked(previousState);
alert(result.error);
}
} catch (error) {
// 回滚
setLiked(previousState);
console.error(error);
} finally {
setLoading(false);
}
};
return (
<button
onClick={handleClick}
disabled={loading}
className={`flex items-center gap-2 px-4 py-2 rounded-full transition-all ${
liked
? 'bg-red-50 text-red-600 hover:bg-red-100'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
<span className={`text-xl ${liked ? 'animate-pulse' : ''}`}>
{liked ? '❤️' : '🤍'}
</span>
<span>{liked ? '已点赞' : '点赞'}</span>
</button>
);
}
2. 收藏按钮
创建 components/BookmarkButton.tsx:
ts
// components/BookmarkButton.tsx
'use client';
import { useState } from 'react';
import { toggleBookmark } from '@/app/actions/interaction';
interface BookmarkButtonProps {
postId: string;
initialBookmarked: boolean;
isAuthenticated: boolean;
}
export function BookmarkButton({
postId,
initialBookmarked,
isAuthenticated
}: BookmarkButtonProps) {
const [bookmarked, setBookmarked] = useState(initialBookmarked);
const [loading, setLoading] = useState(false);
const handleClick = async () => {
if (!isAuthenticated) {
alert('请先登录');
return;
}
const previousState = bookmarked;
setBookmarked(!previousState);
setLoading(true);
try {
const result = await toggleBookmark(postId);
if (!result.success) {
setBookmarked(previousState);
alert(result.error);
}
} catch (error) {
setBookmarked(previousState);
console.error(error);
} finally {
setLoading(false);
}
};
return (
<button
onClick={handleClick}
disabled={loading}
className={`flex items-center gap-2 px-4 py-2 rounded-full transition-all ${
bookmarked
? 'bg-yellow-50 text-yellow-600 hover:bg-yellow-100'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
<span className="text-xl">
{bookmarked ? '⭐' : '☆'}
</span>
<span>{bookmarked ? '已收藏' : '收藏'}</span>
</button>
);
}
七、🔐 登录页面
1. 自定义登录页
创建 app/auth/signin/page.tsx:
ts
// app/auth/signin/page.tsx
import { signIn } from '@/auth';
import { Github } from 'lucide-react';
export const metadata = {
title: '登录 - 全栈博客',
description: '使用 GitHub 账号登录',
};
export default function SignInPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
<h1 className="text-3xl font-bold text-center mb-8">欢迎回来</h1>
<form
action={async () => {
'use server';
await signIn('github', {
redirectTo: '/'
});
}}
className="space-y-4"
>
<button
type="submit"
className="w-full flex items-center justify-center gap-3 px-6 py-3 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors"
>
<Github className="w-5 h-5" />
使用 GitHub 登录
</button>
</form>
<p className="mt-6 text-center text-sm text-gray-600">
登录后即可评论、点赞、收藏文章
</p>
</div>
</div>
);
}
🔑 Server Actions 表单:
ts
<form action={async () => {
'use server';
await signIn('github', { redirectTo: '/' });
}}>
<button type="submit">登录</button>
</form>
这种写法:
- ✅ 无需 JavaScript 也可工作
- ✅ 自动处理 CSRF Token
- ✅ 简洁优雅
八、⚡ 性能优化深度实践
1. 图片懒加载与优先级
ts
// 首屏图片:优先加载
<Image
src={heroImage}
priority // 关键!
alt="Hero"
/>
// 非首屏图片:懒加载(默认行为)
<Image
src={thumbnail}
alt="Thumbnail"
loading="lazy" // 可省略,默认就是 lazy
/>
2. 字体优化
创建 app/layout.tsx:
ts
// app/layout.tsx
import { Inter } from 'next/font/google';
// Next.js 自动优化字体
const inter = Inter({
subsets: ['latin'],
display: 'swap', // 避免 FOIT(Flash of Invisible Text)
});
export default function RootLayout({ children }) {
return (
<html lang="zh-CN">
<body className={inter.className}>
{children}
</body>
</html>
);
}
优势:
- ✅ 自动托管字体文件(CDN)
- ✅ 消除布局偏移
- ✅ 预加载关键字体
3. 代码分割
Next.js App Router 自动进行代码分割:
- 每个路由独立 bundle
- 客户端组件按需加载
- 第三方库 Tree Shaking
无需手动配置!
4. 流式渲染(Streaming)
对于慢查询,可以使用 Suspense:
ts
// app/blog/[slug]/page.tsx
import { Suspense } from 'react';
export default function BlogPostPage({ params }) {
return (
<article>
{/* 快速加载的部分 */}
<PostHeader />
{/* 慢查询部分:流式加载 */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</article>
);
}
async function Comments() {
// 模拟慢查询
await new Promise(resolve => setTimeout(resolve, 2000));
return <div>评论内容...</div>;
}
function CommentsSkeleton() {
return <div className="animate-pulse">加载中...</div>;
}
效果:
- 用户先看到文章头部
- 评论逐步加载,无需等待
九、🚀 部署上线
1. Vercel 部署(推荐)
步骤 1:推送代码到 GitHub
bash
git init
git add .
git commit -m "feat: 完成博客系统"
git remote add origin https://github.com/yourusername/fullstack-blog.git
git push -u origin main
步骤 2:连接 Vercel
- 访问 vercel.com
- 点击 "New Project"
- 导入 GitHub 仓库
- 配置环境变量
步骤 3:配置环境变量
在 Vercel Dashboard → Settings → Environment Variables 中添加:
bash
DATABASE_URL=postgresql://...
AUTH_SECRET=your-secret-key
GITHUB_ID=your-github-id
GITHUB_SECRET=your-github-secret
OPENAI_API_KEY=sk-your-key
NEXT_PUBLIC_APP_URL=https://your-domain.vercel.app
步骤 4:自动部署
每次推送到 main 分支,Vercel 会自动:
- 安装依赖
- 执行
next build - 部署到全球 CDN
- 提供预览 URL
2. 数据库托管(Neon)
Neon 提供免费 Serverless PostgreSQL:
- 注册 neon.tech
- 创建新项目
- 获取连接字符串
- 更新
DATABASE_URL
优势:
- ✅ 免费 tier: 0.5 GB 存储
- ✅ 自动扩缩容
- ✅ 分支功能(类似 Git)
3. 自定义域名
在 Vercel Dashboard → Settings → Domains 中:
- 添加你的域名
- 按提示配置 DNS(CNAME/A Record)
- 等待 SSL 证书签发(自动)
十一、📊 监控与分析
1. Vercel Analytics
在 app/layout.tsx 中添加:
ts
import { Analytics } from '@vercel/analytics/react';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
);
}
安装依赖:
bash
npm install @vercel/analytics
功能:
- 页面浏览量
- 用户地理位置
- 设备类型
- 性能指标
2. Core Web Vitals 监控
Vercel 自动收集:
- LCP(Largest Contentful Paint): 最大内容绘制时间
- FID(First Input Delay): 首次输入延迟
- CLS(Cumulative Layout Shift): 累积布局偏移
目标值:
- LCP < 2.5s
- FID < 100ms
- CLS < 0.1
十二、📝 本章小结
通过上下两篇的学习,你已完成了一个生产级全栈博客系统:
✅ 已完成功能
| 模块 | 功能 | 技术栈 |
|---|---|---|
| 用户系统 | GitHub OAuth 登录 | Auth.js |
| 文章管理 | CRUD、Markdown 渲染 | Prisma、MDX |
| AI 增强 | 自动摘要、标签推荐 | OpenAI API |
| 社交互动 | 评论、点赞、收藏 | Server Actions |
| 性能优化 | 缓存、懒加载、流式渲染 | Next.js 内置 |
| 部署运维 | Vercel 自动化部署 | CI/CD |
🎯 核心知识点回顾
- App Router 架构: 文件系统路由、嵌套布局、并行路由
- React Server Components: 服务端渲染、减少客户端 JS
- Server Actions: 类型安全的表单处理
- 数据缓存策略 :
revalidateTag、generateStaticParams - 性能优化 :
next/image、字体优化、代码分割 - SEO 最佳实践: Metadata API、Open Graph、Sitemap
🚀 下一步扩展方向
- 全文搜索: 集成 Meilisearch 或 Algolia
- RSS 订阅: 生成 RSS/Atom Feed
- 邮件通知: 新评论提醒(Resend/SendGrid)
- 管理后台: 文章审核、数据统计、用户管理
- 暗黑模式 :
next-themes实现主题切换 - 国际化 :
next-intl多语言支持 - PWA: 离线访问、推送通知
💪 练习作业
- 实现"相关文章推荐"功能(基于标签相似度)
- 添加"阅读进度条"(客户端组件)
- 实现"代码复制"按钮(CodeBlock 组件)
- 添加 Google Analytics 集成
- 实现简单的站内搜索(使用 Prisma 全文搜索)
🎉 结语
恭喜你完成了这个完整的 Next.js 全栈项目!
从环境配置到生产部署,你已掌握了:
- ✅ 现代 Web 开发的最佳实践
- ✅ 全栈应用的架构设计思路
- ✅ 性能优化与 SEO 技巧
- ✅ 自动化部署与监控
记住 : 学习编程最好的方式就是不断实践。在此基础上,尝试添加新功能、优化现有代码、重构架构。
祝你成为一名优秀的 Next.js 全栈开发者! 🚀
资源链接: