CTO Plus技术服务栈

Next.js 14+ 全栈开发实战:App Router、Server Components 与最佳实践

基于Next.js 14/15的现代全栈开发指南:App Router路由系统、React Server Components(RSC)、Server Actions、数据获取策略、中间件、ISR/SSG/SSR渲染策略选择、以及生产部署优化。

SteveRocket
北京,中国
2 min read

Next.js 为什么成为 React 全栈首选?

2025-2026 年,Next.js 已经稳坐 React 框架的头把交椅。根据 State of JS 调查,超过 60% 的 React 开发者选择 Next.js 作为首选框架。

我们团队从 2024 年开始将多个项目迁移到 Next.js App Router,经历了从”这玩意好难”到”回不去了”的过程。这篇文章分享核心经验和踩坑记录。

三个最重要的转变:

  1. Server Components 默认:组件默认在服务端渲染,只有明确需要交互的才用客户端组件
  2. Server Actions:不用再写 API Route,直接在组件里定义服务端函数
  3. 流式渲染:页面可以边生成边发送,不用等全部数据加载完

App Router 核心概念

文件约定即路由

app/
├── layout.tsx          # 根布局(所有页面共享)
├── page.tsx            # 首页 /
├── loading.tsx         # 加载状态
├── error.tsx           # 错误边界
├── not-found.tsx       # 404 页面
├── blog/
│   ├── layout.tsx      # 博客布局
│   ├── page.tsx        # /blog
│   ├── loading.tsx
│   └── [slug]/
│       ├── page.tsx    # /blog/:slug
│       └── loading.tsx
└── api/
    └── hello/
        └── route.ts    # /api/hello

文件系统的约定替代了手写路由配置。每个文件夹可以有自己的 layout.tsxloading.tsxerror.tsx,自然形成嵌套结构。

Layout 嵌套机制

// app/layout.tsx — 根布局
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN">
      <body>
        <Header />
        <main>{children}</main>
        <Footer />
      </body>
    </html>
  );
}

// app/blog/layout.tsx — 博客布局
export default function BlogLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="blog-container">
      <Sidebar />
      <article>{children}</article>
    </div>
  );
}

关键特性:导航时 Layout 不会重新渲染,只有 children 变化。这意味着侧边栏的状态(比如滚动位置)会保持,用户体验非常流畅。

React Server Components(RSC)

这是 Next.js 14+ 最重要的变革。理解 RSC 需要先搞清楚一个问题:为什么需要 Server Components?

传统 React 的问题

// 传统 CSR 组件
import { heavyLibrary } from 'heavy-lib'; // 100KB 的库

export function Page() {
  // 这个组件及其依赖全部打包到客户端 JS
  const data = heavyLibrary.process(someData);
  return <div>{data}</div>;
}

问题:即使用户只需要静态内容,也要下载 100KB 的库代码。

Server Components 的答案

// Server Component(默认)
import { heavyLibrary } from 'heavy-lib'; // 不会打包到客户端!

export default async function Page() {
  const data = heavyLibrary.process(someData);
  const posts = await db.query('SELECT * FROM posts'); // 直接查数据库!
  
  return (
    <div>
      <h1>{data}</h1>
      <PostList posts={posts} />
    </div>
  );
}

Server Component 在服务端运行,返回的是序列化的 React 树(不是 HTML),客户端只需要渲染,不需要下载组件代码和依赖。

Client Components:需要交互时使用

'use client'; // 明确声明这是客户端组件

import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <button onClick={() => setCount(count + 1)}>
      点击了 {count} 次
    </button>
  );
}

Server vs Client 决策树

组件需要...吗?
├── 用户交互(useState, onClick, useEffect)
│   └── 是 → Client Component(加 'use client')

├── 浏览器 API(localStorage, window, navigator)
│   └── 是 → Client Component

├── 数据库查询、文件系统访问
│   └── 是 → Server Component

├── 大依赖库(语法高亮、Markdown 解析)
│   └── 是 → Server Component(不要发到客户端)

└── 纯展示 + 数据获取
    └── Server Component(默认)

一个常见误区'use client' 不是说”这个组件只在客户端渲染”,而是”这个组件也需要客户端 JS”。它仍然会在服务端预渲染(SSR),只是会额外发送 JS 到客户端用于水合(hydration)。

组合模式:Server 包裹 Client

// Server Component — 获取数据
export default async function BlogPage({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  
  return (
    <article>
      <h1>{post.title}</h1>
      <PostContent content={post.content} />  {/* Server Component */}
      <CommentSection postId={post.id} />     {/* Client Component */}
    </article>
  );
}

// Client Component — 需要交互
'use client';
function CommentSection({ postId }: { postId: string }) {
  const [comments, setComments] = useState([]);
  
  useEffect(() => {
    fetch(`/api/comments?postId=${postId}`)
      .then(res => res.json())
      .then(setComments);
  }, [postId]);
  
  return (
    <div>
      <CommentForm postId={postId} />
      {comments.map(c => <Comment key={c.id} {...c} />)}
    </div>
  );
}

Server Component 的数据直接传给 Client Component 作为 props,序列化开销很小(只序列化数据,不序列化组件代码)。

Server Actions:告别 API Route

Server Actions 让”调用服务端函数”变得和调用普通函数一样简单:

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;
  
  // 服务端验证
  if (!title || title.length < 5) {
    return { error: '标题至少5个字符' };
  }
  
  // 直接操作数据库(安全,因为在服务端执行)
  await db.post.create({
    data: { title, content }
  });
  
  // 重新验证缓存
  revalidatePath('/blog');
  
  return { success: true };
}
// 客户端组件中直接使用
'use client';
import { createPost } from '@/app/actions';

export function CreatePostForm() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="标题" />
      <textarea name="content" placeholder="内容" />
      <button type="submit">发布</button>
    </form>
  );
}

Server Actions 与传统 API Route 对比

特性API RouteServer Actions
类型安全需要手动定义类型自动推断
表单处理手动 serialize/deserializeFormData 直接传递
渐进增强需要额外处理天然支持(无 JS 也能提交)
乐观更新需要手动实现useOptimistic 内置
代码组织分散在 route 文件和组件放在一起

高级模式:乐观更新

'use client';
import { useOptimistic } from 'react';
import { addComment } from './actions';

export function Comments({ initialComments }: { initialComments: Comment[] }) {
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    initialComments,
    (state, newComment: Comment) => [newComment, ...state]
  );
  
  async function handleSubmit(formData: FormData) {
    const content = formData.get('content') as string;
    
    // 立即更新 UI(乐观更新)
    addOptimisticComment({
      id: 'temp-' + Date.now(),
      content,
      author: '我',
      createdAt: new Date(),
    });
    
    // 发送到服务端
    await addComment(formData);
  }
  
  return (
    <div>
      <form action={handleSubmit}>
        <textarea name="content" />
        <button type="submit">评论</button>
      </form>
      {optimisticComments.map(c => (
        <div key={c.id}>{c.content}</div>
      ))}
    </div>
  );
}

数据获取策略

三种渲染策略

// 1. SSG(静态生成)— 构建时生成,适合不经常变化的内容
export const revalidate = 3600; // ISR:每小时重新生成

export default async function BlogList() {
  const posts = await getPosts();
  return <PostList posts={posts} />;
}

// 2. SSR(服务端渲染)— 每次请求都重新生成
export const dynamic = 'force-dynamic';

export default async function Dashboard() {
  const stats = await getRealTimeStats();
  return <Dashboard stats={stats} />;
}

// 3. ISR(增量静态再生)— 静态生成 + 按需更新
export const revalidate = 60; // 最多60秒后重新生成

并行数据获取

// 串行获取(慢)——不要这样写
const user = await getUser();
const posts = await getPosts(user.id);
const comments = await getComments(posts);

// 并行获取(快)
const [user, posts, comments] = await Promise.all([
  getUser(),
  getPosts(),
  getComments(),
]);

流式渲染(Streaming)

import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      <h1>仪表盘</h1>
      
      {/* 立即渲染 */}
      <Suspense fallback={<StatsSkeleton />}>
        <Stats />  {/* 数据就绪后流式发送 */}
      </Suspense>
      
      <Suspense fallback={<ChartSkeleton />}>
        <Charts />  {/* 独立加载,不阻塞其他部分 */}
      </Suspense>
      
      <Suspense fallback={<TableSkeleton />}>
        <RecentOrders />
      </Suspense>
    </div>
  );
}

生产部署优化

图片优化

import Image from 'next/image';

// 自动优化:格式转换(WebP/AVIF)、尺寸适配、懒加载
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={630}
  priority  // 首屏图片优先加载
  placeholder="blur"  // 模糊占位符
/>

Bundle 分析

# 安装分析工具
npm install @next/bundle-analyzer

# next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
  // config
});

常见性能陷阱

  1. 不要把所有东西都标记为 'use client'——这会让大量代码发送到客户端
  2. 不要在 Server Component 里导入客户端专用的库
  3. 注意水合错误——服务端和客户端渲染不一致会导致闪烁
  4. 使用 loading.tsx 而不是在 layout 里写加载逻辑

我们的实践经验

经验一:渐进式迁移

不需要一次性重写整个项目。Next.js 支持 Pages Router 和 App Router 共存:

app/
├── (new-features)/  # 新功能用 App Router
pages/               # 旧功能保持 Pages Router

经验二:数据库访问模式

在 Server Components 中直接查询数据库是合法的,但要避免 N+1 查询:

// 不好:N+1 查询
const posts = await db.post.findMany();
for (const post of posts) {
  post.author = await db.user.findUnique({ where: { id: post.authorId } });
}

// 好:用 include 一次性加载
const posts = await db.post.findMany({
  include: { author: true }
});

经验三:缓存策略

Next.js 有四级缓存:

层级机制控制方式
数据缓存fetch 请求缓存cache: 'no-store'
全路由缓存HTML/RSC 缓存revalidate / dynamic
路由器缓存客户端内存缓存router.refresh()
请求记忆化同一请求的去重自动(React cache)

总结

Next.js 14+ 的 App Router 是 React 开发范式的重大转变。核心要点:

  1. Server Components 默认:把不需要交互的代码留在服务端
  2. Server Actions 取代 API Route:更自然的全栈开发体验
  3. 流式渲染 + Suspense:用户感知性能大幅提升
  4. 文件约定路由:减少配置,提高可维护性
  5. 渐进式迁移:不要一次性重写,逐步替换

如果说 2020-2023 年是”SPA + API”的时代,那么 2024-2026 年就是”Server Components + Server Actions”的时代。这不是倒退,而是在新的技术高度上的螺旋上升。

Comments

Link copied to clipboard!