: Next.js 面试 · 性能 / 部署 / 工程化

第 6 章 · 性能 / 部署 / 工程化:图片、流式渲染、JS 体积、build 产物、部署

本章主线一句话:性能优化的本质是回答三件事——"发了多少数据""渲染花多久""要不要重新渲染";Next 给的答案分别是 next/image(少发图)、流式渲染(快出内容)、缓存/静态化(少渲染)与 RSC 边界(少发 JS)。


6.0 为什么需要这一章(动机)

前面五章讲透了渲染模型、路由、RSC、缓存、Server Actions。但面试最后往往会落到一句:"你实际怎么让一个 Next 应用更快?"

这一章把性能问题收敛成三个可操作的维度:

维度问题Next 的解法
网络载荷发了多少数据?next/image(图片)、next/dynamic + RSC 边界(JS)
渲染速度首屏要等多久?流式渲染(Streaming)、缓存
渲染频率要不要重新渲染?SSG/ISR、四层缓存、revalidatePath

记忆锚点:性能 = 少发(JS/图片) + 快出(流式/缓存) + 少算(静态化)


6.1 少发数据:next/image 图片优化

图片通常是页面里最大的资源。原生 <img> 会整张加载,不做懒加载、不控尺寸。

next/image 内置优化:

import Image from "next/image"

<Image
  src="/photo.jpg"
  alt="照片"
  width={800}     // 声明宽高,防止布局偏移(CLS)
  height={600}
  loading="lazy"  // 默认:视口外图片延迟加载
  placeholder="blur" // 占位(可选,配 blurDataURL)
/>

关键点(面试常考)

  • 声明 width/height:为图片预留空间,避免加载后页面跳动(CLS)。
  • 默认懒加载:视口外的图片不阻塞首屏。
  • 远程图片:域名必须在 next.config.tsimages.remotePatterns 登记,否则报错。
  • 本项目next.config 设了 unoptimized: true(跳过优化服务,用原始 URL),生产可开启优化服务 / CDN 获得更佳压缩。

6.1b 远程图片不知道宽高怎么办

next/image 需要 width/height(或 fill)来预留空间防 CLS,但远程图片宽高未知。按场景选:

方案一:fill + 容器定尺寸(最常用)——让容器决定占多大,图片填满它:

{/* 父容器必须 relative + 有明确尺寸 */}
<div className="relative h-60 w-full overflow-hidden">
  <Image
    src="https://远程图片.jpg"
    alt="描述"
    fill
    className="object-cover"
    sizes="(max-width: 768px) 100vw, 50vw" // 告诉浏览器不同宽度下占多少
  />
</div>

方案二:width/height + CSS 自适应(自由流式插图):

<Image
  src="https://远程图片.jpg"
  alt="描述"
  width={1600}      // 给一个合理上限
  height={900}
  style={{ width: "100%", height: "auto" }} // 覆盖为自适应,不按给定宽高拉伸
/>

方案三:服务端先取真实尺寸(零 CLS)——服务端组件里解析图片宽高再传给 next/image

async function getImageSize(url: string) {
  // 用 image-size 之类库解析,或后端返回时带尺寸
  return { width: 1600, height: 900 }
}
export default async function Page() {
  const { width, height } = await getImageSize("https://远程.jpg")
  return <Image src="https://远程.jpg" alt="" width={width} height={height} />
}
场景推荐
图片撑满已知尺寸的容器(卡片/横幅/封面)方案一 fill
自由流式插图(正文图)方案二 width/height + auto
要零 CLS、尺寸重要方案三 服务端取真实尺寸

6.1c 瀑布流(不等高图片)

瀑布流图片宽度固定(=列宽)、高度不等,关键难点是必须按宽高比预留精确高度,否则整列会乱跳(CLS)。绕不开"知道宽高比"。

正解:数据源带宽高 + aspectRatio + fill(零 CLS)

// 数据源返回每张图:{ url, width, height }
function MasonryCard({ img }: { img: { url: string; width: number; height: number } }) {
  return (
    <div
      className="relative w-full overflow-hidden"
      style={{ aspectRatio: `${img.width} / ${img.height}` }} // 按真实比例预留高度
    >
      <Image
        src={img.url}
        alt=""
        fill
        sizes="300px"          // 列宽
        className="object-cover"
        loading="lazy"
      />
    </div>
  )
}
  • aspectRatio 按真实宽高比预留空间 → 图片加载后完全不跳动
  • 尺寸来源:① 数据源/后端返回(最推荐);② 服务端用库解析;③ 客户端 onLoad 拿到(会有跳动,仅兜底)。

对比普通布局 vs 瀑布流

普通流式图瀑布流
宽度撑满容器(fill= 列宽(固定)
高度CSS 自适应(height:auto必须按宽高比预留(aspectRatio
知道宽高吗可不精确必须精确(否则整列跳动)
核心手段fill / width+height+autoaspectRatio + fill

一句话:远程图片不知道宽高,用 fill(容器定尺寸)或 width/height + auto(自适应);瀑布流则必须用 aspectRatio 按比例预留高度,尺寸靠数据源给。


6.2 快出内容:流式渲染(Streaming)

问题:SSR 如果等最慢的组件算完才返回整页,首屏 TTFB 会被最慢的那块拖住。

解法:用 <Suspense> 把慢速区块包住,页面分块流式下发——不慢的部分先返回,慢区块就绪后再填充。

import { Suspense } from "react"

export default function Page() {
  return (
    <div>
      {/* 立即渲染:不等慢数据 */}
      <Header />

      {/* 慢区块:先显示骨架,就绪后流式填充 */}
      <Suspense fallback={<Skeleton />}>
        <SlowSection />
      </Suspense>
    </div>
  )
}
// SlowSection —— 服务端组件,await 慢数据(如耗时查询)
async function SlowSection() {
  const data = await slowQuery()
  return <div>{data}</div>
}

关键点

  • 每个 Suspense 边界是一个"流式填充节点",就绪后逐块发给浏览器。
  • 收益:首屏 TTFB 更快、用户先看到已有内容/骨架、感知更快。
  • loading.tsx(第 2 章约定文件)= 整个页面默认包了一层 Suspense
  • 流式渲染适合"页面大部分快、个别区块慢"的场景。
  • 坑(重要)页面必须是"动态的"才能流式渲染。如果页面是静态预渲染(),构建时 await 已执行完,用户看到的是"算好的完整页",没有流式效果。需要 export const dynamic = "force-dynamic" 强制动态,每次请求重新渲染、Suspense 边界逐个流式下发。(本项目实验已加,见 lab/perf/streaming。)

6.3 少发 JS:next/dynamic 动态导入

问题:如果首屏就把所有组件(含图表库、编辑器、地图等大依赖)的 JS 都发下去,首屏 JS 体积爆炸。

解法:把不首屏需要的重量级组件,用 next/dynamic 拆成独立 chunk,按需加载。

import dynamic from "next/dynamic"

// HeavyWidget 不会进首屏 bundle,点击时才加载
const HeavyWidget = dynamic(() => import("./heavy-widget"), {
  ssr: false, // 可选:纯客户端依赖时设为 false
  loading: () => <p>加载中…</p>,
})

关键点

  • next/dynamic 让组件成为独立 JS chunk,不进首屏。
  • 适用:图表、编辑器、地图、只在用户操作后才出现的重组件。
  • React.lazy 类似,但还支持 ssr 开关和 loading 占位。
  • 更强的思路:如果组件能改成服务端组件(不发 JS),比动态导入更优(承接第 3 章)——动态导入是"降级 JS",RSC 是"根本不发 JS"。

6.3b 包体积优化与分包

先区分两个概念:分包(Code Splitting)是手段,包体积(Bundle Size)是目标——分包 = 把代码拆成多个 chunk 按需加载,让浏览器总下载量(尤其首屏)更小。

关键认知:Next 默认就分包了,你不用手动做。

Next 默认的 chunk 划分:
├─ 主 bundle(框架 + 公共代码)—— 所有页面共享,首屏必下
├─ 每个路由一个 page chunk —— 访问到哪个页才下哪个
├─ 共享依赖 chunk —— 多个页面都用的大库单独拆一份
└─ 动态导入 chunk —— next/dynamic 拆出来的,点击才下

Next 内置分包机制

  1. 路由级分包:每个 page.tsx 一个独立 chunk,只加载当前路由需要的那份(默认)。
  2. 依赖自动拆分:React / 框架等共享依赖抽成公共 chunk,被多个路由复用(默认)。
  3. 动态导入(next/dynamic:手动再拆一层,大组件/库按需加载。

分包方式一览

方式作用Next 内置
路由级分包每个页面一个 chunk✅ 默认
公共依赖拆分React/框架抽公共 chunk✅ 默认
next/dynamic手动拆大组件/库,点击才下✅ 默认
React.lazy + Suspense客户端侧懒加载需手动
手动 vendor 拆分把大依赖单独拆⚙️ 需配置

包体积优化的实操手段

  1. 按需引入依赖(tree-shaking,最有效)
// 差:整个库打包进来
import _ from "lodash"
// 好:只引需要的函数(可被 tree-shaking 剔除未用)
import { debounce } from "lodash"
// 差:整个组件库
import { Button } from "antd"
// 好:按需引(babel-plugin-import 或引子路径)
  1. next/dynamic 拆大组件——图表/编辑器/地图等不进首屏的动态导入。

  2. 避免重复打包——共享逻辑抽公共模块,别让同一大库在多个 chunk 各打一份。

  3. 用 bundle analyzer 定位大依赖(关键工具)

npm i -D @next/bundle-analyzer

生产构建后可视化每个 chunk 大小,一眼看出"哪个库最大、该不该拆"。

  1. Tree-shaking 靠 ESM——用 import { x }(ESM)而非 require(CJS),构建器才能剔除未用代码。

一句话:Next 默认做了路由级 + 公共依赖分包;你要做的包体积优化是"按需引依赖、动态拆大组件、用 analyzer 定位大依赖"——核心是"首屏只发首屏需要的,其他按需拆走"。


6.4 减少 JS 的根本:RSC 边界(面试加分)

next/dynamic 只是"把 JS 拆开按需发"。更根本的是让组件根本不发 JS

  • 纯展示、读服务端数据的组件 → 做成服务端组件(默认,不发 JS)。
  • 只有需要交互/浏览器 API 的部分 → 才用客户端组件('use client')。
  • 把"客户端边界"往下压,客户端 JS 就越小。
尽量往上(服务端组件,零 JS)
  ┌─────────────┐
  │  页面骨架    │ ← 服务端,不发 JS
  ├─────────────┤
  │ 列表(读数据)│ ← 服务端,不发 JS
  ├─────────────┤
  │  交互按钮    │ ← 客户端边界('use client'),才发 JS
  └─────────────┘
  客户端 JS 只包含最下面这一小块

一句话:动态导入解决"少发首屏 JS",RSC 边界解决"根本不发 JS"——后者更优,是 Next 的性能杀手锏。


6.5 少渲染:缓存 / 静态化(承接第 4 章)

性能不只是"快出",还有"少算":

  • SSG/ISR:构建时渲染一次,之后直接返回缓存 HTML(服务器零渲染成本)。
  • 四层缓存(第 4 章):数据缓存 + 完整路由缓存让"同一个页面"不重复渲染。
  • revalidatePath / revalidateTag:只在需要时让缓存失效。

面试连贯题:"SSR 性能差怎么办?" → 先问"这页需要 SEO 吗、内容因人而异吗"→ 能静态就 SSG/ISR,不能就流式渲染 + 缓存。


6.6 读 next build 产物(实操必备)

pnpm build 后日志会打印每个路由的渲染标记(末尾有图例):

标记含义
Static:静态预渲染(构建时渲染一次)
SSG:用 generateStaticParams 的静态 HTML
ƒDynamic:按需服务端渲染

用 build 产物做性能定位

  • 看某页是不是静态:日志找它的标记,/ 静态、ƒ 动态。
  • 静态页产物在 .next/server/app/.../*.html;动态页每次请求才渲染。
  • 找 JS 体积:看 .next/static/chunks/*.js,或 bundle 分析插件定位大依赖。
  • 判断"为什么变动态":回看第 1 章 1.6(动态 API / fetch 不缓存 / force-dynamic)和第 4 章(缓存失效)。

6.7 部署

部署方式特点适用
Vercel(官方推荐)零配置、边缘函数、预览部署、自动 HTTPS个人/中小项目,Next 官方平台
Node 自托管pnpm build && pnpm start,用 PM2/Docker 托管需要自己控制服务器、内网部署
Docker官方镜像 node + 运行 build 产物容器化、K8s 部署
边缘(Edge)中间件/部分路由跑在边缘节点全球加速、低延迟

部署注意事项

  • 构建产物是可复现的.next 由 build 生成,部署时重新 build(不在服务器上乱改)。
  • 环境变量:服务端环境变量在 build 时注入,客户端 NEXT_PUBLIC_* 会打进 bundle,勿放密钥。
  • 缓存:静态资源上 CDN,配合 Cache-Control;ISR 页面可被 CDN 缓存。
  • 本项目的 next-sitemappnpm buildpostbuild 会生成 sitemap/robots(构建产物,注意 git 提交范围)。

6.8 Next 16 新特性(面试加分)

基于本项目实测(Next 16.1.7):

  • Turbopack 默认:Next 16 默认用 Turbopack 做构建(本项目 build 日志显示 Next.js 16.1.7 (Turbopack)),编译更快。
  • React Compilernext.configreactCompiler: true 开启(本项目已开启),自动记忆化组件,减少不必要的重渲染。
  • use cache / Cache Components:新一代缓存指令,允许同一路由混合静态/动态(第 4 章提到)。
  • revalidateTag 签名变化:Next 16 起需要第二个参数 profile(缓存生命周期档位),形如 revalidateTag("tag", "default")(第 4 章实验已踩坑)。
  • 构建/运行时对动态 API、缓存失效的判断更严格,行为更可预期。

面试讲"新特性"别堆名词,讲清楚"解决什么问题":Turbopack 解决构建慢,React Compiler 解决重渲染,use cache 解决"同页混合静态/动态"。


6.9 代码实验

位置:src/app/lab/perf/,入口 /lab/perf

实验目录验证目标
图片优化image/next/image vs 原生 img)尺寸防 CLS、懒加载、占位
流式渲染streaming/(Suspense + 骨架屏)慢区块不阻塞整页,先出骨架再填充
动态导入dynamic/next/dynamic 懒加载)重量组件不进首屏,点击才加载独立 chunk
读 build 产物build/(标记说明 + 本项目实测)理解 //ƒ.next 结构

观察方法

  • streaming:Network 把网速调慢,看页面先出内容/骨架,再流式填充(不整页等待)。
  • dynamic:首次点"加载"时 Network 出现新的 JS chunk。
  • buildpnpm build 后对照日志标记理解每个路由。
  • 全部性能实验,最终结合 pnpm build && pnpm start 看真实标记与产物。

6.10 面试自测题

<details> <summary>1. 让一个 Next 应用更快,你会从哪三件事入手?</summary>

网络载荷(少发 JS/图片,用 next/image + next/dynamic + RSC 边界)、渲染速度(流式渲染 + 缓存,快出内容)、渲染频率(SSG/ISR + 四层缓存,少算)。一句话:少发、快出、少算。

</details> <details> <summary>2. next/image 相比原生 img 有什么好处?</summary>

自动处理宽高(防布局偏移 CLS)、默认懒加载(视口外不阻塞首屏)、可配占位、可接入优化服务/CDN 做压缩。远程图片域名需在 next.config 的 remotePatterns 登记。

</details> <details> <summary>3. 什么是流式渲染?解决什么问题?</summary>

用 Suspense 把慢速区块包住,页面分块流式下发:不慢的部分先返回,慢区块就绪后再填充。解决 SSR 首屏被最慢组件拖住、TTFB 变慢的问题,让用户先看到已有内容/骨架。

</details> <details> <summary>4. next/dynamic 是做什么的?</summary>

把重量级组件拆成独立 JS chunk,按需加载,不进首屏 bundle。适用图表/编辑器/地图等大依赖。比 React.lazy 多了 ssr 开关和 loading 占位。

</details> <details> <summary>5. next/dynamic 和 RSC 边界有什么区别?哪个更优?</summary>

next/dynamic 是"把 JS 拆开按需发",RSC 边界是"根本不发 JS"(服务端组件不发 bundle)。RSC 更优——能把组件做成服务端组件的,就别让它发 JS;动态导入是"降级"手段。

</details> <details> <summary>6. build 日志里 ○、●、ƒ 分别代表什么?</summary>

○ = Static(静态预渲染,构建时渲染一次);● = SSG(用 generateStaticParams 的静态 HTML);ƒ = Dynamic(按需服务端渲染)。看标记能判断页面是否静态化、性能如何。

</details> <details> <summary>7. 一个页面"为什么变成了动态(ƒ)"?怎么排查?</summary>

因为它用了动态 API(cookies/headers/searchParams)、fetch 默认不缓存、或显式 force-dynamic。排查看 build 标记 + 回看第 1 章 1.6 动态 API 列表和第 4 章缓存机制。

</details> <details> <summary>8. Next 能部署到哪些环境?</summary>

Vercel(官方推荐,零配置+边缘+预览)、Node 自托管(pnpm build && pnpm start,配 PM2/Docker)、容器化(Docker/K8s)、边缘节点。选型看控制力、性能、运维成本。

</details> <details> <summary>9. 部署时环境变量有什么要注意的?</summary>

服务端环境变量在 build 时注入;客户端 NEXT_PUBLIC_* 会打进 bundle,不能放密钥。生产敏感信息必须走服务端环境变量或密钥管理服务。

</details> <details> <summary>10. Next 16 有哪些值得提的新特性?</summary>

Turbopack 默认(构建更快)、React Compiler(自动记忆化,减少重渲染)、use cache / Cache Components(同页混合静态动态)、revalidateTag 签名变化(需第二个参数 profile)。讲的时候要说明"解决什么问题"。

</details>

6.11 常见追问(加分项)

  • "SSR 性能差怎么优化?":先判断页面是否需要 SEO/是否因人而异——能静态就 SSG/ISR,不能就流式渲染(先出骨架)+ 缓存(数据/路由缓存)+"少发 JS"(RSC 边界 + 动态导入)。
  • "图片优化具体做了什么?":尺寸预设防 CLS、懒加载、占位、转 WebP/AVIF 压缩、接 CDN。核心是"少发 + 不发多余的"。
  • "为什么动态导入不如 RSC?":动态导入仍会下载那段 JS(只是延后),RSC 让服务端组件根本不进 bundle——彻底不发。
  • "next build 和 next start 的关系?":build 生成生产产物(含静态页 HTML、chunk、优化),start 用这些产物启动生产服务器。部署 = 构建产物 + 运行环境。
  • "build 标记对性能意味着什么?":○/● 是"构建时已算好",请求零渲染成本;ƒ 是"每次请求现算",有服务器开销。性能问题先看有多少 ƒ、能不能转成 ○。
  • "Next 和纯前端相比,性能瓶颈在哪?":主要在服务端渲染成本(SSR/动态)和 Node 单线程。缓解靠静态化、缓存、流式、边缘渲染;重计算交给 Java/Python(承接第 5 章 5.11 架构视角)。

六章到这里结束。核心主线回顾:渲染模型(1)→ 路由(2)→ 组件边界(3)→ 数据缓存(4)→ 写操作(5)→ 性能工程化(6)。面试前按"主线一句话 + 自测题"过一遍即可。