本文基于 React 19 正式版与 Next.js 15 最新特性,深入讲解现代前端开发的核心技术栈,包含完整项目实战代码。
一、前言:前端开发的范式转移
2024-2025年,React 生态经历了自 Hooks 以来最大的架构升级。React 19 带来了编译器自动优化、Actions 原生支持、Ref as Prop 等革命性特性;Next.js 15 全面拥抱 App Router,Server Components 成为默认选择。
这意味着:
- 开发者不再手动使用
useMemo/useCallback------ React Compiler 自动完成 - 前后端边界模糊 ------ 组件可以在服务器端运行,直接访问数据库
- 流式渲染成为标配 ------ 用户更快看到内容,SEO 不再妥协
二、React 19 核心新特性
2.1 React Compiler:自动性能优化
React 19 引入官方编译器,无需手动优化:
tsx
// React 18 及以前:需要手动优化
function UserList({ users, onSelect }) {
// 必须手动 memoization,否则每次渲染都会重新创建
const sortedUsers = useMemo(() =>
users.sort((a, b) => a.name.localeCompare(b.name)),
[users]
);
const handleClick = useCallback((user) => {
onSelect(user);
}, [onSelect]);
return (
<ul>
{sortedUsers.map(user => (
<li key={user.id} onClick={() => handleClick(user)}>
{user.name}
</li>
))}
</ul>
);
}
// React 19:编译器自动优化
function UserList({ users, onSelect }) {
// 直接写,编译器自动识别并优化
const sortedUsers = users.sort((a, b) => a.name.localeCompare(b.name));
return (
<ul>
{sortedUsers.map(user => (
<li key={user.id} onClick={() => onSelect(user)}>
{user.name}
</li>
))}
</ul>
);
}
编译器工作原理:
- 静态分析组件依赖关系
- 自动插入 memoization
- 自动跳过不必要的重渲染
- 编译时生成优化代码
启用编译器(Next.js 15):
javascript
// next.config.js
const next = require('next');
const nextConfig = {
experimental: {
// Next.js 15 默认启用
reactCompiler: true,
},
};
module.exports = nextConfig;
2.2 Actions:原生表单处理
React 19 引入 Actions,统一处理异步操作:
tsx
// 传统方式:手动管理状态
function TraditionalForm() {
const [pending, setPending] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setPending(true);
setError(null);
try {
const formData = new FormData(e.target);
await submitToServer(formData);
setSuccess(true);
} catch (err) {
setError(err.message);
} finally {
setPending(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<button type="submit" disabled={pending}>
{pending ? '提交中...' : '提交'}
</button>
{error && <p className="error">{error}</p>}
{success && <p className="success">提交成功!</p>}
</form>
);
}
// React 19 Actions:声明式
import { useFormStatus, useFormState } from 'react-dom';
async function submitAction(prevState, formData) {
try {
await submitToServer(formData);
return { success: true, error: null };
} catch (err) {
return { success: false, error: err.message };
}
}
function ModernForm() {
const [state, formAction] = useFormState(submitAction, {
success: false,
error: null
});
return (
<form action={formAction}>
<input name="email" type="email" required />
<SubmitButton />
{state.error && <p className="error">{state.error}</p>}
{state.success && <p className="success">提交成功!</p>}
</form>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '提交中...' : '提交'}
</button>
);
}
Server Action(服务器端执行):
tsx
// actions/user.ts - 服务器端代码
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
export async function createUser(formData: FormData) {
const email = formData.get('email') as string;
const name = formData.get('name') as string;
// 直接访问数据库,无需 API 层
await db.user.create({
data: { email, name }
});
// 自动刷新缓存
revalidatePath('/users');
return { success: true };
}
// app/users/page.tsx
import { createUser } from '@/actions/user';
function CreateUserForm() {
return (
<form action={createUser}>
<input name="email" type="email" required />
<input name="name" required />
<button type="submit">创建用户</button>
</form>
);
}
2.3 Ref as Prop:forwardRef 即将退休
React 19 允许 ref 作为普通 prop 传递:
tsx
// React 18 及以前:必须使用 forwardRef
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, ...props }, ref) => {
return (
<div>
<label>{label}</label>
<input ref={ref} {...props} />
</div>
);
}
);
// React 19:ref 作为普通 prop
function Input({ label, ref, ...props }: InputProps) {
return (
<div>
<label>{label}</label>
<input ref={ref} {...props} />
</div>
);
}
// 使用方式不变
function App() {
const inputRef = useRef<HTMLInputElement>(null);
return (
<div>
<Input ref={inputRef} label="邮箱" />
<button onClick={() => inputRef.current?.focus()}>
聚焦输入框
</button>
</div>
);
}
2.4 use() Hook:Promise 和 Context 的统一读取
tsx
import { use } from 'react';
// 读取 Promise
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
// 自动 Suspense,无需 .then()
const user = use(userPromise);
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
// 读取 Context(更简洁)
const ThemeContext = createContext<'light' | 'dark'>('light');
function ThemedButton() {
// 以前:const theme = useContext(ThemeContext);
const theme = use(ThemeContext);
return (
<button className={`btn btn-${theme}`}>
主题按钮
</button>
);
}
// 完整示例:Suspense + use()
function UserPage({ id }: { id: string }) {
const userPromise = fetchUser(id); // 返回 Promise
return (
<Suspense fallback={<UserSkeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
三、Next.js 15 App Router 核心概念
3.1 目录结构
app/
├── layout.tsx # 根布局(必选)
├── page.tsx # 首页
├── loading.tsx # 加载状态
├── error.tsx # 错误边界
├── not-found.tsx # 404 页面
├── globals.css # 全局样式
│
├── (auth)/ # 路由组(不影响 URL)
│ ├── layout.tsx
│ ├── login/
│ │ └── page.tsx # /login
│ └── register/
│ └── page.tsx # /register
│
├── dashboard/ # 路由段
│ ├── layout.tsx
│ ├── page.tsx # /dashboard
│ ├── analytics/
│ │ └── page.tsx # /dashboard/analytics
│ └── settings/
│ └── page.tsx # /dashboard/settings
│
├── api/ # API 路由
│ └── users/
│ └── route.ts # /api/users
│
└── users/
├── page.tsx # /users
└── [id]/ # 动态路由
└── page.tsx # /users/123
3.2 Server Components vs Client Components
| 特性 | Server Components | Client Components |
|---|---|---|
| 运行环境 | 服务器 | 浏览器 |
| 可访问数据库 | ✅ 是 | ❌ 否 |
| 可使用 Hooks | ❌ 否 | ✅ 是 |
| 可使用事件处理 | ❌ 否 | ✅ 是 |
| 可访问浏览器 API | ❌ 否 | ✅ 是 |
| 默认类型 | ✅ 是 | ❌ 需要 'use client' |
Server Component 示例:
tsx
// app/users/page.tsx - 默认就是 Server Component
import { db } from '@/lib/db';
// 直接访问数据库,无需 API
async function UsersPage() {
const users = await db.user.findMany({
orderBy: { createdAt: 'desc' },
take: 20,
});
return (
<div className="users-grid">
{users.map(user => (
<UserCard key={user.id} user={user} />
))}
</div>
);
}
// UserCard 也是 Server Component
async function UserCard({ user }: { user: User }) {
// 可以继续访问数据库
const posts = await db.post.count({
where: { authorId: user.id }
});
return (
<div className="user-card">
<img src={user.avatar} alt={user.name} />
<h3>{user.name}</h3>
<p>文章数: {posts}</p>
</div>
);
}
Client Component 示例:
tsx
// components/like-button.tsx
'use client';
import { useState } from 'react';
import { likePost } from '@/actions/post';
export function LikeButton({ postId, initialLikes }: Props) {
const [likes, setLikes] = useState(initialLikes);
const [optimisticLikes, setOptimisticLikes] = useOptimistic(
likes,
(state, increment: number) => state + increment
);
async function handleLike() {
setOptimisticLikes(1);
const result = await likePost(postId);
setLikes(result.likes);
}
return (
<button onClick={handleLike}>
❤️ {optimisticLikes}
</button>
);
}
混合使用:
tsx
// app/posts/[id]/page.tsx
import { db } from '@/lib/db';
import { LikeButton } from '@/components/like-button';
import { Comments } from '@/components/comments';
async function PostPage({ params }: { params: { id: string } }) {
// Server Component:直接访问数据库
const post = await db.post.findUnique({
where: { id: params.id },
include: { author: true },
});
return (
<article>
<h1>{post.title}</h1>
<p>作者: {post.author.name}</p>
<div>{post.content}</div>
{/* Client Component:交互逻辑 */}
<LikeButton postId={post.id} initialLikes={post.likes} />
{/* Server Component 嵌套 Client Component */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments postId={post.id} />
</Suspense>
</article>
);
}
3.3 布局与模板
tsx
// app/layout.tsx - 根布局
import './globals.css';
import { Nav } from '@/components/nav';
import { Footer } from '@/components/footer';
export const metadata = {
title: '我的应用',
description: '使用 Next.js 15 构建',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body>
<Nav />
<main className="container">{children}</main>
<Footer />
</body>
</html>
);
}
// app/dashboard/layout.tsx - 嵌套布局
import { Sidebar } from '@/components/sidebar';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="dashboard-layout">
<Sidebar />
<section className="dashboard-content">{children}</section>
</div>
);
}
// app/dashboard/template.tsx - 模板(每次导航重新挂载)
'use client';
import { useEffect } from 'react';
export default function DashboardTemplate({
children,
}: {
children: React.ReactNode;
}) {
useEffect(() => {
// 每次进入 dashboard 子页面都执行
console.log('Dashboard template mounted');
}, []);
return <div className="template-wrapper">{children}</div>;
}
Layout vs Template 区别:
- Layout:在导航时保持状态,不重新挂载
- Template:每次导航都重新挂载,适合需要重置状态的场景
四、流式渲染(Streaming SSR)
4.1 基本原理
流式渲染允许服务器逐步发送 HTML,而不是等待所有数据加载完成:
传统 SSR:
请求 → 等待所有数据 → 完整 HTML → 返回
流式渲染:
请求 → 发送外壳 HTML → 数据1就绪 → 发送片段1 → 数据2就绪 → 发送片段2
4.2 实现流式渲染
tsx
// app/dashboard/page.tsx
import { Suspense } from 'react';
async function DashboardPage() {
return (
<div className="dashboard">
{/* 立即显示的部分 */}
<header className="dashboard-header">
<h1>仪表盘</h1>
<p>欢迎回来</p>
</header>
{/* 流式加载:快速显示骨架屏 */}
<div className="dashboard-grid">
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
</div>
</div>
);
}
// 慢速组件:模拟数据库查询
async function Stats() {
// 这个查询可能需要 2 秒
const stats = await db.stats.findFirst();
return (
<div className="stats-card">
<div className="stat">
<span className="stat-value">{stats.totalUsers}</span>
<span className="stat-label">总用户</span>
</div>
<div className="stat">
<span className="stat-value">{stats.totalRevenue}</span>
<span className="stat-label">总收入</span>
</div>
</div>
);
}
async function RevenueChart() {
// 这个查询可能需要 3 秒
const data = await db.revenue.getMonthly();
return (
<div className="chart-container">
<LineChart data={data} />
</div>
);
}
// 骨架屏组件
function StatsSkeleton() {
return (
<div className="stats-card skeleton">
<div className="skeleton-item" />
<div className="skeleton-item" />
</div>
);
}
4.3 Streaming with Loading UI
tsx
// app/products/loading.tsx - 自动匹配同目录下的 page.tsx
export default function ProductsLoading() {
return (
<div className="products-loading">
{[1, 2, 3, 4, 5, 6].map(i => (
<div key={i} className="product-skeleton">
<div className="skeleton-image" />
<div className="skeleton-title" />
<div className="skeleton-price" />
</div>
))}
</div>
);
}
// app/products/page.tsx
async function ProductsPage() {
const products = await db.product.findMany();
return (
<div className="products-grid">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
4.4 Error Handling
tsx
// app/dashboard/error.tsx
'use client';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('Dashboard error:', error);
}, [error]);
return (
<div className="error-container">
<h2>出错了!</h2>
<p>{error.message}</p>
<Button onClick={reset}>重试</Button>
</div>
);
}
五、缓存策略
5.1 请求缓存
tsx
// 默认缓存行为
async function getUser(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`);
// 默认:缓存直到手动失效
return res.json();
}
// 禁用缓存
async function getLiveStockPrice(symbol: string) {
const res = await fetch(`https://api.example.com/stock/${symbol}`, {
cache: 'no-store', // 每次都重新请求
});
return res.json();
}
// 定时重新验证
async function getArticles() {
const res = await fetch('https://api.example.com/articles', {
next: {
revalidate: 3600, // 每小时重新验证
},
});
return res.json();
}
// 按标签重新验证
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: {
tags: ['products'], // 按标签控制
},
});
return res.json();
}
5.2 服务器端缓存控制
tsx
// app/actions/product.ts
'use server';
import { revalidateTag, revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
export async function createProduct(formData: FormData) {
const product = await db.product.create({
data: {
name: formData.get('name') as string,
price: parseFloat(formData.get('price') as string),
},
});
// 使特定标签的缓存失效
revalidateTag('products');
// 或使特定路径的缓存失效
revalidatePath('/products');
return product;
}
export async function updateProduct(id: string, data: Partial<Product>) {
await db.product.update({
where: { id },
data,
});
// 精确控制:只刷新这个产品的缓存
revalidatePath(`/products/${id}`);
revalidateTag('products');
}
5.3 ISR(增量静态再生)
tsx
// app/blog/[slug]/page.tsx
// 静态参数生成
export async function generateStaticParams() {
const posts = await db.post.findMany({
select: { slug: true },
});
return posts.map(post => ({ slug: post.slug }));
}
// 动态参数配置
export const dynamicParams = true; // 允许动态生成新页面
async function BlogPost({ params }: { params: { slug: string } }) {
const post = await db.post.findUnique({
where: { slug: params.slug },
});
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
// 重新验证策略
export const revalidate = 3600; // 每小时重新验证
// 或
export const dynamic = 'force-static'; // 强制静态生成
六、完整项目实战:博客系统
6.1 项目结构
blog-app/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── blog/
│ │ ├── page.tsx
│ │ └── [slug]/
│ │ └── page.tsx
│ ├── admin/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── posts/
│ │ ├── page.tsx
│ │ └── [id]/
│ │ └── page.tsx
│ └── api/
│ └── upload/
│ └── route.ts
├── components/
│ ├── ui/
│ │ ├── button.tsx
│ │ ├── input.tsx
│ │ └── card.tsx
│ ├── blog/
│ │ ├── post-card.tsx
│ │ └── post-list.tsx
│ └── admin/
│ ├── post-form.tsx
│ └── dashboard-stats.tsx
├── lib/
│ ├── db.ts
│ ├── auth.ts
│ └── utils.ts
├── actions/
│ ├── post.ts
│ └── auth.ts
└── types/
└── index.ts
6.2 数据库配置(Prisma)
prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
password String
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id String @id @default(cuid())
title String
slug String @unique
content String
excerpt String?
coverImage String?
published Boolean @default(false)
authorId String
author User @relation(fields: [authorId], references: [id])
tags Tag[]
viewCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([published, createdAt])
}
model Tag {
id String @id @default(cuid())
name String @unique
posts Post[]
}
enum Role {
USER
ADMIN
}
typescript
// lib/db.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const db =
globalForPrisma.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
6.3 Server Actions
typescript
// actions/post.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { z } from 'zod';
const postSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
excerpt: z.string().max(500).optional(),
coverImage: z.string().url().optional().or(z.literal('')),
published: z.boolean(),
tags: z.array(z.string()).optional(),
});
export async function createPost(formData: FormData) {
const session = await auth();
if (!session?.user || session.user.role !== 'ADMIN') {
throw new Error('Unauthorized');
}
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
excerpt: formData.get('excerpt'),
coverImage: formData.get('coverImage'),
published: formData.get('published') === 'true',
tags: formData.getAll('tags'),
};
const validated = postSchema.parse(rawData);
const slug = validated.title
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.slice(0, 100);
const post = await db.post.create({
data: {
title: validated.title,
slug,
content: validated.content,
excerpt: validated.excerpt,
coverImage: validated.coverImage || null,
published: validated.published,
authorId: session.user.id,
tags: {
connectOrCreate: validated.tags?.map(tag => ({
where: { name: tag },
create: { name: tag },
})) || [],
},
},
include: { tags: true },
});
revalidateTag('posts');
revalidatePath('/blog');
redirect(`/admin/posts/${post.id}`);
}
export async function updatePost(id: string, formData: FormData) {
const session = await auth();
if (!session?.user || session.user.role !== 'ADMIN') {
throw new Error('Unauthorized');
}
const validated = postSchema.parse({
title: formData.get('title'),
content: formData.get('content'),
excerpt: formData.get('excerpt'),
coverImage: formData.get('coverImage'),
published: formData.get('published') === 'true',
tags: formData.getAll('tags'),
});
await db.post.update({
where: { id },
data: {
title: validated.title,
content: validated.content,
excerpt: validated.excerpt,
coverImage: validated.coverImage || null,
published: validated.published,
tags: {
set: [],
connectOrCreate: validated.tags?.map(tag => ({
where: { name: tag },
create: { name: tag },
})) || [],
},
},
});
revalidateTag('posts');
revalidatePath('/blog');
revalidatePath(`/blog/${id}`);
}
export async function deletePost(id: string) {
const session = await auth();
if (!session?.user || session.user.role !== 'ADMIN') {
throw new Error('Unauthorized');
}
await db.post.delete({ where: { id } });
revalidateTag('posts');
revalidatePath('/blog');
redirect('/admin/posts');
}
export async function incrementViewCount(slug: string) {
await db.post.update({
where: { slug },
data: { viewCount: { increment: 1 } },
});
}
6.4 博客列表页面
tsx
// app/blog/page.tsx
import { Suspense } from 'react';
import { db } from '@/lib/db';
import { PostCard } from '@/components/blog/post-card';
import { PostListSkeleton } from '@/components/blog/skeletons';
export const metadata = {
title: '博客文章',
description: '阅读最新的技术文章和教程',
};
export const revalidate = 300; // 5 分钟重新验证
async function getPosts() {
const posts = await db.post.findMany({
where: { published: true },
include: {
author: { select: { name: true, email: true } },
tags: true,
},
orderBy: { createdAt: 'desc' },
take: 20,
});
return posts;
}
export default async function BlogPage() {
return (
<div className="container mx-auto px-4 py-8">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-2">博客文章</h1>
<p className="text-gray-600">分享技术见解与实战经验</p>
</header>
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
</div>
);
}
async function PostList() {
const posts = await getPosts();
if (posts.length === 0) {
return (
<div className="text-center py-12 text-gray-500">
暂无文章
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
tsx
// components/blog/post-card.tsx
import Link from 'next/link';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
interface PostCardProps {
post: {
id: string;
title: string;
slug: string;
excerpt: string | null;
coverImage: string | null;
createdAt: Date;
author: { name: string | null };
tags: { name: string }[];
};
}
export function PostCard({ post }: PostCardProps) {
return (
<article className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
{post.coverImage && (
<Link href={`/blog/${post.slug}`}>
<img
src={post.coverImage}
alt={post.title}
className="w-full h-48 object-cover"
/>
</Link>
)}
<div className="p-5">
<div className="flex gap-2 mb-3">
{post.tags.slice(0, 3).map(tag => (
<span
key={tag.name}
className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded"
>
{tag.name}
</span>
))}
</div>
<Link href={`/blog/${post.slug}`}>
<h2 className="text-xl font-semibold mb-2 hover:text-blue-600 transition-colors">
{post.title}
</h2>
</Link>
{post.excerpt && (
<p className="text-gray-600 text-sm mb-3 line-clamp-2">
{post.excerpt}
</p>
)}
<div className="flex items-center justify-between text-sm text-gray-500">
<span>{post.author.name}</span>
<time dateTime={post.createdAt.toISOString()}>
{format(post.createdAt, 'PPP', { locale: zhCN })}
</time>
</div>
</div>
</article>
);
}
6.5 博客详情页面
tsx
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { Metadata } from 'next';
import { Suspense } from 'react';
import { db } from '@/lib/db';
import { incrementViewCount } from '@/actions/post';
import { CommentSection } from '@/components/blog/comment-section';
import { RelatedPosts } from '@/components/blog/related-posts';
interface Props {
params: { slug: string };
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await db.post.findUnique({
where: { slug: params.slug },
select: { title: true, excerpt: true, coverImage: true },
});
if (!post) return { title: '文章未找到' };
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt || undefined,
images: post.coverImage ? [post.coverImage] : [],
},
};
}
async function getPost(slug: string) {
const post = await db.post.findUnique({
where: { slug, published: true },
include: {
author: { select: { name: true, email: true, image: true } },
tags: true,
},
});
if (!post) notFound();
return post;
}
export default async function BlogPostPage({ params }: Props) {
const post = await getPost(params.slug);
// 增加阅读量(不阻塞渲染)
incrementViewCount(params.slug);
return (
<article className="container mx-auto px-4 py-8 max-w-4xl">
<header className="mb-8">
<div className="flex gap-2 mb-4">
{post.tags.map(tag => (
<span
key={tag.name}
className="px-3 py-1 bg-blue-100 text-blue-700 text-sm rounded-full"
>
{tag.name}
</span>
))}
</div>
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center gap-4 text-gray-600">
<div className="flex items-center gap-2">
{post.author.image && (
<img
src={post.author.image}
alt={post.author.name || ''}
className="w-10 h-10 rounded-full"
/>
)}
<span>{post.author.name}</span>
</div>
<time>
{new Date(post.createdAt).toLocaleDateString('zh-CN')}
</time>
<span>{post.viewCount} 次阅读</span>
</div>
</header>
{post.coverImage && (
<img
src={post.coverImage}
alt={post.title}
className="w-full h-auto rounded-lg mb-8"
/>
)}
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
<Suspense fallback={<div>加载评论中...</div>}>
<CommentSection postId={post.id} />
</Suspense>
<Suspense fallback={null}>
<RelatedPosts postId={post.id} tags={post.tags} />
</Suspense>
</article>
);
}
6.6 管理后台
tsx
// app/admin/layout.tsx
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
import { AdminNav } from '@/components/admin/nav';
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user || session.user.role !== 'ADMIN') {
redirect('/login');
}
return (
<div className="min-h-screen bg-gray-100">
<div className="flex">
<aside className="w-64 bg-white shadow-md min-h-screen">
<div className="p-4">
<h2 className="text-xl font-bold">管理后台</h2>
</div>
<AdminNav />
</aside>
<main className="flex-1 p-8">{children}</main>
</div>
</div>
);
}
tsx
// app/admin/posts/page.tsx
import Link from 'next/link';
import { db } from '@/lib/db';
import { DeletePostButton } from '@/components/admin/delete-post-button';
export const dynamic = 'force-dynamic';
async function getAdminPosts() {
return db.post.findMany({
include: { author: { select: { name: true } }, tags: true },
orderBy: { createdAt: 'desc' },
});
}
export default async function AdminPostsPage() {
const posts = await getAdminPosts();
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">文章管理</h1>
<Link
href="/admin/posts/new"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
新建文章
</Link>
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
标题
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
状态
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
作者
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
创建时间
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
操作
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{posts.map(post => (
<tr key={post.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="font-medium">{post.title}</div>
<div className="text-sm text-gray-500">
{post.tags.map(t => t.name).join(', ')}
</div>
</td>
<td className="px-6 py-4">
<span
className={`px-2 py-1 text-xs rounded ${
post.published
? 'bg-green-100 text-green-700'
: 'bg-yellow-100 text-yellow-700'
}`}
>
{post.published ? '已发布' : '草稿'}
</span>
</td>
<td className="px-6 py-4 text-gray-500">
{post.author.name}
</td>
<td className="px-6 py-4 text-gray-500 text-sm">
{new Date(post.createdAt).toLocaleDateString('zh-CN')}
</td>
<td className="px-6 py-4 text-right space-x-2">
<Link
href={`/admin/posts/${post.id}`}
className="text-blue-600 hover:text-blue-800"
>
编辑
</Link>
<DeletePostButton postId={post.id} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
tsx
// components/admin/post-form.tsx
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { createPost, updatePost } from '@/actions/post';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
interface PostFormProps {
post?: {
id: string;
title: string;
content: string;
excerpt: string | null;
coverImage: string | null;
published: boolean;
tags: { name: string }[];
};
}
export function PostForm({ post }: PostFormProps) {
const action = post
? updatePost.bind(null, post.id)
: createPost;
const [state, formAction] = useFormState(action, { error: null });
return (
<form action={formAction} className="space-y-6">
<div>
<label className="block text-sm font-medium mb-2">标题</label>
<Input
name="title"
defaultValue={post?.title}
required
placeholder="输入文章标题"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">内容</label>
<textarea
name="content"
defaultValue={post?.content}
required
rows={15}
className="w-full border rounded-lg p-3"
placeholder="支持 Markdown 格式"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">摘要</label>
<textarea
name="excerpt"
defaultValue={post?.excerpt || ''}
rows={3}
className="w-full border rounded-lg p-3"
placeholder="文章简介(可选)"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">封面图片</label>
<Input
name="coverImage"
type="url"
defaultValue={post?.coverImage || ''}
placeholder="图片 URL"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">标签</label>
<Input
name="tags"
defaultValue={post?.tags.map(t => t.name).join(', ')}
placeholder="用逗号分隔多个标签"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
name="published"
id="published"
value="true"
defaultChecked={post?.published}
/>
<label htmlFor="published" className="text-sm">
立即发布
</label>
</div>
{state.error && (
<div className="text-red-600 text-sm">{state.error}</div>
)}
<SubmitButton />
</form>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending} className="w-full">
{pending ? '保存中...' : '保存文章'}
</Button>
);
}
七、部署方案
7.1 Vercel 部署(推荐)
bash
# 安装 Vercel CLI
npm i -g vercel
# 登录
vercel login
# 部署
vercel
# 生产环境部署
vercel --prod
vercel.json 配置:
json
{
"framework": "nextjs",
"regions": ["hkg1"],
"functions": {
"app/api/**/*.ts": {
"memory": 1024,
"maxDuration": 10
}
},
"headers": [
{
"source": "/fonts/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
}
]
}
7.2 Docker 容器化部署
dockerfile
# Dockerfile
FROM node:20-alpine AS base
# 安装依赖
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
COPY prisma ./prisma/
RUN npm ci
# 构建
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npx prisma generate
RUN npm run build
# 运行
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
docker-compose.yml:
yaml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://user:password@db:5432/blog
- NEXTAUTH_SECRET=your-secret-key
- NEXTAUTH_URL=http://localhost:3000
depends_on:
- db
command: >
sh -c "npx prisma migrate deploy && node server.js"
db:
image: postgres:15-alpine
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
- POSTGRES_DB=blog
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
postgres_data:
bash
# 构建并运行
docker-compose up -d --build
# 查看日志
docker-compose logs -f app
7.3 环境变量配置
bash
# .env.production
DATABASE_URL="postgresql://user:password@host:5432/blog"
NEXTAUTH_SECRET="your-production-secret-min-32-chars"
NEXTAUTH_URL="https://your-domain.com"
# 可选:图片存储
AWS_ACCESS_KEY_ID="your-key"
AWS_SECRET_ACCESS_KEY="your-secret"
AWS_REGION="ap-east-1"
AWS_S3_BUCKET="your-bucket"
八、与 Vue 3.6 + Vapor Mode 对比
8.1 编译器优化对比
| 维度 | React 19 Compiler | Vue 3.6 Vapor Mode |
|---|---|---|
| 优化方式 | 自动 memoization | 无虚拟 DOM,直接 DOM 操作 |
| 心智模型 | React 模型不变,编译器优化 | 响应式系统,细粒度更新 |
| 学习曲线 | 低(无需学习新概念) | 中(理解 Vapor Mode 特性) |
| 调试难度 | 较高(编译后代码不同) | 中(响应式追踪清晰) |
| 生态兼容 | 完全兼容现有代码 | 部分库可能需要适配 |
8.2 代码风格对比
vue
<!-- Vue 3.6 Vapor Mode -->
<script setup vapor>
import { ref, computed } from 'vue';
const count = ref(0);
const doubled = computed(() => count.value * 2);
function increment() {
count.value++;
}
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<p>Doubled: {{ doubled }}</p>
<button @click="increment">+1</button>
</div>
</template>
tsx
// React 19
function Counter() {
const [count, setCount] = useState(0);
const doubled = count * 2; // 编译器自动优化
return (
<div>
<p>Count: {count}</p>
<p>Doubled: {doubled}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
);
}
8.3 Server Components 对比
| 维度 | React Server Components | Vue SSR |
|---|---|---|
| 模型 | 组件级别服务器运行 | 页面级别 SSR |
| 数据获取 | 组件内直接 await | setup 中 async |
| 流式渲染 | 原生支持 | 需要额外配置 |
| 状态保留 | 服务端无状态 | 客户端水合保留 |
vue
<!-- Vue 3 Async Setup -->
<script setup>
const data = await fetch('/api/data').then(r => r.json());
</script>
<template>
<div>{{ data.title }}</div>
</template>
tsx
// React Server Component
async function DataComponent() {
const data = await fetch('/api/data').then(r => r.json());
return <div>{data.title}</div>;
}
8.4 选择建议
选择 React 19 + Next.js 15 当:
- 大型团队协作,需要统一规范
- 已有 React 生态投资
- SEO 和首屏性能是核心指标
- 需要 Server Components 的细粒度控制
选择 Vue 3.6 + Vapor Mode 当:
- 追求极致运行时性能
- 偏好模板语法和响应式模型
- 项目复杂度中等,需要快速开发
- 团队有 Vue 背景
九、最佳实践总结
9.1 性能优化清单
- 使用 React Compiler :无需手动
useMemo/useCallback - 合理拆分 Server/Client Components:默认 Server,按需 Client
- 流式渲染关键路径:Suspense 包裹慢速组件
- 优化图片 :使用
next/image自动优化 - 字体优化 :
next/font自托管字体 - 缓存策略:静态页面 ISR,动态页面按需 revalidate
9.2 常见陷阱
tsx
// ❌ 错误:在 Server Component 中使用客户端 Hook
async function WrongComponent() {
const [state, setState] = useState(); // Error!
return <div>{state}</div>;
}
// ✅ 正确:拆分为 Client Component
'use client';
function ClientCounter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// Server Component 可以导入 Client Component
async function ServerParent() {
const data = await fetchData();
return (
<div>
<h1>{data.title}</h1>
<ClientCounter />
</div>
);
}
tsx
// ❌ 错误:大型组件全为 Client Component
'use client';
function BigComponent() {
// 所有逻辑都在客户端
}
// ✅ 正确:拆分静态和动态部分
async function BigComponent() {
const staticData = await fetchStaticData();
return (
<div>
<StaticSection data={staticData} />
<InteractiveSection /> {/* 只有这部分是 Client */}
</div>
);
}
9.3 安全注意事项
tsx
// ❌ 危险:直接渲染用户输入
<div dangerouslySetInnerHTML={{ __html: userInput }} />
// ✅ 安全:使用 DOMPurify 净化
import DOMPurify from 'dompurify';
<div dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(userInput)
}} />
// Server Actions 安全验证
'use server';
export async function sensitiveAction(formData: FormData) {
// 验证用户身份
const session = await auth();
if (!session) throw new Error('Unauthorized');
// 验证 CSRF(Next.js 自动处理)
// 验证输入
const data = schema.parse(Object.fromEntries(formData));
// 执行操作
await performAction(data);
}
十、总结
React 19 和 Next.js 15 代表了现代前端开发的最新范式:
- 编译时优化:React Compiler 自动处理性能优化,开发者专注业务逻辑
- 服务器优先:Server Components 让前端代码安全访问后端资源
- 流式渲染:Suspense + Streaming 提供最佳用户体验
- 类型安全:TypeScript 全栈类型共享,减少运行时错误
这些特性使得 React 生态系统在性能、开发体验和安全性上都达到了新的高度。无论是新项目还是现有项目迁移,都值得深入学习和实践。
参考资源:
本文完整代码仓库: github.com/example/react19-next15-blog
作者:前端技术爱好者
更新时间:2025年7月
如有疑问或建议,欢迎评论区交流讨论