TIÊU ĐỀ: Next.js 15 Partial Prerendering (PPR): Hybrid Streaming, Vòng đời Cache & Kiến trúc Suspense

Mục lục bài viết(15 mục)
Next.js 15 giới thiệu Partial Prerendering (PPR) như một sự thay đổi kiến trúc nền tảng, vượt ra ngoài sự phân đôi truyền thống giữa Static Site Generation (SSG) và Server-Side Rendering (SSR). PPR tận dụng Suspense và khả năng streaming của React để mang lại một phương pháp kết hợp: một lớp vỏ tĩnh nhanh, được phục vụ ngay lập tức, với các "lỗ hổng" động được truyền tải vào vị trí khi dữ liệu có sẵn. Hướng dẫn này sẽ phân tích cơ chế của PPR, tác động của nó đến việc lưu trữ cache và các chiến lược triển khai thực tế.
Lộ trình Kiến trúc Next.js 15 & React 19
Hiểu về Partial Prerendering (PPR)
PPR về cơ bản định nghĩa lại cách các trang Next.js được render và phục vụ. Thay vì chờ tất cả dữ liệu được giải quyết trước khi gửi bất kỳ HTML nào (SSR) hoặc tiền render toàn bộ trang tại thời điểm build (SSG), PPR xác định các phân đoạn tĩnh và động của một trang.
Trong quá trình build, Next.js xác định các phần tĩnh của ứng dụng của bạn, thường là các component không phụ thuộc vào dữ liệu động hoặc ngữ cảnh cụ thể của người dùng. Các phần tĩnh này tạo thành "lớp vỏ tĩnh". Các phần động, thường được bao bọc trong các ranh giới Suspense, được coi là "lỗ hổng".
Khi một yêu cầu đến:
- Lớp vỏ tĩnh được phục vụ ngay lập tức từ bộ nhớ cache (tương tự như SSG). Điều này cung cấp Thời gian đến Byte đầu tiên (TTFB) và First Contentful Paint (FCP) gần như tức thì.
- Đồng thời, máy chủ tìm nạp dữ liệu cho các lỗ hổng động.
- Khi dữ liệu động được giải quyết, Next.js truyền tải HTML tương ứng đến client, thay thế phần dự phòng
Suspense.
Phương pháp streaming kết hợp này đảm bảo tải ban đầu nhanh chóng trong khi vẫn duy trì sự tươi mới của nội dung động.
Bật PPR
PPR là một tính năng thử nghiệm trong Next.js 15. Để bật nó, hãy cấu hình next.config.mjs của bạn:
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: true,
// Other experimental flags if needed
},
// ... other Next.js configurations
};
export default nextConfig;
Với ppr: true, Next.js sẽ tự động cố gắng áp dụng các tối ưu hóa PPR cho các trang chứa các ranh giới Suspense.
Vai trò của Suspense
React Suspense là nền tảng của PPR. Nó cho phép bạn khai báo rõ ràng các trạng thái tải cho các phần UI của bạn phụ thuộc vào dữ liệu không đồng bộ. Trong ngữ cảnh PPR, các ranh giới Suspense phân định các "lỗ hổng" động sẽ được truyền tải.
Hãy xem xét một trang chi tiết sản phẩm:
// app/products/[slug]/page.tsx
import { Suspense } from 'react';
import { ProductDetails } from './ProductDetails';
import { RelatedProducts } from './RelatedProducts';
import { Reviews } from './Reviews';
import { Skeleton } from '@/components/ui/skeleton'; // A simple skeleton component
export default async function ProductPage({ params }: { params: { slug: string } }) {
// Static shell content
const staticProductInfo = await getStaticProductInfo(params.slug); // Example: product name, description
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-4">{staticProductInfo.name}</h1>
<p className="text-lg mb-6">{staticProductInfo.description}</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Dynamic hole 1: Product Details (e.g., price, stock, images) */}
<Suspense fallback={<ProductDetailsSkeleton />}>
<ProductDetails slug={params.slug} />
</Suspense>
{/* Dynamic hole 2: Related Products */}
<Suspense fallback={<RelatedProductsSkeleton />}>
<RelatedProducts slug={params.slug} />
</Suspense>
{/* Dynamic hole 3: User Reviews */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews slug={params.slug} />
</Suspense>
</div>
</div>
);
}
// Example data fetching functions (un-memoized for PPR)
async function getStaticProductInfo(slug: string) {
// Simulate fetching static product data
await new Promise(resolve => setTimeout(resolve, 50));
return { name: `Product ${slug.toUpperCase()}`, description: `A detailed description for product ${slug}.` };
}
// Component for dynamic product details
async function ProductDetails({ slug }: { slug: string }) {
// Simulate fetching dynamic product details (e.g., real-time price, stock)
await new Promise(resolve => setTimeout(resolve, 1000));
return (
<div className="col-span-2 bg-gray-100 p-4 rounded-lg">
<h2 className="text-2xl font-semibold mb-2">Details</h2>
<p>Price: $99.99</p>
<p>Stock: In Stock</p>
{/* More dynamic details */}
</div>
);
}
// Component for related products
async function RelatedProducts({ slug }: { slug: string }) {
await new Promise(resolve => setTimeout(resolve, 1500));
return (
<div className="bg-gray-100 p-4 rounded-lg">
<h2 className="text-2xl font-semibold mb-2">Related Products</h2>
<ul>
<li>Related Item A</li>
<li>Related Item B</li>
</ul>
</div>
);
}
// Component for reviews
async function Reviews({ slug }: { slug: string }) {
await new Promise(resolve => setTimeout(resolve, 2000));
return (
<div className="col-span-3 bg-gray-100 p-4 rounded-lg">
<h2 className="text-2xl font-semibold mb-2">Customer Reviews</h2>
<p>No reviews yet.</p>
</div>
);
}
// Skeleton components for fallbacks
function ProductDetailsSkeleton() {
return (
<div className="col-span-2 bg-gray-100 p-4 rounded-lg animate-pulse">
<Skeleton className="h-6 w-3/4 mb-2" />
<Skeleton className="h-4 w-1/2 mb-1" />
<Skeleton className="h-4 w-1/3" />
</div>
);
}
function RelatedProductsSkeleton() {
return (
<div className="bg-gray-100 p-4 rounded-lg animate-pulse">
<Skeleton className="h-6 w-3/4 mb-2" />
<Skeleton className="h-4 w-full mb-1" />
<Skeleton className="h-4 w-full" />
</div>
);
}
function ReviewsSkeleton() {
return (
<div className="col-span-3 bg-gray-100 p-4 rounded-lg animate-pulse">
<Skeleton className="h-6 w-3/4 mb-2" />
<Skeleton className="h-4 w-full" />
</div>
);
}
Trong ví dụ này:
- Các thẻ
h1vàpchostaticProductInfolà một phần của lớp vỏ tĩnh. ProductDetails,RelatedProductsvàReviewsđược bao bọc trongSuspense, biến chúng thành các lỗ hổng động. Các componentSkeletontương ứng của chúng đóng vai trò là các phần dự phòng.
Khi người dùng yêu cầu /products/widget, họ ngay lập tức nhận được HTML cho tên và mô tả sản phẩm. Trình duyệt sau đó dần dần nhận và render các chi tiết, sản phẩm liên quan và đánh giá khi dữ liệu của chúng có sẵn.
Hybrid Streaming: Phản hồi HTTP
Sự kỳ diệu của PPR nằm ở phản hồi streaming HTTP của nó. Thay vì một tài liệu HTML đơn lẻ, nguyên khối, máy chủ gửi một phản hồi đa phần.
- HTML ban đầu (Lớp vỏ tĩnh): Phần đầu tiên của phản hồi chứa HTML tĩnh, bao gồm các phần dự phòng
Suspense. Phần này được gửi với tiêu đềContent-Type: text/html. - Streaming các đoạn HTML: Các phần tiếp theo của phản hồi được truyền tải dưới dạng các thẻ
<template>chứa nội dung động đã được giải quyết. Các đoạn này đi kèm với các thẻ<script>hướng dẫn React hydrate và thay thế phần dự phòngSuspensetương ứng bằng nội dung mới.
Đây không chỉ là việc gửi HTML; đó là việc gửi các hướng dẫn có thể thực thi đến client để cải thiện trang một cách dần dần.
Hồ sơ vòng đời bộ nhớ cache và tìm nạp dữ liệu
PPR giới thiệu các hồ sơ cacheLife, cho phép kiểm soát chi tiết về thời gian nội dung động được coi là mới và khi nào nó nên được xác thực lại. Điều này rất quan trọng để cân bằng hiệu suất với sự tươi mới của dữ liệu.
Hồ sơ cacheLife
Next.js 15 giới thiệu một tùy chọn cacheLife mới cho các hàm tìm nạp dữ liệu, cho phép bạn chỉ định thời gian nội dung động trong một lỗ hổng PPR nên được lưu vào bộ nhớ cache. Điều này khác với bộ nhớ cache của lớp vỏ tĩnh, thường là bất biến sau khi build.
// lib/data.ts
import { unstable_cache as cache } from 'next/cache';
export const getProductDetails = cache(
async (slug: string) => {
console.log(`Fetching product details for ${slug}...`);
await new Promise(resolve => setTimeout(resolve, 1000));
return { price: 99.99, stock: Math.floor(Math.random() * 100) };
},
['product-details'], // Cache key
{
tags: ['product-details'], // Invalidation tags
// cacheLife: 60, // Cache for 60 seconds
// cacheLife: 'revalidate', // Revalidate on every request (default for dynamic)
cacheLife: 'static', // Cache indefinitely (like SSG)
}
);
export const getRelatedProducts = cache(
async (slug: string) => {
console.log(`Fetching related products for ${slug}...`);
await new Promise(resolve => setTimeout(resolve, 1500));
return ['Related A', 'Related B', 'Related C'];
},
['related-products'],
{
tags: ['related-products'],
cacheLife: 3600, // Cache for 1 hour
}
);
Tùy chọn cacheLife có thể nhận một số giá trị:
number(giây): Chỉ định xác thực lại dựa trên thời gian. Sau khoảng thời gian này, bộ nhớ cache được coi là cũ và sẽ được xác thực lại trong yêu cầu tiếp theo.'revalidate': Hành vi mặc định cho dữ liệu động. Dữ liệu được xác thực lại trong mỗi yêu cầu. Điều này tương đương vớifetch(..., { cache: 'no-store' })hoặcrevalidate = 0trong các tùy chọnrevalidatecấp trang.'static': Dữ liệu được lưu vào bộ nhớ cache vô thời hạn, tương tự như SSG. Điều này phù hợp với dữ liệu thay đổi rất ít hoặc thực sự là tĩnh.
Quan trọng: cacheLife áp dụng cho chính dữ liệu, không phải đoạn HTML. Đoạn HTML cho một lỗ hổng động được tạo sau khi dữ liệu được tìm nạp. Nếu cacheLife được đặt, hàm tìm nạp dữ liệu sẽ sử dụng dữ liệu được lưu trong bộ nhớ cache nếu có sẵn và mới.
Các mẫu tìm nạp dữ liệu không được ghi nhớ
Một cạm bẫy phổ biến với PPR là việc ghi nhớ quá mức các lần tìm nạp dữ liệu. Trong một component React truyền thống, bạn có thể sử dụng useMemo hoặc useCallback để ngăn chặn việc render lại hoặc tìm nạp lại không cần thiết. Tuy nhiên, với PPR, giai đoạn render phía máy chủ cho các lỗ hổng động được thiết kế để tìm nạp dữ liệu theo mỗi yêu cầu trừ khi được lưu vào bộ nhớ cache một cách rõ ràng.
Tránh các mẫu ngăn chặn các hàm tìm nạp dữ liệu thực thi trong các yêu cầu tiếp theo trong một ranh giới Suspense, trừ khi bạn cố ý sử dụng cache với hồ sơ cacheLife.
// app/products/[slug]/ProductDetails.tsx
import { getProductDetails } from '@/lib/data';
export async function ProductDetails({ slug }: { slug: string }) {
// This fetch will run on every request for the dynamic hole
// unless getProductDetails itself is wrapped in unstable_cache with a cacheLife.
const details = await getProductDetails(slug);
return (
<div className="col-span-2 bg-gray-100 p-4 rounded-lg">
<h2 className="text-2xl font-semibold mb-2">Details</h2>
<p>Price: ${details.price}</p>
<p>Stock: {details.stock}</p>
</div>
);
}
Nếu getProductDetails không được bao bọc trong unstable_cache, nó sẽ thực thi trong mỗi yêu cầu kích hoạt lỗ hổng động. Đây thường là hành vi mong muốn cho dữ liệu thực sự động. Nếu bạn muốn lưu nó vào bộ nhớ cache, hãy sử dụng unstable_cache với cacheLife thích hợp.
So sánh TTFB với SSR hoàn toàn động
Lợi ích hiệu suất chính của PPR là TTFB và FCP được cải thiện đáng kể so với SSR truyền thống, đặc biệt đối với các trang có các phụ thuộc dữ liệu chậm.
| Tính năng | SSR truyền thống | Partial Prerendering (PPR) |
|---|---|---|
| TTFB | Cao (chờ tất cả dữ liệu) | Thấp (lớp vỏ tĩnh được phục vụ ngay lập tức) |
| FCP | Cao (chờ tất cả dữ liệu) | Thấp (lớp vỏ tĩnh được render ngay lập tức) |
| LCP | Phụ thuộc vào dữ liệu chậm nhất | Phụ thuộc vào dữ liệu động quan trọng chậm nhất |
| Độ tươi mới của dữ liệu | Luôn mới (theo mỗi yêu cầu) | Có thể cấu hình theo từng lỗ hổng động (cacheLife) |
| Độ phức tạp | Mô hình tư duy đơn giản hơn | Yêu cầu quản lý Suspense và cacheLife cẩn thận |
| Lưu trữ cache | revalidate cấp trang hoặc no-store | Lớp vỏ tĩnh được lưu vào bộ nhớ cache vô thời hạn, các lỗ hổng động thông qua cacheLife |
| Streaming | Streaming HTML cơ bản (không có nội dung tiến bộ) | Streaming HTML nâng cao với nội dung tiến bộ |
| Thời gian build | Tối thiểu (không tiền render) | Cao hơn (xác định các phần tĩnh, tiền render lớp vỏ) |
Kịch bản so sánh: Hãy xem xét một trang có tiêu đề/chân trang tĩnh và ba phần động, mỗi phần mất 1 giây để tìm nạp dữ liệu.
- SSR truyền thống: TTFB sẽ là ~3 giây (tổng của tất cả các lần tìm nạp dữ liệu) + thời gian render máy chủ.
- PPR: TTFB sẽ là ~50ms (lớp vỏ tĩnh) + thời gian render máy chủ. Các phần động sẽ được truyền tải trong 3 giây tiếp theo.
Phản hồi tức thì này cho người dùng, ngay cả với các phần giữ chỗ, cải thiện đáng kể hiệu suất cảm nhận và trải nghiệm người dùng.
Những vấn đề và cách khắc phục trong sản xuất
-
Không khớp Hydration với các phần dự phòng
Suspense:- Vấn đề: Bạn thấy
Warning: Prop 'className' did not match. Server: "..." Client: "..."hoặc các lỗi hydration tương tự khi nội dung động được truyền tải. Điều này thường xảy ra nếu phần dự phòngSuspensecủa bạn render HTML khác với lần render phía client ban đầu trước khi nội dung động đến. - Cách khắc phục: Đảm bảo phần dự phòng
Suspensecủa bạn (ví dụ: một bộ tải khung xương) render HTML giống hệt nhau trên cả máy chủ và client cho đến khi nội dung động sẵn sàng. Tránh logic chỉ phía client hoặc ID ngẫu nhiên trong các phần dự phòng. Nếu sử dụng một thư viện nhưreact-loading-skeleton, hãy đảm bảo nó được cấu hình nhất quán.
- Vấn đề: Bạn thấy
-
Tải ban đầu chậm mặc dù có PPR:
- Vấn đề: TTFB của bạn vẫn cao, ngay cả với
ppr: true. - Cách khắc phục:
- Kiểm tra các ranh giới
Suspense: Các phần thực sự động của bạn có được bao bọc trongSuspensekhông? Nếu gốc của trang hoặc các phần lớn không bị tạm dừng, Next.js vẫn có thể chờ tất cả dữ liệu. awaitcấp gốc: Nếupage.tsxcủa bạn trực tiếpawaitmột lần tìm nạp dữ liệu chậm bên ngoài một ranh giớiSuspense, nó sẽ chặn lớp vỏ tĩnh. Di chuyển các lần tìm nạp đó vào các component được bao bọc bởiSuspense.cacheLife: 'revalidate'trên nội dung tĩnh: Vô tình đặtcacheLife: 'revalidate'hoặcrevalidate = 0trên dữ liệu có thể là tĩnh sẽ ngăn lớp vỏ tĩnh được phục vụ từ bộ nhớ cache. Đảm bảo các lần tìm nạp dữ liệu tĩnh sử dụngcacheLife: 'static'hoặc không được bao bọc trongunstable_cachenếu chúng thực sự tĩnh và là một phần của bản build.
- Kiểm tra các ranh giới
- Vấn đề: TTFB của bạn vẫn cao, ngay cả với
-
Sử dụng
cacheLifekhông chính xác dẫn đến dữ liệu cũ:- Vấn đề: Người dùng đang thấy dữ liệu cũ trong các phần động.
- Cách khắc phục: Xem lại cấu hình
unstable_cachecủa bạn. NếucacheLifeđược đặt quá cao (ví dụ:3600giây cho giá cổ phiếu thay đổi nhanh chóng), dữ liệu sẽ được phục vụ từ bộ nhớ cache. Điều chỉnhcacheLifeđể phản ánh các yêu cầu về độ tươi mới thực tế. Đối với dữ liệu thời gian thực, hãy sử dụngcacheLife: 'revalidate'hoặc bỏ quaunstable_cachehoàn toàn. Hãy nhớrevalidateTaghoặcrevalidatePathcó thể được sử dụng để vô hiệu hóa theo yêu cầu.
-
Tải máy chủ quá mức từ
cacheLife: 'revalidate':- Vấn đề: Các dịch vụ backend của bạn bị quá tải vì mỗi yêu cầu đến một trang PPR đều kích hoạt việc tìm nạp lại cho các lỗ hổng động.
- Cách khắc phục: Đây là hành vi dự kiến cho
cacheLife: 'revalidate'. Nếu dữ liệu không cần phải hoàn toàn thời gian thực trong mỗi yêu cầu, hãy cân nhắc giới thiệu mộtcacheLifenhỏ (ví dụ:5hoặc10giây) để giảm tải backend trong khi vẫn duy trì độ tươi mới hợp lý. Triển khai một lớp lưu trữ cache mạnh mẽ trong các dịch vụ backend của bạn.
-
PPR không hoạt động trong quá trình phát triển:
- Vấn đề: Các lợi ích của PPR (streaming, TTFB nhanh) không rõ ràng trong
next dev. - Cách khắc phục: Các khả năng đầy đủ của PPR, đặc biệt là lưu trữ cache lớp vỏ tĩnh và streaming, chủ yếu được tối ưu hóa cho các bản build sản xuất (
next buildvànext start). Mặc dùSuspensehoạt động trong quá trình phát triển, các tối ưu hóa lưu trữ cache và streaming ít rõ rệt hơn hoặc có thể không hoạt động hoàn toàn. Luôn kiểm tra hiệu suất PPR trong môi trường giống như sản xuất.
- Vấn đề: Các lợi ích của PPR (streaming, TTFB nhanh) không rõ ràng trong
Các câu hỏi thường gặp
Q1: Tôi có thể sử dụng PPR với generateStaticParams không?
A1: Có. generateStaticParams tiền render lớp vỏ tĩnh cho tất cả các đường dẫn được chỉ định tại thời điểm build. Khi một yêu cầu cho một trong các đường dẫn này đến, lớp vỏ tĩnh đã được tiền render sẽ được phục vụ ngay lập tức và các lỗ hổng động sẽ được truyền tải. Điều này kết hợp các lợi ích của SSG (tải ban đầu nhanh cho các đường dẫn đã biết) với SSR (độ tươi mới của nội dung động).
Q2: PPR tương tác với revalidatePath và revalidateTag như thế nào?
A2: revalidatePath và revalidateTag chủ yếu vô hiệu hóa bộ nhớ cache dữ liệu được quản lý bởi unstable_cache. Nếu bạn sử dụng unstable_cache với tags và cacheLife, việc gọi revalidateTag('your-tag') sẽ đánh dấu dữ liệu được lưu trong bộ nhớ cache đó là cũ. Yêu cầu tiếp theo cho một lỗ hổng động phụ thuộc vào dữ liệu đó sẽ kích hoạt việc tìm nạp lại. Bản thân lớp vỏ tĩnh thường là bất biến sau khi build, trừ khi toàn bộ trang được build lại.
Q3: PPR có phù hợp với các trang được cá nhân hóa cao (ví dụ: bảng điều khiển người dùng) không?
A3: PPR có thể có lợi ngay cả đối với các trang được cá nhân hóa cao. Lớp vỏ tĩnh có thể chứa các yếu tố bố cục chung (tiêu đề, điều hướng, cấu trúc bảng điều khiển trống). Dữ liệu được cá nhân hóa (tên người dùng, số liệu cụ thể, hoạt động gần đây) sau đó sẽ được truyền tải vào các lỗ hổng động. Điều này vẫn cung cấp một lần render ban đầu nhanh hơn so với việc chờ tất cả dữ liệu được cá nhân hóa. Tuy nhiên, nếu mọi phần của trang đều động và dành riêng cho người dùng, lợi ích của lớp vỏ tĩnh sẽ giảm đi và SSR truyền thống có thể đơn giản hơn để quản lý.
Q4: PPR có ý nghĩa gì đối với SEO?
A4: PPR nói chung là thân thiện với SEO. Các trình thu thập thông tin của công cụ tìm kiếm thường thực thi JavaScript và chờ nội dung render. Vì lớp vỏ tĩnh được phân phối ngay lập tức, nội dung cốt lõi có sẵn nhanh chóng. Nội dung động được truyền tải sau đó cũng sẽ được lập chỉ mục sau khi được trình thu thập thông tin render. Điều quan trọng là đảm bảo các phần dự phòng Suspense của bạn cung cấp nội dung có ý nghĩa hoặc các chỉ báo rõ ràng, và nội dung động của bạn cuối cùng được render chính xác. TTFB và FCP nhanh là các tín hiệu SEO tích cực.
Q5: PPR ảnh hưởng đến kích thước gói JavaScript phía client như thế nào?
A5: Bản thân PPR không trực tiếp tăng hoặc giảm kích thước gói JavaScript phía client. Nó chủ yếu tối ưu hóa quá trình render và streaming phía máy chủ. Tuy nhiên, việc sử dụng Suspense và kiến trúc streaming của React có nghĩa là runtime React phía client là cần thiết để hydrate và render dần dần các đoạn được truyền tải. Việc chia tách mã tự động của Next.js đảm bảo rằng chỉ JavaScript cần thiết cho mỗi component mới được tải.
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

TanStack Query v5 với Next.js 15: Cập nhật lạc quan, đồng bộ hóa bộ nhớ đệm & Server Actions
Hướng dẫn toàn diện về TanStack Query v5 với Next.js 15: cập nhật lạc quan, đồng bộ hóa bộ nhớ đệm và Server Actions với kiến trúc cấp độ sản xuất và ví dụ mã.
Read more
Nắm vững Metadata & Open Graph trong Next.js 14+: Thẻ xã hội động ở quy mô lớn
Biến các lượt chia sẻ trên mạng xã hội thành động lực thúc đẩy lưu lượng truy cập tự nhiên khổng lồ bằng cách nắm vững generateMetadata của Next.js, thẻ Open Graph, Twitter Cards và tạo hình ảnh Edge OG động.
Read more
Cấu trúc thư mục Next.js App Router: Các phương pháp hay nhất & Kiến trúc doanh nghiệp (2026)
Hướng dẫn cấu trúc thư mục Next.js App Router đã được kiểm nghiệm trong thực tế. Tìm hiểu về route groups, private folders, colocation vs FSD, và tải xuống một template doanh nghiệp có khả năng mở rộng.
Read more