React18+Next.js全栈实战:SSR到流式渲染
发布日期: 2026/08/16 阅读总量: 0

一个让我加班到凌晨3点的bug

2024年3月,我们上线了一个B2B电商项目:Next.js 13.4 + React 18.2,服务端渲染商品列表页。上线后的核心指标惨不忍睹:LCP 4.8s,TTFB 2.3s,SEO抓取的商品页收录率只有12%。更离谱的是,用户反馈「页面闪一下才出现内容」——SSR渲染出来的HTML,和客户端hydration后的DOM不一致,导致React在客户端重新渲染了一次。

我的第一反应是:Next.js不是号称SSR开箱即用吗?

排查后发现——问题出在React 18的并发特性上。我们用的Next.js 12(React 17),升级到Next.js 13(React 18)后,renderToString被废弃,renderToPipeableStream才是正解。但代码里还残留着一堆useEffect里拉数据的老写法,把SSR的HTML活生生打成了「空壳」。

三种方案:全量SSR vs Streaming SSR vs Server Components

针对「服务端渲染但客户端还要重复拉数据」的问题,我压测了三种方案。测试环境:单台4核8G的云服务器,Node.js 18.17,Next.js 13.4.19,React 18.2.0,PostgreSQL 15。压测工具Autocannon 7.10,并发50,持续30秒。页面为商品列表页(20个商品卡片,每个卡片含图片URL、标题、价格、库存状态)。

方案A:传统全量SSR(getServerSideProps + 客户端useEffect兜底)

这是Next.js 12时代的标准写法。服务端拿到数据,渲染完整HTML返回。数据也有重复请求的问题。

指标方案A (渲染)方案A (客户端)
TTFB1.8s2.3s(含JS执行)
LCP3.2s4.8s
HTML体积62KB
FCP2.4s3.1s

问题核心:服务端已经渲染了完整HTML,但客户端JS一执行,React发现服务端标记(data-reactroot)与客户端首屏输出不一致,直接丢弃整个DOM树重建。这也解释了「闪一下」的现象。

方案B:React18 Streaming SSR(renderToPipeableStream)

React 18的核心更新之一。服务端把HTML分块(chunk)流式返回:先发送静态骨架(Suspense fallback),数据到了再补发真正的内容。

指标方案B (流式TTFB)方案B (完整加载)
TTFB210ms1.2s
LCP1.8s(流式注入后)
HTML体积首包18KB完整75KB
FCP1.1s1.4s

关键变化:用户在1秒内就能看到页面骨架,而不是盯着白屏。等数据库查询完成,React把商品卡片流式注入到原本的Suspense边界内。

方案C:React Server Components(App Router + RSC)

Next.js 13.4 App Router默认启用RSC。服务器组件(Server Component)只在服务端运行,不打包进客户端JS。数据获取直接用async/await,不用useEffect

指标方案C (RSC)
TTFB180ms
LCP1.3s
HTML体积首包12KB,完整68KB
客户端JS体积比方案A减少43%
FCP950ms

最终选了方案C + 方案B结合:RSC作为默认架构,Streaming负责加载态。接下来看完整实现。

完整代码实现:基于Next.js 13.4全栈商品列表

1. 项目初始化(bash)

# 版本锁定:Next.js 13.4.19, React 18.2.0
npx create-next-app@13.4.19 ecommerce-demo \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir \
  --import-alias "@/*"

cd ecommerce-demo

# 安装数据库ORM(Drizzle轻量,适合SSR场景)
npm install drizzle-orm @neondatabase/serverless
npm install -D drizzle-kit

# 安装压测工具(生产环境验证用)
npm install -D autocannon

2. 数据库Schema(sql)

-- PostgreSQL 15.0,Neon Serverless
CREATE TABLE products (
  id SERIAL PRIMARY KEY,
  title VARCHAR(200) NOT NULL,
  price DECIMAL(10, 2) NOT NULL,
  stock_status VARCHAR(20) NOT NULL DEFAULT 'in_stock',
  image_url TEXT NOT NULL,
  category_id INTEGER REFERENCES categories(id),
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_products_category_id ON products(category_id);
CREATE INDEX idx_products_stock_status ON products(stock_status);

-- 插入压测数据:10万条
INSERT INTO products (title, price, stock_status, image_url, category_id)
SELECT 
  '商品_' || generate_series(1, 100000),
  ROUND((RANDOM() * 500 + 10)::NUMERIC, 2),
  CASE WHEN RANDOM() > 0.2 THEN 'in_stock' ELSE 'out_of_stock' END,
  'https://cdn.example.com/img/' || (generate_series(1, 100000) % 1000) || '.jpg',
  1 + (generate_series(1, 100000) % 20)
FROM generate_series(1, 100000);

3. Drizzle ORM配置(typescript)

// src/db/schema.ts
import { pgTable, serial, text, decimal, timestamp, integer } from 'drizzle-orm/pg-core';

export const products = pgTable('products', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  price: decimal('price', { precision: 10, scale: 2 }).notNull(),
  stockStatus: text('stock_status').notNull(),
  imageUrl: text('image_url').notNull(),
  categoryId: integer('category_id'),
  createdAt: timestamp('created_at').defaultNow(),
  updatedAt: timestamp('updated_at').defaultNow(),
});

// src/db/index.ts
import { drizzle } from 'drizzle-orm/neon-serverless';
import { Pool } from '@neondatabase/serverless';
import * as schema from './schema';

const pool = new Pool({ connectionString: process.env.DATABASE_URL! });
export const db = drizzle(pool, { schema });

export type Product = typeof products.$inferSelect;

4. Server Component实现商品列表页(tsx)

这是方案C的核心。商品列表是Server Component,不需要发送任何JS到客户端。用async function直接在服务端查询数据库。

// src/app/products/page.tsx
import { Suspense } from 'react';
import { eq, desc } from 'drizzle-orm';
import { db, products } from '@/db';
import ProductCard from './ProductCard';
import ProductSkeleton from './ProductSkeleton';
import { redisCache } from '@/lib/cache';

// 1. 服务端直接查询数据库(不是API调用)
async function getProducts() {
  // 加Redis缓存(Redis 7.0,TTL 60秒)
  const cached = await redisCache.get('products:list:page1');
  if (cached) return JSON.parse(cached);

  const result = await db
    .select()
    .from(products)
    .orderBy(desc(products.createdAt))
    .limit(20);

  await redisCache.set('products:list:page1', JSON.stringify(result), 60);
  return result;
}

// 2. Server Component:直接await异步函数
export default async function ProductListPage() {
  const productList = await getProducts();

  return (
    // 3. Suspense定义流式加载边界
    }>
      
{productList.map((product) => ( ))}
); }

5. 客户端组件:商品卡片(tsx)

只有需要交互的部分才是Client Component。商品卡片有「加入购物车」按钮,所以加'use client'指令。

// src/app/products/ProductCard.tsx
'use client';

import { useOptimistic, useTransition } from 'react';
import type { Product } from '@/db/schema';
import { addToCart } from '@/app/actions/cart';

export default function ProductCard({ product }: { product: Product }) {
  const [isPending, startTransition] = useTransition();
  const [optimisticStock, setOptimisticStock] = useOptimistic(
    product.stockStatus,
    (_, newStatus: string) => newStatus
  );

  const handleAddToCart = () => {
    startTransition(async () => {
      // 乐观更新:先改界面,再发请求
      setOptimisticStock('processing');
      await addToCart(product.id, 1);
    });
  };

  return (
    
{product.title}

{product.title}

¥{Number(product.price).toFixed(2)}

{optimisticStock === 'in_stock' ? '现货' : '缺货'}
); }

6. Server Actions处理表单提交(tsx)

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

import { revalidatePath } from 'next/cache';
import { createClient } from '@/lib/supabase/server';

export async function addToCart(productId: number, quantity: number) {
  // 1. 获取当前用户会话(Supabase Auth)
  const supabase = createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) throw new Error('请先登录');

  // 2. 写入购物车表(PostgreSQL)
  const { error } = await supabase
    .from('cart_items')
    .upsert({
      user_id: user.id,
      product_id: productId,
      quantity,
      updated_at: new Date().toISOString(),
    });

  if (error) throw error;

  // 3. 触发ISR重新验证商品列表页
  revalidatePath('/products');
  return { success: true };
}

7. next.config.js优化流式传输(js)

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    // 开启React 18的流式SSR传输
    streaming: true,
    // 优化Server Components打包体积
    serverComponentsExternalPackages: ['@neondatabase/serverless'],
  },
  // CDN配置:图片走独立域名,不阻塞渲染
  images: {
    remotePatterns: [
      { protocol: 'https', hostname: 'cdn.example.com' },
    ],
  },
  // 压缩:SSR场景用gzip比brotli更快
  compress: true,
}

module.exports = nextConfig

8. Dockerfile生产部署(dockerfile)

# 多阶段构建
FROM node:18.17-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

FROM node:18.17-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

FROM node:18.17-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# 非root用户运行
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]

效果数据:上线一周的完整对比

用方案C替换方案A后,同一台服务器、同一份数据库,压测结果如下。

Autocannon压测结果(30秒,并发50)

# 方案A:传统SSR
$ autocannon -c 50 -d 30 http://localhost:3000/products
┌─────────┬────────┬────────┬────────┬────────┬───────────┬──────────┐
│ Stat    │ 2.5%   │ 50%    │ 97.5%  │ 99%    │ Avg       │ Stdev    │
├─────────┼────────┼────────┼────────┼────────┼───────────┼──────────┤
│ Req/Sec │ 24     │ 31     │ 38     │ 41     │ 32        │ 3.21     │
├─────────┼────────┼────────┼────────┼────────┼───────────┼──────────┤
│ Latency │ 1083ms │ 1510ms │ 2132ms │ 2380ms│ 1548.3ms  │ 310.45   │
└─────────┴────────┴────────┴────────┴────────┴───────────┴──────────┘

# 方案C:RSC + Streaming
$ autocannon -c 50 -d 30 http://localhost:3000/products
┌─────────┬────────┬────────┬────────┬────────┬───────────┬──────────┐
│ Stat    │ 2.5%   │ 50%    │ 97.5%  │ 99%    │ Avg       │ Stdev    │
├─────────┼────────┼────────┼────────┼────────┼───────────┼──────────┤
│ Req/Sec │ 89     │ 104    │ 121    │ 127    │ 103       │ 8.92     │
├─────────┼────────┼────────┼────────┼────────┼───────────┼──────────┤
│ Latency │ 382ms  │ 451ms  │ 612ms  │ 683ms  │ 468.4ms   │ 52.16    │
└─────────┴────────┴────────┴────────┴────────┴───────────┴──────────┘

吞吐量提升(32 → 103 req/s):+222%。平均延迟降低(1548ms → 468ms):降低70%。RSC把数据获取移到了服务端,客户端只有极少的JS,数据库查询还有Redis缓存。

前端性能指标(Lighthouse 10 + Web Vitals)

指标方案A方案C变化
FCP3.1s950ms-69%
LCP4.8s1.3s-73%
TTFB2.3s180ms-92%
CLS0.320.08-75%
客户端JS体积418KB238KB-43%

LCP从4.8s掉到1.3s,核心原因:RSC不把组件代码打包发送到浏览器,直接以序列化HTML形式传输。useEffect拉数据的逻辑被彻底移除——数据在服务端就已经查好了。

避坑指南:我踩过的6个坑

坑1:useSearchParams在SSR中导致整个页面变Client Component
商品列表页需要从URL读取搜索参数。我第一版用了useSearchParams,结果整个页面被标记为Client Component,服务端渲染直接被跳过。排查半天才发现Next.js 13.4的一个坑:useSearchParams必须放在Suspense内部,否则SSR会静默失败。
正确解法:如果是Server Component直接用searchParamsprop,别用useSearchParams

坑2:Drizzle ORM + Neon Serverless连接池爆掉
压测时发现连接数飙到200+,数据库直接拒绝连接。原因是Pool对象被放在模块顶层,每次请求都新建连接。
正确解法:Drizzle提供了neon-serverless的HTTP驱动,不需要长连接。改用httpBatchLink,或者把db实例放进globalThis缓存。

坑3:Server Action在Nginx反代下取不到客户端IP
Nginx默认配置不带X-Forwarded-For头,导致所有限流IP都是127.0.0.1。排查了一整天。
正确解法:Nginx配置proxy_set_header X-Forwarded-For $remote_addr;,如果前面还有CDN,记得在Next.js里配置trustHost

坑4:流式SSR和ISR冲突
用了export const dynamic = 'force-static'加上Streaming,页面直接报错。因为Streaming要求动态渲染,ISR的静态路径不兼容。
正确解法:Streaming页面不要强制静态化,用revalidate代替force-static

坑5:React 18.2的useOptimistic和Suspense配合会闪屏
购物车按钮点击后,乐观更新先显示「处理中」,然后Server Action返回结果,再改为「已加入」。这个切换在低端安卓机上会闪一下。
正确解法:useTransition包住setOptimisticStock,把优先级降低,让React并发渲染自然处理过渡。

坑6:生产环境跑Shell脚本直接内存溢出
npm run build在1GB内存的VPS上直接OOM killed。Next.js 13.4的Webpack构建需要大量内存。
正确解法:加swap空间,或者用Docker跑构建时加--memory=2g限制,同时设置NODE_OPTIONS=--max-old-space-size=1536

总结:这个方案还能怎么扩展?

这套基于React 18 Streaming SSR + Server Components的架构,目前已经覆盖了我们所有B2B、B2C页面。如果你要做的场景和我不完全一样,几个参考方向:

  • 后台管理面板:把Server Actions作为唯一的数据变更入口,不需要单独写API路由。
  • 内容站(博客/文档):直接全静态生成(SSG),只要generateStaticParams用于动态路由即可。
  • 实时数据仪表盘:RSC + WebSocket推送,不过注意RSC不支持服务端事件,要用Client Component包一层。

React 18的并发渲染能力比React 17强太多,但不升级的话,你永远不会知道Next.js 13到底值不值得换。拿一个非核心页面试水,压测对比数据说话。