Lazy Load nâng cao với Intersection Observer: kiểm soát chính xác hơn so với loading=”lazy”

Thuộc tính loading="lazy" rất tiện nhưng “mặc định” thì không tối ưu cho mọi tình huống. Với Intersection Observer (IO), bạn có thể điều khiển chính xác thời điểm tải ảnh/iframe/video, đặt ngưỡng prefetch, dùng placeholder mượt mà, và tránh lười tải các phần tử quan trọng như LCP hero image. Bài này đưa ra cách triển khai lazy load nâng cao, kèm best practices cho WordPress.

Lazy Load nâng cao với Intersection Observer: kiểm soát chính xác hơn so với loading=”lazy”

Nguyên tắc vàng trước khi lazy load

  • Không lazy ảnh hero/LCP (trên màn hình đầu tiên). Hãy đặt fetchpriority="high", decoding="async", và preload font/critical CSS.
  • Lazy các phần tử ngoài viewport: ảnh bài viết dài, gallery, iframe nhúng (YouTube, Maps), video poster, background-image.
  • Prefetch hợp lý: dùng rootMargin để tải sớm trước khi người dùng cuộn tới (ví dụ 300px).
  • Có fallback nếu trình duyệt không hỗ trợ IO, và luôn thêm <noscript> cho SEO.

Markup chuẩn: tách URL thật sang data-*

<!-- Ảnh tiêu chuẩn -->
<img
  class="lazy"
  src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='9' viewBox='0 0 16 9'%3E%3C/svg%3E"
  data-src="https://cdn.example.com/img/post-1234-800.jpg"
  data-srcset="https://cdn.example.com/img/post-1234-400.jpg 400w, https://cdn.example.com/img/post-1234-800.jpg 800w, https://cdn.example.com/img/post-1234-1200.jpg 1200w"
  data-sizes="(max-width: 768px) 100vw, 768px"
  alt="Mô tả ảnh"
  width="800" height="450"
  decoding="async"
/>
<noscript>
  <img src="https://cdn.example.com/img/post-1234-800.jpg" alt="Mô tả ảnh" width="800" height="450" />
</noscript>

<!-- Iframe YouTube -->
<div class="lazy-embed" data-embed="youtube" data-src="https://www.youtube.com/embed/VIDEO_ID?rel=0">
  <button class="lazy-embed-play" aria-label="Phát video">Phát video</button>
</div>

<!-- Phần tử có background-image -->
<div class="lazy-bg" data-bg="https://cdn.example.com/img/hero-1600.jpg"></div>

Script Intersection Observer đa năng

Script này hỗ trợ ảnh, srcset, iframe và background-image. Bạn có thể đặt trong file JS riêng và enqueue trong WordPress.

/**
 * Lazy Loader sử dụng Intersection Observer
 * - Ảnh: data-src, data-srcset, data-sizes
 * - Iframe: data-src
 * - Background: data-bg
 */
(function() {
  var supportsIO = 'IntersectionObserver' in window;
  var throttleRAF;

  function revealImage(el) {
    if (el.dataset.src) el.src = el.dataset.src;
    if (el.dataset.srcset) el.srcset = el.dataset.srcset;
    if (el.dataset.sizes) el.sizes = el.dataset.sizes;
    el.removeAttribute('data-src');
    el.removeAttribute('data-srcset');
    el.removeAttribute('data-sizes');
    el.addEventListener('load', function() {
      el.classList.add('is-loaded');
    }, { once: true });
  }

  function revealIframe(el) {
    if (el.dataset.src) el.src = el.dataset.src;
    el.removeAttribute('data-src');
    el.classList.add('is-loaded');
  }

  function revealBg(el) {
    var url = el.dataset.bg;
    if (!url) return;
    var img = new Image();
    img.onload = function() {
      el.style.backgroundImage = 'url("' + url + '")';
      el.classList.add('is-loaded');
    };
    img.src = url;
    el.removeAttribute('data-bg');
  }

  function onIntersect(entries, observer) {
    entries.forEach(function(entry) {
      if (!entry.isIntersecting) return;
      var el = entry.target;
      if (el.tagName === 'IMG') {
        revealImage(el);
      } else if (el.classList.contains('lazy-bg')) {
        revealBg(el);
      } else if (el.tagName === 'IFRAME') {
        revealIframe(el);
      } else if (el.classList.contains('lazy-embed')) {
        hydrateEmbed(el);
      }
      observer.unobserve(el);
    });
  }

  function hydrateEmbed(wrapper) {
    // Ví dụ YouTube: tạo iframe khi người dùng bấm, hoặc auto khi vào viewport
    var type = wrapper.dataset.embed;
    var src = wrapper.dataset.src;
    var button = wrapper.querySelector('.lazy-embed-play');

    function buildIframe() {
      var iframe = document.createElement('iframe');
      iframe.setAttribute('allowfullscreen', '');
      iframe.setAttribute('loading', 'lazy');
      iframe.setAttribute('title', 'Video');
      iframe.src = src;
      wrapper.innerHTML = '';
      wrapper.appendChild(iframe);
      wrapper.classList.add('is-loaded');
    }

    if (button) {
      button.addEventListener('click', buildIframe, { once: true });
    } else {
      buildIframe();
    }
  }

  function fallbackLazy() {
    // Fallback cho trình duyệt không có IO: degrade gracefully
    var lazyImgs = document.querySelectorAll('img.lazy[data-src], img[data-srcset], iframe[data-src], .lazy-bg[data-bg], .lazy-embed[data-src]');
    var loadAll = function() {
      if (throttleRAF) cancelAnimationFrame(throttleRAF);
      throttleRAF = requestAnimationFrame(function() {
        var vh = window.innerHeight, vw = window.innerWidth;
        lazyImgs.forEach(function(el) {
          var rect = el.getBoundingClientRect();
          if (rect.bottom >= -300 && rect.top <= vh + 300 && rect.right >= -300 && rect.left <= vw + 300) {
            if (el.tagName === 'IMG') revealImage(el);
            else if (el.classList.contains('lazy-bg')) revealBg(el);
            else if (el.tagName === 'IFRAME') revealIframe(el);
            else if (el.classList.contains('lazy-embed')) hydrateEmbed(el);
          }
        });
      });
    };
    ['scroll','resize','orientationchange','load'].forEach(function(evt){
      window.addEventListener(evt, loadAll, { passive: true });
    });
    loadAll();
  }

  function init() {
    // Đừng lazy ảnh LCP: selector dưới bỏ qua ảnh có data-lcp hoặc .no-lazy
    var candidates = document.querySelectorAll('img.lazy:not(.no-lazy):not([data-lcp]), img[data-srcset], iframe[data-src], .lazy-bg[data-bg], .lazy-embed[data-src]');
    if (supportsIO) {
      var io = new IntersectionObserver(onIntersect, {
        root: null,
        rootMargin: '300px 0px', // Prefetch trước 300px
        threshold: 0.01
      });
      candidates.forEach(function(el){ io.observe(el); });
    } else {
      fallbackLazy();
    }
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();

Style nhỏ để chuyển cảnh mượt

img.lazy { filter: blur(8px); transition: filter .3s ease; }
img.lazy.is-loaded { filter: blur(0); }
.lazy-bg { background-size: cover; background-position: center; min-height: 240px; }
.lazy-bg.is-loaded { transition: opacity .3s ease; opacity: 1; }

Tích hợp WordPress: enqueue script và tự động “chuẩn hoá” ảnh trong nội dung

Đoạn dưới enqueue file JS và filter nội dung để chuyển img sang lazy markup (đơn giản hoá, có thể mở rộng cho srcset).

<?php
// functions.php
add_action('wp_enqueue_scripts', function() {
  wp_enqueue_script('io-lazy', get_template_directory_uri() . '/assets/js/io-lazy.js', [], '1.0', true);
});

// Biến đổi <img> trong the_content thành lazy
add_filter('the_content', function($content) {
  if (is_admin() || wp_doing_ajax()) return $content;

  // Thay src="..." thành placeholder + data-src
  $content = preg_replace_callback('#<img([^>]+)src=([\'"])([^\'"]+)\\2([^>]*)>#i', function($m){
    $attrsBefore = $m[1];
    $quote = $m[2];
    $src = $m[3];
    $attrsAfter = $m[4];

    // Bỏ qua ảnh có class no-lazy hoặc ảnh trong phần đầu trang
    if (strpos($attrsBefore . $attrsAfter, 'no-lazy') !== false) {
      return '<img' . $attrsBefore . 'src=' . $quote . esc_url($src) . $quote . $attrsAfter . '>';
    }

    $placeholder = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E";

    // Thêm class lazy nếu chưa có
    if (strpos($attrsBefore . $attrsAfter, 'class=') === false) {
      $attrsBefore .= ' class="lazy"';
    } else {
      $attrsBefore = preg_replace('/class=([\'"])(.*?)\\1/i', 'class=$1$2 lazy$1', $attrsBefore, 1);
    }

    // Đưa src thật vào data-src
    $new = '<img' . $attrsBefore . ' src=' . $quote . esc_url($placeholder) . $quote . ' data-src=' . $quote . esc_url($src) . $quote . $attrsAfter . '>';
    // Bổ sung noscript cho SEO/crawler
    $new .= '<noscript><img' . $m[1] . 'src=' . $quote . esc_url($src) . $quote . $m[4] . '></noscript>';
    return $new;
  }, $content);

  return $content;
}, 12);

Lazy background-image trong block/shortcode

Với các section hero/gợi ý dùng background, bạn có thể dùng lớp lazy-bg và thuộc tính data-bg, script IO ở trên sẽ tự tải:

<section class="teaser lazy-bg" data-bg="https://cdn.example.com/cover-1600.jpg">
  <div class="container">Nội dung...</div>
</section>

Lazy iframe YouTube kèm tương tác

Để tối ưu thêm, chỉ tạo iframe khi người dùng bấm “Phát”:

<div class="lazy-embed" data-embed="youtube" data-src="https://www.youtube.com/embed/VIDEO_ID?rel=0">
  <button class="lazy-embed-play">Phát video</button>
</div>

Best practices tinh chỉnh IO

  • rootMargin: tăng prefetch nếu ảnh nặng, ví dụ 600px cho gallery.
  • threshold: chỉ cần chạm một chút là đủ (0.01) để khởi tải.
  • Batch: để browser tự tối ưu, observer chung cho nhiều phần tử là đủ, không cần một observer/element.
  • SEO: luôn có <noscript> và điền đủ alt, width, height.
  • Đừng lazy hero: đặt class="no-lazy", fetchpriority="high", có thể thêm preload nếu ảnh thật sự quan trọng.

Kiểm thử và đo lường

  • Dùng Lighthouse/CrUX để xem LCP, CLS, TBT; lazy load đúng sẽ cải thiện TBT và giảm băng thông.
  • Kiểm tra thiết bị chậm/thấp mạng: tăng rootMargin để tải sớm hơn.
  • Đếm số request tiết kiệm: so sánh trước–sau khi triển khai.

Kết luận

Intersection Observer giúp bạn đưa lazy load lên “level kiểm soát”: chỉ tải khi cần, tải sớm vừa đủ trước khi người dùng nhìn thấy, và giữ nguyên trải nghiệm SEO. Với vài đoạn JS/PHP gọn gàng như trên, bạn có thể áp dụng đồng loạt cho ảnh, iframe và background-image trong WordPress mà không chạm vào nội dung gốc của tác giả.

Bình luận


  • Không có bình luận.

Init Toolbox

Nhấn Ctrl + \ trên máy tính, hoặc vuốt sang trái ở bất kỳ đâu trên mobile.

Đăng nhập





Đang tải...