Next.js 应用变慢的 8 个原因及解决办法

Next.js 应用变慢的情况比你想象的更常见。过长的加载时间会让用户感到沮丧,降低参与度。但大多数性能问题都可以归结为几个常见原因 ------ 从繁重的数据获取、路由延迟到过大的包体积、缓存错误和未优化的图像。

在本文中,我将指出 Next.js 应用中 8 个常见的性能问题,并分享清晰、实用的解决办法,帮助你打造更快、更流畅的用户体验,让用户切实感受到差异。

我假设你已经基本掌握 React 组件、useState 和 useEffect 等钩子,以及 Next.js 路由和数据获取的基础知识。你还应该熟悉使用浏览器开发者工具和在命令行中运行构建命令。如果这些听起来不熟悉,你可能需要先复习一下,不过我会尽量把解释写得简单明了。

快速说明一下 ------ 我会在示例中使用 getServerSideProps,因为许多现有项目仍然依赖 Pages Router,而且性能问题并非 Next.js 特定版本所独有。即使语法略有变化,这些优化原则同样适用于使用较新的 App Router 的情况。我们的目标是专注于最重要的解决办法,无论你的项目配置如何。

1. 感知性能不足

我们来谈谈感知性能 ------ 即应用给用户的感觉有多快,而不仅仅是它实际有多快。Jakob Nielsen 在他 1993 年的《可用性工程》一书中,为用户耐心设定了一些经典基准:

  • 0.1 秒 ------ 系统感觉 "即时响应" 的极限。在此范围内,用户不会察觉到延迟。

  • 1.0 秒 ------ 保持用户思维流畅的上限,尽管他们会注意到延迟。

  • 10 秒 ------ 用户完全失去注意力之前的最长时间。

如果你的 Next.js 应用显示内容的时间超过 1 秒,对用户来说它就正式 "变慢" 了,即使数据实际上正在后台加载。而等待 10 秒?那几乎是数字时代的永恒,足以让用户彻底失去兴趣。

但这里有个关键点:你的应用真的慢吗?还是只是 "感觉" 慢?

Reddit 上有个讨论:"为什么大多数 Next.js 应用都这么慢?"

用户说:"每次我访问一个网站,点击链接要等 2 秒,我就会想'这肯定是个 Next 应用',查看代码后发现确实如此。我猜是服务器组件在等待数据获取,但初始加载真的很慢(之后就好了)。我见过一些优化得很好的 Next 应用,初始加载很快,但 90% 我遇到的都有加载慢的问题。是新手开发者的问题还是框架本身的问题?"

这就是感知性能不足的体现。有时候,无论数据获取需要多长时间,你处理等待的方式都会产生巨大差异。

解决办法:使用加载状态和 React Suspense

关键在于立即向用户展示一些内容 ------ 即使不是最终内容。设计良好的加载状态可以让 2 秒的等待感觉比盯着空白屏幕 1 秒更快。

React Suspense 让在 Next.js 中实现这一点变得容易。你可以这样包裹组件,在内容加载时显示占位符:

jsx 复制代码
import { Suspense } from 'react';

export default function Dashboard () {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<DashboardSkeleton />}>
        <DashboardContent />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <AnalyticsChart />
      </Suspense>
    </div>
  );
}

当用户重新加载页面时,他们会立即看到占位符,让他们知道系统正在处理:

( Dashboard 重新加载演示:Next.js Suspense + 骨架屏加载演示)

(展示内容:John Doe 的仪表盘,包含用户信息、总用户数、收入、最近活动等,加载时显示骨架屏)

2. Next.js 混合渲染拖慢速度

说完了感知性能,我们来看看一些实际的性能问题。如你所知,Next.js 不仅是关于服务器端渲染 ------ 也不纯粹是单页应用。它是一个混合框架,兼顾两者的优点。但有时,也会带来两者的缺点。

这种混合模式很强大。它让你的应用可以为 SEO 和快速初始加载提供完全渲染的页面,然后切换到 SPA 行为以实现流畅的客户端导航。但这也意味着你要同时处理两种不同的性能特征 ------ 这正是问题可能出现的地方。

理解两种模式

首次加载时,你的 Next.js 应用表现得像传统的服务器渲染网站。它在服务器上获取数据,渲染完整的 HTML,然后发送到浏览器。这对 SEO 和快速显示有意义的内容非常有利:

jsx 复制代码
// 首次访问:服务器承担所有繁重工作
export async function getServerSideProps() {
  // 这在服务器上运行,会阻塞响应
  const userData = await fetchUser();
  const dashboardData = await fetchDashboard(userData.id);
  const notifications = await fetchNotifications(userData.id);

  return {
    props: {
      userData,
      dashboardData,
      notifications
    }
  };
}

但一旦初始页面加载完成,React 接管后,Next.js 就会切换到 SPA 模式。点击操作会触发客户端导航,无需整页重新加载 ------ 就像 React Router 一样:

jsx 复制代码
// 后续导航:纯 SPA 行为
function Dashboard({ userData }) {
  const router = useRouter();

  const goToProfile = () => {
    // 不访问服务器------纯客户端导航
    router.push('/profile');
  };

  return (
    <div>
      <h1>Welcome, {userData.name}</h1>
      <button onClick={goToProfile}>View Profile</button>
    </div>
  );
}

性能问题出现的地方

棘手之处在于,你实际上在运行两个应用:

  • 处理初始请求的服务器渲染应用

  • 处理导航和交互的客户端 SPA

每个都有自己的性能特征,如果没有适当优化,每个都可能拖慢整个应用:

  • 在服务器上,缓慢的数据库查询或阻塞操作会延迟 HTML 响应

  • 在客户端,过大的 JavaScript 包或低效的 API 调用会阻碍流畅的导航

更糟的是,这些问题会相互叠加。假设用户访问你的主页(服务器渲染),然后点击进入仪表盘(客户端)。这个仪表盘需要一个 2MB 的 JavaScript 包 ------ 而且还没有缓存。那么用户就必须等待包加载和客户端数据获取:

jsx 复制代码
// 性能陷阱:繁重的客户端页面
import HeavyChart from './HeavyChart'; // 500KB
import ComplexTable from './ComplexTable'; // 300KB
import RichEditor from './RichEditor'; // 400KB

export default function Dashboard () {
  const [data, setData] = useState(null);

  useEffect(() => {
    // 导航后在客户端获取数据
    fetchDashboardData().then(setData);
  }, []);

  // 用户需要等待包加载 + 数据加载
  if (!data) return <div>Loading...</div>;

  return (
    <div>
      <HeavyChart data={data.charts} />
      <ComplexTable data={data.tables} />
      <RichEditor content={data.content} />
    </div>
  );
}

解决办法:同时优化两种模式

当服务器和客户端都经过优化时,你就能获得两者的最佳效果 ------ 快速的初始加载、良好的 SEO,以及流畅的、类应用的导航。但如果忽视任何一方,整个体验都会变得迟缓。

现在我们已经了解了混合渲染如何引入隐藏的性能成本,接下来让我们聚焦服务器端一个最大的罪魁祸首 ------ 缓慢的、顺序的数据获取。

3. 数据获取过慢且按顺序执行

你点击应用中的某个东西,然后...... 什么都没有。没有反馈,没有内容。十有八九,问题在于同步数据获取。这是指应用 "礼貌地" 一次加载一条数据。以下是典型 Next.js 配置中的情况:

jsx 复制代码
// 慢方法------每个请求都要等前一个完成
export async function getServerSideProps({ req }) {
  // 步骤 1:获取用户(300ms)
  const user = await fetchUser(req.session.userId);

  // 步骤 2:等用户数据,再获取个人资料(400ms)
  const profile = await fetchUserProfile(user.id);

  // 步骤 3:等个人资料,再获取仪表盘数据(600ms)
  const dashboardData = await fetchDashboardData(user.id, profile.preferences);

  // 步骤 4:等仪表盘数据,再获取通知(200ms)
  const notifications = await fetchNotifications(user.id);

  // 总时间:300 + 400 + 600 + 200 = 1500ms(1.5 秒!)
  return {
    props: {
      user,
      profile,
      dashboardData,
      notifications
    }
  };
}

这种方法把本可以 4800ms 的页面加载变成了 1.5 秒的加载。每个 await 都在说 ------"暂停所有操作 ------ 在这个完成之前我们不继续。" 但这些请求真的需要一个接一个地进行吗?

解决办法:使用 Promise.all ()

如果你的请求彼此不依赖,就没有理由不能同时运行。这就是 Promise.all () 的用武之地:

jsx 复制代码
export async function getServerSideProps({ req }) {
  // 步骤 1:先获取用户(仍然是其他请求所需要的)
  const user = await fetchUser(req.session.userId);

  // 步骤 2:并行获取其他所有数据
  const [profile, dashboardData, notifications] = await Promise.all([
    fetchUserProfile(user.id),          // 400ms
    fetchDashboardData(user.id),        // 600ms
    fetchNotifications(user.id)         // 200ms
  ]);

  // 总时间:300ms(用户) + 600ms(最长的并行请求) = 900ms
  // 我们节省了 600ms!

  return {
    props: {
      user,
      profile,
      dashboardData,
      notifications
    }
  };
}

这个简单的改变就减少了 600ms------ 而且这只是一次页面加载。

额外解决办法:全局数据的并行获取

你可以更进一步。如果某些数据不依赖于用户,比如系统范围的设置或服务器状态,你可以在获取用户数据的同时获取这些数据:

jsx 复制代码
export async function getServerSideProps({ req }) {
  // 并行获取与用户无关的数据和用户数据
  const [user, globalSettings, systemStatus] = await Promise.all([
    fetchUser(req.session.userId),
    fetchGlobalSettings(),              // 不需要用户数据
    fetchSystemStatus()                 // 不需要用户数据
  ]);

  // 现在并行获取依赖于用户的数据
  const [profile, dashboardData, notifications] = await Promise.all([
    fetchUserProfile(user.id),
    fetchDashboardData(user.id),
    fetchNotifications(user.id)
  ]);

  return {
    props: {
      user,
      profile,
      dashboardData,
      notifications,
      globalSettings,
      systemStatus
    }
  };
}

通过更智能的并行化,你可以减少加载时间,提高响应速度 ------ 不需要复杂的工具或库。只需要更好地使用 JavaScript。

即使数据获取更快,你的应用可能仍然感觉不流畅。为什么?因为你的路由行为可能在进行不必要的完整服务器往返。让我们接下来看看这个问题。

4. 路由触发不必要的服务器往返

使用 Next.js App Router 时,每次导航都可能触发服务器端渲染 ------ 即使是本可以(也应该)在客户端处理的路由。这不仅低效,而且肯定会让快速的应用感觉变慢。

假设你有一个用户在浏览产品目录。在传统的 SPA 中,在产品页面之间点击应该是即时的。JavaScript 处理状态更新,URL 变化而无需重新加载页面。

但如果 Next.js App Router 配置不当,每次点击都可能往返服务器。情况如下:

jsx 复制代码
// app/products/[id]/page.js
// 每次访问产品页面时,这都会在服务器上运行
export default async function ProductPage({ params }) {
  // 每次导航都向服务器发起网络请求
  const product = await fetchProduct(params.id);
  const reviews = await fetchReviews(params.id);
  const recommendations = await fetchRecommendations(params.id);

  return (
    <div>
      <ProductDetails product={product} />
      <ReviewsList reviews={reviews} />
      <RecommendationGrid recommendations={recommendations} />
    </div>
  );
}

在这种设置下,每次点击都要经过同样缓慢的流程:

  1. 点击产品链接

  2. 浏览器向服务器发出请求

  3. 服务器获取产品数据(数据库调用)

  4. 服务器渲染 HTML

  5. 服务器将 HTML 发送到浏览器

  6. 浏览器显示页面

这有六个步骤,每个都可能有延迟。乘以十次产品浏览,就有十次服务器请求 ------ 而不是十次即时的页面转换。

解决办法:转向客户端路由

为避免这些往返,尽可能将路由转移到客户端。方法如下:

jsx 复制代码
// app/products/[id]/page.js
'use client'; // 这使其成为客户端渲染

import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';

export default function ProductPage () {
  const params = useParams();
  const [product, setProduct] = useState(null);
  const [reviews, setReviews] = useState(null);

  useEffect(() => {
    // 在客户端获取数据------没有服务器往返
    Promise.all([
      fetch(`/api/products/${params.id}`).then(res => res.json()),
      fetch(`/api/reviews/${params.id}`).then(res => res.json())
    ]).then(([productData, reviewsData]) => {
      setProduct(productData);
      setReviews(reviewsData);
    });
  }, [params.id]);

  if (!product) return <ProductSkeleton />;

  return (
    <div>
      <ProductDetails product={product} />
      <ReviewsList reviews={reviews} />
    </div>
  );
}

这样,产品页面之间的导航会立即发生 ------ 数据在后台加载,无需在服务器上重新渲染整个页面。

何时使用服务器端 vs 客户端渲染

快速指南:

使用服务器端渲染(SSR)当:

  • SEO 至关重要(产品页面、博客文章)

  • 你要显示用户特定的敏感数据

  • 初始页面加载速度比导航速度更重要

  • 内容不经常变化

使用客户端渲染(CSR)当:

  • 用户频繁在相似页面之间导航
  • 你可以有效地缓存数据
  • SEO 不是优先事项(用户仪表盘、管理面板)
  • 你想要即时的、类应用的导航

额外解决办法:混合渲染

有时,你需要 SSR 用于初始页面加载,但希望后续交互受益于 CSR。Next.js App Router 允许你结合两者:

jsx 复制代码
// app/products/[id]/page.js
// 为 SEO 进行服务器渲染初始页面
export default async function ProductPage({ params }) {
  const initialProduct = await fetchProduct(params.id);

  return (
    <div>
      <ProductClient initialData={initialProduct} productId={params.id} />
    </div>
  );
}

// components/ProductClient.js
'use client';

export default function ProductClient({ initialData, productId }) {
  const [product, setProduct] = useState(initialData);

  // 后续导航在客户端进行
  const router = useRouter();

  const navigateToProduct = async (newId) => {
    // 立即更新 URL(感觉是即时的)
    router.push(`/products/${newId}`);

    // 在后台获取新数据
    const newProduct = await fetch(`/api/products/${newId}`).then(res => res.json());
    setProduct(newProduct);
  };

  return (
    <div>
      <ProductDetails product={product} />
      <RelatedProducts onProductClick={navigateToProduct} />
    </div>
  );
}

但即使有智能路由和优化的数据获取,如果应用拖着过大的 JavaScript 包,仍然会感觉迟缓。让我们谈谈为什么会出现这个问题 ------ 以及如何解决。

5. JavaScript 包体积过大

我曾在一个黑客马拉松期间加入一个 Next.js 项目,当时主 JavaScript 包的大小是 2.3 MB。之前的开发者为了使用几个函数就导入了整个库。没有代码分割。没有动态导入。只是把一个巨大的负载丢给每个用户。

JavaScript 包体积直接影响你的交互时间(TTI)------ 衡量页面何时完全可用的指标。包越大,用户盯着加载 spinner 的时间就越长。

以下是经常导致包膨胀的原因:

jsx 复制代码
// 第一个包膨胀器:导入整个库
import _ from 'lodash'; // 导入整个 70KB 的库
import * as dateFns from 'date-fns'; // 另一个大型导入

// 第二个包膨胀器:在各处导入重型组件
import { DataVisualization } from './DataVisualization'; // 500KB 组件
import { VideoPlayer } from './VideoPlayer'; // 300KB 组件
import { RichTextEditor } from './RichTextEditor'; // 400KB 组件

export default function HomePage () {
  return (
    <div>
      <h1>Welcome</h1>
      {/* 这些组件可能在初始加载时甚至不可见 */}
      <DataVisualization />
      <VideoPlayer />
      <RichTextEditor />
    </div>
  );
}

这种方法会向每个用户加载所有内容 ------ 即使他们从未与这些组件交互。幸运的是,有更好的方法。

解决办法:代码分割和动态导入

Next.js 默认支持智能代码分割。但要充分利用它,你需要使用动态导入来仅在需要时加载代码。

基于路由的代码分割

默认情况下,Next.js 按路由分割代码。但你可以使用 next/dynamic 进一步优化:

jsx 复制代码
// pages/dashboard.js - 仅当用户访问 /dashboard 时加载
import dynamic from 'next/dynamic';

// 仅在需要时加载重型组件
const AnalyticsChart = dynamic(() => import('../components/AnalyticsChart'), {
  loading: () => <ChartSkeleton />,
  ssr: false // 对仅客户端组件跳过服务器端渲染
});

const DataExporter = dynamic(() => import('../components/DataExporter'), {
  loading: () => <p>Loading exporter...</p>
});

export default function Dashboard() {
  const [showAnalytics, setShowAnalytics] = useState(false);
  const [showExporter, setShowExporter] = useState(false);

  return (
    <div>
      <h1>Dashboard</h1>

      <button onClick={() => setShowAnalytics(true)}>
        View Analytics
      </button>

      {showAnalytics && <AnalyticsChart />}

      <button onClick={() => setShowExporter(true)}>
        Export Data
      </button>

      {showExporter && <DataExporter />}
    </div>
  );
}

通过这种模式,用户只在请求时才下载图表或导出逻辑,而不是之前。

基于组件的代码分割

如果你有在路由之间共享但仅在特定情况下需要的组件,也可以延迟加载它们:

jsx 复制代码
// components/ConditionalFeatures.js
import dynamic from 'next/dynamic';

// 仅当用户有高级订阅时加载
const PremiumChart = dynamic(() => import('./PremiumChart'), {
  loading: () => <div>Loading premium features...</div>
});

// 仅当用户点击"高级设置"时加载
const AdvancedSettings = dynamic(() => import('./AdvancedSettings'));

export function ConditionalFeatures({ user, showAdvanced }) {
  return (
    <div>
      {user.isPremium && <PremiumChart />}
      {showAdvanced && <AdvancedSettings />}
    </div>
  );
}

这确保你的用户不会为他们甚至无法访问的功能付出性能代价。

额外解决办法:使用 @next/bundle-analyzer 分析包

要查看是什么占用了你的包体积,使用官方的包分析器:

jsx 复制代码
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true'
});

module.exports = withBundleAnalyzer({
  // 你的 Next.js 配置
});

运行 ANALYZE=true npm run build 查看 JavaScript 的可视化地图 ------ 每个过大的库、每个庞大的组件。这就像性能问题的 X 光片。

通过动态导入、条件加载和包分析,你可以毫不费力地将初始包缩小 50-70%。

6. React hydration(水合)也可能是问题所在

即使你小心处理 JavaScript 包,React 应用中还有一个性能杀手 ------ 水合。服务器向浏览器发送 HTML 后,React 需要 "水合" 它,即附加事件监听器,并使虚拟 DOM 与服务器渲染的标记协调。这个过程可能会阻塞交互性,影响性能。

问题是这样的:

jsx 复制代码
// 传统 Next.js 页面,存在水合瓶颈
export default function ProductPage({ products }) {
  return (
    <div>
      <Header /> {/* 必须先水合,用户才能交互 */}
      <ProductGrid products={products} /> {/* 大型组件树 */}
      <FilterSidebar /> {/* 复杂的交互组件 */}
      <Footer /> {/* 不需要 JS 的静态内容 */}

      {/* 所有内容同时水合,阻塞交互性 */}
    </div>
  );
}

在水合期间,浏览器的主线程会被阻塞,而 React 处理整个组件树。对于复杂页面,这在低端设备上可能需要数百毫秒甚至几秒,造成用户能看到 UI 但无法交互的令人沮丧的延迟。

解决办法:使用 React 服务器组件和部分水合

Next.js App Router 带来了 React 服务器组件,从根本上改变了这种动态,让你可以选择应用的哪些部分需要客户端 JavaScript:

jsx 复制代码
// app/products/page.js - 服务器组件(不向客户端发送 JS)
import { ProductGrid } from './components/ProductGrid';
import { ClientSideFilter } from './components/ClientSideFilter';

// 这个组件在服务器上运行,只发送 HTML
export default async function ProductPage () {
  // 数据获取在服务器上进行
  const products = await fetchProducts();

  return (
    <div>
      <h1>Products</h1>
      {/* 静态部分仅作为 HTML 存在 */}
      <ProductGrid products={products} />

      {/* 仅交互部分需要水合 */}
      <ClientSideFilter products={products} />
    </div>
  );
}

// components/ClientSideFilter.js
'use client'; // 标记为需要水合

export function ClientSideFilter({ products }) {
  const [filters, setFilters] = useState({});
  // 交互组件逻辑...
}

这种方法带来几个主要性能优势:

  • 默认零 JavaScript------ 服务器组件只向浏览器发送 HTML,除非用 'use client' 明确标记

  • 选择性水合 ------ 只有交互组件消耗客户端 JavaScript

  • 流式渲染 ------ 页面的各个部分可以独立加载并变得可交互

  • 减小包体积 ------ 服务器组件的代码永远不会发送到客户端

实施智能水合技术是个好开始,但如果你的应用不断重新获取相同的数据,就像失忆了一样,用户仍然会感到延迟。让我们谈谈缓存。

7. 没有在请求间有效缓存数据

缓存就像给你的应用一个好记性。它防止应用每次都要重新获取信息。但我见过很多 Next.js 应用把每个请求都当作第一次处理 ------ 特别是对于权限、用户数据或博客文章等内容。

糟糕的缓存不仅会减慢应用速度 ------ 还会浪费服务器资源。最常见的缓存错误往往很基础:

重复获取相同数据:

jsx 复制代码
export default function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  // 每次组件挂载时运行------没有缓存!
  useEffect(() => {
    fetch(`/api/users/${userId}`).then(res => res.json()).then(setUser);
  }, [userId]);

  return user ? <div>{user.name}</div> : <div>Loading...</div>;
}

每次页面加载都拉取新数据:

jsx 复制代码
export async function getServerSideProps({ params }) {
  // 每次请求都访问数据库
  const posts = await db.posts.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' }
  });

  return { props: { posts } };
}

这就是我所说的 "系统失忆症"------ 应用在用户刷新或点击离开时就忘记了所有学到的东西。

解决办法:尽可能使用 SSG 和 SWR

有效的缓存在不同层面发挥作用:API 路由、页面渲染,甚至数据库查询。让我们看看如何让它为你工作:

使用 ISR 进行服务器端缓存

如果你的数据不是每秒都变化,就不要每秒都重新获取。使用增量静态再生(ISR)来提供预构建的页面,并偶尔刷新它们:

jsx 复制代码
// pages/blog/[slug].js
export async function getStaticProps({ params }) {
  const post = await fetchPost(params.slug);

  return {
    props: { post },
    revalidate: 3600, // 最多每小时再生一次
  };
}

export async function getStaticPaths() {
  // 为热门帖子生成路径
  const popularPosts = await fetchPopularPosts();

  return {
    paths: popularPosts.map((post) => ({
      params: { slug: post.slug }
    })),
    fallback: 'blocking' // 按需生成其他页面
  };
}

这能保持内容新鲜和快速,同时最小化服务器负载。

额外解决办法:在 API 上应用智能缓存控制头

对于消耗大的 API 操作,使用 unstable_cache 缓存服务器端逻辑:

jsx 复制代码
// pages/api/posts.js
import { unstable_cache } from 'next/cache';

const getCachedPosts = unstable_cache(
  async () => {
    // 消耗大的数据库查询
    return await db.posts.findMany({
      include: {
        author: true,
        comments: { take: 5 },
        tags: true
      },
      orderBy: { createdAt: 'desc' }
    });
  },
  ['posts-list'],
  {
    revalidate: 300, // 缓存 5 分钟
    tags: ['posts']
  }
);

export default async function handler(req, res) {
  const posts = await getCachedPosts();
  res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600');
  res.json(posts);
}

现在你的服务器不必为相同的查询过度工作,用户也能获得更快的体验。

如果缓存得当,你的应用会感觉像提前知道了用户的下一步行动。但即使有完美的缓存,还有一个陷阱会拖慢一切 ------ 未优化的图像。

8. 媒体资源拖慢应用

我曾审计过一个 Next.js 应用,其中单个英雄图像有 4.2MB------ 而且在每个页面上都加载。为了让你有概念,这比大多数完整应用的整个 JavaScript 包还要大。

问题不仅仅是文件大小。处理不当的图像会导致布局偏移、延迟页面渲染、在解码时阻塞主线程,并使最大内容绘制(LCP)远远超出可接受范围。这就像看电影时视频不断缓冲 ------ 技术上能看,但体验很糟糕。

我经常看到的错误是这样的:

使用原始 标签:

jsx 复制代码
export default function ProductCard({ product }) {
  return (
    <div className="product-card">
      {/* 没有优化,没有懒加载,导致布局偏移 */}
      <img src={product.imageUrl} alt={product.name} />
      <h3>{product.name}</h3>
      <p>${product.price}</p>
    </div>
  );
}

急切加载所有内容:

jsx 复制代码
export default function Gallery({ images }) {
  return (
    <div className="gallery">
      {images.map((image, index) => (
        // 所有 50 张图像同时加载,即使用户只看到 6 张
        <img key={index} src={image.url} alt={image.caption} />
      ))}
    </div>
  );
}

这种策略向用户交付了远超他们实际需要的内容,破坏了性能和用户体验。

解决办法:使用 next/image 和响应式尺寸

Next.js 提供了一个图像组件,处理响应式尺寸、懒加载和格式转换(如 WebP/AVIF)。它更快、更易访问,并节省大量带宽。以下是有效使用它的方法:

基本优化

jsx 复制代码
// components/ProductCard.js
import Image from 'next/image';

export default function ProductCard({ product }) {
  return (
    <div className="product-card">
      <Image
        src={product.imageUrl}
        alt={product.name}
        width={300}
        height={200}
        priority={product.featured} // 立即加载精选产品
        placeholder="blur"
        blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAAoDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAhEAACAQMDBQAAAAAAAAAAAAABAgMABAUGIWGRkqGx0f/EABUBAQEAAAAAAAAAAAAAAAAAAAMF/8QAGhEAAgIDAAAAAAAAAAAAAAAAAAECEgMRkf/aAAwDAQACEQMRAD8AltJagyeH0AthI5xdrLcNM91BF5pX2HaH9bcfaSXWGaRmknyJckliyjqTzSlT54b6bk+h0R//2Q=="
        className="rounded-lg object-cover"
      />
      <h3>{product.name}</h3>
      <p>${product.price}</p>
    </div>
  );
}

这本身就能改善 LCP、防止布局偏移,并帮助用户更快开始交互。

响应式英雄图像

对于在不同屏幕上显示不同尺寸的图像:

jsx 复制代码
// components/HeroSection.js
import Image from 'next/image';

export default function HeroSection () {
  return (
    <div className="hero relative h-screen">
      <Image
        src="/hero-image.jpg"
        alt="Hero image"
        fill
        priority
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
        className="object-cover"
      />
      <div className="absolute inset-0 flex items-center justify-center">
        <h1 className="text-white text-6xl font-bold">Welcome</h1>
      </div>
    </div>
  );
}

sizes 属性确保浏览器为每个屏幕尺寸选择最佳版本,在小设备上节省带宽。

带懒加载的智能画廊

对于图像画廊,实现渐进式加载:

jsx 复制代码
// components/ImageGallery.js
import Image from 'next/image';
import { useState } from 'react';

export default function ImageGallery({ images }) {
  const [visibleCount, setVisibleCount] = useState(6);

  const loadMore = () => {
    setVisibleCount(prev => Math.min(prev + 6, images.length));
  };

  return (
    <div className="gallery">
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {images.slice(0, visibleCount).map((image, index) => (
          <div key={image.id} className="aspect-square relative">
            <Image
              src={image.url}
              alt={image.caption}
              fill
              sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
              className="object-cover rounded-lg"
              priority={index < 6} // 优先加载前 6 张图像
            />
          </div>
        ))}
      </div>

      {visibleCount < images.length && (
        <button
          onClick={loadMore}
          className="mt-8 px-6 py-3 bg-blue-600 text-white rounded-lg"
        >
          Load More ({images.length - visibleCount} remaining)
        </button>
      )}
    </div>
  );
}

这样,用户只下载他们看到的内容 ------ 提高性能并减少移动设备上的内存使用。

总而言之 ------ 如果你在 Next.js 项目中不使用 组件,你就错过了巨大的性能提升。优化你的图像,用户会立即感受到差异。

为什么在移动设备上优化更为重要

性能问题影响所有人 ------ 但移动用户受到的影响最严重。这就是为什么你的 Next.js 应用需要特别友好的移动体验:

  • 网络速度慢 ------ 许多移动用户仍然使用 3G 或不稳定的 4G 网络。在宽带下 200ms 加载完的 500KB JavaScript 包,在移动网络上可能需要超过 2 秒。

  • CPU 较弱 ------ 移动设备的处理能力远低于桌面。在你的 MacBook 上 300ms 运行完的 JavaScript,在廉价安卓手机上可能需要 1.5 秒 ------ 只是为了水合一个页面。

  • 内存限制严格 ------ 移动浏览器更容易崩溃和频繁垃圾回收,特别是当你的应用依赖大的包或重型图像时。

这对你的 Next.js 性能策略意味着:

  • 最小化包体积 ------ 这不是奢侈品,而是必需品

  • 优化图像 ------ 臃肿的英雄图像不仅减慢页面速度,还可能消耗用户的实际流量

  • 使用智能加载状态 ------ 在较慢的网络上,感知性能更重要

专业提示 ------ 如果你的应用在廉价手机上通过 3G 网络运行良好,那么在其他地方都会流畅运行。

衡量关键指标:如何优化性能

Next.js 中的性能优化不是选择一个解决方案 ------ 而是识别问题所在,并有条不紊地解决。事实是,性能工作不是一个勾选框,而是开发速度和用户体验之间持续的平衡。

每个新功能、每个额外的依赖项、每个在截止日期压力下走的捷径,都可能慢慢侵蚀你已经取得的进展。最好的方法是不要把性能当作事后才考虑的事情。从一开始就考虑它。

不要盲目优化。使用以下工具:

  • Next.js 内置分析,用于核心网络指标

  • Lighthouse CI,用于 CI/CD 管道中的自动化性能测试

  • 真实用户监控(RUM),了解实际用户体验

  • 包分析器,及早发现依赖膨胀

我们在本指南中涵盖的大部分内容 ------ 缓存、图像、代码分割和 SSR 策略 ------ 可以解决大约 80% 的 Next.js 性能问题。剩下的 20% 通常涉及更复杂的优化,如边缘渲染、CDN 策略、查询优化,有时甚至是全面的架构调整。

但不要从边缘情况开始。先关注大的改进点。

结论

性能的棘手之处在于:应用实际有多快和用户感觉它有多快之间存在差距。你的应用可能在技术上 2 秒内加载完成 ------ 但如果用户在 1.8 秒内都盯着空白屏幕,那感觉会非常慢。感知和指标同样重要。

记住这一点。如果感觉快,那就是快 ------ 至少对用户来说是这样。

所以要牢记这一点进行开发。添加加载状态,显示占位符,给用户视觉反馈。这样,当有人访问你的应用时,他们不仅会看到速度 ------ 还会感受到速度。

LogRocket:全面监控生产环境中的 Next.js 应用

调试 Next 应用可能很困难,特别是当用户遇到难以重现的问题时。如果你有兴趣监控和跟踪状态、自动显示 JavaScript 错误、跟踪缓慢的网络请求和组件加载时间,可以试试 LogRocket。

相关推荐
@大迁世界18 分钟前
React 及其生态新闻 — 2025年6月
前端·javascript·react.js·前端框架·ecmascript
红尘散仙1 小时前
Rust 终端 UI 开发新玩法:用 Ratatui Kit 轻松打造高颜值 CLI
前端·后端·rust
新酱爱学习1 小时前
前端海报生成的几种方式:从 Canvas 到 Skyline
前端·javascript·微信小程序
袁煦丞1 小时前
把纸堆变数据流!Paperless-ngx让文件管理像打游戏一样爽:cpolar内网穿透实验室第539个成功挑战
前端·程序员·远程工作
慧慧吖@2 小时前
关于两种网络攻击方式XSS和CSRF
前端·xss·csrf
徐小夕2 小时前
失业半年,写了一款多维表格编辑器pxcharts
前端·react.js·架构
LaoZhangAI3 小时前
Kiro vs Cursor:2025年AI编程IDE深度对比
前端·后端
止观止3 小时前
CSS3 粘性定位解析:position sticky
前端·css·css3
爱编程的喵3 小时前
深入理解JavaScript单例模式:从Storage封装到Modal弹窗的实战应用
前端·javascript