•18 min read

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

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

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ế.

Audio Briefing
0:00 / 0:00

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:

  1. 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ì.
  2. Đồng thời, máy chủ tìm nạp dữ liệu cho các lỗ hổng động.
  3. 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ẻ h1 và p cho staticProductInfo là một phần của lớp vỏ tĩnh.
  • ProductDetails, RelatedProducts và Reviews được bao bọc trong Suspense, biến chúng thành các lỗ hổng động. Các component Skeleton tươ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.

  1. 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.
  2. 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òng Suspense tươ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.

Advertisement

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ới fetch(..., { cache: 'no-store' }) hoặc revalidate = 0 trong các tùy chọn revalidate cấ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ăngSSR truyền thốngPartial Prerendering (PPR)
TTFBCao (chờ tất cả dữ liệu)Thấp (lớp vỏ tĩnh được phục vụ ngay lập tức)
FCPCao (chờ tất cả dữ liệu)Thấp (lớp vỏ tĩnh được render ngay lập tức)
LCPPhụ thuộc vào dữ liệu chậm nhấtPhụ thuộc vào dữ liệu động quan trọng chậm nhất
Độ tươi mới của dữ liệuLuô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ạpMô hình tư duy đơn giản hơnYêu cầu quản lý Suspense và cacheLife cẩn thận
Lưu trữ cacherevalidate cấp trang hoặc no-storeLớ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
StreamingStreaming 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 buildTố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

  1. 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òng Suspense củ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 Suspense củ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.
  2. 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 trong Suspense khô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.
      • await cấp gốc: Nếu page.tsx của bạn trực tiếp await một lần tìm nạp dữ liệu chậm bên ngoài một ranh giới Suspense, 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ởi Suspense.
      • cacheLife: 'revalidate' trên nội dung tĩnh: Vô tình đặt cacheLife: 'revalidate' hoặc revalidate = 0 trê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ụng cacheLife: 'static' hoặc không được bao bọc trong unstable_cache nếu chúng thực sự tĩnh và là một phần của bản build.
  3. Sử dụng cacheLife khô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_cache của bạn. Nếu cacheLife được đặt quá cao (ví dụ: 3600 giâ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ỉnh cacheLife để 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ụng cacheLife: 'revalidate' hoặc bỏ qua unstable_cache hoàn toàn. Hãy nhớ revalidateTag hoặc revalidatePath có thể được sử dụng để vô hiệu hóa theo yêu cầu.
  4. 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ột cacheLife nhỏ (ví dụ: 5 hoặc 10 giâ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.
  5. 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 build và next start). Mặc dù Suspense hoạ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.
Advertisement

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.

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