第十篇:【React SSR 与 SSG】服务端渲染与静态生成实战指南

告别白屏加载!React 应用性能与 SEO 的终极提升方案

各位 React 开发者,你是否曾经面临过这些困扰:

  • 首屏加载时间过长,用户体验差?
  • 搜索引擎无法有效索引你的应用内容?
  • 大型应用的性能与可扩展性问题?
  • 复杂应用架构的选型困惑?

在过去的九篇文章中,我们详细探讨了 React 客户端渲染(CSR)的各种优化方法。今天,我们将揭开全新的篇章,探索服务端渲染(SSR)与静态站点生成(SSG)这两种强大的渲染策略,帮助你的 React 应用更快、更强、对 SEO 更友好!

1. 为什么需要 SSR 与 SSG?

在深入技术细节前,让我们先理解这些渲染策略解决的核心问题:

scss 复制代码
┌────────────────────────────────────────────────────┐
│ 渲染策略比较                                        │
├────────────┬───────────────┬───────────┬───────────┤
│            │ CSR(客户端渲染)│SSR(服务端渲染)│SSG(静态生成)│
├────────────┼───────────────┼───────────┼───────────┤
│ 首屏加载    │    慢    │    快    │   最快   │
│ SEO支持     │    差    │    好    │    好    │
│ 服务器负载  │    低    │    高    │    低    │
│ 内容更新    │  实时更新  │  每次请求  │ 构建时生成 │
│ 交互性      │    高    │    中    │    中    │
└────────────┴───────────────┴───────────┴───────────┘

Next.js:React SSR/SSG 的首选框架

Next.js 是 React SSR/SSG 应用开发的事实标准,它提供了:

  • 零配置的服务端渲染
  • 自动代码分割
  • 基于文件系统的路由
  • API 路由内置支持
  • 多种渲染模式灵活切换
  • 增量静态再生(ISR)

让我们从头开始构建一个 Next.js 应用,体验这一切的美妙:

bash 复制代码
# 创建新的Next.js应用
npx create-next-app@latest my-nextjs-app
cd my-nextjs-app
npm run dev

2. Next.js 应用核心结构与配置

理解 Next.js 的项目结构是掌握它的第一步:

ruby 复制代码
my-nextjs-app/
├── app/                    # App Router (Next.js 13+)
│   ├── layout.tsx          # 根布局组件
│   ├── page.tsx            # 首页
│   ├── about/              # 关于页面路由
│   │   └── page.tsx        # 关于页面
│   ├── blog/               # 博客路由
│   │   ├── [slug]/         # 动态路由
│   │   │   └── page.tsx    # 博客文章页面
│   │   └── page.tsx        # 博客列表页面
│   └── api/                # API路由
│       └── hello/
│           └── route.ts    # API端点
├── components/             # 共享组件
├── public/                 # 静态资源
├── styles/                 # 样式文件
├── lib/                    # 工具库
├── next.config.js          # Next.js配置
├── package.json            # 项目依赖
└── tsconfig.json           # TypeScript配置
jsx 复制代码
// app/layout.tsx - 根布局组件
import { Inter } from "next/font/google";
import { Metadata } from "next";
import "./globals.css";

// 使用Google字体
const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "我的Next.js应用",
  description: "使用Next.js构建的现代React应用",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode,
}) {
  return (
    <html lang="zh-CN">
      <body className={inter.className}>
        <header className="bg-blue-600 text-white p-4">
          <div className="container mx-auto flex justify-between items-center">
            <h1 className="text-2xl font-bold">我的Next.js应用</h1>
            <nav>
              <ul className="flex space-x-4">
                <li>
                  <a href="/" className="hover:underline">
                    首页
                  </a>
                </li>
                <li>
                  <a href="/about" className="hover:underline">
                    关于
                  </a>
                </li>
                <li>
                  <a href="/blog" className="hover:underline">
                    博客
                  </a>
                </li>
              </ul>
            </nav>
          </div>
        </header>
        <main className="container mx-auto py-8 px-4">{children}</main>
        <footer className="bg-gray-100 p-4 mt-8">
          <div className="container mx-auto text-center text-gray-600">
            © {new Date().getFullYear()} 我的Next.js应用 | 使用Next.js构建
          </div>
        </footer>
      </body>
    </html>
  );
}
jsx 复制代码
// app/page.tsx - 首页(使用服务端组件)
export default function Home() {
  return (
    <div className="space-y-8">
      <section className="text-center py-12">
        <h1 className="text-4xl font-bold mb-4">欢迎来到Next.js世界</h1>
        <p className="text-xl text-gray-600 max-w-2xl mx-auto">
          探索服务端渲染、静态站点生成和React应用开发的全新可能性
        </p>
      </section>

      <section className="grid grid-cols-1 md:grid-cols-3 gap-6">
        <div className="border rounded-lg p-6 shadow-sm">
          <h2 className="text-2xl font-bold mb-3">服务端渲染</h2>
          <p className="text-gray-600">
            每次请求时在服务器渲染页面,适合动态内容
          </p>
        </div>
        <div className="border rounded-lg p-6 shadow-sm">
          <h2 className="text-2xl font-bold mb-3">静态站点生成</h2>
          <p className="text-gray-600">
            在构建时预渲染页面,提供极快的加载速度
          </p>
        </div>
        <div className="border rounded-lg p-6 shadow-sm">
          <h2 className="text-2xl font-bold mb-3">增量静态再生</h2>
          <p className="text-gray-600">静态生成与按需更新的完美结合</p>
        </div>
      </section>

      <section className="text-center">
        <a
          href="/blog"
          className="inline-block bg-blue-600 text-white px-6 py-3 rounded-md hover:bg-blue-700 transition"
        >
          浏览博客文章
        </a>
      </section>
    </div>
  );
}

3. 不同渲染模式的实现方式

Next.js 13 的 App Router 提供了强大的服务端组件(RSC),但也支持多种渲染模式:

静态站点生成(SSG)

jsx 复制代码
// app/blog/page.tsx - 静态生成的博客列表页
import Link from "next/link";
import { getBlogPosts } from "@/lib/blog";

// 这个函数在构建时执行,用于静态生成页面
export async function generateStaticParams() {
  return [{}]; // 静态生成博客列表页
}

// 默认情况下,App Router中的页面组件是服务端组件
// 当使用generateStaticParams时,它们会在构建时静态生成
export default async function BlogListPage() {
  // 在构建时获取博客文章
  const posts = await getBlogPosts();

  return (
    <div>
      <h1 className="text-3xl font-bold mb-6">博客文章</h1>

      <div className="grid gap-6">
        {posts.map((post) => (
          <article key={post.slug} className="border rounded-lg p-6 shadow-sm">
            <h2 className="text-2xl font-bold mb-2">
              <Link
                href={`/blog/${post.slug}`}
                className="hover:text-blue-600 transition"
              >
                {post.title}
              </Link>
            </h2>
            <p className="text-gray-600 mb-4">{post.excerpt}</p>
            <div className="text-sm text-gray-500">
              发布于 {new Date(post.date).toLocaleDateString("zh-CN")}
            </div>
          </article>
        ))}
      </div>
    </div>
  );
}
jsx 复制代码
// app/blog/[slug]/page.tsx - 动态博客文章页面
import { getBlogPosts, getBlogPostBySlug } from "@/lib/blog";
import { notFound } from "next/navigation";
import { Metadata } from "next";

// 为每篇文章生成静态页面
export async function generateStaticParams() {
  const posts = await getBlogPosts();

  return posts.map((post) => ({
    slug: post.slug,
  }));
}

// 动态生成元数据
export async function generateMetadata({
  params,
}: {
  params: { slug: string },
}): Promise<Metadata> {
  const post = await getBlogPostBySlug(params.slug);

  if (!post) {
    return {
      title: "文章未找到",
    };
  }

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: "article",
      publishedTime: post.date,
      authors: [post.author.name],
    },
  };
}

export default async function BlogPostPage({
  params,
}: {
  params: { slug: string },
}) {
  const post = await getBlogPostBySlug(params.slug);

  if (!post) {
    notFound();
  }

  return (
    <article className="max-w-3xl mx-auto">
      <h1 className="text-4xl font-bold mb-4">{post.title}</h1>

      <div className="flex items-center space-x-4 mb-8 text-gray-600">
        <div className="flex items-center">
          <img
            src={post.author.avatar}
            alt={post.author.name}
            className="w-10 h-10 rounded-full mr-3"
          />
          <span>{post.author.name}</span>
        </div>
        <span>•</span>
        <time dateTime={post.date}>
          {new Date(post.date).toLocaleDateString("zh-CN", {
            year: "numeric",
            month: "long",
            day: "numeric",
          })}
        </time>
      </div>

      <div
        className="prose prose-lg max-w-none"
        dangerouslySetInnerHTML={{ __html: post.content }}
      />
    </article>
  );
}

增量静态再生(ISR)

jsx 复制代码
// lib/blog.ts - 博客数据获取逻辑
import fs from 'fs/promises';
import path from 'path';
import matter from 'gray-matter';
import { remark } from 'remark';
import html from 'remark-html';
import { cache } from 'react';

export interface BlogPost {
  slug: string;
  title: string;
  date: string;
  excerpt: string;
  content: string;
  author: {
    name: string;
    avatar: string;
  };
}

// 使用React的cache包装函数避免重复获取
export const getBlogPosts = cache(async (): Promise<BlogPost[]> => {
  const postsDirectory = path.join(process.cwd(), 'content/blog');
  const filenames = await fs.readdir(postsDirectory);

  const posts = await Promise.all(
    filenames.map(async (filename) => {
      const filePath = path.join(postsDirectory, filename);
      const fileContent = await fs.readFile(filePath, 'utf8');

      const { data, content } = matter(fileContent);
      const slug = filename.replace(/\.md$/, '');

      // 处理Markdown转HTML
      const processedContent = await remark()
        .use(html)
        .process(content);
      const contentHtml = processedContent.toString();

      return {
        slug,
        title: data.title,
        date: data.date,
        excerpt: data.excerpt || '',
        content: contentHtml,
        author: data.author || {
          name: '博客作者',
          avatar: '/images/default-avatar.png',
        },
      } as BlogPost;
    })
  );

  // 按日期排序
  return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
});

export async function getBlogPostBySlug(slug: string): Promise<BlogPost | null> {
  const posts = await getBlogPosts();
  return posts.find((post) => post.slug === slug) || null;
}
jsx 复制代码
// next.config.js - 配置ISR
/** @type {import('next').NextConfig} */
const nextConfig = {
  // App Router模式下,可以通过页面导出的revalidate属性配置ISR
  // 或者通过fetch的next.revalidate选项
  experimental: {
    // 启用实验性功能(根据Next.js版本可能不需要)
    serverActions: true,
  },
};

module.exports = nextConfig;
jsx 复制代码
// app/products/[id]/page.tsx - 使用ISR的产品页面
import { getProduct, getProductIds } from "@/lib/products";
import { notFound } from "next/navigation";
import Image from "next/image";
import AddToCartButton from "@/components/AddToCartButton";

// 在构建时静态生成一部分产品页面
export async function generateStaticParams() {
  // 获取热门产品ID,只预渲染这些页面
  const popularProductIds = await getPopularProductIds();

  return popularProductIds.map((id) => ({
    id: id.toString(),
  }));
}

// 设置该页面每60秒可以重新验证(ISR)
export const revalidate = 60;

export default async function ProductPage({
  params,
}: {
  params: { id: string },
}) {
  // 使用fetch API时也可以设置revalidate
  const product = await getProduct(params.id);

  if (!product) {
    notFound();
  }

  return (
    <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
      <div>
        <Image
          src={product.imageUrl}
          alt={product.name}
          width={600}
          height={600}
          className="rounded-lg"
          priority
        />
      </div>

      <div>
        <h1 className="text-3xl font-bold mb-4">{product.name}</h1>
        <div className="text-2xl font-semibold text-blue-600 mb-4">
          ¥{product.price.toFixed(2)}
        </div>

        <div className="mb-6">
          <div className="bg-green-50 text-green-700 px-4 py-2 rounded-md inline-block">
            有货 - 预计3-5天送达
          </div>
        </div>

        <p className="text-gray-700 mb-6">{product.description}</p>

        <AddToCartButton productId={product.id} />
      </div>
    </div>
  );
}

客户端组件与服务端数据交互

jsx 复制代码
// components/AddToCartButton.tsx - 客户端组件
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";

// 服务器操作
import { addToCart } from "@/app/actions";

interface AddToCartButtonProps {
  productId: string;
}

export default function AddToCartButton({ productId }: AddToCartButtonProps) {
  const [quantity, setQuantity] = useState(1);
  const [isLoading, setIsLoading] = useState(false);
  const router = useRouter();

  const handleAddToCart = async () => {
    setIsLoading(true);
    try {
      // 调用服务器操作
      await addToCart(productId, quantity);
      // 刷新服务器组件
      router.refresh();
      // 显示成功消息
      showSuccessToast("商品已添加到购物车");
    } catch (error) {
      console.error("添加到购物车失败", error);
      showErrorToast("添加到购物车失败,请重试");
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div className="space-y-4">
      <div className="flex items-center">
        <button
          onClick={() => setQuantity(Math.max(1, quantity - 1))}
          className="px-3 py-1 border rounded-l-md bg-gray-100"
          aria-label="减少数量"
        >
          -
        </button>
        <span className="px-4 py-1 border-t border-b text-center w-16">
          {quantity}
        </span>
        <button
          onClick={() => setQuantity(quantity + 1)}
          className="px-3 py-1 border rounded-r-md bg-gray-100"
          aria-label="增加数量"
        >
          +
        </button>
      </div>

      <button
        onClick={handleAddToCart}
        disabled={isLoading}
        className="w-full bg-blue-600 text-white py-3 px-6 rounded-md hover:bg-blue-700 transition disabled:opacity-70"
      >
        {isLoading ? "添加中..." : "添加到购物车"}
      </button>
    </div>
  );
}

// 显示消息的辅助函数
function showSuccessToast(message: string) {
  // 实现toast通知
}

function showErrorToast(message: string) {
  // 实现错误toast通知
}
jsx 复制代码
// app/actions.ts - 服务端操作
"use server";

import { cookies } from "next/headers";
import { revalidatePath } from "next/cache";

// 服务器操作:添加到购物车
export async function addToCart(productId: string, quantity: number) {
  // 获取当前购物车
  const cookieStore = cookies();
  const cartCookie = cookieStore.get("cart");
  let cart = cartCookie ? JSON.parse(cartCookie.value) : [];

  // 查找商品是否已在购物车
  const existingItemIndex = cart.findIndex(
    (item: any) => item.productId === productId
  );

  if (existingItemIndex >= 0) {
    // 更新数量
    cart[existingItemIndex].quantity += quantity;
  } else {
    // 添加新商品
    cart.push({ productId, quantity });
  }

  // 保存购物车到cookie
  cookieStore.set("cart", JSON.stringify(cart), {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    maxAge: 60 * 60 * 24 * 7, // 一周
    path: "/",
  });

  // 重新验证购物车相关页面
  revalidatePath("/cart");
  revalidatePath("/products/[id]");

  return { success: true };
}

4. API 路由与全栈应用开发

Next.js 不只用于渲染页面,还可以作为 API 服务器:

jsx 复制代码
// app/api/products/route.ts - API路由
import { NextResponse } from "next/server";
import { getProducts } from "@/lib/products";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const category = searchParams.get("category");
  const page = parseInt(searchParams.get("page") || "1");
  const limit = parseInt(searchParams.get("limit") || "10");

  try {
    const products = await getProducts({ category, page, limit });

    return NextResponse.json({
      products: products.items,
      pagination: {
        total: products.total,
        page,
        limit,
        pages: Math.ceil(products.total / limit),
      },
    });
  } catch (error) {
    console.error("获取产品列表失败", error);
    return NextResponse.json({ error: "获取产品列表失败" }, { status: 500 });
  }
}
jsx 复制代码
// app/api/products/[id]/route.ts - 动态API路由
import { NextResponse } from "next/server";
import { getProduct } from "@/lib/products";

export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  try {
    const product = await getProduct(params.id);

    if (!product) {
      return NextResponse.json({ error: "产品未找到" }, { status: 404 });
    }

    return NextResponse.json(product);
  } catch (error) {
    console.error("获取产品详情失败", error);
    return NextResponse.json({ error: "获取产品详情失败" }, { status: 500 });
  }
}

5. 数据获取与优化策略

Next.js 提供了强大的数据获取机制:

jsx 复制代码
// lib/database.ts - 数据库连接
import { Pool } from "pg";
import { cache } from "react";

// 创建数据库连接池
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  ssl:
    process.env.NODE_ENV === "production"
      ? { rejectUnauthorized: false }
      : false,
});

// 缓存数据库查询函数
export const queryDB = cache(async (text: string, params?: any[]) => {
  const client = await pool.connect();
  try {
    const result = await client.query(text, params);
    return result.rows;
  } finally {
    client.release();
  }
});
jsx 复制代码
// app/dashboard/page.tsx - 带缓存控制的数据获取
import { Suspense } from "react";
import SalesChart from "@/components/SalesChart";
import ProductsTable from "@/components/ProductsTable";
import { getSalesData, getTopProducts } from "@/lib/analytics";

// 控制服务器端数据获取
export const dynamic = "force-dynamic"; // 或 'auto' | 'force-static'
export const revalidate = 3600; // 1小时重新验证

export default async function DashboardPage() {
  // 并行数据获取
  const [salesData, topProducts] = await Promise.all([
    getSalesData(),
    getTopProducts(),
  ]);

  return (
    <div className="space-y-8">
      <h1 className="text-3xl font-bold">仪表盘</h1>

      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        <div className="col-span-2 bg-white p-6 rounded-lg shadow">
          <h2 className="text-xl font-semibold mb-4">销售趋势</h2>
          <Suspense fallback={<div>加载销售数据...</div>}>
            <SalesChart data={salesData} />
          </Suspense>
        </div>

        <div className="bg-white p-6 rounded-lg shadow">
          <h2 className="text-xl font-semibold mb-4">概览</h2>
          <dl className="space-y-4">
            <div>
              <dt className="text-gray-600">今日销售额</dt>
              <dd className="text-3xl font-bold">
                ¥{salesData.today.toLocaleString()}
              </dd>
            </div>
            <div>
              <dt className="text-gray-600">本月销售额</dt>
              <dd className="text-3xl font-bold">
                ¥{salesData.month.toLocaleString()}
              </dd>
            </div>
            <div>
              <dt className="text-gray-600">订单完成率</dt>
              <dd className="text-3xl font-bold">
                {salesData.completionRate}%
              </dd>
            </div>
          </dl>
        </div>
      </div>

      <div className="bg-white p-6 rounded-lg shadow">
        <h2 className="text-xl font-semibold mb-4">热门产品</h2>
        <Suspense fallback={<div>加载产品数据...</div>}>
          <ProductsTable products={topProducts} />
        </Suspense>
      </div>
    </div>
  );
}

6. 部署与性能优化

jsx 复制代码
// next.config.js - 生产优化配置
/** @type {import('next').NextConfig} */
const nextConfig = {
  // 图像优化
  images: {
    domains: ["example.com", "cdn.example.com"],
    formats: ["image/avif", "image/webp"],
  },

  // 国际化
  i18n: {
    locales: ["zh-CN", "en-US"],
    defaultLocale: "zh-CN",
  },

  // 压缩优化
  compress: true,

  // 响应头
  async headers() {
    return [
      {
        source: "/:path*",
        headers: [
          {
            key: "Cache-Control",
            value:
              "public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800",
          },
          {
            key: "X-Content-Type-Options",
            value: "nosniff",
          },
          {
            key: "X-Frame-Options",
            value: "DENY",
          },
          {
            key: "X-XSS-Protection",
            value: "1; mode=block",
          },
        ],
      },
    ];
  },

  // 重定向
  async redirects() {
    return [
      {
        source: "/old-blog/:slug",
        destination: "/blog/:slug",
        permanent: true,
      },
    ];
  },

  // 输出优化
  output: "standalone", // 适合容器化部署
};

module.exports = nextConfig;
yaml 复制代码
# .github/workflows/deploy.yml - 部署工作流
name: Deploy Next.js

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          API_KEY: ${{ secrets.API_KEY }}

      - name: Deploy to Vercel
        uses: amondnet/vercel-action@v20
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: "--prod"

下一篇预告:《【React 与微前端】大型应用的模块化与微服务化架构》

在系列的下一篇中,我们将探索如何处理大型企业级 React 应用的架构挑战:

  • 微前端架构的优势与实现方案
  • 前端微服务设计模式
  • 基于 Module Federation 的代码共享与独立部署
  • 微前端的性能优化与状态管理
  • 团队协作与开发效率的平衡

随着企业应用规模不断扩大,如何保持代码可维护性和团队并行开发效率成为关键问题。下一篇,我们将为你揭开构建高效大型前端应用的秘密!

敬请期待!

关于作者

Hi,我是 hyy,一位热爱技术的全栈开发者:

  • 🚀 专注 TypeScript 全栈开发,偏前端技术栈
  • 💼 多元工作背景(跨国企业、技术外包、创业公司)
  • 📝 掘金活跃技术作者
  • 🎵 电子音乐爱好者
  • 🎮 游戏玩家
  • 💻 技术分享达人

加入我们

欢迎加入前端技术交流圈,与 10000+开发者一起:

  • 探讨前端最新技术趋势
  • 解决开发难题
  • 分享职场经验
  • 获取优质学习资源

添加方式:掘金摸鱼沸点 👈 扫码进群

相关推荐
_处女座程序员的日常5 分钟前
css媒体查询及css变量
前端·css·媒体
GanGuaGua2 小时前
CSS:盒子模型
开发语言·前端·css·html
进取星辰2 小时前
23、Next.js:时空传送门——React 19 全栈框架
开发语言·javascript·react.js
GalenWu8 小时前
对象转换为 JSON 字符串(或反向解析)
前端·javascript·微信小程序·json
GUIQU.8 小时前
【Vue】微前端架构与Vue(qiankun、Micro-App)
前端·vue.js·架构
zwjapple8 小时前
“ES7+ React/Redux/React-Native snippets“常用快捷前缀
javascript·react native·react.js
数据潜水员8 小时前
插槽、生命周期
前端·javascript·vue.js
2401_837088508 小时前
CSS vertical-align
前端·html
优雅永不过时·8 小时前
实现一个漂亮的Three.js 扫光地面 圆形贴图扫光
前端·javascript·智慧城市·three.js·贴图·shader
CodeCraft Studio10 小时前
报表控件stimulsoft教程:使用 JoinType 关系参数创建仪表盘
前端·ui