React 19 + Next.js 15 现代前端开发实战:App Router / Server Components / 流式渲染

本文基于 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>
  );
}

编译器工作原理:

  1. 静态分析组件依赖关系
  2. 自动插入 memoization
  3. 自动跳过不必要的重渲染
  4. 编译时生成优化代码

启用编译器(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 性能优化清单

  1. 使用 React Compiler :无需手动 useMemo/useCallback
  2. 合理拆分 Server/Client Components:默认 Server,按需 Client
  3. 流式渲染关键路径:Suspense 包裹慢速组件
  4. 优化图片 :使用 next/image 自动优化
  5. 字体优化next/font 自托管字体
  6. 缓存策略:静态页面 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 代表了现代前端开发的最新范式:

  1. 编译时优化:React Compiler 自动处理性能优化,开发者专注业务逻辑
  2. 服务器优先:Server Components 让前端代码安全访问后端资源
  3. 流式渲染:Suspense + Streaming 提供最佳用户体验
  4. 类型安全:TypeScript 全栈类型共享,减少运行时错误

这些特性使得 React 生态系统在性能、开发体验和安全性上都达到了新的高度。无论是新项目还是现有项目迁移,都值得深入学习和实践。


参考资源:

本文完整代码仓库: github.com/example/react19-next15-blog


作者:前端技术爱好者

更新时间:2025年7月

如有疑问或建议,欢迎评论区交流讨论

相关推荐
谙忆10242 小时前
前端图片直传对象存储:OSS/S3 预签名 URL、STS 临时凭证与回调校验
前端
CHNE_TAO_EMSM3 小时前
Android studio 打开文件时自动下载源码
前端·javascript·android studio
一孤程4 小时前
Airtest自动化测试第五篇:小程序与Web测试——跨平台自动化全覆盖
前端·自动化测试·小程序·自动化·测试·airtest
IT_陈寒4 小时前
SpringBoot自动配置不是你以为的那样的智能
前端·人工智能·后端
yume_sibai5 小时前
大屏数据可视化 - 边框红绿呼吸灯实现详解
前端·信息可视化·typescript
竹林8185 小时前
从 ethers.js 迁移到 Viem:一个签名验证 Bug 让我彻底放弃旧爱
javascript
Hyyy5 小时前
很多Desktop都在上的Computer Use是什么
前端·llm
慢功夫5 小时前
开篇:VS Code 为什么不是一个普通 React App
前端·visual studio code
你挚爱的强哥5 小时前
Vue2 实现 1.5s 十连击监听(连续点击若干次),封装通用可复用点击检测工具,不用 data!Vue 封装通用连击监听方法,支持自定义时长与点击次数
前端·javascript·vue.js