React/Next.js 现代化 Web 应用:从 CSR 到 SSR/RSC,渲染策略的选型与落地

cover

一、渲染策略的选型困境:CSR 不够快,SSR 不够灵活

React 应用的渲染策略经历了从 CSR(客户端渲染)到 SSR(服务端渲染)再到 RSC(React Server Components)的演进。CSR 首屏加载慢但交互流畅,SSR 首屏快但 TTFB(首字节时间)长,RSC 试图兼顾两者但引入了新的复杂度。

选型困境在于:同一个应用的不同页面可能需要不同的渲染策略。首页和商品详情页需要 SEO 和首屏速度,适合 SSR;仪表盘和编辑器需要交互流畅,适合 CSR;数据展示页需要服务端数据获取但不需全页 SSR,适合 RSC。Next.js 的 App Router 支持页面级别的渲染策略选择,但混合策略的边界划分和状态共享是工程挑战。

二、Next.js App Router 的渲染架构

flowchart TD
    A[用户请求] --> B{路由类型}
    B -->|静态页面| C[SSG: 构建时生成]
    B -->|动态页面| D{数据获取方式}
    D -->|服务端组件| E[RSC: 流式渲染]
    D -->|客户端组件| F[CSR: 客户端渲染]
    E --> E1[服务端数据获取: 无需 API]
    E --> E2[流式 HTML: 逐步传输]
    F --> F1[客户端数据获取: useEffect/SWR]
    F --> F2[交互逻辑: 状态/事件]
    E1 --> G[混合渲染输出]
    E2 --> G
    F1 --> G
    G --> H[用户交互]
    H --> I{交互类型}
    I -->|导航| J[路由预取: Link prefetch]
    I -->|数据变更| K[乐观更新 + 重新验证]

2.1 Server Components 与 Client Components 的边界

// app/products/page.tsx — 服务端组件(默认)
// 设计意图:在服务端获取数据,零客户端 JS,
// 适合 SEO 和首屏性能

import { Suspense } from 'react';
import { ProductList } from '@/components/ProductList';
import { ProductFilters } from '@/components/ProductFilters';
import { ProductListSkeleton } from '@/components/Skeletons';

// 服务端数据获取:直接访问数据库,无需 API 层
async function getProducts(filters: ProductFilters) {
  const products = await db.product.findMany({
    where: {
      category: filters.category || undefined,
      price: {
        gte: filters.minPrice || 0,
        lte: filters.maxPrice || Infinity,
      },
    },
    include: { reviews: true },
    orderBy: { createdAt: 'desc' },
    take: 20,
  });

  return products;
}

// 页面组件:服务端组件,不发送 JS 到客户端
export default async function ProductsPage({
  searchParams,
}: {
  searchParams: Record<string, string>;
}) {
  const filters = parseFilters(searchParams);

  return (
    <div className="product-page">
      {/* 客户端组件:需要交互的筛选器 */}
      <ProductFilters initialFilters={filters} />

      {/* 服务端组件:流式渲染,逐步传输 */}
      <Suspense fallback={<ProductListSkeleton />}>
        <ProductList filters={filters} />
      </Suspense>
    </div>
  );
}
// components/ProductFilters.tsx — 客户端组件
// 设计意图:需要用户交互(筛选、排序)的组件,
// 必须标记为 'use client'

'use client';

import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useTransition } from 'react';

interface ProductFiltersProps {
  initialFilters: {
    category?: string;
    minPrice?: number;
    maxPrice?: number;
    sort?: string;
  };
}

export function ProductFilters({ initialFilters }: ProductFiltersProps) {
  const router = useRouter();
  const searchParams = useSearchParams();
  const [isPending, startTransition] = useTransition();

  // 更新 URL 查询参数,触发服务端重新渲染
  const updateFilter = useCallback(
    (key: string, value: string) => {
      const params = new URLSearchParams(searchParams.toString());
      if (value) {
        params.set(key, value);
      } else {
        params.delete(key);
      }

      // 使用 transition 包裹路由变更,保持当前 UI 直到新页面准备好
      startTransition(() => {
        router.push(`/products?${params.toString()}`);
      });
    },
    [router, searchParams]
  );

  return (
    <div className={`filters ${isPending ? 'opacity-50' : ''}`}>
      <select
        value={initialFilters.category || ''}
        onChange={(e) => updateFilter('category', e.target.value)}
      >
        <option value="">全部分类</option>
        <option value="electronics">电子产品</option>
        <option value="clothing">服装</option>
      </select>

      <select
        value={initialFilters.sort || 'latest'}
        onChange={(e) => updateFilter('sort', e.target.value)}
      >
        <option value="latest">最新</option>
        <option value="price-asc">价格升序</option>
        <option value="price-desc">价格降序</option>
      </select>
    </div>
  );
}

2.2 流式渲染与 Suspense 边界

// components/ProductList.tsx — 流式渲染的商品列表
// 设计意图:使用 Suspense 实现流式渲染,
// 快速部分先展示,慢速部分逐步加载

import { Suspense } from 'react';

async function ProductList({ filters }: { filters: ProductFilters }) {
  const products = await getProducts(filters);

  return (
    <div className="product-grid">
      {products.map((product) => (
        <div key={product.id} className="product-card">
          <img src={product.imageUrl} alt={product.name} />
          <h3>{product.name}</h3>
          <p className="price">¥{product.price}</p>

          {/* 评分组件:可能加载慢,独立 Suspense */}
          <Suspense fallback={<div>加载评分...</div>}>
            <ProductRating productId={product.id} />
          </Suspense>
        </div>
      ))}
    </div>
  );
}

// 评分组件:需要额外的数据获取
async function ProductRating({ productId }: { productId: string }) {
  const rating = await getProductRating(productId);

  return (
    <div className="rating">
      {'★'.repeat(Math.round(rating.average))}
      <span className="count">({rating.count})</span>
    </div>
  );
}

三、数据获取策略与缓存

3.1 Next.js 缓存策略

// lib/data-fetching.ts — 分层数据获取策略
// 设计意图:根据数据变更频率选择不同的缓存策略,
// 平衡数据新鲜度和响应速度

// 策略1:静态数据 — 构建时获取,永久缓存
async function getCategories() {
  const res = await fetch('https://api.example.com/categories', {
    cache: 'force-cache',  // 永久缓存,直到 revalidate
  });
  return res.json();
}

// 策略2:半静态数据 — 定时重新验证
async function getProducts(category: string) {
  const res = await fetch(
    `https://api.example.com/products?category=${category}`,
    {
      next: {
        revalidate: 3600,  // 每小时重新验证
        tags: ['products'],  // 按需重新验证的标签
      },
    }
  );
  return res.json();
}

// 策略3:动态数据 — 每次请求都获取最新
async function getUserProfile(userId: string) {
  const res = await fetch(
    `https://api.example.com/users/${userId}`,
    {
      cache: 'no-store',  // 不缓存,每次请求最新数据
    }
  );
  return res.json();
}

// 按需重新验证:当数据变更时主动刷新缓存
import { revalidateTag } from 'next/cache';

async function updateProduct(productId: string, data: any) {
  await fetch(`https://api.example.com/products/${productId}`, {
    method: 'PUT',
    body: JSON.stringify(data),
  });

  // 主动刷新 products 相关缓存
  revalidateTag('products');
}

3.2 客户端数据获取与乐观更新

// hooks/use-product-mutation.ts — 客户端数据变更 Hook
// 设计意图:使用 SWR 实现乐观更新,
// 变更操作立即反映到 UI,后台同步到服务端

import useSWR, { mutate } from 'swr';

interface Product {
  id: string;
  name: string;
  price: number;
  stock: number;
}

export function useProduct(productId: string) {
  const { data, error, isLoading } = useSWR<Product>(
    `/api/products/${productId}`,
    fetcher,
    {
      revalidateOnFocus: false,
      dedupingInterval: 60000,
    }
  );

  return { product: data, error, isLoading };
}

export function useUpdateProduct() {
  const updateProduct = async (
    productId: string,
    updates: Partial<Product>
  ) => {
    // 乐观更新:立即更新本地缓存
    mutate(
      `/api/products/${productId}`,
      (current: Product | undefined) => {
        if (!current) return current;
        return { ...current, ...updates };
      },
      false // 不立即重新验证
    );

    try {
      // 发送更新请求
      await fetch(`/api/products/${productId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(updates),
      });

      // 更新成功,重新验证缓存
      mutate(`/api/products/${productId}`);
    } catch (error) {
      // 更新失败,回滚乐观更新
      mutate(`/api/products/${productId}`);
      throw error;
    }
  };

  return { updateProduct };
}

async function fetcher(url: string) {
  const res = await fetch(url);
  if (!res.ok) throw new Error('Fetch failed');
  return res.json();
}

四、边界分析与架构权衡

Server/Client 组件的边界划分:组件树中 Server 和 Client 的边界划分影响性能和开发体验。过度使用 Client Components 会增加客户端 JS 体积,过度使用 Server Components 会限制交互能力。原则是"尽可能使用 Server Component,只在需要交互时切换到 Client Component",但实际判断需要经验。

流式渲染的 SEO 影响:Suspense 流式渲染会将页面分块传输。搜索引擎爬虫可能只抓取到首屏 HTML,后续块的内容不会被索引。对于 SEO 敏感的页面,需要确保关键内容在首屏 HTML 中完整输出,非关键内容才使用 Suspense 延迟加载。

缓存一致性:Next.js 的多层缓存(构建缓存、请求缓存、CDN 缓存、客户端缓存)可能导致数据不一致。一个更新操作可能只刷新了部分缓存层,其他层仍返回旧数据。需要建立统一的缓存失效策略,确保所有层级同步更新。

RSC 的调试困难:Server Components 的错误堆栈可能跨越服务端和客户端,调试时需要同时查看服务端日志和浏览器控制台。开发体验不如纯 CSR 或纯 SSR 直观。Next.js 的 DevTools 正在改善这一点,但仍有提升空间。

五、总结

React/Next.js 现代化 Web 应用通过 Server Components、Client Components 和 Suspense 的组合,实现了页面级别的渲染策略选择。核心机制包括:Server Components 在服务端获取数据减少客户端 JS,Client Components 处理交互逻辑,Suspense 实现流式渲染提升感知性能。数据获取策略根据变更频率选择缓存级别,客户端变更通过 SWR 实现乐观更新。但组件边界划分、流式渲染 SEO、缓存一致性和 RSC 调试是需要权衡的边界条件。落地建议:默认使用 Server Component,交互组件标记 'use client';SEO 页面确保关键内容在首屏;缓存策略按数据变更频率分级;建立统一的缓存失效机制。

更多推荐