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。

相关推荐
wordbaby4 分钟前
TanStack Router 路由概念
前端
wordbaby6 分钟前
TanStack Router 路由匹配
前端
cc蒲公英7 分钟前
vue nextTick和setTimeout区别
前端·javascript·vue.js
程序员刘禹锡11 分钟前
Html中常用的块标签!!!12.16日
前端·html
我血条子呢22 分钟前
【CSS】类似渐变色弯曲border
前端·css
DanyHope22 分钟前
LeetCode 两数之和:从 O (n²) 到 O (n),空间换时间的经典实践
前端·javascript·算法·leetcode·职场和发展
hgz071024 分钟前
企业级多项目部署与Tomcat运维实战
前端·firefox
用户18878710698424 分钟前
基于vant3的搜索选择组件
前端
zhoumeina9924 分钟前
懒加载图片
前端·javascript·vue.js
用户18878710698425 分钟前
SVG描边 - CSS3实现动画绘制矢量图
前端