•18 min read

Next.js 15 Cache Components & Server Actions: Hướng dẫn kiến trúc sản xuất hoàn chỉnh

Next.js 15 Cache Components & Server Actions: Hướng dẫn kiến trúc sản xuất hoàn chỉnh

Next.js 15 mang đến một sự thay đổi mô hình trong việc tìm nạp và lưu trữ dữ liệu, thay đổi cơ bản cách các nhà phát triển xây dựng các ứng dụng web động, hiệu suất cao. Chỉ thị 'use cache', cùng với Server Actions được cải tiến, cung cấp khả năng kiểm soát chi tiết hành vi lưu trữ, cho phép Thời gian đến Byte đầu tiên (TTFB) dưới 50ms ở biên trong khi giảm thiểu các vấn đề dữ liệu cũ. Hướng dẫn này phân tích các tính năng này, cung cấp một bản thiết kế kiến trúc cấp độ sản xuất.

Audio Briefing
0:00 / 0:00

Kiến trúc Cache của Next.js 15: Đi sâu

Đổi mới cốt lõi nằm ở nguyên thủy React Cache mới, được hiển thị thông qua chỉ thị 'use cache'. Chỉ thị này cho phép các nhà phát triển ghi nhớ kết quả của việc tìm nạp và tính toán dữ liệu trực tiếp trong các thành phần React, tận dụng bộ nhớ đệm theo phạm vi yêu cầu theo mặc định, với các tùy chọn cho bộ nhớ đệm liên tục.

Chỉ thị 'use cache'

Chỉ thị 'use cache' biến một thành phần thành một đơn vị có thể lưu vào bộ nhớ đệm. Khi React hiển thị một thành phần được đánh dấu bằng 'use cache', nó sẽ kiểm tra xem các props và ngữ cảnh của thành phần có khớp với một lần hiển thị đã được lưu vào bộ nhớ đệm trước đó hay không. Nếu tìm thấy sự trùng khớp và mục nhập bộ nhớ đệm hợp lệ, React sẽ sử dụng lại đầu ra đã lưu vào bộ nhớ đệm, bỏ qua việc thực thi lại hàm render của thành phần và bất kỳ thao tác tìm nạp dữ liệu nào trong đó.

// app/components/ProductDetails.tsx
import { cache } from 'react'; // React's cache primitive

interface Product {
  id: string;
  name: string;
  description: string;
  price: number;
}

// This function is memoized by React's cache.
// Subsequent calls with the same productId within the same request
// will return the cached result without re-fetching.
const getProductData = cache(async (productId: string): Promise<Product> => {
  console.log(`Fetching product data for ID: ${productId}`); // This will only log once per request for a given productId
  const res = await fetch(`https://api.example.com/products/${productId}`, {
    next: {
      tags: [`product-${productId}`, 'all-products'], // Cache tags for invalidation
      revalidate: 3600, // Stale-While-Revalidate for 1 hour
    },
  });

  if (!res.ok) {
    throw new Error(`Failed to fetch product ${productId}: ${res.statusText}`);
  }
  return res.json();
});

interface ProductDetailsProps {
  productId: string;
}

export default async function ProductDetails({ productId }: ProductDetailsProps) {
  // The `getProductData` call here benefits from the `cache` wrapper.
  // If this component is rendered multiple times with the same productId
  // within the same request, the fetch will only execute once.
  const product = await getProductData(productId);

  return (
    <div className="p-4 border rounded-lg shadow-sm">
      <h2 className="text-2xl font-bold">{product.name}</h2>
      <p className="text-gray-700 mt-2">{product.description}</p>
      <p className="text-xl font-semibold text-green-600 mt-3">${product.price.toFixed(2)}</p>
    </div>
  );
}

Thẻ Cache và Vô hiệu hóa

Next.js 15 tận dụng cacheTag để vô hiệu hóa bộ nhớ đệm chi tiết. Khi một yêu cầu fetch bao gồm next: { tags: [...] }, Next.js sẽ liên kết các thẻ này với dữ liệu đã tìm nạp. Server Actions sau đó có thể sử dụng revalidateTag(tag) để vô hiệu hóa tất cả dữ liệu đã lưu vào bộ nhớ đệm được liên kết với thẻ đó, đảm bảo dữ liệu luôn mới.

// app/actions/productActions.ts
'use server';

import { revalidateTag } from 'next/cache';
import { z } from 'zod'; // For robust schema validation

const updateProductSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(3).max(255),
  description: z.string().min(10),
  price: z.number().positive(),
});

export async function updateProduct(formData: FormData) {
  const rawFormData = {
    id: formData.get('productId'),
    name: formData.get('name'),
    description: formData.get('description'),
    price: parseFloat(formData.get('price') as string),
  };

  const validationResult = updateProductSchema.safeParse(rawFormData);

  if (!validationResult.success) {
    return {
      success: false,
      errors: validationResult.error.flatten().fieldErrors,
    };
  }

  const { id, name, description, price } = validationResult.data;

  try {
    const res = await fetch(`https://api.example.com/products/${id}`, {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ name, description, price }),
    });

    if (!res.ok) {
      const errorData = await res.json();
      return { success: false, message: errorData.message || 'Failed to update product.' };
    }

    // Invalidate cache for this specific product and all products list
    revalidateTag(`product-${id}`);
    revalidateTag('all-products'); // Invalidate any list views that show all products

    return { success: true, message: 'Product updated successfully.' };
  } catch (error) {
    console.error('Error updating product:', error);
    return { success: false, message: 'An unexpected error occurred.' };
  }
}

Hồ sơ cacheLife và Cô lập IO Động

Next.js 15 giới thiệu hồ sơ cacheLife, cho phép kiểm soát chi tiết thời gian và hành vi của bộ nhớ đệm. Điều này đặc biệt hữu ích cho nội dung động cần phải mới nhưng có thể chịu được một số dữ liệu cũ. Cô lập IO động đảm bảo rằng các thành phần có tìm nạp dữ liệu động không vô tình ngăn các phần tĩnh của trang được lưu vào bộ nhớ đệm.

Tùy chọn revalidate trong fetch là một dạng kiểm soát cacheLife, triển khai chiến lược Stale-While-Revalidate.

// app/page.tsx
import ProductDetails from './components/ProductDetails';
import ProductList from './components/ProductList';
import { Suspense } from 'react';

export default function HomePage() {
  const productId = 'a1b2c3d4-e5f6-7890-1234-567890abcdef'; // Example product ID

  return (
    <main className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-6">Welcome to Our Store</h1>

      <section className="mb-8">
        <h2 className="text-2xl font-semibold mb-4">Featured Product</h2>
        {/* Suspense boundary for dynamic content */}
        <Suspense fallback={<p>Loading product details...</p>}>
          <ProductDetails productId={productId} />
        </Suspense>
      </section>

      <section>
        <h2 className="text-2xl font-semibold mb-4">All Products</h2>
        {/* Another Suspense boundary for potentially different caching needs */}
        <Suspense fallback={<p>Loading product list...</p>}>
          <ProductList />
        </Suspense>
      </section>
    </main>
  );
}

// app/components/ProductList.tsx
import { cache } from 'react';

interface ProductSummary {
  id: string;
  name: string;
}

const getAllProducts = cache(async (): Promise<ProductSummary[]> => {
  console.log('Fetching all products list');
  const res = await fetch('https://api.example.com/products', {
    next: {
      tags: ['all-products'],
      revalidate: 600, // Revalidate every 10 minutes
    },
  });

  if (!res.ok) {
    throw new Error('Failed to fetch product list');
  }
  return res.json();
});

export default async function ProductList() {
  const products = await getAllProducts();

  return (
    <ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
      {products.map((product) => (
        <li key={product.id} className="p-3 border rounded-md">
          <h3 className="font-medium">{product.name}</h3>
        </li>
      ))}
    </ul>
  );
}
Advertisement

Server Actions: Thay đổi dữ liệu bền vững

Server Actions cung cấp một cách an toàn và hiệu quả để thực hiện các thay đổi dữ liệu phía máy chủ trực tiếp từ các thành phần client, loại bỏ nhu cầu về các tuyến API rõ ràng cho các hoạt động đơn giản. Next.js 15 tăng cường khả năng phục hồi và tích hợp của chúng với bộ nhớ đệm.

Hoàn tác lạc quan

Cập nhật giao diện người dùng lạc quan là rất quan trọng để có trải nghiệm người dùng mượt mà. Server Actions tạo điều kiện cho điều này bằng cách cho phép cập nhật giao diện người dùng ngay lập tức trên client, với cơ chế hoàn tác nếu hành động máy chủ thất bại. Hook useOptimistic là chìa khóa ở đây.

// app/components/AddToCartButton.tsx
'use client';

import { useOptimistic, useState } from 'react';
import { addToCart } from '../actions/cartActions'; // Server Action

interface AddToCartButtonProps {
  productId: string;
  initialQuantity: number;
}

export function AddToCartButton({ productId, initialQuantity }: AddToCartButtonProps) {
  const [optimisticQuantity, addOptimisticItem] = useOptimistic(
    initialQuantity,
    (currentQuantity, amountToAdd: number) => currentQuantity + amountToAdd
  );
  const [isPending, setIsPending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleAddToCart = async () => {
    setIsPending(true);
    setError(null);
    addOptimisticItem(1); // Optimistically update UI

    try {
      const result = await addToCart(productId, 1); // Call the server action
      if (!result.success) {
        // Rollback optimistic update on failure
        addOptimisticItem(-1);
        setError(result.message || 'Failed to add to cart.');
      }
    } catch (e) {
      // Rollback on network or unexpected errors
      addOptimisticItem(-1);
      setError('An unexpected error occurred.');
      console.error('Add to cart error:', e);
    } finally {
      setIsPending(false);
    }
  };

  return (
    <div>
      <button
        onClick={handleAddToCart}
        disabled={isPending}
        className={`px-4 py-2 rounded-md text-white ${
          isPending ? 'bg-blue-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'
        }`}
      >
        {isPending ? 'Adding...' : `Add to Cart (${optimisticQuantity})`}
      </button>
      {error && <p className="text-red-500 text-sm mt-1">{error}</p>}
    </div>
  );
}

// app/actions/cartActions.ts
'use server';

import { revalidatePath } from 'next/cache'; // For path-based revalidation

export async function addToCart(productId: string, quantity: number) {
  // Simulate API call
  await new Promise((resolve) => setTimeout(resolve, 500));

  if (Math.random() < 0.2) { // Simulate 20% failure rate
    return { success: false, message: 'Failed to add item to cart due to a server error.' };
  }

  // In a real app, update database/session here
  console.log(`Added ${quantity} of product ${productId} to cart.`);

  // Revalidate any paths that display cart contents
  revalidatePath('/cart');
  revalidatePath('/'); // If cart summary is on homepage

  return { success: true, message: 'Item added to cart.' };
}

Xác thực Schema Zod

Xác thực đầu vào mạnh mẽ là rất quan trọng đối với bảo mật và tính toàn vẹn của dữ liệu. Tích hợp Zod với Server Actions cung cấp một cách khai báo và an toàn về kiểu để xác thực dữ liệu biểu mẫu đến.

// app/actions/contactActions.ts
'use server';

import { z } from 'zod';

const contactFormSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters.').max(50, 'Name cannot exceed 50 characters.'),
  email: z.string().email('Invalid email address.'),
  message: z.string().min(10, 'Message must be at least 10 characters.').max(500, 'Message cannot exceed 500 characters.'),
});

export async function submitContactForm(formData: FormData) {
  const rawFormData = {
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  };

  const validationResult = contactFormSchema.safeParse(rawFormData);

  if (!validationResult.success) {
    return {
      success: false,
      errors: validationResult.error.flatten().fieldErrors,
    };
  }

  const { name, email, message } = validationResult.data;

  try {
    // Simulate sending email or saving to DB
    await new Promise((resolve) => setTimeout(resolve, 1000));
    console.log(`Contact form submitted by ${name} (${email}): ${message}`);

    // No revalidation needed for a simple contact form submission
    return { success: true, message: 'Your message has been sent successfully!' };
  } catch (error) {
    console.error('Error submitting contact form:', error);
    return { success: false, message: 'An unexpected error occurred while sending your message.' };
  }
}

// app/components/ContactForm.tsx
'use client';

import { useFormState, useFormStatus } from 'react-dom';
import { submitContactForm } from '../actions/contactActions';

const initialState = {
  success: false,
  message: '',
  errors: undefined as Record<string, string[]> | undefined,
};

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button
      type="submit"
      disabled={pending}
      className={`px-6 py-3 rounded-md text-white font-semibold ${
        pending ? 'bg-indigo-400 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700'
      }`}
    >
      {pending ? 'Sending...' : 'Send Message'}
    </button>
  );
}

export function ContactForm() {
  const [state, formAction] = useFormState(submitContactForm, initialState);

  return (
    <form action={formAction} className="space-y-4 p-6 border rounded-lg shadow-md max-w-md mx-auto">
      <div>
        <label htmlFor="name" className="block text-sm font-medium text-gray-700">Name</label>
        <input
          type="text"
          id="name"
          name="name"
          className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
        />
        {state.errors?.name && <p className="text-red-500 text-xs mt-1">{state.errors.name.join(', ')}</p>}
      </div>
      <div>
        <label htmlFor="email" className="block text-sm font-medium text-gray-700">Email</label>
        <input
          type="email"
          id="email"
          name="email"
          className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
        />
        {state.errors?.email && <p className="text-red-500 text-xs mt-1">{state.errors.email.join(', ')}</p>}
      </div>
      <div>
        <label htmlFor="message" className="block text-sm font-medium text-gray-700">Message</label>
        <textarea
          id="message"
          name="message"
          rows={5}
          className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
        ></textarea>
        {state.errors?.message && <p className="text-red-500 text-xs mt-1">{state.errors.message.join(', ')}</p>}
      </div>
      <SubmitButton />
      {state.success && <p className="text-green-600 mt-2">{state.message}</p>}
      {!state.success && state.message && <p className="text-red-500 mt-2">{state.message}</p>}
    </form>
  );
}

So sánh kiến trúc: Next.js 15 Caching so với các phương pháp truyền thống

Tính năngNext.js 15 Cache Components & Server ActionsTìm nạp phía Client truyền thống (ví dụ: SWR/React Query)Kết xuất phía Server truyền thống (SSR)
Vị trí tìm nạp dữ liệuServer (trong quá trình render)Client (sau khi render ban đầu)Server (trong quá trình yêu cầu)
Cơ chế CacheReact Cache ('use cache'), tùy chọn fetch, revalidateTag/revalidatePathCache phía client (trong bộ nhớ, localStorage)Cache phía server theo phạm vi yêu cầu (hạn chế)
Giảm thiểu dữ liệu cũrevalidateTag chi tiết, revalidatePath, revalidate trong fetchStale-While-Revalidate (SWR), tìm nạp lại nềnRender lại toàn bộ trang hoặc vô hiệu hóa cache thủ công
TTFBTuyệt vời (dưới 50ms có thể với cache biên)Tốt (sau khi tải ban đầu), nhưng HTML ban đầu trốngTốt (nhưng có thể chậm đối với dữ liệu động)
SEOTuyệt vời (HTML đầy đủ khi tải lần đầu)Kém (yêu cầu thực thi JS cho nội dung)Tuyệt vời
Độ phức tạpTrung bình (mô hình tư duy mới cho cache)Trung bình (hooks, providers, quản lý trạng thái)Trung bình (logic phía server)
Optimistic UIHook useOptimistic gốcTriển khai cụ thể thư việnPhức tạp hơn để triển khai
Xác thựcZod với Server ActionsPhía client (ví dụ: Zod, Formik)Phía server (tuyến API)
Thời gian BuildBuild tăng dần TurbopackNhanh (chỉ phía client)Có thể chậm đối với các ứng dụng lớn

Những vấn đề và cách khắc phục trong sản xuất

  1. Dữ liệu cũ sau khi triển khai:
    • Triệu chứng: Người dùng báo cáo thấy dữ liệu cũ ngay cả sau khi triển khai mới.
    • Nguyên nhân: Cache build của Next.js hoặc cache CDN có thể đang phục vụ HTML cũ.
    • Cách khắc phục: Đảm bảo pipeline CI/CD của bạn kích hoạt revalidatePath('/') hoặc revalidateTag('all-data') cho các trang/dữ liệu quan trọng sau khi triển khai thành công. Đối với Vercel, các triển khai mới tự động xóa cache CDN. Nếu tự host, hãy cấu hình CDN của bạn để xóa khi triển khai.
  2. cache không hoạt động như mong đợi:
    • Triệu chứng: Một hàm được bao bọc bởi cache đang thực thi nhiều lần trong cùng một yêu cầu.
    • Nguyên nhân: Hàm đang được gọi với các đối số khác nhau, hoặc nó không thực sự là một hàm thuần túy (ví dụ: dựa vào trạng thái có thể thay đổi toàn cục). Hãy nhớ rằng, cache ghi nhớ dựa trên các đối số.
    • Cách khắc phục: Xác minh rằng các đối số được truyền cho hàm đã lưu vào bộ nhớ đệm là giống hệt nhau cho các lần gọi tiếp theo. Đảm bảo hàm là bất biến và không có tác dụng phụ đối với đầu vào của nó.
  3. Server Action revalidateTag không vô hiệu hóa:
    • Triệu chứng: Dữ liệu được cập nhật bởi Server Action không hiển thị trên client, ngay cả sau khi revalidateTag được gọi.
    • Nguyên nhân: Lời gọi fetch truy xuất dữ liệu có thể không có tags chính xác được định nghĩa trong tùy chọn next của nó, hoặc lời gọi revalidateTag đang sử dụng tên thẻ khác.
    • Cách khắc phục: Kiểm tra kỹ tên thẻ để khớp chính xác giữa fetch và revalidateTag. Đảm bảo lời gọi fetch thực sự đang sử dụng fetch mở rộng của Next.js (tức là không phải lời gọi node-fetch hoặc axios thô mà không có tùy chọn next).
  4. Tái xác thực quá mức dẫn đến điều tiết API:
    • Triệu chứng: Các API backend đang bị truy cập quá thường xuyên, dẫn đến giới hạn tốc độ hoặc suy giảm hiệu suất.
    • Nguyên nhân: Giá trị revalidate quá mạnh trong fetch (ví dụ: revalidate: 0 hoặc số rất thấp) hoặc các lời gọi revalidateTag/revalidatePath thường xuyên mà không có debounce/throttling thích hợp.
    • Cách khắc phục: Xem xét lại các giá trị revalidate; sử dụng số cao hơn (ví dụ: 600 giây cho dữ liệu động vừa phải). Chỉ gọi revalidateTag/revalidatePath khi dữ liệu thực sự thay đổi. Cân nhắc sử dụng webhook từ CMS/cơ sở dữ liệu của bạn để kích hoạt các lời gọi revalidateTag cụ thể thay vì revalidatePath('/') rộng rãi trên mỗi thay đổi dữ liệu.
  5. Lỗi Build Turbopack với Server Actions:
    • Triệu chứng: Lỗi build liên quan đến Server Actions, đặc biệt khi di chuyển từ các phiên bản Next.js cũ hơn hoặc các thiết lập phức tạp.
    • Nguyên nhân: Đặt chỉ thị 'use server' không chính xác, các vấn đề với phân giải module, hoặc các trường hợp biên Turbopack cụ thể.
    • Cách khắc phục: Đảm bảo 'use server' nằm ở đầu tệp. Kiểm tra bất kỳ dữ liệu không thể tuần tự hóa nào đang được truyền qua ranh giới client/server. Cập nhật Next.js lên phiên bản canary/beta mới nhất nếu gặp phải các vấn đề mới, vì Turbopack đang được phát triển tích cực. Đơn giản hóa cấu trúc tệp Server Action nếu có thể.
Advertisement

Các câu hỏi thường gặp

Q1: Khi nào tôi nên sử dụng revalidateTag so với revalidatePath?

revalidateTag thường được ưu tiên để vô hiệu hóa chi tiết. Sử dụng nó khi bạn cập nhật dữ liệu cụ thể (ví dụ: một sản phẩm, một hồ sơ người dùng) và bạn đã gắn thẻ các yêu cầu fetch cho dữ liệu đó. revalidatePath rộng hơn; nó vô hiệu hóa tất cả các thao tác tìm nạp dữ liệu trên một đường dẫn nhất định. Sử dụng revalidatePath khi một thay đổi ảnh hưởng đến toàn bộ trang hoặc một tập hợp lớn dữ liệu khó gắn thẻ riêng lẻ (ví dụ: một bài đăng blog mới trên trang chỉ mục /blog). Tránh revalidatePath('/') trừ khi thực sự cần thiết, vì nó vô hiệu hóa toàn bộ trang web.

Q2: Tôi có thể sử dụng cache với các thành phần client không?

Không, nguyên thủy cache từ react được thiết kế cho các thành phần server và tìm nạp dữ liệu phía server. Các thành phần client không thể trực tiếp sử dụng cache để tìm nạp dữ liệu. Đối với các thành phần client, bạn thường sẽ sử dụng các thư viện tìm nạp dữ liệu phía client như SWR hoặc React Query, có cơ chế lưu trữ riêng, hoặc tìm nạp dữ liệu từ Server Action.

Q3: Cache của Next.js 15 tương tác với cache CDN như thế nào?

Cache của Next.js 15 (cả cache fetch và nguyên thủy React cache) hoạt động trước cache CDN. Cache fetch xác định thời gian Next.js tự coi dữ liệu là mới. Khi Next.js tạo HTML, HTML đó sau đó có thể được CDN lưu vào bộ nhớ đệm. revalidateTag và revalidatePath chủ yếu vô hiệu hóa cache dữ liệu nội bộ của Next.js và kích hoạt render lại các trang bị ảnh hưởng, sau đó tạo ra HTML mới mà CDN có thể lấy. Đối với các triển khai Vercel, các tái xác thực này cũng kích hoạt xóa cache CDN cho các đường dẫn bị ảnh hưởng.

Q4: Việc sử dụng quá nhiều Server Actions có ý nghĩa gì về hiệu suất?

Server Actions hiệu quả, nhưng giống như bất kỳ hoạt động phía server nào, chúng gây ra độ trễ mạng và thời gian xử lý của server. Sử dụng nhiều Server Actions nhỏ, độc lập cho các cập nhật giao diện người dùng nhỏ có thể dẫn đến hiệu ứng "thác nước" của các yêu cầu mạng. Đối với các biểu mẫu phức tạp hoặc nhiều thay đổi liên quan, hãy cân nhắc nhóm các hoạt động trong một Server Action duy nhất hoặc sử dụng một tuyến API chuyên dụng nếu logic trở nên quá phức tạp đối với một hành động duy nhất. Lợi ích hiệu suất chính đến từ việc tránh điều hướng toàn bộ trang và các gói JavaScript phía client cho các thay đổi đơn giản.

Q5: Turbopack cải thiện trải nghiệm nhà phát triển với Next.js 15 như thế nào?

Turbopack, công cụ kế nhiệm Webpack dựa trên Rust của Next.js, tăng tốc đáng kể quá trình phát triển cục bộ. Đối với Next.js 15, khả năng biên dịch tăng dần của nó là rất quan trọng. Khi bạn thực hiện thay đổi đối với Server Action hoặc một thành phần đã lưu vào bộ nhớ đệm, Turbopack có thể biên dịch lại chỉ các module bị ảnh hưởng, dẫn đến thay thế module nóng (HMR) gần như tức thì và khởi động lạnh nhanh hơn. Điều này làm giảm đáng kể vòng lặp phản hồi trong quá trình phát triển, đặc biệt đối với các ứng dụng lớn hơn với logic phía server phức tạp.

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