- 发布日期
第 35 讲:Next.js 应用性能优化清单(Bundle / TTFB / LCP / INP)
Bundle 瘦身、TTFB 优化、LCP/INP 改善的系统性 Next.js 性能优化清单
第 26 讲讲过代码分割机制,第 30 讲讲过 ISR cache。本讲做一次整合性巡查:把 Next.js 应用性能拆成 4 个维度(Bundle Size、TTFB、LCP、INP),针对每个维度列出诊断方法 + 优化手段 + 真实业务案例。读完这一讲,你拿到一个生产 Next.js 应用就能按这套 checklist 系统性优化,而不是"瞎调"。
学习目标
- 区分 4 类性能指标的含义、测量工具和"谁来负责":Bundle / TTFB / LCP / INP。
- 看懂
next buildtree view 输出每一列的含义,会读自己的 bundle 表。 - 掌握 8 个最有效的 Next.js 内置优化开关:
optimizePackageImports、optimisticClientCache、modularizeImports、'use cache'、<Image priority>、next/font、Edge Middleware、PPR。 - 用 RUM (
useReportWebVitals) + 合成监控(Lighthouse / WebPageTest)建立持续性能基线。 - 排查 5 类常见性能问题:bundle 巨大、TTFB 高、LCP 慢、INP 卡顿、内存泄漏。
一、4 类指标速记
| 指标 | 含义 | 优化责任 | 工具 |
|---|---|---|---|
| Bundle Size | JS/CSS 下载传输量 | 构建期 | next build tree view、@next/bundle-analyzer |
| TTFB | Time To First Byte,请求到首字节 | 服务端 | curl、Lighthouse、APM |
| LCP | Largest Contentful Paint,最大元素出现 | 服务端 + 资源加载 | Chrome DevTools、web-vitals |
| INP | Interaction to Next Paint,交互响应延迟(替代 FID) | 客户端 JS | Chrome DevTools Performance、web-vitals |
记住这条因果链:Bundle Size → 影响下载时间 → 影响 LCP 与 INP;TTFB → 影响首字节 → 影响 LCP 起点;INP 与 Bundle Size、组件设计直接相关。
二、读懂 next build 的 tree view
export async function printTreeView(...) {
// 输出每条 route 的 size / First Load JS / ISR 标记
}
build 完成后的输出大致:
Route (app) Size First Load JS
┌ ○ / 135 B 85 kB
├ ○ /_not-found 868 B 86 kB
├ ƒ /api/posts 0 B 0 B
├ ● /blog/[slug] 1.2 kB 95 kB
└ ƒ /admin/dashboard 8.4 kB 140 kB
+ First Load JS shared by all 85 kB
├ chunks/framework-xxxx.js 45 kB
├ chunks/main-xxxx.js 30 kB
└ chunks/webpack-xxxx.js 10 kB
每列含义:
- Size:该 route 专属 chunk 的 gzip 后大小(不含 shared)
- First Load JS:首次访问该 route 用户实际下载的 JS 总量(含 shared 框架代码)
- ○:静态预渲染
- ●:SSG + getStaticPaths(含 ISR)
- ƒ:动态渲染(SSR)
- 末尾
+ First Load JS shared是所有 page 都会下载的基础块
性能预算建议
| 指标 | 目标 |
|---|---|
| First Load JS(任意 route) | ≤ 130 kB gzip |
| Shared baseline | ≤ 85 kB gzip |
| 单 route 专属代码 | ≤ 30 kB gzip |
| LCP 图片(移动端) | < 100 kB(WebP/AVIF) |
| Server Action payload | < 5 kB |
超出预算时立刻 audit;不要等到"用户投诉慢"再回头看。
三、Bundle Size 优化
3.1 用 bundle-analyzer 找元凶
pnpm add -D @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
module.exports = withBundleAnalyzer({});
ANALYZE=true pnpm build
输出 3 张 treemap:
client.html:浏览器端总 bundlenodejs.html:Node.js server bundle(next start 用)edge.html:Edge runtime bundle
打开 treemap,按面积排序看 top 5。常见"大块头":
- moment.js(300kB)→ 换 date-fns / dayjs
- lodash(70kB+)→ 用
lodash-es+ tree-shake;或 modularize - antd 全量(巨大)→
optimizePackageImports - 一个 helper 函数因为依赖 server-only 模块被打进 client → import 'server-only' 标记
- 整个 emoji 表(500kB)→ 拆懒加载
3.2 optimizePackageImports
针对像 @mui/material, lodash-es, lucide-react 这种"导出几百个组件、用户只用几个"的库:
// next.config.js
module.exports = {
experimental: {
optimizePackageImports: [
"lodash-es",
"lucide-react",
"@mui/material",
"@radix-ui/react-icons",
],
},
};
效果:
// 你写:
import { Button } from "@mui/material";
// Next.js 自动重写成:
import { Button } from "@mui/material/Button";
只打你用到的子模块,不打整个 barrel。
实测 lucide-react 从 600+ icon 全打 → 仅打你用的几个,bundle 减小 80%+。
3.3 modularizeImports(精确控制)
optimizePackageImports 不灵的库手动写规则:
module.exports = {
modularizeImports: {
"date-fns": {
transform: "date-fns/{{member}}",
},
lodash: {
transform: "lodash/{{member}}",
},
"@my-org/icons": {
transform: "@my-org/icons/dist/{{kebabCase member}}",
},
},
};
这是底层 SWC transform,比 optimizePackageImports 更精确。
3.4 dynamic import 拆懒块
// 之前:editor 800kB 始终在首屏
import RichEditor from "@/components/RichEditor";
// 之后:用户点击才下载
import dynamic from "next/dynamic";
const RichEditor = dynamic(() => import("@/components/RichEditor"), {
ssr: false,
loading: () => <p>Loading editor...</p>,
});
export default function Page() {
const [open, setOpen] = useState(false);
return open ? (
<RichEditor />
) : (
<button onClick={() => setOpen(true)}>Edit</button>
);
}
第 26 讲讲过实现原理。这里强调业务判断:哪些组件值得拆?
- ✅ 编辑器、富文本、PDF viewer、图表库、地图、视频播放器
- ✅ 模态框(modal)、抽屉(drawer)里的复杂内容
- ❌ Header、Footer、初屏 hero
- ❌ 1kB 以下的小组件(split 开销大于收益)
3.5 移到 Server Component
最强力的优化:根本不发到客户端。
// ❌ 之前:Client 调用 lodash 处理数据
'use client'
import { groupBy } from 'lodash-es'
export function PostList({ posts }) {
const grouped = groupBy(posts, 'category')
return ...
}
// ✅ 之后:Server 处理,Client 只渲染
import { groupBy } from 'lodash-es'
export default async function PostList() {
const posts = await db.posts.findMany()
const grouped = groupBy(posts, 'category')
return <PostsClient grouped={grouped} />
}
// PostsClient 不依赖 lodash,bundle 干净
'use client'
export function PostsClient({ grouped }) {
return ...
}
lodash 完全不进 client bundle。
四、TTFB 优化
TTFB = DNS + TCP + TLS + 请求到达 origin + origin 处理 + 首字节发出。
4.1 CDN 命中
理想情况:HTML 命中 CDN cache → TTFB ≈ 边缘节点 RTT(30-80ms)。
- 用 ISR(第 30 讲)让 HTML 可被 CDN 缓存
Cache-Control: s-maxage=60, stale-while-revalidate=300- Vercel 自动;自托管要在 CDN 配置
4.2 Streaming + Suspense
import { Suspense } from "react";
export default function Page() {
return (
<>
<Header />
<Suspense fallback={<Skeleton />}>
<SlowSection /> {/* 异步 fetch,慢 */}
</Suspense>
<Footer />
</>
);
}
效果:
- 服务端先把
<Header />+ Skeleton 流式发出(TTFB 几乎不变) - 浏览器拿到首字节就能渲染 → LCP 提前
<SlowSection />完成后再 flush
第 18 讲讲过实现原理;这里强调用法决策:把慢的部分(DB 复杂查询、外部 API)包到 Suspense 里,剩余 UI 不被阻塞。
4.3 数据库查询并行化
// ❌ 串行:3 次 100ms = 300ms
const user = await db.users.find(id);
const posts = await db.posts.find({ userId: id });
const comments = await db.comments.find({ userId: id });
// ✅ 并行:max(100ms) = 100ms
const [user, posts, comments] = await Promise.all([
db.users.find(id),
db.posts.find({ userId: id }),
db.comments.find({ userId: id }),
]);
每个 Server Component 内部都要审视一下:能不能 Promise.all?
4.4 React cache() 去重同请求内重复查询
import { cache } from "react";
export const getUser = cache(async (id: string) => {
return db.users.find(id);
});
// 多个 Server Component 同时调,只查一次
// Layout.tsx
const user = await getUser(id);
// Page.tsx
const user = await getUser(id); // 命中 cache
第 11 讲讲过。
4.5 PPR / 'use cache'
PPR:静态壳 + 动态洞,静态部分直接从 CDN/磁盘读,TTFB 接近 0(第 19 讲)。
// page.tsx
export const experimental_ppr = true;
export default function Page() {
return (
<>
<StaticHeader />
<Suspense fallback={null}>
<DynamicCart /> {/* postpone 到运行时 */}
</Suspense>
</>
);
}
或函数级 'use cache':
async function getProducts() {
"use cache";
cacheTag("products");
return db.products.findMany();
}
4.6 减少 cold start
Edge Function 首次启动 ~50ms;Node.js Lambda 首次启动 200-2000ms。优化:
- 用 Edge Runtime 部署纯 API endpoint(第 21 讲)
- Provisioned concurrency(AWS)
- Vercel Pro:Fluid Compute / standalone keep-warm
- 减少 import 体积(第 36 讲细讲)
五、LCP 优化
LCP 元素一般是:首屏最大的图片 / 标题 / 视频封面。
5.1 <Image priority> + 正确的 sizes
import Image from "next/image";
export default function Hero() {
return (
<Image
src="/hero.jpg"
alt="hero"
width={1920}
height={1080}
priority // 关键:preload + fetchpriority="high"
sizes="(max-width: 768px) 100vw, 1920px"
/>
);
}
priority 做的事:
- 在
<head>插<link rel="preload"> - 设
fetchpriority="high"(浏览器优先下载) - 不延迟到 viewport 才加载
⚠️ 每个 page 只给 LCP 那 1 张图加 priority,否则浏览器并发抢占网络反而变慢。
5.2 next/font 避免 CLS
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
adjustFontFallback: true, // 自动调系统字体 metrics,减小 CLS
});
export default function Layout({ children }) {
return <html className={inter.className}>{children}</html>;
}
第 27 讲讲过实现:build 时下载字体本地化 + 注入 metrics 接近的 fallback。避免运行时 fetch Google Fonts(200-500ms 阻塞)。
5.3 关键 CSS inline
App Router 自动把每条 route 的 CSS 合并并 inline 在 <head>,无需手动配。
确认方式:build 后 view-source:/your-page,能看到 <style> 内联块。
5.4 Streaming 优先于渲染完整 HTML
第 17 讲讲过 Fizz Stream + RSC Stream 怎么协作。LCP 元素出现在流的早期 → 浏览器解析就能开始渲染。所以:
- 把 LCP 元素放在组件树靠前位置
- 不要被慢 fetch 阻塞
- 用 Suspense 把慢内容隔开
5.5 减小 LCP 图片体积
// next.config.js
module.exports = {
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
minimumCacheTTL: 60,
},
};
Next.js 自动生成多尺寸 + 现代格式。AVIF 比 JPEG 小 50%+,对 LCP 效果显著。
六、INP 优化
INP = "用户点击/输入到下次绘制" 的延迟。Google Web Vitals 在 2024 已将 INP 替代 FID。
慢的根源 = 主线程被长任务阻塞。
6.1 找 long tasks
Chrome DevTools Performance:
- 录一段交互(点按钮、输入)
- 看 Main thread 红条 (>50ms 的 task)
- 展开看是谁
- 一般是大组件 re-render / 大量 layout effect / hydration
6.2 减少 hydration 体积
App Router 已经做了 server / client 分割。检查:
- 哪些
'use client'其实可以是 server? - Client Component 里能不能不接收大对象 prop?(第 32 讲讲的"taint")
6.3 useTransition / useDeferredValue
"use client";
import { useState, useTransition, useDeferredValue } from "react";
export function SearchBox() {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const [isPending, startTransition] = useTransition();
const onChange = (e) => {
setQuery(e.target.value); // 紧急:输入框立刻更新
startTransition(() => {
// 非紧急:搜索结果可被打断
runHeavySearch(e.target.value);
});
};
// 或对子组件 props 用 deferred value
return (
<>
<input value={query} onChange={onChange} />
<SearchResults query={deferredQuery} />
</>
);
}
React 19 的 useTransition 让用户输入永远不卡,重计算被打断。INP 立刻改善。
6.4 拆分大 list 用 virtualization
10000 行表格用 react-window / @tanstack/react-virtual:
import { useVirtualizer } from "@tanstack/react-virtual";
function BigList({ items }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
});
return (
<div ref={parentRef} style={{ height: 600, overflow: "auto" }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((v) => (
<div
key={v.key}
style={{ position: "absolute", top: v.start, height: v.size }}
>
{items[v.index].name}
</div>
))}
</div>
</div>
);
}
10000 → 渲染 20 个 DOM 节点;scroll/click 都丝滑。
6.5 避免 useEffect 里大量 setState
// ❌ 多次 setState 触发多次 re-render
useEffect(() => {
setA(1);
setB(2);
setC(3);
}, []);
// ✅ React 18+ 自动 batch;但显式更清晰
useEffect(() => {
setState({ a: 1, b: 2, c: 3 });
}, []);
七、optimisticClientCache:路由级体感优化
module.exports = {
experimental: {
optimisticClientCache: true, // 默认就是 true
},
};
效果:用户从 / 点 <Link href="/posts/1">,client router 在请求未完成前先用 cached layout / loading.tsx 渲染。视觉上"瞬间"切页。
第 9 讲讲过 client router segment cache 的实现。
八、设置 RUM:真实用户性能监控
// app/web-vitals.tsx
"use client";
import { useReportWebVitals } from "next/web-vitals";
export function WebVitalsReporter() {
useReportWebVitals((metric) => {
const body = JSON.stringify({
...metric,
url: window.location.pathname,
ua: navigator.userAgent,
});
// 用 sendBeacon 兼容页面卸载场景
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/vitals", body);
} else {
fetch("/api/vitals", { body, method: "POST", keepalive: true });
}
});
return null;
}
// app/layout.tsx
import { WebVitalsReporter } from "./web-vitals";
export default function Layout({ children }) {
return (
<html>
<body>
<WebVitalsReporter />
{children}
</body>
</html>
);
}
收集后端:
// app/api/vitals/route.ts
export async function POST(req: Request) {
const data = await req.json();
// 写到 BI / Prometheus / Datadog
logger.info(data, "web-vital");
return new Response(null, { status: 204 });
}
监控 P50 / P75 / P95 → 制定 SLO。
Web Vitals 关键字段
useReportWebVitals 回调收到的 metric 对象:
{
name: 'LCP',
value: 2300,
rating: 'needs-improvement', // 'good' | 'needs-improvement' | 'poor'
delta: 2300,
id: 'v3-...',
navigationType: 'navigate' | 'reload' | 'back-forward' | ...,
attribution: { ... } // 需启用 webVitalsAttribution
}
启用 attribution 看更细:
module.exports = {
experimental: {
webVitalsAttribution: ["LCP", "INP", "CLS"],
},
};
数据里多出 attribution.element、attribution.url、attribution.eventEntry 等,直接告诉你是哪个元素拖了 LCP。
九、合成监控:Lighthouse CI
每次 PR 跑:
# .github/workflows/lighthouse.yml
- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v11
with:
urls: |
https://preview-${{ env.PR_NUMBER }}.example.com/
https://preview-${{ env.PR_NUMBER }}.example.com/products
budgetPath: .lighthouserc.json
.lighthouserc.json 定 budget:
{
"ci": {
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.85 }],
"first-contentful-paint": ["error", { "maxNumericValue": 2000 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"interaction-to-next-paint": ["error", { "maxNumericValue": 200 }]
}
}
}
}
回归立刻挂红,避免"上线后慢慢退化"。
十、排障实战清单
| 现象 | 顺序 |
|---|---|
| 某 page Bundle 200kB+ | ANALYZE=true pnpm build 看 treemap → 找最大块 → 是否能 server-only / dynamic / optimizePackageImports |
| 全站 TTFB > 1s | check ISR cache hit 率 → check DB query 慢 → check 上游 API → cold start |
| LCP > 4s | 看是不是图片:priority 没设 / 尺寸太大 / 没用 webp/avif;或字体阻塞;或 LCP 元素被 client component 包裹延迟渲染 |
| INP > 500ms | DevTools Performance 录交互 → 找 long task → 拆懒加载 / useTransition / virtualize |
| CLS > 0.1 | Image / iframe 缺尺寸属性;字体 swap 没配 fallback metrics;动态插入 banner |
| Memory growing | 第 36 讲细讲:模块单例没被 GC、大对象进 cache、event listener 没 cleanup |
十一、配套 fixture
fixtures/lecture-35/ 提供:
/列出几个性能"反模式"页面/bundle-bad:错误用法(lodash 全量 import + 大组件不拆)/bundle-good:同样功能优化后的版本/lcp-badvs/lcp-good:图片优化对比useReportWebVitals收集 vitals 打到/api/vitals看终端
启动:
cd learning/nextjs-40-lectures/fixtures/lecture-35
pnpm install && pnpm build && pnpm start
build 输出对比 bundle-bad vs bundle-good 的 First Load JS。
十二、本讲小结
- 4 类指标各有责任人和工具:Bundle / TTFB / LCP / INP。
- next build tree view 必读:First Load JS 是真实下载量,设预算 ≤ 130kB。
- Bundle 优化套路:bundle-analyzer 找元凶 → server-only / dynamic / optimizePackageImports 三板斧。
- TTFB 优化套路:CDN cache → Streaming + Suspense → 并行 fetch → PPR/
'use cache'。 - LCP 优化套路:Image priority + next/font + Streaming + AVIF。
- INP 优化套路:减少 hydration → useTransition → virtualization。
- RUM + Lighthouse CI 是性能基线的护栏,缺一不可。
下讲预告
第 36 讲《热路径优化:V8 / RSC / Streaming 视角》。会深入 V8 的 hidden class / IC / megamorphic deopt 在 Next.js server 热路径里的具体表现,结合 $v8-jit skill 里的反模式,分析 base-server / app-render 里几个真实优化案例。