“页面有点慢”是一个很难直接动手的问题。有人说首屏要等很久,有人说按钮点下去像没反应,还有人只是觉得页面加载时一直在晃。它们都叫“慢”,原因却可能分别藏在服务端响应、客户端 JavaScript 和布局计算里。
这一节我们继续使用 BoardFlow 项目。现在它有三个具体反馈:项目面板的首屏内容出现得晚,切换任务筛选时有明显停顿,活动流加载后会把下方内容推开。我们不会从“删几个依赖”或“把所有组件都改成懒加载”开始,而是完整走一遍性能工作的闭环:先测量,再还原关键路径,然后只改真正的瓶颈,最后用预算挡住回归。
本文以当前项目实际使用的 Next.js 16.1.6、React 19.2.3、App Router 和 Webpack 为基准。Next.js 16 默认构建器已经是 Turbopack,但这个仓库的 dev 与 build 命令都显式传了 --webpack,所以后面的包分析会先讲 Webpack,再说明 Turbopack 的边界。
性能优化不是追一个孤立的 Lighthouse 分数。我们真正要回答的是:哪一类用户,在什么页面、什么设备和什么操作上遇到了问题;改动之后,真实用户的等待是否减少;下次发版会不会把问题带回来。

先把 BoardFlow 的三句反馈翻译成可以验证的假设。
这张表的意义是把“体感”变成调查入口,而不是提前宣布答案。比如 LCP 很慢,只说明最大的候选内容出现得晚。它可能在等服务端 HTML,可能在等 CSS 才能被发现,也可能是图片虽然早早请求,却下载了远超展示尺寸的文件。只有把时间线展开,才能知道该改哪一段。
我建议每个问题至少记录下面这些上下文。否则同一个数字混进不同设备、不同路由和不同版本后,平均值很容易把真正的长尾藏起来。
// lib/performance/context.ts
export type PerformanceContext = {
release: string
routeGroup: string
deviceClass: 'mobile' | 'desktop'
connection: 'slow' | 'regular' | 'fast' | 'unknown'
navigationType:
| 'navigate'
| 'reload'
| 'back-forward'
| 'back-forward-cache'
| 'prerender'
| 'restore'
authenticated: boolean
}BoardFlow 的第一轮问题单可以这样写:
/projects/[projectId]在移动端真实用户数据中,LCP 的第 75 百分位升高;同一路由切换“只看我的任务”时,部分会话的 INP 超过良好阈值;活动流 Suspense 边界完成后出现布局偏移。先验证问题是否集中在某个发布版本和设备类型,再进入代码诊断。
这里刻意没有写“把 LCP 优化到 1 秒”。没有基线、没有样本范围,也没有确认瓶颈时,漂亮目标通常只是愿望。
这个项目已经做了一些正确的事,也有几处值得测量的候选点:
next/font/local 加载 Noto Sans SC 可变字体,并设置了 display: 'swap'。loading.tsx,具备使用 Streaming 的基础。width 与 quality 参数交给媒体服务。cacheComponents、React Compiler、Web Vitals 上报或 Bundle Analyzer。这些事实只能形成检查清单。例如“存在很多 Client Component”不是性能结论;真正要看的是哪些模块进入了初始客户端图谱、下载了多少、执行了多久,以及用户有没有用到它们。
开发模式为热更新、源码映射和额外检查服务,它不代表生产性能。一次可复现的 BoardFlow 基线,至少应固定构建版本、测试页面、登录状态、设备与网络条件,并分别记录冷导航和站内导航。
先生成生产构建并用生产服务器启动。当前项目明确使用 Webpack,所以基线也要保留 --webpack,不要一边测 Webpack、一边拿 Turbopack 构建结果作比较。
为 BoardFlow 选择固定场景,例如未登录首页、已登录项目面板、从项目列表进入详情、在详情页切换筛选。为测试账户准备数量稳定的数据,避免今天 20 条任务、明天 2 万条任务。
固定桌面与移动端配置,分别做冷缓存和暖缓存测试。每个场景重复多次,保留中位数和长尾,而不是挑一次最好成绩截图。
同时保存 Network 瀑布、Performance trace、构建提交 SHA 与浏览器版本。只有这样,优化前后出现差异时才知道究竟改变了什么。
在 macOS 或 Linux 中,可以直接运行:
cd we-learn
NODE_OPTIONS=--max-old-space-size=8192 npx next build --webpack
npm run start当前 package.json 中的 set NODE_OPTIONS=... 是 Windows cmd 语法,在 POSIX shell 中不会按预期导出环境变量。更便携的做法是使用 cross-env:
npm install --save-dev cross-env{
"scripts": {
"dev": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next dev --webpack",
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build --webpack",
"start": "next start"
}
}不过,8GB 堆上限只是让构建进程有更大的空间,不是“性能已经优化”的证明。后面我们会单独检查构建内存。
BoardFlow 的合理工作流是:Field 告诉我们 /projects/[projectId] 的移动端 p75 出了问题,Lab 再用固定项目数据复现并拆解;代码修改后先用 Lab 验证因果,发布后再看 Field 是否真正回落。
不要把 .next 目录大小当成“用户下载了多少 JavaScript”。它混合了服务端产物、客户端产物、构建缓存和开发缓存。Next.js 16 也移除了容易误导的构建输出 First Load JS 汇总;路由客户端成本应通过包分析器、浏览器 Network 和 Performance 一起判断。
当前 Core Web Vitals 只有三项:LCP、INP、CLS。旧的首次输入延迟指标已经在 2024 年 3 月被 INP 取代;FCP 和 TTFB 仍然有用,但它们是诊断指标,不应再被写成“全部核心指标”。
判断页面是否达到“良好”,通常看第 75 百分位,并把移动端和桌面端分开。它的直观含义是约 75% 的有效访问不慢于这个值;具体落在哪两个样本之间,由统计库采用的百分位算法决定。假设 BoardFlow 的移动端 LCP p75 是 2.9 秒,那么这组体验并未达到良好,即使平均值只有 2.2 秒。计算前还要先约定时间窗口、最小样本量、机器人过滤和异常数据规则,不能让少量样本替整个路由下结论。

Lighthouse 没有真实用户持续操作页面,因此无法在一次普通导航审计中测出完整 INP。TBT 可以当实验室代理线索,但不能写成“把 TBT 降下来,INP 就一定合格”。
假设真实数据出现下面的组合:
这就是为什么不能拿一个总分替代指标。三个用户反馈需要三条证据链。
App Router 提供 useReportWebVitals。它必须运行在 Client Component 中,但这个组件可以非常小,不必把根布局整体变成客户端组件。
// app/_components/WebVitalsReporter.tsx
'use client'
import { useReportWebVitals } from 'next/web-vitals'
type ReportedMetric = Parameters<
Parameters<typeof useReportWebVitals>[0]
>[0]
const trackedMetricNames = ['CLS', 'FCP', 'INP', 'LCP', 'TTFB'] as const
type TrackedMetricName = (
sendMetric 放在组件外面是有意的:Next.js 会把回调变化时已经可用的指标再次交给新回调,保持引用稳定可以避免重复报告。采样决定也必须对当前文档保持稳定,不能每收到一项指标就重新抽一次随机数。
这里记录的是文档级 Web Vitals。App Router 的一次客户端导航不会自动生成一套新的 LCP、CLS 和 INP,因此示例在文档启动时捕获脱敏后的 routeGroup,不会在指标最终上报时读取可能已经变化的地址。若要衡量 SPA 路由切换,应另建导航计时,不能把它伪装成新的 Core Web Vitals。
根布局仍然是 Server Component,只增加一个叶子客户端组件:
// app/layout.tsx
import { WebVitalsReporter } from './_components/WebVitalsReporter'
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN">
<body>
{children}
<WebVitalsReporter />
</body
sendBeacon() 返回 true 只表示浏览器已经把数据加入发送队列,并不保证服务端最终收到;返回 false 时,示例才退回带 keepalive 的 fetch。所以性能遥测适合做聚合统计,不适合承担计费、审计等不能丢失的业务事件。
脱敏和采样也不自动等于合规。上线前仍要根据适用地区、隐私政策和用户选择决定是否采集,并定义保存期限、访问权限与删除流程。NEXT_PUBLIC_WEB_VITALS_SAMPLE_RATE 是公开的客户端配置,只能控制抽样比例,不能存放秘密。
上报接口至少要校验结构并限制请求体;生产环境还要在网关和存储层完成同源策略、限流、采样核验、发布标识白名单与批量写入。浏览器里的公开配置可以被伪造,不能因为客户端传来一个 release 就无限创建标签。也不要相信客户端传来的用户身份,或发送原始动态路径、查询参数、任务标题和用户输入。下面的端点可直接运行,但 console.info 只适合教学和开发期,生产中应换成非阻塞队列。
// app/api/vitals/route.ts
import { z } from 'zod'
const metricSchema = z.object({
schemaVersion: z.literal(1),
id: z.string().min(1).max(200),
name: z.enum(['CLS', 'FCP', 'INP', 'LCP', 'TTFB']),
value: z.number
同一个页面加载中的指标可以用 id 关联。构建分布时保存 value,并按 release + id + name 去重或更新;不要把 delta 当成一次新的页面访问。服务器还应设置真实的请求体上限,因为只检查客户端声明的 Content-Length 不能防住分块传输的大请求。
instrumentation-client.ts 位于项目根目录,在 HTML 加载后、React Hydration 和用户交互前运行,适合初始化轻量级浏览器监控。Next.js 在开发环境中会对超过 16ms 的初始化发出警告,因此不要在这里静态导入整套分析 SDK。任何监控异常也不应阻止应用启动。
// instrumentation-client.ts
function classifyNavigation(url: string) {
try {
const pathname = new URL(url, window.location.origin).pathname
if (pathname === '/') return '/'
if (/^\/projects\/?$/.test(pathname)) return '/projects'
if (/
服务端的 instrumentation.ts 用于进程级初始化,例如注册 OpenTelemetry。register 会在服务实例准备期间执行,不应把每个请求的业务逻辑放进去。
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./instrumentation.node')
}
}Web Vitals、客户端 instrumentation 和服务端 tracing 各自回答不同问题。先用 release 与脱敏后的路由分组对齐趋势;平台支持 trace 上下文时,再关联一次请求内部的服务端 span。不要为了关联方便,把用户标识或完整 URL 塞进指标标签。
BoardFlow 项目面板从用户按下回车到能够操作,通常经历下面几段:浏览器解析地址并建立连接;CDN 或 Next.js 返回 HTML/RSC 数据;浏览器发现 CSS、字体和 LCP 资源;React 下载需要的客户端模块并 Hydrate;事件处理器开始响应。

总时间不是简单的“每个资源耗时相加”。并行下载会重叠,依赖会制造等待。我们更关心的是哪一条依赖链决定了最终时刻。
在 Chrome DevTools 中打开 Disable cache,录制项目面板的冷导航,逐项问:
use client 边界带入?可以在服务端响应中加入只包含聚合耗时的 Server-Timing,让 Network 面板同时显示后端阶段。不要把 SQL、内部主机名或敏感标识写进响应头。
// app/api/projects/[projectId]/summary/route.ts
import { NextResponse } from 'next/server'
import { loadProjectSummary } from '@/lib/data/projects'
export async function GET(
_request: Request,
context: { params: Promise<{ projectId: string }> },
) {
const { projectId } = await context.params
const startedAt =
示例聚焦计时;真实项目必须先从服务端会话识别调用者,并在 loadProjectSummary 内部或数据访问层校验项目读取权限,不能因为拿到了 projectId 就直接返回数据。随后还应为数据库和上游调用建立 span,而不是只测一个总时长。Server-Timing 会到达浏览器,只放阶段名和聚合耗时,不要放查询文本、内部地址或项目标识。
App Router 中的组件默认是 Server Component。'use client' 不是“只让当前文件在浏览器运行”的局部标记,它定义了一条模块图边界:这个文件静态导入的组件和库都会进入客户端图谱。
BoardFlow 如果把整个面板标记为 Client Component,标题、成员列表、静态说明、图表、弹窗和筛选状态可能一起进入客户端包。更稳妥的拆法是让页面与静态区域留在服务端,只把筛选控件和真正需要交互的叶子下沉。

// app/projects/[projectId]/page.tsx
import { ProjectFilters } from './ProjectFilters'
import { TaskList } from './TaskList'
import { getProject, getTasks } from '@/lib/data/projects'
export default async function ProjectPage({
params,
searchParams,
}: {
params: Promise<{ projectId: string }>
searchParams: Promise<{ owner
// app/projects/[projectId]/ProjectFilters.tsx
'use client'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useTransition } from 'react'
export function ProjectFilters({ owner }: { owner: 'all' | 'me' }) {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
这里浏览器只需要接管筛选器。项目标题与任务列表仍由服务端生成,服务端还可以直接访问数据库或内部服务。页面先把不可信的查询参数收窄为 all | me;真正的项目读取权限仍必须由服务端数据层检查,不能拿筛选参数当授权条件。useTransition 提供待处理状态,但它不会让昂贵计算自动消失;如果筛选事件本身堵住主线程,还要在 INP 部分继续拆任务。
“共有 69 个文件写了 use client”不是一条性能缺陷。一个很小的按钮和一个包含整套编辑器的页面都算一个文件。应通过包分析器确认依赖图,再通过浏览器确认下载、解析、执行和 Hydration 成本。
代码分割和延迟使用不是同一件事。把活动图表拆成单独 chunk,只说明它可以独立下载;如果页面第一次渲染就无条件渲染图表,浏览器仍会立刻请求这段代码。
BoardFlow 的活动趋势图默认收起,用户点击“查看趋势”后才需要。这个交互意图才是真正的延迟边界:
// app/projects/[projectId]/ActivityTrendLauncher.tsx
'use client'
import dynamic from 'next/dynamic'
import { useState } from 'react'
const ActivityTrend = dynamic(() => import('./ActivityTrend'), {
ssr: false,
loading: () => (
<div role="status" style={{ minHeight: 320 }}>
正在加载趋势图…
</
ssr: false 只能在 Client Component 中使用;把这段 dynamic 声明移进 Client Component,也是 Next.js 正常完成客户端拆包的要求。这里的图表依赖浏览器尺寸和 Canvas,所以选择纯客户端渲染,并给加载状态预留高度。如果组件可以在服务端输出有意义的首屏 HTML,就不应机械关闭 SSR。
验证时不要只看源码中有没有 dynamic()。重新打开 Network:
如果某个 Server Component 被动态导入,Next.js 仍会按 Server Component 规则处理它;真正被懒加载的是它下游的客户端代码。当前从 Server Component 动态导入 Client Component 也不支持自动代码分割,因此不要用动态导入绕过 Server/Client 边界。
项目名称、主要任务列表和 LCP 候选图像属于首屏主任务。把它们塞进用户可见后才触发的动态模块,可能让初始 JavaScript 变小,却把 LCP 和可用时间推迟。拆包前先问一句:用户进入这个页面,是否几乎一定马上需要它?答案是“是”时,延迟通常得不偿失。
当前仓库通过 next build --webpack 构建,所以应先使用 @next/bundle-analyzer。安装后,在现有 MDX 配置的最外层增加分析器:
npm install --save-dev @next/bundle-analyzer@16.1.6// next.config.mjs 中与现有 withMDX 组合
import createBundleAnalyzer from '@next/bundle-analyzer'
const withBundleAnalyzer = createBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
})
export default withBundleAnalyzer(withMDX(nextConfig))在 macOS 或 Linux 中运行:
ANALYZE=true npm run build分析报告要沿着“模块为什么进入这个路由”去看,不能只找最大的彩色方块。对 BoardFlow,我们依次检查:
Next.js 16 默认 Turbopack,16.1 以后提供实验性的交互分析器。切换到 Turbopack 后,对应命令是:
npx next experimental-analyze
# 只写静态分析文件,不启动交互界面:
npx next experimental-analyze --output这个命令只生成分析结果,不生产可部署的应用构建。本项目显式选择 Webpack,不能拿 Turbopack 分析结果解释 Webpack 产物。若将来切换构建器,应先建立新的基线,再使用对应工具。
不要因为 .next 目录很大就删除业务代码,也不要因为某个模块源码体积大就断言网络成本高。服务端模块、客户端模块、压缩后传输体积和运行时执行成本是四件不同的事。
BoardFlow 项目面板需要项目概要、成员和活动流。如果三份数据彼此独立,却写成连续 await,总等待会接近三段耗时之和。
// 容易形成瀑布的写法
const project = await getProject(projectId)
const members = await getMembers(projectId)
const activity = await getActivity(projectId)把独立工作先启动,再一起等待:
// app/projects/[projectId]/page.tsx
import { ActivityList } from './ActivityList'
import {
getActivity,
getMembers,
getProject,
} from '@/lib/data/projects'
export default async function ProjectPage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } =
但不要为了追求“全部并行”伪造并行。如果权限范围依赖项目记录,就应该先读取并验证项目,再查询受权限约束的数据:
const project = await getProject(projectId)
await assertCanReadProject(currentUser, project)
const [members, activity] = await Promise.all([
getMembers(project.id),
getActivity(project.id),
])如果页面和 Route Handler 在同一个 Next.js 应用中,Server Component 通常应复用数据访问函数,而不是再通过 HTTP 调用自己的 /api:
// lib/data/projects.ts
import { cache } from 'react'
import { db } from '@/lib/db'
export const getProject = cache(async (projectId: string) => {
return db.project.findUniqueOrThrow({
where: { id: projectId },
select: { id: true, name: true, description: true },
})
})React cache 只能用于 Server Components。React 会在每次服务端请求之间使这类缓存失效,所以它适合让同一请求内的多个组件共享结果,不是跨请求永久缓存。示例把 cache 放在模块作用域也很关键:每调用一次 cache(fn) 都会得到一套彼此独立的记忆化函数。相同参数产生的错误也会在当前请求内被记忆化;传对象、数组等参数时,能否命中取决于引用是否相同。持久缓存、重验证和失效策略属于前面的数据获取章节;本节只观察它们如何改变 TTFB。
当前 Next.js 模式下,不要再写“fetch 默认持久缓存”。是否缓存应显式表达,例如 cache: 'force-cache'、cache: 'no-store' 或 next.revalidate,并结合数据正确性决定。缓存是产品语义,不是看到慢请求后随手加的装饰器。
并行请求仍然可能有一项很慢。假设项目概要 180ms、成员 260ms、活动流 1.4s,如果整页 Promise.all 后才返回,用户会为了活动流多等一秒。活动流并不是理解页面所必需的内容,可以让项目概要先输出,再由 Suspense 边界流式补上。

// app/projects/[projectId]/page.tsx
import { Suspense } from 'react'
import { getActivity, getProject } from '@/lib/data/projects'
type Activity = {
id: string
actorName: string
action: string
}
export default async function ProjectPage({
params,
}: {
params: Promise<{ projectId
activityPromise 在等待项目概要之前已经启动,因此没有把瀑布移进 Suspense。fallback 设置了接近真实内容的最小高度,避免活动流到达时把下方内容整体推开。真实界面应根据常见活动条数设计骨架,而不是随手写一个高度。
Streaming 改善的是“先看到和先使用什么”,不会缩短那条 1.4 秒查询本身。查询仍需通过索引、数据模型、缓存或上游服务治理。
Next.js 16 可以通过 cacheComponents: true 启用 Cache Components。启用后,框架会围绕静态外壳、use cache 和 Suspense 边界构建部分预渲染模型。
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig// app/projects/[projectId]/ProjectSummary.tsx
import { cacheLife } from 'next/cache'
import { getPublicProjectSummary } from '@/lib/data/projects'
export async function ProjectSummary({ projectId }: { projectId: string }) {
'use cache'
cacheLife('minutes')
const project = await getPublicProjectSummary(projectId)
return <p>{project.description}</p>
当前仓库没有开启该选项,不能把普通 Suspense Streaming 直接叫作 PPR。开启后,PPR 是 Cache Components 在 App Router 中的默认渲染行为,不再需要旧的实验性 PPR 开关。
use cache 的参数会进入缓存键,参数和返回值必须满足 React Server Components 的序列化约束。cookies()、headers() 等请求期 API 应在缓存作用域外读取,再把真正影响结果且适合进入缓存键的值作为参数传入。更不能只为“看起来更快”就缓存带权限的项目数据:use cache 不是鉴权边界,示例才特意读取公开摘要。启用 Cache Components 会改变动态数据和缓存边界,应回到数据正确性、隐私、缓存寿命和失效策略逐项检查。
next/link 的自动预取发生在生产环境。链接进入视口后,Next.js 会根据目标路由的静态、动态状态以及是否有 loading.tsx 预取相应内容,并把 RSC Payload 按路由片段放进客户端缓存。开发模式下没看到预取,不能据此判断生产失效。
对于常用的“进入项目”链接,默认行为通常合理:
import Link from 'next/link'
export function ProjectCard({
id,
name,
}: {
id: string
name: string
}) {
return <Link href={`/projects/${id}`}>进入 {name}</Link>
}但如果一个虚拟列表展示上千个项目,让每个可见链接都自动预取,可能抢占图片和接口带宽。可以关闭自动预取,在用户表达意图时再预取:
// app/projects/IntentPrefetchLink.tsx
'use client'
import Link from 'next/link'
import { useState } from 'react'
export function IntentPrefetchLink({
href,
children,
}: {
href: string
children: React.ReactNode
}) {
const [hasIntent, setHasIntent] = useState(
prefetch={null} 会在用户表达意图后恢复 Link 的默认预取,让 Next.js 继续负责缓存和失效;这比永久关闭 Link 再手写一套 router.prefetch() 更不容易遗漏框架行为。鼠标悬停覆盖桌面指针,焦点事件照顾键盘用户。触屏用户可能没有这段提前量,所以仍要保证目标页面本身足够快。
被预取的布局或页面不应在模块顶层执行分析上报、写操作或其他副作用。预取本来应该是一次纯读取;如果它触发埋点、创建会话或修改数据,就会出现“用户还没点击,业务事件已经发生”的错误。
Next.js 16 对布局去重和增量预取做了改进,但预取仍然要用 Network 验证。我们关注两项结果:点击后的导航等待是否减少,预取有没有挤占当前页面的 LCP 资源或产生过量流量。
路由 prefetch、资源 preload 和 DNS preconnect 是不同机制。Link 负责准备目标路由;LCP 图片的 preload 负责提高当前页面某个资源的发现与优先级。不要用手写 link 标签把三者混成一个“预加载”。
INP 观察页面生命周期中的交互,并选择接近最慢的一次代表性交互。一次点击的延迟可以拆成三段:输入发生后等待主线程的时间、事件回调执行时间,以及浏览器完成下一帧呈现的时间。

浏览器把超过 50ms 的主线程任务视为长任务。50ms 不是“整个交互必须小于 50ms”的指标阈值,它是诊断任务是否长时间霸占主线程的边界。INP 良好阈值仍是 200ms。
如果筛选必须在客户端做,可以把批量工作切片。scheduler.yield() 可用时优先使用,并提供兼容回退:
// app/projects/[projectId]/LocalTaskFilter.tsx
'use client'
import { useState } from 'react'
type Task = {
id: string
ownerId: string
title: string
}
type YieldScheduler = {
yield?: () => Promise<void>
}
async function yieldToMainThread() {
const
示例先让浏览器有机会画出“正在筛选”,再按时间而不是固定地每批都让出主线程:短任务不承担无意义的调度开销,连续工作接近 40ms 时才 yield。scheduler.yield() 的续体优先级优于普通 setTimeout;不支持时的回退仍能让出任务,但没有同样的优先级保证。
这个示例的目的不是鼓励浏览器处理海量任务。数据量继续增长时,更好的方案可能是服务端分页筛选、列表虚拟化,或把纯 CPU 计算放进 Web Worker。切片只负责让主线程有机会处理输入与绘制,最终一次渲染几万条 DOM 仍然会造成新的呈现延迟。
下面这种循环会反复写样式、读布局,浏览器可能被迫同步计算:
for (const card of cards) {
card.classList.add('compact')
heights.push(card.getBoundingClientRect().height)
}应先批量读取,再批量写入,或者让 CSS 布局直接完成工作。录制 Performance 时,如果一次筛选里出现大量 Recalculate Style 和 Layout,就要沿调用栈找到触发者,而不是继续给 React 组件加 memo。
React Compiler 1.0 已经稳定,Next.js 16 支持显式开启。它会在编译阶段分析组件和 Hook,减少可以安全跳过的重复计算与渲染,但并非默认启用。当前项目也没有安装编译器插件。
npm install --save-dev --save-exact babel-plugin-react-compiler// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
reactCompiler: true,
}
export default nextConfig项目现有 next.config.mjs 还包含 MDX 和图片配置,实际修改时要把 reactCompiler: true 合入原对象,不能用上面的教学片段覆盖整份配置。Next.js 会用 SWC 先筛选需要交给 Babel 插件的相关文件,但构建仍可能比未启用时稍慢。启用后要跑完整功能与端到端测试,并重新建立构建耗时、客户端执行和交互基线;后续升级编译器版本也要按同样流程验证。
手写 memo、useMemo 和 useCallback 也不该成为默认动作。它们有比较成本和依赖正确性要求,自定义比较函数尤其容易因为漏比较函数属性而读到旧闭包。先用 React Performance tracks 或 Profiler 找到重复渲染,再决定由编译器或手工边界处理。
如果一次筛选的主要时间花在网络等待、数据排序或布局上,把每个组件都包进 memo 只会增加代码复杂度。优化动作必须对应 trace 中的一段真实成本。
图片、字体和第三方脚本经常分别由不同人维护,但浏览器只看到同一条网络和主线程。BoardFlow 可以建立一张资源表,写清资源是否首屏必需、由谁负责转换、什么时候请求。

Next.js 16 中,只有当一张图片已经被测量确认是当前视口唯一、稳定的 LCP 候选,而且需要在 <head> 中比正文解析更早被发现时,才考虑 preload。如果不同视口会出现不同 LCP 候选,预加载可能下载错资源;设置了 preload 时也不要再同时设置 loading 或 fetchPriority。官方文档建议多数场景先考虑 loading="eager" 或 fetchPriority="high"。普通卡片图保持默认懒加载,不要给所有图片都加高优先级。
// app/projects/[projectId]/ProjectHero.tsx
import Image from 'next/image'
export function ProjectHero() {
return (
<Image
src="https://media.edu-free.com/uploads/boardflow-cover.png"
alt="BoardFlow 项目看板概览"
width={1600}
height={900}
sizes="(max-width: 768px) 100vw, 1200px"
quality={75}
preload
quality={75} 也不是随手选的数字:Next.js 16 默认的 images.qualities 允许列表只有 75;需要其他质量值时,要先把它加入配置并验证媒体服务确实支持。不要给 Image 组件添加一个并不存在的“指定 AVIF”属性。Next.js 默认图片优化器通过 images.formats 决定输出格式;但本项目设置了自定义 loader,当前实现是:
// lib/utils/loader.ts
export default function myImageLoader({
src,
width,
quality,
}: {
src: string
width: number
quality?: number
}) {
return `${src}${src.includes('?') ? '&' : '?'}w=${width ||
这个函数只拼出宽度与质量参数;当调用者没有传质量时,它甚至会回退到 100。最终有没有按宽度缩放、是否返回 WebP/AVIF、缓存键是否正确,都由媒体服务决定。images.formats 不会替自定义媒体服务完成转码。验证方法是查看实际响应的 Content-Type、Content-Length、Vary 和缓存头,而不是看到 Image 组件就假定格式已经优化。
项目已经用本地可变字体和 display: 'swap',比在示例中改用 Inter Latin 更贴合中文应用。下一步要测的是:这个字体是否覆盖实际中文字符,文件是否在所有路由都需要,fallback 切换是否改变文字尺寸。中文字体字符范围大,不能凭一句“自动子集化”就认为成本已经消失。
// app/projects/layout.tsx
import Script from 'next/script'
export default function ProjectsLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
{children}
<Script
src="https://analytics.example.com/client.js"
strategy="afterInteractive"
/>
<Script
src
beforeInteractive 只能放在根布局,Next.js 会把它注入文档 <head>;它留给需要尽早下载的全站关键脚本,并不意味着脚本执行会阻塞 Hydration。分析脚本通常在部分 Hydration 后加载;低优先级客服组件可以等浏览器空闲。worker 策略仍属不稳定能力,而且不支持 App Router,不应写成通用答案。脚本只在项目区域需要时,就放在对应布局,不要自动提升到根布局。
加载策略只决定时机,不代替隐私同意和供应链安全。上面的分析脚本示例假设服务端已经确认它允许加载;若必须征得用户同意,应在同意状态成立后才渲染 Script。第三方域名还要进入 CSP 允许列表;供应商提供固定版本和 SRI 摘要时,再配合 integrity 与正确的 crossOrigin,并验证失败回退。
浏览器里的 TTFB 是多个阶段叠加:网络往返、CDN、应用排队、鉴权、数据读取、Server Component 渲染和首批字节发送。只记录一个平均响应时间,很难知道 BoardFlow 为什么偶尔特别慢。
一个简单的计时包装器可以帮助开发期定位,但生产环境应接入 tracing,并控制日志基数:
// lib/observability/timed.ts
export async function timed<T>(
name: string,
operation: () => Promise<T>,
): Promise<T> {
const startedAt = performance.now()
try {
return await operation()
} finally {
不要把 projectId、用户邮箱等高基数字段直接设为指标标签,也不要在日志中输出令牌或原始查询。
当前构建脚本把 Node 堆上限设为 8GB,这只能改变进程何时触顶。若构建内存异常,可以先使用 Next.js 的调试模式:
npx next build --webpack --experimental-debug-memory-usage这个模式会持续打印堆与垃圾回收统计,并在接近堆上限时生成快照;它与 Webpack build worker 不兼容,因此是诊断运行,不应拿它的构建时长与正常并行构建直接比较。再结合包分析器检查超大模块、并行任务、source map 和插件。若峰值确认来自 Webpack,可把 experimental.webpackMemoryOptimizations: true 当成受控实验,它可能以略增编译时间换取较低峰值,仍需重新测量。
运行时则观察 process.memoryUsage()、实例重启、并发和请求负载。构建内存与服务实例内存发生在不同阶段,不能用一次 .next 目录统计替代。
const memory = process.memoryUsage()
console.info({
rssMb: Math.round(memory.rss / 1024 / 1024),
heapUsedMb: Math.round(memory.heapUsed / 1024 / 1024),
heapTotalMb: Math.round(memory.heapTotal / 1024 / 1024),
externalMb: Math.round(memory.external / 1024 / 1024),
})生产采样应进入监控系统,而不是每个请求都打印一次。看到 RSS 上升时,要结合流量、垃圾回收和缓存上限判断;“内存高”本身不等于泄漏。
应用代码优化完成后,代理和 CDN 仍可能改变结果。next start 默认会对可压缩响应使用 gzip;如果前置代理统一提供 Brotli 或 gzip,可以让代理负责,但必须验证真实响应,不能同时重复压缩。
curl -sS -D - -o /dev/null \
-H 'Accept-Encoding: br, gzip' \
http://localhost:3000/projects/demo这里刻意发送一次 GET,而不是只发 HEAD;某些压缩层不会为无响应体的 HEAD 展示与真实页面完全相同的编码行为。如果代理已经稳定提供 Brotli 或 gzip,再考虑在 Next 配置中设置 compress: false,避免重复工作。
重点检查:
Content-Encoding 是否符合预期。Age、命中状态或平台提供的缓存诊断头。Vary 是否覆盖真正影响响应内容的请求头,同时没有无限放大缓存键。本地 next start 能分块输出,不代表生产 CDN 也会及时转发。可以使用 curl -N、浏览器 Timing 和服务端日志对比首批字节与边界完成时间:
curl -N \
-H 'Accept-Encoding: identity' \
https://preview.example.com/projects/demo如果使用 nginx,Next.js 官方自托管指南给出的做法是对响应设置 X-Accel-Buffering: no。可以在现有 Next 配置中加入对应响应头:
// next.config.mjs 中合并到现有 nextConfig
const nextConfig = {
async headers() {
return [
{
source: '/:path*{/}?',
headers: [
{
key: 'X-Accel-Buffering',
value: 'no',
},
],
},
]
},
}这个响应头主要控制 nginx;负载均衡器、其他反向代理和 CDN 仍要分别确认。若服务端在 200ms 发出外壳,浏览器却在 1.5s 后一次收到全部内容,应检查整条链路的缓冲、压缩实现和 Streaming 支持。基础设施配置的完整做法放到部署章节,本章只定义验收结果。
缓存响应头属于数据边界。不要为了降低 TTFB 给带身份信息的页面加公共缓存,也不要把 Cookie、Authorization 或用户数据混进可共享响应。性能收益不能以越权读取为代价。
优化没有门禁,很容易在两次发版后反弹。BoardFlow 应把真实用户目标、实验室诊断和资源数量放进同一份性能预算,但要区分通用的 Web Vitals 良好阈值和项目自己制定的工程预算。

下面是一份示例,不是所有 Next.js 项目的通用标准:
实验室预算应根据当前基线、目标设备和业务复杂度设定。不要为了通过门禁删掉无障碍代码或必要功能;预算超标时,先查看差异,再决定优化、调整方案或经过评审修改预算。
一个最小配置可以对固定预览页面重复采样。涉及登录的 BoardFlow 页面需要准备受控测试账户或专用测试数据,不能把生产 Cookie 写进仓库。把下面配置保存为项目根目录的 lighthouserc.json:
npm install --save-dev @lhci/cli
npx next build --webpack
npx lhci autorunlhci autorun 会按配置启动已经构建好的生产服务器、采集结果并执行断言。
{
"ci": {
"collect": {
"url": [
"http://localhost:3000/",
"http://localhost:3000/projects/demo"
],
"startServerCommand": "npm run start",
"numberOfRuns": 5,
"settings": {
"preset": "desktop"
}
},
"assert": {
"assertions": {
"largest-contentful-paint": [
"error",
{
这里显式使用 5 次运行的中位数;如果不写 aggregationMethod,Lighthouse CI 的断言默认偏向最容易通过的 optimistic 结果。maxNumericValue 对这些时间审计使用毫秒,CLS 则没有单位。配置中的 desktop 只定义桌面实验室场景;需要移动端门禁时应另建固定配置和预算,不能把两种环境的结果混成一个序列。
Lighthouse CI 负责可重复导航,不替代真实 INP,也不会自动落实表格中的 Webpack gzip 包预算;包体需要另用构建报告脚本断言。发布后仍要按 release 观察 RUM;如果候选版本的 Lab 通过但移动端 INP 长尾恶化,门禁应根据真实数据暂停灰度。
在修改前保存 BoardFlow 的 Field 分布、生产构建、Network 瀑布、Performance trace 和 Bundle Analyzer 报告,写清设备、网络、路由和提交 SHA。
给每个改动绑定证据:缩小客户端边界对应客户端图谱,动态导入对应请求时机,并行数据对应瀑布,骨架尺寸对应 CLS,任务切片对应 INP trace。
在候选版本中重跑相同 Lab 场景,同时检查功能、无障碍、错误率和数据正确性。性能变快但功能退化不算通过。
先小流量发布,按 routeGroup、device、release 查看 p75 和服务端 p95。样本达到约定规模后再扩大流量;明显回归时停止或回滚。
做到这里,BoardFlow 的“首屏晚、筛选卡顿、活动流跳动”就不再是三个模糊感受:它们分别有指标、有时间线、有对应改动,也有防止复发的门禁。性能工作的结束点不是某次分数变绿,而是团队已经能稳定发现、解释并阻止下一次回归。
把最终预算留在 CI 和监控告警中,并记录这次瓶颈的证据。下一次新增图表或 Provider 时,团队能在合并前看见成本。