一个让我加班到凌晨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 (客户端) |
|---|---|---|
| TTFB | 1.8s | 2.3s(含JS执行) |
| LCP | 3.2s | 4.8s |
| HTML体积 | 62KB | — |
| FCP | 2.4s | 3.1s |
问题核心:服务端已经渲染了完整HTML,但客户端JS一执行,React发现服务端标记(data-reactroot)与客户端首屏输出不一致,直接丢弃整个DOM树重建。这也解释了「闪一下」的现象。
方案B:React18 Streaming SSR(renderToPipeableStream)
React 18的核心更新之一。服务端把HTML分块(chunk)流式返回:先发送静态骨架(Suspense fallback),数据到了再补发真正的内容。
| 指标 | 方案B (流式TTFB) | 方案B (完整加载) |
|---|---|---|
| TTFB | 210ms | 1.2s |
| LCP | — | 1.8s(流式注入后) |
| HTML体积 | 首包18KB | 完整75KB |
| FCP | 1.1s | 1.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) |
|---|---|
| TTFB | 180ms |
| LCP | 1.3s |
| HTML体积 | 首包12KB,完整68KB |
| 客户端JS体积 | 比方案A减少43% |
| FCP | 950ms |
最终选了方案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}
¥{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 | 变化 |
|---|---|---|---|
| FCP | 3.1s | 950ms | -69% |
| LCP | 4.8s | 1.3s | -73% |
| TTFB | 2.3s | 180ms | -92% |
| CLS | 0.32 | 0.08 | -75% |
| 客户端JS体积 | 418KB | 238KB | -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到底值不值得换。拿一个非核心页面试水,压测对比数据说话。