发布日期

第 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 系统性优化,而不是"瞎调"。

学习目标

  1. 区分 4 类性能指标的含义、测量工具和"谁来负责":Bundle / TTFB / LCP / INP。
  2. 看懂 next build tree view 输出每一列的含义,会读自己的 bundle 表。
  3. 掌握 8 个最有效的 Next.js 内置优化开关:optimizePackageImportsoptimisticClientCachemodularizeImports'use cache'<Image priority>next/font、Edge Middleware、PPR。
  4. 用 RUM (useReportWebVitals) + 合成监控(Lighthouse / WebPageTest)建立持续性能基线。
  5. 排查 5 类常见性能问题:bundle 巨大、TTFB 高、LCP 慢、INP 卡顿、内存泄漏。

一、4 类指标速记

指标含义优化责任工具
Bundle SizeJS/CSS 下载传输量构建期next build tree view、@next/bundle-analyzer
TTFBTime To First Byte,请求到首字节服务端curl、Lighthouse、APM
LCPLargest Contentful Paint,最大元素出现服务端 + 资源加载Chrome DevTools、web-vitals
INPInteraction to Next Paint,交互响应延迟(替代 FID)客户端 JSChrome DevTools Performance、web-vitals

记住这条因果链:Bundle Size → 影响下载时间 → 影响 LCP 与 INP;TTFB → 影响首字节 → 影响 LCP 起点;INP 与 Bundle Size、组件设计直接相关。

二、读懂 next build 的 tree view

packages/next/src/build/utils.ts
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:浏览器端总 bundle
  • nodejs.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:

  1. 录一段交互(点按钮、输入)
  2. 看 Main thread 红条 (>50ms 的 task)
  3. 展开看是谁
  4. 一般是大组件 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.elementattribution.urlattribution.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 > 1scheck ISR cache hit 率 → check DB query 慢 → check 上游 API → cold start
LCP > 4s看是不是图片:priority 没设 / 尺寸太大 / 没用 webp/avif;或字体阻塞;或 LCP 元素被 client component 包裹延迟渲染
INP > 500msDevTools Performance 录交互 → 找 long task → 拆懒加载 / useTransition / virtualize
CLS > 0.1Image / iframe 缺尺寸属性;字体 swap 没配 fallback metrics;动态插入 banner
Memory growing第 36 讲细讲:模块单例没被 GC、大对象进 cache、event listener 没 cleanup

十一、配套 fixture

fixtures/lecture-35/ 提供:

  • / 列出几个性能"反模式"页面
  • /bundle-bad:错误用法(lodash 全量 import + 大组件不拆)
  • /bundle-good:同样功能优化后的版本
  • /lcp-bad vs /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。

十二、本讲小结

  1. 4 类指标各有责任人和工具:Bundle / TTFB / LCP / INP。
  2. next build tree view 必读:First Load JS 是真实下载量,设预算 ≤ 130kB。
  3. Bundle 优化套路:bundle-analyzer 找元凶 → server-only / dynamic / optimizePackageImports 三板斧。
  4. TTFB 优化套路:CDN cache → Streaming + Suspense → 并行 fetch → PPR/'use cache'
  5. LCP 优化套路:Image priority + next/font + Streaming + AVIF。
  6. INP 优化套路:减少 hydration → useTransition → virtualization。
  7. RUM + Lighthouse CI 是性能基线的护栏,缺一不可。

下讲预告

第 36 讲《热路径优化:V8 / RSC / Streaming 视角》。会深入 V8 的 hidden class / IC / megamorphic deopt 在 Next.js server 热路径里的具体表现,结合 $v8-jit skill 里的反模式,分析 base-server / app-render 里几个真实优化案例。