服务端阅读 06月19日 16:48
Next.js 性能优化该从哪些关键指标入手?
Next.js 性能优化最容易踩的坑,是一上来就改配置、加缓存、拆组件,却没有先量化问题。真正有效的做法应该从指标开始:LCP 慢就看首屏图片、字体、服务端响应;INP 差就看客户端 JavaScript、长任务和交互组件;CLS 高就看图片尺寸、广告位、字体切换和异步内容插入。下面这些优化手段不是越多越好,而是要和指标对应起来。先用 Lighthouse、Chrome DevTools、Web Vitals、真实用户监控确认瓶颈,再决定该动哪一块。先测量,再优化性能优化不能只看本地开发环境。本地机器快、网络近、缓存热,很容易把问题藏起来。至少要同时关注两类数据:实验室数据:Lighthouse、WebPageTest、Chrome Performance 面板,适合定位问题。真实用户数据:Web Vitals、RUM、日志平台,适合判断线上用户到底慢在哪里。Next.js 可以上报 Web Vitals:// pages/_app.jsimport { useReportWebVitals } from 'next/web-vitals';export function reportWebVitals(metric) { console.log(metric); // 可以发送到 GA、Sentry、Datadog 或自建埋点服务}export default function App({ Component, pageProps }) { useReportWebVitals(reportWebVitals); return <Component {...pageProps} />;}重点看这些指标:| 指标 | 主要反映 | 常见原因 ||---|---|---|| LCP | 首屏主要内容加载速度 | 大图、慢接口、字体阻塞、服务端响应慢 || INP | 用户交互响应 | 客户端 JS 过重、长任务、重复渲染 || CLS | 页面布局稳定性 | 图片无尺寸、字体切换、异步插入内容 || TTFB | 服务端响应速度 | SSR 过重、缓存缺失、数据库慢查询 |如果没有测量,所谓优化很可能只是把问题从一个地方挪到另一个地方。用动态导入减少首屏 JavaScriptNext.js 会按页面自动做代码分割,但这不代表所有组件都应该进首屏包。图表、富文本编辑器、地图、弹窗、低频面板这类重组件,通常适合动态加载。import dynamic from 'next/dynamic';const ChartPanel = dynamic(() => import('../components/ChartPanel'), { loading: () => <p>图表加载中...</p>, ssr: false,});export default function Dashboard() { return ( <section> <h2>数据概览</h2> <ChartPanel /> </section> );}ssr: false 不是默认答案。它会让组件只在浏览器端渲染,适合强依赖 window、Canvas、地图 SDK 的组件;如果组件内容对 SEO 或首屏体验重要,就不要随手关掉 SSR。更实用的判断方式是:这个组件首屏必须看到吗?用户不操作也会用到吗?如果答案是否定的,可以考虑动态导入。用 next/image 处理 LCP 图片图片是 Next.js 项目里最常见的 LCP 问题。next/image 能自动处理尺寸、格式、懒加载和响应式图片,但首屏大图仍然需要认真配置。import Image from 'next/image';export default function Hero() { return ( <Image src="/hero.jpg" alt="产品首页截图" width={1200} height={630} priority sizes="(max-width: 768px) 100vw, 1200px" /> );}几个细节很关键:首屏最大图片通常是 LCP 元素,应该加 priority。必须提供准确的 width 和 height,避免 CLS。sizes 不要省略,否则浏览器可能下载过大的图片。非首屏图片保持默认懒加载,不要到处加 priority。如果使用远程图片,记得在 next.config.js 里配置允许的图片域名。placeholder="blur" 可以改善观感,但它不是性能银弹。真正决定 LCP 的,还是图片体积、尺寸、CDN、缓存和首屏请求链路。用 next/font 降低字体带来的抖动字体加载慢会影响文本显示,也可能造成布局偏移。next/font 会在构建时优化字体加载,减少额外网络请求,并自动处理字体相关的性能问题。import { Inter } from 'next/font/google';const inter = Inter({ subsets: ['latin'], display: 'swap', variable: '--font-inter',});export default function RootLayout({ children }) { return ( <html lang="zh-CN" className={inter.variable}> <body>{children}</body> </html> );}中文站点要特别小心字体文件体积。全量中文字体非常大,很多时候系统字体栈反而更稳。如果确实要使用品牌字体,建议做子集化,并观察字体对 LCP 和 CLS 的影响。选对 fetch 缓存和 ISR 策略App Router 里,服务器组件可以直接 fetch 数据。性能好不好,很大程度取决于缓存策略是否匹配业务。async function Page() { const data = await fetch('https://api.example.com/data', { next: { revalidate: 60, tags: ['home-data'], }, }).then(res => res.json()); return <div>{data.title}</div>;}常见选择可以这样理解:内容不常变:用缓存或 ISR,减少重复渲染和接口压力。内容按分钟更新:用 revalidate,让页面定期刷新。内容必须实时:用 cache: 'no-store',但要接受更高的 TTFB。需要后台主动刷新:用 tags 配合按需重新验证。不要把所有页面都做 SSR。很多列表页、详情页、营销页并不需要每次请求都重新渲染。对这类页面,ISR 往往比 SSR 更稳,也更省服务器资源。客户端数据用 SWR 或 React Query 管理用户态数据、筛选结果、通知数量、管理后台表格这类内容,通常放在客户端请求更合适。直接用 useEffect 也能写,但缓存、去重、重试和重新验证很快会变得难维护。'use client';import useSWR from 'swr';const fetcher = url => fetch(url).then(res => res.json());export default function UserPanel() { const { data, error, isLoading } = useSWR('/api/user', fetcher, { revalidateOnFocus: false, dedupingInterval: 60000, }); if (isLoading) return <p>加载中...</p>; if (error) return <p>加载失败</p>; return <p>{data.name}</p>;}React Query 也适合复杂后台场景,尤其是分页、筛选、乐观更新和失效刷新较多的页面。关键不是选哪个库,而是别让同一个接口在多个组件里重复请求。谨慎使用 Link prefetchNext.js 的 Link 会帮助预取路由资源,通常能提升页面切换速度。import Link from 'next/link';export default function Navigation() { return ( <nav> <Link href="/about">关于我们</Link> <Link href="/reports" prefetch={false}>大型报表</Link> </nav> );}预取不是越多越好。移动端网络差、列表链接很多、目标页面很重时,过度 prefetch 反而会抢占带宽。对低概率点击的链接、大型后台页面、无限列表里的链接,可以考虑关闭预取。用 next/script 控制第三方脚本第三方脚本经常是性能问题的来源,比如统计、客服、广告、A/B 测试和热力图。next/script 的价值在于让脚本加载时机更可控。import Script from 'next/script';export default function Page() { return ( <> <Script src="https://www.googletagmanager.com/gtag/js" strategy="afterInteractive" /> <Script id="ga" strategy="afterInteractive"> {` window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', 'GA_MEASUREMENT_ID'); `} </Script> </> );}常见策略:beforeInteractive:非常少用,只给真正影响页面初始化的脚本。afterInteractive:页面可交互后加载,适合大多数统计脚本。lazyOnload:浏览器空闲时加载,适合不影响核心流程的脚本。如果一个第三方脚本让 INP 变差,先问它是否真的必须首屏加载。很多脚本延后几秒,业务上没有任何损失。memo 和 useMemo 不是免费午餐React.memo、useMemo、useCallback 可以减少重复计算和重复渲染,但它们也有成本。为了“看起来更专业”到处包一层,通常不会让页面更快。'use client';import { memo, useMemo } from 'react';const ExpensiveList = memo(function ExpensiveList({ items }) { const visibleItems = useMemo(() => { return items.map(item => ({ ...item, label: expensiveFormat(item), })); }, [items]); return <div>{visibleItems.map(item => <p key={item.id}>{item.label}</p>)}</div>;});适合使用它们的场景:计算真的昂贵,比如大数组处理、复杂格式化、图形计算。子组件很重,而且 props 在多数渲染中保持稳定。已经通过 React Profiler 看到重复渲染带来的开销。如果 items 每次渲染都会被重新创建,memo 也救不了。先处理数据结构和状态位置,再考虑记忆化。长列表要虚拟化几千条 DOM 节点同时渲染,浏览器会很吃力。后台表格、聊天记录、日志列表、搜索结果页,都可能需要虚拟化。'use client';import { useRef } from 'react';import { useVirtualizer } from '@tanstack/react-virtual';export default function VirtualList({ items }) { const parentRef = useRef(null); const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, estimateSize: () => 50, }); return ( <div ref={parentRef} style={{ height: 400, overflow: 'auto' }}> <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}> {virtualizer.getVirtualItems().map(row => ( <div key={row.key} style={{ position: 'absolute', transform: `translateY(${row.start}px)`, height: row.size, width: '100%', }} > {items[row.index]} </div> ))} </div> </div> );}虚拟化会增加实现复杂度,也可能影响浏览器搜索、无障碍和滚动定位。数据量不大时,分页或“加载更多”可能更简单。用服务器组件减少客户端边界App Router 默认使用服务器组件。服务器组件不会把组件逻辑发送到浏览器,可以减少客户端 JavaScript,尤其适合纯展示内容、详情页、列表页和数据聚合页面。async function ProductInfo({ id }) { const product = await getProduct(id); return ( <section> <h2>{product.name}</h2> <p>{product.description}</p> </section> );}需要交互的地方再切到客户端组件:'use client';import { useState } from 'react';export function Counter() { const [count, setCount] = useState(0); return <button onClick={() => setCount(count + 1)}>{count}</button>;}客户端边界要尽量小。不要因为一个按钮需要交互,就把整个页面都标成 'use client'。边界越大,浏览器要下载和执行的 JavaScript 就越多。用 Streaming 和 Suspense 改善等待体验有些接口就是慢,比如报表、推荐、权限聚合。与其让整页等最慢的接口,不如让重要内容先出来,慢内容放进 Suspense。import { Suspense } from 'react';async function SlowPanel() { const data = await slowFetch(); return <div>{data.title}</div>;}export default function Page() { return ( <main> <h2>账户概览</h2> <Suspense fallback={<p>明细加载中...</p>}> <SlowPanel /> </Suspense> </main> );}Streaming 不能让慢接口变快,但能让用户更早看到可用内容。它适合“部分内容慢,但页面不应该整体卡住”的场景。Redis 缓存适合放在服务端热点路径Next.js 自带缓存能解决很多页面级和请求级问题,但有些业务缓存更适合放到 Redis:排行榜、权限结果、推荐结果、第三方接口响应、数据库聚合结果。import { Redis } from '@upstash/redis';const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL, token: process.env.UPSTASH_REDIS_REST_TOKEN,});export async function getCachedData(key) { const cached = await redis.get(key); if (cached) return cached; const data = await fetchData(); await redis.set(key, data, { ex: 3600 }); return data;}缓存要同时考虑失效策略。没有失效策略的缓存,后面很容易变成线上事故。可以按时间过期,也可以在内容更新时主动清理。用 Bundle Analyzer 找出真正的大包包体积问题不要靠猜。@next/bundle-analyzer 可以直接看到哪些依赖进了客户端包。// next.config.jsconst withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true',});module.exports = withBundleAnalyzer({});运行分析:ANALYZE=true npm run build常见优化方向:替换体积过大的工具库。避免把服务端依赖带进客户端组件。对图表、编辑器、地图做动态导入。检查是否误引入整个组件库或整包图标库。先看分析结果,再决定怎么拆。盲目拆包可能让请求数量变多,收益并不稳定。压缩和构建配置要看 Next.js 版本compress: true 可以启用 Next.js 服务端 gzip 压缩,但如果前面已经有 Nginx、CDN、Vercel 或其他网关做压缩,就要避免重复配置带来的误判。实际线上通常还要确认 Brotli、gzip、缓存头是否生效。// next.config.jsmodule.exports = { compress: true, productionBrowserSourceMaps: false, images: { formats: ['image/avif', 'image/webp'], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], },};旧文章里常见的 swcMinify: true 需要更新一下:现代 Next.js 已经默认使用 SWC 压缩,较新的版本中这个配置不再需要,甚至可能被提示为无效配置。不要为了“优化”保留过时选项,应该以当前 Next.js 版本文档和构建输出为准。CDN 和缓存头别忽略前端代码写得再好,如果静态资源没有 CDN、缓存头混乱,用户还是会慢。Next.js 构建后的静态资源文件名带 hash,适合长期缓存;HTML 和接口响应则要按业务设置缓存策略。可以重点检查:静态资源是否命中 CDN。JS、CSS、字体、图片是否有合理的 Cache-Control。接口是否重复请求,是否能被服务端缓存。页面是否因为个性化内容过多而无法缓存。性能优化经常不是某一个 React API 的问题,而是浏览器、网络、服务端和缓存一起决定的结果。一个实际排查顺序如果线上 Next.js 页面“感觉很慢”,可以按这个顺序查:看 Web Vitals,确认是 LCP、INP、CLS 还是 TTFB 问题。如果 LCP 慢,先看首屏图片、字体、接口瀑布流和服务端响应。如果 INP 差,用 Performance 面板找长任务,再看客户端包和重复渲染。如果 CLS 高,检查图片尺寸、动态内容插入和字体切换。如果 TTFB 高,检查 SSR、数据库、外部接口、缓存和部署区域。用 Bundle Analyzer 确认客户端包体积,不要凭感觉删依赖。改完后重新测量,并对比真实用户数据。Next.js 性能优化不是把所有功能都打开,而是让每个页面只加载它真正需要的东西。图片、字体、数据缓存、脚本、客户端边界、流式渲染、Redis、CDN、构建产物都值得看,但优先级应该由指标决定。能被测量验证的优化,才算真的优化。