Back to Notes
Performance2 phút đọcOctober 29, 2025

Product Image Optimization — Tại sao ảnh sản phẩm giết LCP của bạn

Ảnh sản phẩm chiếm 60-80% bandwidth của trang ecommerce. Một lỗi lazy load sai chỗ có thể phá hỏng LCP. Bài này đi qua các kỹ thuật tối ưu ảnh từ priority loading, LQIP, CDN transform đến gallery zoom on-demand.

Product Image Optimization — Tại sao ảnh sản phẩm giết LCP của bạn

LCP (Largest Contentful Paint) trên trang sản phẩm thường là ảnh hero hoặc ảnh đầu tiên trong grid. Lazy load sai chỗ, thiếu srcset, hoặc không dùng CDN transform — bất kỳ điều nào cũng có thể đẩy LCP lên 4-5 giây.

Vấn đề 1: LCP Trap — Lazy Load Ảnh Hero

tsx
// ❌ Sai: lazy load ảnh đầu tiên → LCP tệ
<img src={product.image} loading="lazy" alt={product.name} />
 
// ✅ Đúng: priority cho ảnh above the fold
// Với Next.js Image:
<Image
  src={product.image}
  alt={product.name}
  width={600}
  height={600}
  priority // Preload, không lazy load
  fetchPriority="high"
/>
 
// Với thẻ img thuần:
<img
  src={product.image}
  alt={product.name}
  fetchpriority="high" // Hint browser tải sớm
  // Không có loading="lazy"
/>

Rule: Ảnh trong viewport ban đầu (trên fold) → không lazy load, dùng fetchPriority="high". Chỉ lazy load ảnh dưới fold.

Vấn đề 2: Responsive Images với srcset

tsx
// CDN transform URL pattern (Cloudinary example)
function buildImageUrl(publicId: string, width: number): string {
  return `https://res.cloudinary.com/YOUR_CLOUD/image/upload/w_${width},f_auto,q_auto/${publicId}`;
}
 
function ProductImage({ publicId, alt }: { publicId: string; alt: string }) {
  const widths = [320, 480, 640, 800, 1200];
  const srcset = widths.map((w) => `${buildImageUrl(publicId, w)} ${w}w`).join(", ");
 
  return (
    <img
      src={buildImageUrl(publicId, 640)} // Fallback
      srcSet={srcset}
      sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
      alt={alt}
      width={640}
      height={640}
      style={{ aspectRatio: "1/1", objectFit: "cover" }}
    />
  );
}

Vấn đề 3: Blur Placeholder (LQIP) để Tránh Layout Shift

LQIP (Low Quality Image Placeholder) — tải ảnh base64 nhỏ xíu (< 1KB) làm blur placeholder trong khi ảnh thật đang tải.

tsx
// Cloudinary cho phép lấy LQIP ngay trong API response
// Hoặc generate lúc upload: w_20,e_blur:200
 
function ProductImageWithLQIP({
  src,
  lqip,
  alt,
}: {
  src: string;
  lqip: string; // base64 của ảnh 20px blur
  alt: string;
}) {
  const [loaded, setLoaded] = useState(false);
 
  return (
    <div className="relative aspect-square overflow-hidden">
      {/* LQIP blur placeholder */}
      <img
        src={lqip}
        alt=""
        aria-hidden
        className="absolute inset-0 w-full h-full object-cover scale-110 blur-sm"
      />
      {/* Ảnh thật */}
      <img
        src={src}
        alt={alt}
        className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${
          loaded ? "opacity-100" : "opacity-0"
        }`}
        onLoad={() => setLoaded(true)}
      />
    </div>
  );
}
tsx
function ProductGallery({ images }: { images: ProductImage[] }) {
  const [activeIndex, setActiveIndex] = useState(0);
  const [zoomSrc, setZoomSrc] = useState<string | null>(null);
 
  const handleZoom = () => {
    // Chỉ tải full-size khi user click zoom — không preload toàn bộ gallery
    const fullSizeUrl = buildImageUrl(images[activeIndex].publicId, 2000);
    setZoomSrc(fullSizeUrl);
  };
 
  return (
    <>
      <div className="relative">
        <ProductImage
          publicId={images[activeIndex].publicId}
          alt={images[activeIndex].alt}
        />
        <button onClick={handleZoom} className="absolute top-2 right-2 p-2 bg-white rounded">
          🔍
        </button>
      </div>
 
      {/* Thumbnail strip — lazy load thumbnails */}
      <div className="flex gap-2 mt-2">
        {images.map((img, i) => (
          <button key={img.id} onClick={() => setActiveIndex(i)}>
            <img
              src={buildImageUrl(img.publicId, 80)}
              alt={img.alt}
              loading="lazy" // Thumbnails có thể lazy load
              width={80}
              height={80}
            />
          </button>
        ))}
      </div>
 
      {/* Zoom modal — chỉ render khi cần */}
      {zoomSrc && (
        <ZoomModal src={zoomSrc} onClose={() => setZoomSrc(null)} />
      )}
    </>
  );
}

Vấn đề 5: CDN Auto Format và Compression

ts
// Cloudinary: f_auto chọn WebP/AVIF tự động theo browser support
// q_auto tối ưu quality tự động
const url = `https://res.cloudinary.com/cloud/image/upload/f_auto,q_auto,w_640/product.jpg`;
 
// Imgix tương tự:
const imgixUrl = `https://yoursite.imgix.net/product.jpg?w=640&auto=format,compress`;

Key Takeaways

  • LCP: ảnh hero và first product card không lazy load, dùng fetchPriority="high"
  • srcset + sizes: cho browser tự chọn size phù hợp, tiết kiệm bandwidth đáng kể
  • LQIP: tránh layout shift, tạo perceived performance tốt hơn
  • CDN transform: Cloudinary/Imgix tự động resize, convert WebP/AVIF, compress
  • Gallery zoom: tải full-size on-demand, không preload trước khi cần

If you found this helpful, leave a like!

Related Posts

Comments available after DB is connected.