Next.js應(yīng)用變慢的8個(gè)原因及解決辦法
引言
Next.js 應(yīng)用變慢的情況比你想象的更常見。過長的加載時(shí)間會讓用戶感到沮喪,降低參與度。但大多數(shù)性能問題都可以歸結(jié)為幾個(gè)常見原因 —— 從繁重的數(shù)據(jù)獲取、路由延遲到過大的包體積、緩存錯(cuò)誤和未優(yōu)化的圖像。
在本文中,我將指出 Next.js 應(yīng)用中 8 個(gè)常見的性能問題,并分享清晰、實(shí)用的解決辦法,幫助你打造更快、更流暢的用戶體驗(yàn),讓用戶切實(shí)感受到差異。
我假設(shè)你已經(jīng)基本掌握 React 組件、useState 和 useEffect 等鉤子,以及 Next.js 路由和數(shù)據(jù)獲取的基礎(chǔ)知識。你還應(yīng)該熟悉使用瀏覽器開發(fā)者工具和在命令行中運(yùn)行構(gòu)建命令。如果這些聽起來不熟悉,你可能需要先復(fù)習(xí)一下,不過我會盡量把解釋寫得簡單明了。
快速說明一下 —— 我會在示例中使用 getServerSideProps,因?yàn)樵S多現(xiàn)有項(xiàng)目仍然依賴 Pages Router,而且性能問題并非 Next.js 特定版本所獨(dú)有。即使語法略有變化,這些優(yōu)化原則同樣適用于使用較新的 App Router 的情況。我們的目標(biāo)是專注于最重要的解決辦法,無論你的項(xiàng)目配置如何。
1. 感知性能不足
我們來談?wù)劯兄阅?—— 即應(yīng)用給用戶的感覺有多快,而不僅僅是它實(shí)際有多快。Jakob Nielsen 在他 1993 年的《可用性工程》一書中,為用戶耐心設(shè)定了一些經(jīng)典基準(zhǔn):
- 0.1 秒 —— 系統(tǒng)感覺 “即時(shí)響應(yīng)” 的極限。在此范圍內(nèi),用戶不會察覺到延遲。
- 1.0 秒 —— 保持用戶思維流暢的上限,盡管他們會注意到延遲。
- 10 秒 —— 用戶完全失去注意力之前的最長時(shí)間。
如果你的 Next.js 應(yīng)用顯示內(nèi)容的時(shí)間超過 1 秒,對用戶來說它就正式 “變慢” 了,即使數(shù)據(jù)實(shí)際上正在后臺加載。而等待 10 秒?那幾乎是數(shù)字時(shí)代的永恒,足以讓用戶徹底失去興趣。
但這里有個(gè)關(guān)鍵點(diǎn):你的應(yīng)用真的慢嗎?還是只是 “感覺” 慢?
Reddit 上有個(gè)討論:“為什么大多數(shù) Next.js 應(yīng)用都這么慢?”
用戶說:“每次我訪問一個(gè)網(wǎng)站,點(diǎn)擊鏈接要等 2 秒,我就會想‘這肯定是個(gè) Next 應(yīng)用’,查看代碼后發(fā)現(xiàn)確實(shí)如此。我猜是服務(wù)器組件在等待數(shù)據(jù)獲取,但初始加載真的很慢(之后就好了)。我見過一些優(yōu)化得很好的 Next 應(yīng)用,初始加載很快,但 90% 我遇到的都有加載慢的問題。是新手開發(fā)者的問題還是框架本身的問題?”
這就是感知性能不足的體現(xiàn)。有時(shí)候,無論數(shù)據(jù)獲取需要多長時(shí)間,你處理等待的方式都會產(chǎn)生巨大差異。
解決辦法:使用加載狀態(tài)和 React Suspense
關(guān)鍵在于立即向用戶展示一些內(nèi)容 —— 即使不是最終內(nèi)容。設(shè)計(jì)良好的加載狀態(tài)可以讓 2 秒的等待感覺比盯著空白屏幕 1 秒更快。
React Suspense 讓在 Next.js 中實(shí)現(xiàn)這一點(diǎn)變得容易。你可以這樣包裹組件,在內(nèi)容加載時(shí)顯示占位符:
import { Suspense } from 'react'; export default function Dashboard () { return ( <div> <h1>Dashboard</h1> <Suspense fallback={<DashboardSkeleton />}> <DashboardContent /> </Suspense> <Suspense fallback={<ChartSkeleton />}> <AnalyticsChart /> </Suspense> </div> ); }
當(dāng)用戶重新加載頁面時(shí),他們會立即看到占位符,讓他們知道系統(tǒng)正在處理:
( Dashboard 重新加載演示:Next.js Suspense + 骨架屏加載演示)
(展示內(nèi)容:John Doe 的儀表盤,包含用戶信息、總用戶數(shù)、收入、最近活動等,加載時(shí)顯示骨架屏)
2. Next.js 混合渲染拖慢速度
說完了感知性能,我們來看看一些實(shí)際的性能問題。如你所知,Next.js 不僅是關(guān)于服務(wù)器端渲染 —— 也不純粹是單頁應(yīng)用。它是一個(gè)混合框架,兼顧兩者的優(yōu)點(diǎn)。但有時(shí),也會帶來兩者的缺點(diǎn)。
這種混合模式很強(qiáng)大。它讓你的應(yīng)用可以為 SEO 和快速初始加載提供完全渲染的頁面,然后切換到 SPA 行為以實(shí)現(xiàn)流暢的客戶端導(dǎo)航。但這也意味著你要同時(shí)處理兩種不同的性能特征 —— 這正是問題可能出現(xiàn)的地方。
理解兩種模式
首次加載時(shí),你的 Next.js 應(yīng)用表現(xiàn)得像傳統(tǒng)的服務(wù)器渲染網(wǎng)站。它在服務(wù)器上獲取數(shù)據(jù),渲染完整的 HTML,然后發(fā)送到瀏覽器。這對 SEO 和快速顯示有意義的內(nèi)容非常有利:
// 首次訪問:服務(wù)器承擔(dān)所有繁重工作 export async function getServerSideProps() { // 這在服務(wù)器上運(yùn)行,會阻塞響應(yīng) 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 模式。點(diǎn)擊操作會觸發(fā)客戶端導(dǎo)航,無需整頁重新加載 —— 就像 React Router 一樣:
// 后續(xù)導(dǎo)航:純 SPA 行為 function Dashboard({ userData }) { const router = useRouter(); const goToProfile = () => { // 不訪問服務(wù)器——純客戶端導(dǎo)航 router.push('/profile'); }; return ( <div> <h1>Welcome, {userData.name}</h1> <button onClick={goToProfile}>View Profile</button> </div> ); }
性能問題出現(xiàn)的地方
棘手之處在于,你實(shí)際上在運(yùn)行兩個(gè)應(yīng)用:
- 處理初始請求的服務(wù)器渲染應(yīng)用
- 處理導(dǎo)航和交互的客戶端 SPA
每個(gè)都有自己的性能特征,如果沒有適當(dāng)優(yōu)化,每個(gè)都可能拖慢整個(gè)應(yīng)用:
- 在服務(wù)器上,緩慢的數(shù)據(jù)庫查詢或阻塞操作會延遲 HTML 響應(yīng)
- 在客戶端,過大的 JavaScript 包或低效的 API 調(diào)用會阻礙流暢的導(dǎo)航
更糟的是,這些問題會相互疊加。假設(shè)用戶訪問你的主頁(服務(wù)器渲染),然后點(diǎn)擊進(jìn)入儀表盤(客戶端)。這個(gè)儀表盤需要一個(gè) 2MB 的 JavaScript 包 —— 而且還沒有緩存。那么用戶就必須等待包加載和客戶端數(shù)據(jù)獲取:
// 性能陷阱:繁重的客戶端頁面 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(() => { // 導(dǎo)航后在客戶端獲取數(shù)據(jù) fetchDashboardData().then(setData); }, []); // 用戶需要等待包加載 + 數(shù)據(jù)加載 if (!data) return <div>Loading...</div>; return ( <div> <HeavyChart data={data.charts} /> <ComplexTable data={data.tables} /> <RichEditor content={data.content} /> </div> ); }
解決辦法:同時(shí)優(yōu)化兩種模式
當(dāng)服務(wù)器和客戶端都經(jīng)過優(yōu)化時(shí),你就能獲得兩者的最佳效果 —— 快速的初始加載、良好的 SEO,以及流暢的、類應(yīng)用的導(dǎo)航。但如果忽視任何一方,整個(gè)體驗(yàn)都會變得遲緩。
現(xiàn)在我們已經(jīng)了解了混合渲染如何引入隱藏的性能成本,接下來讓我們聚焦服務(wù)器端一個(gè)最大的罪魁禍?zhǔn)?—— 緩慢的、順序的數(shù)據(jù)獲取。
3. 數(shù)據(jù)獲取過慢且按順序執(zhí)行
你點(diǎn)擊應(yīng)用中的某個(gè)東西,然后…… 什么都沒有。沒有反饋,沒有內(nèi)容。十有八九,問題在于同步數(shù)據(jù)獲取。這是指應(yīng)用 “禮貌地” 一次加載一條數(shù)據(jù)。以下是典型 Next.js 配置中的情況:
// 慢方法——每個(gè)請求都要等前一個(gè)完成 export async function getServerSideProps({ req }) { // 步驟 1:獲取用戶(300ms) const user = await fetchUser(req.session.userId); // 步驟 2:等用戶數(shù)據(jù),再獲取個(gè)人資料(400ms) const profile = await fetchUserProfile(user.id); // 步驟 3:等個(gè)人資料,再獲取儀表盤數(shù)據(jù)(600ms) const dashboardData = await fetchDashboardData(user.id, profile.preferences); // 步驟 4:等儀表盤數(shù)據(jù),再獲取通知(200ms) const notifications = await fetchNotifications(user.id); // 總時(shí)間:300 + 400 + 600 + 200 = 1500ms(1.5 秒!) return { props: { user, profile, dashboardData, notifications } }; }
這種方法把本可以 4800ms 的頁面加載變成了 1.5 秒的加載。每個(gè) await 都在說 ——“暫停所有操作 —— 在這個(gè)完成之前我們不繼續(xù)。” 但這些請求真的需要一個(gè)接一個(gè)地進(jìn)行嗎?
解決辦法:使用 Promise.all ()
如果你的請求彼此不依賴,就沒有理由不能同時(shí)運(yùn)行。這就是 Promise.all () 的用武之地:
export async function getServerSideProps({ req }) { // 步驟 1:先獲取用戶(仍然是其他請求所需要的) const user = await fetchUser(req.session.userId); // 步驟 2:并行獲取其他所有數(shù)據(jù) const [profile, dashboardData, notifications] = await Promise.all([ fetchUserProfile(user.id), // 400ms fetchDashboardData(user.id), // 600ms fetchNotifications(user.id) // 200ms ]); // 總時(shí)間:300ms(用戶) + 600ms(最長的并行請求) = 900ms // 我們節(jié)省了 600ms! return { props: { user, profile, dashboardData, notifications } }; }
這個(gè)簡單的改變就減少了 600ms—— 而且這只是一次頁面加載。
額外解決辦法:全局?jǐn)?shù)據(jù)的并行獲取
你可以更進(jìn)一步。如果某些數(shù)據(jù)不依賴于用戶,比如系統(tǒng)范圍的設(shè)置或服務(wù)器狀態(tài),你可以在獲取用戶數(shù)據(jù)的同時(shí)獲取這些數(shù)據(jù):
export async function getServerSideProps({ req }) { // 并行獲取與用戶無關(guān)的數(shù)據(jù)和用戶數(shù)據(jù) const [user, globalSettings, systemStatus] = await Promise.all([ fetchUser(req.session.userId), fetchGlobalSettings(), // 不需要用戶數(shù)據(jù) fetchSystemStatus() // 不需要用戶數(shù)據(jù) ]); // 現(xiàn)在并行獲取依賴于用戶的數(shù)據(jù) const [profile, dashboardData, notifications] = await Promise.all([ fetchUserProfile(user.id), fetchDashboardData(user.id), fetchNotifications(user.id) ]); return { props: { user, profile, dashboardData, notifications, globalSettings, systemStatus } }; }
通過更智能的并行化,你可以減少加載時(shí)間,提高響應(yīng)速度 —— 不需要復(fù)雜的工具或庫。只需要更好地使用 JavaScript。
即使數(shù)據(jù)獲取更快,你的應(yīng)用可能仍然感覺不流暢。為什么?因?yàn)槟愕穆酚尚袨榭赡茉谶M(jìn)行不必要的完整服務(wù)器往返。讓我們接下來看看這個(gè)問題。
4. 路由觸發(fā)不必要的服務(wù)器往返
使用 Next.js App Router 時(shí),每次導(dǎo)航都可能觸發(fā)服務(wù)器端渲染 —— 即使是本可以(也應(yīng)該)在客戶端處理的路由。這不僅低效,而且肯定會讓快速的應(yīng)用感覺變慢。
假設(shè)你有一個(gè)用戶在瀏覽產(chǎn)品目錄。在傳統(tǒng)的 SPA 中,在產(chǎn)品頁面之間點(diǎn)擊應(yīng)該是即時(shí)的。JavaScript 處理狀態(tài)更新,URL 變化而無需重新加載頁面。
但如果 Next.js App Router 配置不當(dāng),每次點(diǎn)擊都可能往返服務(wù)器。情況如下:
// app/products/[id]/page.js // 每次訪問產(chǎn)品頁面時(shí),這都會在服務(wù)器上運(yùn)行 export default async function ProductPage({ params }) { // 每次導(dǎo)航都向服務(wù)器發(fā)起網(wǎng)絡(luò)請求 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> ); }
在這種設(shè)置下,每次點(diǎn)擊都要經(jīng)過同樣緩慢的流程:
- 點(diǎn)擊產(chǎn)品鏈接
- 瀏覽器向服務(wù)器發(fā)出請求
- 服務(wù)器獲取產(chǎn)品數(shù)據(jù)(數(shù)據(jù)庫調(diào)用)
- 服務(wù)器渲染 HTML
- 服務(wù)器將 HTML 發(fā)送到瀏覽器
- 瀏覽器顯示頁面
這有六個(gè)步驟,每個(gè)都可能有延遲。乘以十次產(chǎn)品瀏覽,就有十次服務(wù)器請求 —— 而不是十次即時(shí)的頁面轉(zhuǎn)換。
解決辦法:轉(zhuǎn)向客戶端路由
為避免這些往返,盡可能將路由轉(zhuǎn)移到客戶端。方法如下:
// 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(() => { // 在客戶端獲取數(shù)據(jù)——沒有服務(wù)器往返 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> ); }
這樣,產(chǎn)品頁面之間的導(dǎo)航會立即發(fā)生 —— 數(shù)據(jù)在后臺加載,無需在服務(wù)器上重新渲染整個(gè)頁面。
何時(shí)使用服務(wù)器端 vs 客戶端渲染
快速指南:
使用服務(wù)器端渲染(SSR)當(dāng):
- SEO 至關(guān)重要(產(chǎn)品頁面、博客文章)
- 你要顯示用戶特定的敏感數(shù)據(jù)
- 初始頁面加載速度比導(dǎo)航速度更重要
- 內(nèi)容不經(jīng)常變化
使用客戶端渲染(CSR)當(dāng):
- 用戶頻繁在相似頁面之間導(dǎo)航
- 你可以有效地緩存數(shù)據(jù)
- SEO 不是優(yōu)先事項(xiàng)(用戶儀表盤、管理面板)
- 你想要即時(shí)的、類應(yīng)用的導(dǎo)航
額外解決辦法:混合渲染
有時(shí),你需要 SSR 用于初始頁面加載,但希望后續(xù)交互受益于 CSR。Next.js App Router 允許你結(jié)合兩者:
// app/products/[id]/page.js // 為 SEO 進(jìn)行服務(wù)器渲染初始頁面 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); // 后續(xù)導(dǎo)航在客戶端進(jìn)行 const router = useRouter(); const navigateToProduct = async (newId) => { // 立即更新 URL(感覺是即時(shí)的) router.push(`/products/${newId}`); // 在后臺獲取新數(shù)據(jù) const newProduct = await fetch(`/api/products/${newId}`).then(res => res.json()); setProduct(newProduct); }; return ( <div> <ProductDetails product={product} /> <RelatedProducts onProductClick={navigateToProduct} /> </div> ); }
但即使有智能路由和優(yōu)化的數(shù)據(jù)獲取,如果應(yīng)用拖著過大的 JavaScript 包,仍然會感覺遲緩。讓我們談?wù)劄槭裁磿霈F(xiàn)這個(gè)問題 —— 以及如何解決。
5. JavaScript 包體積過大
我曾在一個(gè)黑客馬拉松期間加入一個(gè) Next.js 項(xiàng)目,當(dāng)時(shí)主 JavaScript 包的大小是 2.3 MB。之前的開發(fā)者為了使用幾個(gè)函數(shù)就導(dǎo)入了整個(gè)庫。沒有代碼分割。沒有動態(tài)導(dǎo)入。只是把一個(gè)巨大的負(fù)載丟給每個(gè)用戶。
JavaScript 包體積直接影響你的交互時(shí)間(TTI)—— 衡量頁面何時(shí)完全可用的指標(biāo)。包越大,用戶盯著加載 spinner 的時(shí)間就越長。
以下是經(jīng)常導(dǎo)致包膨脹的原因:
// 第一個(gè)包膨脹器:導(dǎo)入整個(gè)庫 import _ from 'lodash'; // 導(dǎo)入整個(gè) 70KB 的庫 import * as dateFns from 'date-fns'; // 另一個(gè)大型導(dǎo)入 // 第二個(gè)包膨脹器:在各處導(dǎo)入重型組件 import { DataVisualization } from './DataVisualization'; // 500KB 組件 import { VideoPlayer } from './VideoPlayer'; // 300KB 組件 import { RichTextEditor } from './RichTextEditor'; // 400KB 組件 export default function HomePage () { return ( <div> <h1>Welcome</h1> {/* 這些組件可能在初始加載時(shí)甚至不可見 */} <DataVisualization /> <VideoPlayer /> <RichTextEditor /> </div> ); }
這種方法會向每個(gè)用戶加載所有內(nèi)容 —— 即使他們從未與這些組件交互。幸運(yùn)的是,有更好的方法。
解決辦法:代碼分割和動態(tài)導(dǎo)入
Next.js 默認(rèn)支持智能代碼分割。但要充分利用它,你需要使用動態(tài)導(dǎo)入來僅在需要時(shí)加載代碼。
基于路由的代碼分割
默認(rèn)情況下,Next.js 按路由分割代碼。但你可以使用 next/dynamic 進(jìn)一步優(yōu)化:
// pages/dashboard.js - 僅當(dāng)用戶訪問 /dashboard 時(shí)加載 import dynamic from 'next/dynamic'; // 僅在需要時(shí)加載重型組件 const AnalyticsChart = dynamic(() => import('../components/AnalyticsChart'), { loading: () => <ChartSkeleton />, ssr: false // 對僅客戶端組件跳過服務(wù)器端渲染 }); 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> ); }
通過這種模式,用戶只在請求時(shí)才下載圖表或?qū)С鲞壿?,而不是之前?/p>
基于組件的代碼分割
如果你有在路由之間共享但僅在特定情況下需要的組件,也可以延遲加載它們:
// components/ConditionalFeatures.js import dynamic from 'next/dynamic'; // 僅當(dāng)用戶有高級訂閱時(shí)加載 const PremiumChart = dynamic(() => import('./PremiumChart'), { loading: () => <div>Loading premium features...</div> }); // 僅當(dāng)用戶點(diǎn)擊“高級設(shè)置”時(shí)加載 const AdvancedSettings = dynamic(() => import('./AdvancedSettings')); export function ConditionalFeatures({ user, showAdvanced }) { return ( <div> {user.isPremium && <PremiumChart />} {showAdvanced && <AdvancedSettings />} </div> ); }
這確保你的用戶不會為他們甚至無法訪問的功能付出性能代價(jià)。
額外解決辦法:使用 @next/bundle-analyzer 分析包
要查看是什么占用了你的包體積,使用官方的包分析器:
// next.config.js const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true' }); module.exports = withBundleAnalyzer({ // 你的 Next.js 配置 });
運(yùn)行 ANALYZE=true npm run build 查看 JavaScript 的可視化地圖 —— 每個(gè)過大的庫、每個(gè)龐大的組件。這就像性能問題的 X 光片。
通過動態(tài)導(dǎo)入、條件加載和包分析,你可以毫不費(fèi)力地將初始包縮小 50-70%。
6. React hydration(水合)也可能是問題所在
即使你小心處理 JavaScript 包,React 應(yīng)用中還有一個(gè)性能殺手 —— 水合。服務(wù)器向?yàn)g覽器發(fā)送 HTML 后,React 需要 “水合” 它,即附加事件監(jiān)聽器,并使虛擬 DOM 與服務(wù)器渲染的標(biāo)記協(xié)調(diào)。這個(gè)過程可能會阻塞交互性,影響性能。
問題是這樣的:
// 傳統(tǒng) Next.js 頁面,存在水合瓶頸 export default function ProductPage({ products }) { return ( <div> <Header /> {/* 必須先水合,用戶才能交互 */} <ProductGrid products={products} /> {/* 大型組件樹 */} <FilterSidebar /> {/* 復(fù)雜的交互組件 */} <Footer /> {/* 不需要 JS 的靜態(tài)內(nèi)容 */} {/* 所有內(nèi)容同時(shí)水合,阻塞交互性 */} </div> ); }
在水合期間,瀏覽器的主線程會被阻塞,而 React 處理整個(gè)組件樹。對于復(fù)雜頁面,這在低端設(shè)備上可能需要數(shù)百毫秒甚至幾秒,造成用戶能看到 UI 但無法交互的令人沮喪的延遲。
解決辦法:使用 React 服務(wù)器組件和部分水合
Next.js App Router 帶來了 React 服務(wù)器組件,從根本上改變了這種動態(tài),讓你可以選擇應(yīng)用的哪些部分需要客戶端 JavaScript:
// app/products/page.js - 服務(wù)器組件(不向客戶端發(fā)送 JS) import { ProductGrid } from './components/ProductGrid'; import { ClientSideFilter } from './components/ClientSideFilter'; // 這個(gè)組件在服務(wù)器上運(yùn)行,只發(fā)送 HTML export default async function ProductPage () { // 數(shù)據(jù)獲取在服務(wù)器上進(jìn)行 const products = await fetchProducts(); return ( <div> <h1>Products</h1> {/* 靜態(tài)部分僅作為 HTML 存在 */} <ProductGrid products={products} /> {/* 僅交互部分需要水合 */} <ClientSideFilter products={products} /> </div> ); } // components/ClientSideFilter.js 'use client'; // 標(biāo)記為需要水合 export function ClientSideFilter({ products }) { const [filters, setFilters] = useState({}); // 交互組件邏輯... }
這種方法帶來幾個(gè)主要性能優(yōu)勢:
- 默認(rèn)零 JavaScript—— 服務(wù)器組件只向?yàn)g覽器發(fā)送 HTML,除非用 'use client' 明確標(biāo)記
- 選擇性水合 —— 只有交互組件消耗客戶端 JavaScript
- 流式渲染 —— 頁面的各個(gè)部分可以獨(dú)立加載并變得可交互
- 減小包體積 —— 服務(wù)器組件的代碼永遠(yuǎn)不會發(fā)送到客戶端
實(shí)施智能水合技術(shù)是個(gè)好開始,但如果你的應(yīng)用不斷重新獲取相同的數(shù)據(jù),就像失憶了一樣,用戶仍然會感到延遲。讓我們談?wù)劸彺妗?/p>
7. 沒有在請求間有效緩存數(shù)據(jù)
緩存就像給你的應(yīng)用一個(gè)好記性。它防止應(yīng)用每次都要重新獲取信息。但我見過很多 Next.js 應(yīng)用把每個(gè)請求都當(dāng)作第一次處理 —— 特別是對于權(quán)限、用戶數(shù)據(jù)或博客文章等內(nèi)容。
糟糕的緩存不僅會減慢應(yīng)用速度 —— 還會浪費(fèi)服務(wù)器資源。最常見的緩存錯(cuò)誤往往很基礎(chǔ):
重復(fù)獲取相同數(shù)據(jù):
export default function UserProfile({ userId }) { const [user, setUser] = useState(null); // 每次組件掛載時(shí)運(yùn)行——沒有緩存! useEffect(() => { fetch(`/api/users/${userId}`).then(res => res.json()).then(setUser); }, [userId]); return user ? <div>{user.name}</div> : <div>Loading...</div>; }
每次頁面加載都拉取新數(shù)據(jù):
export async function getServerSideProps({ params }) { // 每次請求都訪問數(shù)據(jù)庫 const posts = await db.posts.findMany({ where: { published: true }, orderBy: { createdAt: 'desc' } }); return { props: { posts } }; }
這就是我所說的 “系統(tǒng)失憶癥”—— 應(yīng)用在用戶刷新或點(diǎn)擊離開時(shí)就忘記了所有學(xué)到的東西。
解決辦法:盡可能使用 SSG 和 SWR
有效的緩存在不同層面發(fā)揮作用:API 路由、頁面渲染,甚至數(shù)據(jù)庫查詢。讓我們看看如何讓它為你工作:
使用 ISR 進(jìn)行服務(wù)器端緩存
如果你的數(shù)據(jù)不是每秒都變化,就不要每秒都重新獲取。使用增量靜態(tài)再生(ISR)來提供預(yù)構(gòu)建的頁面,并偶爾刷新它們:
// pages/blog/[slug].js export async function getStaticProps({ params }) { const post = await fetchPost(params.slug); return { props: { post }, revalidate: 3600, // 最多每小時(shí)再生一次 }; } export async function getStaticPaths() { // 為熱門帖子生成路徑 const popularPosts = await fetchPopularPosts(); return { paths: popularPosts.map((post) => ({ params: { slug: post.slug } })), fallback: 'blocking' // 按需生成其他頁面 }; }
這能保持內(nèi)容新鮮和快速,同時(shí)最小化服務(wù)器負(fù)載。
額外解決辦法:在 API 上應(yīng)用智能緩存控制頭
對于消耗大的 API 操作,使用 unstable_cache 緩存服務(wù)器端邏輯:
// pages/api/posts.js import { unstable_cache } from 'next/cache'; const getCachedPosts = unstable_cache( async () => { // 消耗大的數(shù)據(jù)庫查詢 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); }
現(xiàn)在你的服務(wù)器不必為相同的查詢過度工作,用戶也能獲得更快的體驗(yàn)。
如果緩存得當(dāng),你的應(yīng)用會感覺像提前知道了用戶的下一步行動。但即使有完美的緩存,還有一個(gè)陷阱會拖慢一切 —— 未優(yōu)化的圖像。
8. 媒體資源拖慢應(yīng)用
我曾審計(jì)過一個(gè) Next.js 應(yīng)用,其中單個(gè)英雄圖像有 4.2MB—— 而且在每個(gè)頁面上都加載。為了讓你有概念,這比大多數(shù)完整應(yīng)用的整個(gè) JavaScript 包還要大。
問題不僅僅是文件大小。處理不當(dāng)?shù)膱D像會導(dǎo)致布局偏移、延遲頁面渲染、在解碼時(shí)阻塞主線程,并使最大內(nèi)容繪制(LCP)遠(yuǎn)遠(yuǎn)超出可接受范圍。這就像看電影時(shí)視頻不斷緩沖 —— 技術(shù)上能看,但體驗(yàn)很糟糕。
我經(jīng)??吹降腻e(cuò)誤是這樣的:
使用原始 標(biāo)簽:
export default function ProductCard({ product }) { return ( <div className="product-card"> {/* 沒有優(yōu)化,沒有懶加載,導(dǎo)致布局偏移 */} <img src={product.imageUrl} alt={product.name} /> <h3>{product.name}</h3> <p>${product.price}</p> </div> ); }
急切加載所有內(nèi)容:
export default function Gallery({ images }) { return ( <div className="gallery"> {images.map((image, index) => ( // 所有 50 張圖像同時(shí)加載,即使用戶只看到 6 張 <img key={index} src={image.url} alt={image.caption} /> ))} </div> ); }
這種策略向用戶交付了遠(yuǎn)超他們實(shí)際需要的內(nèi)容,破壞了性能和用戶體驗(yàn)。
解決辦法:使用 next/image 和響應(yīng)式尺寸
Next.js 提供了一個(gè)圖像組件,處理響應(yīng)式尺寸、懶加載和格式轉(zhuǎn)換(如 WebP/AVIF)。它更快、更易訪問,并節(jié)省大量帶寬。以下是有效使用它的方法:
基本優(yōu)化
// 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} // 立即加載精選產(chǎn)品 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、防止布局偏移,并幫助用戶更快開始交互。
響應(yīng)式英雄圖像
對于在不同屏幕上顯示不同尺寸的圖像:
// 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 屬性確保瀏覽器為每個(gè)屏幕尺寸選擇最佳版本,在小設(shè)備上節(jié)省帶寬。
帶懶加載的智能畫廊
對于圖像畫廊,實(shí)現(xiàn)漸進(jìn)式加載:
// 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} // 優(yōu)先加載前 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> ); }
這樣,用戶只下載他們看到的內(nèi)容 —— 提高性能并減少移動設(shè)備上的內(nèi)存使用。
總而言之 —— 如果你在 Next.js 項(xiàng)目中不使用 組件,你就錯(cuò)過了巨大的性能提升。優(yōu)化你的圖像,用戶會立即感受到差異。
為什么在移動設(shè)備上優(yōu)化更為重要
性能問題影響所有人 —— 但移動用戶受到的影響最嚴(yán)重。這就是為什么你的 Next.js 應(yīng)用需要特別友好的移動體驗(yàn):
- 網(wǎng)絡(luò)速度慢 —— 許多移動用戶仍然使用 3G 或不穩(wěn)定的 4G 網(wǎng)絡(luò)。在寬帶下 200ms 加載完的 500KB JavaScript 包,在移動網(wǎng)絡(luò)上可能需要超過 2 秒。
- CPU 較弱 —— 移動設(shè)備的處理能力遠(yuǎn)低于桌面。在你的 MacBook 上 300ms 運(yùn)行完的 JavaScript,在廉價(jià)安卓手機(jī)上可能需要 1.5 秒 —— 只是為了水合一個(gè)頁面。
- 內(nèi)存限制嚴(yán)格 —— 移動瀏覽器更容易崩潰和頻繁垃圾回收,特別是當(dāng)你的應(yīng)用依賴大的包或重型圖像時(shí)。
這對你的 Next.js 性能策略意味著:
- 最小化包體積 —— 這不是奢侈品,而是必需品
- 優(yōu)化圖像 —— 臃腫的英雄圖像不僅減慢頁面速度,還可能消耗用戶的實(shí)際流量
- 使用智能加載狀態(tài) —— 在較慢的網(wǎng)絡(luò)上,感知性能更重要
專業(yè)提示 —— 如果你的應(yīng)用在廉價(jià)手機(jī)上通過 3G 網(wǎng)絡(luò)運(yùn)行良好,那么在其他地方都會流暢運(yùn)行。
衡量關(guān)鍵指標(biāo):如何優(yōu)化性能
Next.js 中的性能優(yōu)化不是選擇一個(gè)解決方案 —— 而是識別問題所在,并有條不紊地解決。事實(shí)是,性能工作不是一個(gè)勾選框,而是開發(fā)速度和用戶體驗(yàn)之間持續(xù)的平衡。
每個(gè)新功能、每個(gè)額外的依賴項(xiàng)、每個(gè)在截止日期壓力下走的捷徑,都可能慢慢侵蝕你已經(jīng)取得的進(jìn)展。最好的方法是不要把性能當(dāng)作事后才考慮的事情。從一開始就考慮它。
不要盲目優(yōu)化。使用以下工具:
- Next.js 內(nèi)置分析,用于核心網(wǎng)絡(luò)指標(biāo)
- Lighthouse CI,用于 CI/CD 管道中的自動化性能測試
- 真實(shí)用戶監(jiān)控(RUM),了解實(shí)際用戶體驗(yàn)
- 包分析器,及早發(fā)現(xiàn)依賴膨脹
我們在本指南中涵蓋的大部分內(nèi)容 —— 緩存、圖像、代碼分割和 SSR 策略 —— 可以解決大約 80% 的 Next.js 性能問題。剩下的 20% 通常涉及更復(fù)雜的優(yōu)化,如邊緣渲染、CDN 策略、查詢優(yōu)化,有時(shí)甚至是全面的架構(gòu)調(diào)整。
但不要從邊緣情況開始。先關(guān)注大的改進(jìn)點(diǎn)。
結(jié)論
性能的棘手之處在于:應(yīng)用實(shí)際有多快和用戶感覺它有多快之間存在差距。你的應(yīng)用可能在技術(shù)上 2 秒內(nèi)加載完成 —— 但如果用戶在 1.8 秒內(nèi)都盯著空白屏幕,那感覺會非常慢。感知和指標(biāo)同樣重要。
記住這一點(diǎn)。如果感覺快,那就是快 —— 至少對用戶來說是這樣。
所以要牢記這一點(diǎn)進(jìn)行開發(fā)。添加加載狀態(tài),顯示占位符,給用戶視覺反饋。這樣,當(dāng)有人訪問你的應(yīng)用時(shí),他們不僅會看到速度 —— 還會感受到速度。
LogRocket:全面監(jiān)控生產(chǎn)環(huán)境中的 Next.js 應(yīng)用
調(diào)試 Next 應(yīng)用可能很困難,特別是當(dāng)用戶遇到難以重現(xiàn)的問題時(shí)。如果你有興趣監(jiān)控和跟蹤狀態(tài)、自動顯示 JavaScript 錯(cuò)誤、跟蹤緩慢的網(wǎng)絡(luò)請求和組件加載時(shí)間,可以試試 LogRocket。
以上就是Next.js應(yīng)用變慢的8個(gè)原因及解決辦法的詳細(xì)內(nèi)容,更多關(guān)于Next.js應(yīng)用變慢的原因及解決的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
.net MVC+Bootstrap下使用localResizeIMG上傳圖片
這篇文章主要為大家詳細(xì)介紹了.net MVC和Bootstrap下使用 localResizeIMG上傳圖片,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-04-04jQuery實(shí)現(xiàn)仿百度首頁滑動伸縮展開的添加服務(wù)效果代碼
這篇文章主要介紹了jQuery實(shí)現(xiàn)仿百度首頁滑動伸縮展開的添加服務(wù)效果代碼,通過jQuery相應(yīng)鼠標(biāo)事件控制頁面元素的動態(tài)變換功能,非常具有實(shí)用價(jià)值,需要的朋友可以參考下2015-09-09ES6如何用一句代碼實(shí)現(xiàn)函數(shù)的柯里化
這篇文章主要介紹了ES6如何用一句代碼實(shí)現(xiàn)函數(shù)的柯里化,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-01-01JS面向?qū)ο缶幊袒A(chǔ)篇(一) 對象和構(gòu)造函數(shù)實(shí)例詳解
這篇文章主要介紹了JS面向?qū)ο缶幊虒ο蠛蜆?gòu)造函數(shù),結(jié)合實(shí)例形式詳細(xì)分析了JS面向?qū)ο缶幊虒ο蠛蜆?gòu)造函數(shù)具體概念、原理、使用方法及操作注意事項(xiàng),需要的朋友可以參考下2020-03-03基于JavaScript實(shí)現(xiàn)熔巖燈效果導(dǎo)航菜單
這篇文章主要介紹了基于JavaScript實(shí)現(xiàn)熔巖燈效果導(dǎo)航菜單,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-01-01給所有的超級練級都加上onmousemove時(shí)間的js代碼
給所有的超級練級都加上onmousemove時(shí)間的js代碼...2007-08-08JS實(shí)現(xiàn)點(diǎn)擊鏈接取消跳轉(zhuǎn)效果的方法
有時(shí)候我們僅僅希望將鏈接<a>作為一個(gè)按鈕使用,但是在默認(rèn)狀態(tài)下,點(diǎn)擊鏈接會出現(xiàn)跳轉(zhuǎn)效果,下面就通過代碼實(shí)例,介紹一下如何實(shí)現(xiàn)此效果2014-01-01