•16 min read

Mẹo tối ưu hiệu suất Next.js: Từ tốt đến hoàn hảo trên Lighthouse

Mẹo tối ưu hiệu suất Next.js: Từ tốt đến hoàn hảo trên Lighthouse
Next.js Performance

Hiệu suất không phải là một danh sách kiểm tra—đó là một thực hành liên tục. Một trang web nhanh cải thiện SEO, giảm tỷ lệ thoát và trực tiếp tăng chuyển đổi. Next.js loại bỏ hầu hết các trở ngại, nhưng bạn vẫn phải hiểu tại sao mỗi tính năng lại quan trọng và khi nào nên sử dụng nó.


Audio Briefing
0:00 / 0:00

Hiểu các chỉ số quan trọng

Trước khi tối ưu hóa, hãy biết bạn đang đo lường điều gì. Core Web Vitals là tiêu chuẩn ngành mà Google sử dụng trong xếp hạng:

Chỉ sốNó đo lường điều gìNgưỡng tốtTác động
LCPLargest Contentful Paint — khi nội dung chính xuất hiện< 2.5sTốc độ tải cảm nhận được
INPInteraction to Next Paint — khả năng phản hồi< 200msKhả năng tương tác của người dùng
CLSCumulative Layout Shift — độ ổn định hình ảnh< 0.1Giật hình / độ tin cậy
FIDFirst Input Delay — độ trễ nhấp/nhấn phím đầu tiên< 100msCảm giác tương tác đầu tiên
Lưu ý về INP

FID đã được thay thế bằng INP vào tháng 3 năm 2024. INP đo lường khả năng phản hồi trên toàn bộ vòng đời trang, không chỉ đầu vào đầu tiên. Đó là một mục tiêu lớn hơn—và việc tối ưu hóa có ý nghĩa giúp ích cho cả hai.


Advertisement

Bộ công cụ tối ưu hóa

Mô hình tư duy

Mọi kỹ thuật hiệu suất trong Next.js đều hướng tới một trong hai mục tiêu: hiển thị các pixel đầu tiên trên màn hình nhanh hơn hoặc gửi ít JavaScript hơn đến client. Hãy ghi nhớ điều đó và "tại sao" sẽ trở nên rõ ràng.

1. Tối ưu hóa hình ảnh — Thành công số 1 cho LCP

Sử dụng thành phần <Image> từ next/image, không phải thẻ <img> thô. Nó tự động thực hiện ba việc:

  • Chuyển đổi WebP/AVIF tự động — tệp nhỏ hơn mà không giảm chất lượng
  • Kiểm soát kích thước/chất lượng — phục vụ hình ảnh 300px trên di động, 1200px trên máy tính để bàn
  • Ngăn chặn dịch chuyển bố cục — yêu cầu chiều rộng/chiều cao để trình duyệt dành không gian phù hợp
import Image from 'next/image';

export function Hero() {
  return (
    <Image
      src="/hero.jpg"
      alt="Dashboard overview"
      width={1200}
      height={675}
      priority
      className="rounded-xl"
    />
  );
}

2. Tối ưu hóa phông chữ — Loại bỏ FOIT

Sử dụng mô-đun next/font. Nó tải xuống phông chữ tại thời điểm build và phục vụ chúng cục bộ—không có yêu cầu bên thứ ba, không có dịch chuyển bố cục.

import { Inter, Roboto_Mono } from 'next/font/google';

const inter = Inter({
subsets: ['latin'],
display: 'swap',     // ✅ Shows fallback text instantly
variable: '--font-inter',
});

const robotoMono = Roboto_Mono({
subsets: ['latin'],
weight: ['400', '700'],
});
Những gì bạn nhận được miễn phí
  • Tệp phông chữ tự lưu trữ — không có yêu cầu bên ngoài tới Google
  • font-display: swap tự động — văn bản hiển thị trong khi phông chữ tải
  • Tập hợp con theo subsets — bỏ các ký tự không sử dụng (ví dụ: bỏ cyrillic nếu không sử dụng)
  • Biến CSS để tích hợp Tailwind dễ dàng

3. Nhập động — Chia nhỏ mã nặng

Không tải các thành phần không hiển thị trên lần hiển thị ban đầu. Sử dụng next/dynamic để chia mã ở cấp thành phần:

// Heavy chart library: ~200KB gzipped
import dynamic from 'next/dynamic';

// Load it only when needed (client-side, no SSR)
const RevenueChart = dynamic(
() => import('@/components/RevenueChart'),
{
ssr: false,           // ✅ Skip server rendering for this component
loading: () => <Skeleton />, // ✅ Show placeholder instantly
}
);

export function Dashboard() {
return <RevenueChart />;
}
Khi nào nên bỏ qua SSR (`ssr: false`)
  • Biểu đồ/đồ thị dựa vào window hoặc API trình duyệt
  • Thư viện hoạt hình nặng (GSAP, Framer Motion)
  • Tiện ích của bên thứ ba (Google Maps, Stripe Elements)
  • Chuyển đổi chế độ tối — đợi đã, cái này đủ rẻ để render trên máy chủ

Nếu không có import động, các bundler có thể nhóm mọi thứ vào một tệp JS duy nhất. Với import động, thành phần sẽ tải trong một chunk riêng biệt chỉ được tìm nạp khi cần. Đây là công cụ giảm kích thước bundle lớn nhất cho các ứng dụng vừa và lớn.

Sử dụng bộ phân tích bundle (bước 6) để tìm các thành phần nặng nhất của bạn. Thông thường: trình chỉnh sửa văn bản phong phú, biểu đồ, trình xem 3D, tiện ích xác thực. Đừng chia nhỏ quá mức—import động thêm một vòng mạng. Chia nhỏ các thành phần ngăn chặn việc tải xuống JS ban đầu có ý nghĩa.

4. Server Components — Mặc định không gửi JS

Trong App Router, tất cả các thành phần đều là Server Components theo mặc định. Đây là đòn bẩy hiệu suất lớn nhất của bạn—bạn nhận được kết quả render mà không gửi JS của thành phần đó đến trình duyệt.

// This entire file runs on the server
// → Zero JavaScript sent to the browser for this component
async function BlogPost({ slug }: { slug: string }) {
const post = await db.posts.findUnique({ where: { slug } });

return (
<article>
  <h1>{post.title}</h1>
  <MDXContent source={post.content} />
</article>
);
}

Bộ nhớ đệm và luồng dữ liệu

Các lớp bộ nhớ đệm của Next.js

Next.js có nhiều cơ chế bộ nhớ đệm. Hiểu sự khác biệt giúp ngăn chặn 90% sự nhầm lẫn "tại sao dữ liệu của tôi lại cũ?".

Ghi nhớ yêu cầu

Khi bạn gọi await fetch(...) bên trong một Server Component, Next.js sẽ loại bỏ các yêu cầu giống hệt nhau trong một lần render. Một yêu cầu giống hệt thứ hai trong cùng một lần render sẽ trả về kết quả được lưu trong bộ nhớ đệm mà không cần truy cập mạng lại.

Bộ nhớ đệm dữ liệu

Next.js mở rộng fetch với bộ nhớ đệm dữ liệu tích hợp. Kết quả được duy trì (theo mặc định, vĩnh viễn). Các lần render tiếp theo trên các yêu cầu sẽ đọc từ bộ nhớ đệm.

// Force revalidation every hour
fetch('https://api.example.com/data', {
next: { revalidate: 3600 } // seconds
});

// No revalidation = cache until you deploy
fetch('https://api.example.com/data');

// Always fresh (no caching)
fetch('https://api.example.com/data', {
cache: 'no-store'
});

Bộ nhớ đệm toàn bộ tuyến đường

Các trang tĩnh được render tại thời điểm build và được lưu vào bộ nhớ đệm dưới dạng HTML. Một trang tĩnh được phục vụ ngay lập tức—không có tính toán máy chủ tại thời điểm yêu cầu.

Bộ nhớ đệm Router (phía Client)

Bộ nhớ đệm router phía client lưu trữ các trang đã render trong bộ nhớ (và localStorage để điều hướng tiến/lùi). Điều hướng trở lại cảm thấy tức thì vì trang tải từ bộ nhớ đệm.

Server Actions làm mờ ranh giới giữa client và server. Gọi chúng từ Client Components, nhưng chúng thực thi trên server với quyền truy cập cơ sở dữ liệu đầy đủ.

'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';

export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
await db.posts.create({ data: { title } });
revalidatePath('/posts'); // Invalidate the posts cache
}
Tại sao lại là Server Actions?

Trước Server Actions, "tạo bài đăng" yêu cầu xây dựng một tuyến API đầy đủ, xử lý xác thực trong một lớp riêng biệt và xác thực lại trên client bằng logic thẻ bộ nhớ đệm phức tạp. Server Actions cho phép bạn viết mã phía máy chủ nội tuyến — không có mã API rườm rà, không có quản lý trạng thái phía client cho chính thao tác đó.

Thay vì chờ tất cả dữ liệu tải, hãy truyền từng phần HTML khi chúng sẵn sàng:

// Slow data component — this is the bottleneck
async function SlowChart() {
const data = await fetch('/api/slow-data').then(r => r.json());
return <Results data={data} />;
}

// Skeleton shows immediately, content streams in
export default async function DashboardPage() {
return (
<div>
  <header>Dashboard</header>          {/* ✅ Instant */}
  <RecentActivity />                   {/* ✅ Fast */}
  <Suspense fallback={<ChartSkeleton />}>  {/* Shows skeleton */}
    <SlowChart />                      {/* Streams when ready */}
  </Suspense>
</div>
);
}
Cách SSR + Streaming hoạt động cùng nhau

Server Components vẫn chạy trên máy chủ. Sự khác biệt với Suspense là phản hồi truyền các chunk khi chúng sẵn sàng. Vỏ gửi ngay lập tức, dữ liệu chậm gửi sau. Người dùng thấy nội dung dần dần thay vì chờ một phản hồi HTML lớn.


Phân tích gói nâng cao

Biết bạn đang gửi gì

Một import không được tối ưu hóa có thể thêm hơn 200KB vào gói client của bạn. Bộ phân tích gói làm cho những thứ vô hình trở nên hữu hình.

Cài đặt @next/bundle-analyzer

Thêm nó vào dự án của bạn. Nó tạo ra một treemap tương tác của mọi chunk JS:

npm install -D @next/bundle-analyzer

Thiết lập next.config.js

const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({});

Chạy bộ phân tích

ANALYZE=true npm run build

Mở URL mà nó in ra (thường là http://localhost:3000/_next/analyze/...). Bạn sẽ thấy một treemap trong đó các hộp lớn hơn = các gói lớn hơn. Di chuột để xem cái gì đang chiếm không gian.

Các nạn nhân phổ biến

Mẫu chốngVấn đềCách tiếp cận tốt hơn
import { format } from 'date-fns'Kéo toàn bộ thư viện (~100+ hàm)Sử dụng tree-shaking của date-fns + import cụ thể, hoặc dayjs
import { debounce } from 'lodash'Toàn bộ lodash (~500KB)Sử dụng lodash-es hoặc viết một debounce 3 dòng
import * as Icons from 'lucide-react'Kéo mọi biểu tượng nhưng chỉ sử dụng 1Sử dụng import có tên: import { ChevronRight } from 'lucide-react'
Các thư viện biểu đồ nặng ở cấp cao nhấtKhông bao giờ được sử dụng phía trên màn hìnhImport động như trong bước 3

Advertisement

Nhưng chờ đã, còn nhiều hơn thế

<Link> từ next/link tự động tìm nạp trước các trang được liên kết trong khung nhìn (khi người dùng có kết nối nhanh):

import Link from 'next/link';

<Link href="/en/blog/nextjs-performance">  {/* ✅ Prefetched when visible */}
Read more
</Link>

<Link href="/heavy-page" prefetch={false}>  {/* ⬇️ Skip prefetch */}
Heavy page (saves bandwidth on mobile)
</Link>

Tìm nạp dữ liệu song song giúp giảm đáng kể các thác nước:

// ❌ Sequential: 200ms + 300ms = 500ms total
async function SlowPage() {
const user  = await fetch('/api/user').then(r => r.json());   // 200ms
const posts = await fetch('/api/posts').then(r => r.json()); // 300ms
return <Dashboard user={user} posts={posts} />;
}

// ✅ Parallel: max(200ms, 300ms) = 300ms total
async function FastPage() {
const [user, posts] = await Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
]);
return <Dashboard user={user} posts={posts} />;
}

Kiểm soát hành vi render trên mỗi trang với cấu hình phân đoạn tuyến đường:

export const dynamic = 'force-static';  // Always static, even in dev
export const revalidate = 3600;           // Revalidate every hour
export const fetchCache = 'force-no-store'; // Never use data cache

// For one-off routes without creating a separate layout:
export const metadata = {
title: 'My Page',
robots: { index: false },  // Prevent indexing this page
};

Danh sách kiểm tra tối ưu hóa

Kiểm traTrạng tháiTác động
Sử dụng <Image> không phải <img> ở mọi nơipublishedCao — LCP + CLS
Phông chữ qua next/font, không có stylesheet bên ngoàipublishedCao — FCP + CLS
Không có thư viện nặng không được chia nhỏ trong gói đầu vàodraftCao — TTI + TBT
Server Components > Client ComponentsdraftCao — Tổng JS
Tìm nạp dữ liệu song song với Promise.alldraftTrung bình — Thời gian tải lần đầu
Tìm nạp trước liên kết được điều chỉnh cho thiết bị di độngdraftTrung bình — Tốc độ điều hướng

Đo lường kết quả của bạn

Đừng đoán. Hãy đo lường. Chạy các thử nghiệm này trong môi trường sản xuất và theo dõi điểm số theo thời gian:

Lighthouse CI

Kiểm tra hiệu suất tự động trong CI

Đặt ngân sách hiệu suất. CI sẽ thất bại nếu một PR làm giảm LCP dưới ngưỡng của bạn. Điều này rất quan trọng để ngăn chặn sự suy thoái. lighthouse https://yoursite.com --budget-path=budget.json

Vercel Analytics

Tích hợp vào triển khai của bạn

Core Web Vitals thực tế từ người dùng thực. Hiển thị phân phối thực tế: p75, p90 LCP — trung thực hơn các chỉ số phòng thí nghiệm.

SpeedCurve

Giám sát hiệu suất liên tục

Theo dõi các lượt xem filmstrip + chỉ số theo thời gian. Tuyệt vời để tương quan các triển khai với các thay đổi hiệu suất.

PageSpeed Insights

Công cụ chính thức miễn phí của Google

Dữ liệu phòng thí nghiệm + thực địa nhanh chóng. Tốt cho các kiểm toán ban đầu và chụp ảnh màn hình "trước" cho PR của bạn.


Tóm tắt

4 điều hàng đầu cần làm ngay hôm nay
  1. Thay thế mọi <img> bằng <Image> — kiểm tra xem có thiếu chiều rộng/chiều cao không
  2. Chuyển việc tải phông chữ sang next/font
  3. Chạy ANALYZE=true npm run build và tìm kiếm các chunk lớn nhất
  4. Tìm một ứng cử viên động trong codebase của bạn và chuyển đổi sang next/dynamic

Bốn hành động này thường xuyên giảm 30-50% kích thước gói và đưa LCP từ 6 giây xuống dưới 2 giây.

Đọc thêm: Để đảm bảo chất lượng mã và hiệu suất trong các monorepo lớn, hãy xem Hiệu suất kiểm thử đơn vị Vitest Monorepo.

Bạn cũng có thể thích

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement