このページの本文へ

体感速度をぐぐっと改善!Medium風プログレッシブ画像ローダーの実装テク

2017年03月31日 14時16分更新

文●Craig Buckler

  • この記事をはてなブックマークに追加
本文印刷
このサイト、表示が遅いな…とユーザーに思われないためによく使われる、画像の遅延読み込み。見栄えがよく、シンプルで使いやすい実装方法を紹介。

ページをスクロールして要素がビュー内に入ると、ぼやけた低解像度の画像がフル解像度の画像に置換される「プログレッシブ画像」をFacebookMediumで見たことがあることでしょう。

progressive image example

プレビュー画像は幅20pxほどと小さく、高圧縮のJPEG画像です。ファイルサイズは300バイト未満なのであっという間に表示され、読み込みが速い印象を与えます。実際の画像(フル解像度)は必要に応じて読み込まれます。

プログレッシブ画像はすばらしいテクニックですが、いまのところ出回っているソリューションは複雑なものです。幸いなことに、ちょっとしたHTML5、CSS3、JavaScriptで以下のようなコードを作成できます。

  • わずか463バイトのCSSと1007バイト(圧縮後)のJavaScriptで高速かつ軽量
  • レスポンシブイメージに対応していて、大画面または高解像度(Retina)画面では代替画像が読み込まれる
  • 依存オブジェクトを持たないので任意のフレームワークで動作する
  • すべてのモダンブラウザー(IE10以上)で動作する
  • 古いブラウザーでも、JavaScriptや画像の読み込みが失敗した場合でも動作するよう徐々に改良中
  • 簡単に使える

デモとGitHubコード

次のようなテクニックを紹介します。

GitHubからコードをダウンロードできます。

HTML

プログレッシブ画像を実装する基本的なHTMLを紹介します。

<a href="full.jpg" class="progressive replace">
  <img src="tiny.jpg" class="preview" alt="image" />
</a>
  • hrefリンクに含まれるfull.jpgは大きなフル解像度画像
  • tiny.jpgは小さなプレビュー画像

これによって、最低限、古いブラウザーやJavaScriptがうまく動作しない場合でも、ユーザーはプレビューをクリックすることでフル解像度画像を表示できます。

フル解像度画像とプレビュー画像はアスペクト比を同じにする必要があります。たとえばfull.jpgが800 x 200の場合、アスペクト比は4:1になります。従ってtiny.jpgのサイズは20 x 5にできます。ただし、幅を30pxにはしないでください。なぜなら、高さが7.5pxになり、端数(小数点以下)が無効になってしまうからです。

リンクとプレビュー画像に使われるクラス名を記録しておきます。JavaScriptのフックとして使います。

インライン画像にするべきか?

プレビュー画像はData URIを使ってインライン化することもできます。

<img src="data:image/jpeg;base64,/9j/4AAQSkZJ..."  class="preview" />

インライン画像にすると、必要なHTTPリクエスト数が減少し、ブラウザーの再計算も避けられ、さらにすばやく表示されます。とはいえ、次のようなデメリットもあります。

  • インライン画像の追加や変更にはより手間がかかる(ただしGulpのような作成プロセスは助けになる)
  • base-64エンコーディングは効率が悪く、バイナリデータに比べて通常30%大きくなる(ただしHTTPリクエストヘッダの追加で相殺される)
  • インライン画像はキャッシュが効かない。HTMLページではキャッシュされるが、同じデータを再送しなければ別のページでは利用できない
  • HTTP/2ではインライン画像の必要性が減少する

インライン記述は、画像が特定のページだけで使われるときや、変換後のコードがURLに比べてさほど長くないときには実用的な選択肢になります。

CSS

最初にリンクコンテナのスタイルを定義します。

a.progressive {
  position: relative;
  display: block;
  overflow: hidden;
  outline: none;
}

このコードではコンテナレイアウトの主要なプロパティを設定しています。リンクには必要に応じて大きさや位置を設定する、別のクラスやスタイルも設定できます。

正確な大きさを設定したり、padding-topを工夫して既定のアスペクト比を強制適用したりできます。これにより、画像を読み込む前にコンテナのサイズが確実に決まるようになり、ブラウザーによる再計算を避けられます。とはいえ、それぞれの画像についてサイズとアスペクト比(幅と高さの比)の両方またはどちらかを計算する必要があるので、ここでは次のようにシンプルにしました。

  • プレビュー画像とフル解像度画像のアスペクト比は必ず同じにする(前述のとおり)
  • プレビュー画像はインライン化されているか、またはすばやく読み込まれるので、コンテナの高さはほぼ瞬時に定義される

コンテナの幅と高さを定義すればパフォーマンスは改善されます。特に、画像がたくさん掲載された、たとえば、ギャラリーのようにすべての画像が同じアスペクト比であるページでは改善が顕著になります。

フル解像度画像が読み込まれるとコンテナのreplaceクラスは削除され、クリックは無効になるので、標準のリンクポインターを削除します。

a.progressive:not(.replace) {
  cursor: default;
}

コンテナ内のプレビュー画像とフル解像度画像のサイズはコンテナ幅で決まります。

a.progressive img {
  display: block;
  width: 100%;
  max-width: none;
  height: auto;
  border: 0 none;
}

ちなみにheight: autoは必須です。これがないとIE10/11では画像の高さが正しく計算されない場合があります。

プレビュー画像のblur(ぼかし)の長さを2vwに設定し、ページの大きさに関係なく確実に同じようにぼやけて見えるようにします。コンテナにoverflow: hiddenを適用すると画像にハードエッジが付きます。さらに1.05に拡大して画像のぼやけた外縁部分からページの背景色が透けないようにします。こうすればフル解像度画像を表示するときに、満足のいくズーム効果が得られます。

a.progressive img.preview {
  filter: blur(2vw);
  transform: scale(1.05);
}

最後にフル解像度画像が表示される際のスタイルとアニメーションを定義します。

a.progressive img.reveal {
  position: absolute;
  left: 0;
  top: 0;
  will-change: transform, opacity;
  animation: reveal 1s ease-out;
}

@keyframes reveal {
  0% {transform: scale(1.05); opacity: 0;}
  100% {transform: scale(1); opacity: 1;}
}

フル解像度画像はプレビュー画像の上に位置するので、1秒かけてoacityを0から1に増加させ、scaleを1.05から1に変化させます。必要に応じてほかの変形やフィルター効果も適用できます。

JavaScript

JavaScriptのコードでは、プログレッシブ画像をレスポンシブに対応させるために必要なブラウザーのAPIが利用可能になっているかどうかをチェックします。その後、ページにloadイベントリスナーを追加します。

// progressive-image.js
if (window.addEventListener && window.requestAnimationFrame && document.getElementsByClassName) window.addEventListener('load', function() {

loadイベントはページとすべてのアセットの読み込み完了時に発生します。フォント、CSS、JavaScript、プレビュー画像など主要なリソースの準備が完了する前にフル解像度画像のロードが始まるのは望ましくありません(DOMの準備完了時に発生するDOMContentLoadedイベントを使うと起きます)。

次に、クラス名がprogressivereplaceの画像コンテナ要素をすべてフェッチします。

var pItem = document.getElementsByClassName('progressive replace'), timer;

getElementsByClassName()は配列に似た動的なHTMLCollectionを返し、関連する要素がページに追加されたり削除されたりすると変更されます。このメリットはすぐにはっきりします。

続いて、関数inView()を定義します。この関数はコンテナのgetBoundingClientRectの位置をwindow.pageYOffsetの垂直スクロール位置と比較して各コンテナがビューポート内に入っているかどうかを判定します。

// image in view?
function inView() {
  var wT = window.pageYOffset, wB = wT + window.innerHeight, cRect, pT, pB, p = 0;
  while (p < pItem.length) {

    cRect = pItem[p].getBoundingClientRect();
    pT = wT + cRect.top;
    pB = pT + cRect.height;

    if (wT < pB && wB > pT) {
      loadFullImage(pItem[p]);
      pItem[p].classList.remove('replace');
    }
    else p++;
  }
}

コンテナがビュー内にある場合、ノードが関数loadFullImage()に渡されてreplaceクラスが削除されます。これによってノードはHTMLCollection「pItem」から瞬時に削除されるのでコンテナは二度と再処理されなくなります。

関数loadFullImage()は新しくHTMLのImage()オブジェクトを作成し、必要に応じてコンテナのhref属性をsrc属性にコピーしrevealクラスを適用して値を設定します。

// replace with full image
function loadFullImage(item) {
  if (!item || !item.href) return;

  // load image
  var img = new Image();
  if (item.dataset) {
    img.srcset = item.dataset.srcset || '';
    img.sizes = item.dataset.sizes || '';
  }
  img.src = item.href;
  img.className = 'reveal';
  if (img.complete) addImg();
  else img.onload = addImg;

内部関数addImgは画像のロードが完了すると呼び出されます。

// replace image
  function addImg() {
    // disable click
    item.addEventListener('click', function(e) { e.preventDefault(); }, false);

    // add full image
    item.appendChild(img).addEventListener('animationend', function(e) {
      // remove preview image
      var pImg = item.querySelector && item.querySelector('img.preview');
      
      if (pImg) {
        e.target.alt = pImg.alt || '';
        item.removeChild(pImg);
        e.target.classList.remove('reveal');
      }
    });
  }
}

このコードを解説すると、次のようになります。

  1. コンテナでのクリックイベントを無効にする
  2. 画像をページに追加してフェード/ズームアニメーションをスタートする
  3. animationendリスナーを使ってアニメーションの終了を待ち、次いでaltタグをコピーし、プレビュー画像ノードを消去し、フル解像度画像からrevealクラスを削除する。このステップによりパフォーマンスが向上するだけでなく、Microsoft Edgeがサイズ変更時に落ちる奇妙なトラブルも防げる

最後に関数inView()を呼び出してページが最初に表示されるときに、プログレッシブ画像のコンテナが表示状態になっているかどうかチェックする必要があります。

inView();

この関数はページのスクロール時やブラウザーのサイズ変更時にも呼び出す必要があります。ある種の古いブラウザー(そう、主にIE)はこうしたイベントにすごいスピードで反応する場合があるので、コールバックを絞り込んで300ミリ秒につき1回を決して超えないようにします。

window.addEventListener('scroll', scroller, false);
window.addEventListener('resize', scroller, false);

function scroller(e) {
  timer = timer || setTimeout(function() {
    timer = null;
    requestAnimationFrame(inView);
  }, 300);
}

ちなみにinViewを実行するrequestAnimationFrameは次回の再描画よりも前に呼び出されます。

レスポンシブイメージ

HTML5では「image」のsrcset属性とsizes属性で複数の画像のさまざまなサイズや解像度を定義します。ブラウザーはデバイスに最適なものを選択します。

上のコードはレスポンシブイメージに対応しています。たとえば、次のようにリンクコンテナにdata-srcset属性とdata-sizes属性を追加できます。

<a href="small.jpg"
  data-srcset="small.jpg 800w, large.jpg 1200w"
  data-sizes="100vw"
  class="progressive replace">
  <img src="preview.jpg" class="preview" alt="image" />
</a>

ロード後のフル解像度画像のコードは次のようになります。

<img src="small.jpg"
    srcset="small.jpg 800w, large.jpg 1200w"
    sizes="100vw"
    alt="image" />

モダンブラウザーではビューポート幅が800px以上の場合large.jpgがロードされます。古いブラウザーやビューポート幅が比較的小さい場合にはsmall.jpgが適用されます。詳しくは『How to Build Responsive Images with srcset(srcsetでレスポンシブイメージを作成する方法)』を参照してください。

注意事項

この記事ではコードをコンパクトに紹介してきましたが、プロジェクトに応じて気軽に改良して使ってください。次のような拡張を検討できます。

  • 水平スクロールのチェック:垂直スクロールだけがチェックされるので、横並びの画像はすべて置換される
  • プログレッシブ画像の動的追加:JavaScriptを使ってページに追加されるプログレッシブ画像はscrollイベントかresizeイベントの発生時にのみ置換される
  • Firefoxでのパフォーマンス:大きい画像の置換がスムーズにいかず、ひどいちらつきが見られる場合がある

(原文:How to Build Your Own Progressive Image Loader

[翻訳:新岡祐佳子/編集:Livit

Web Professionalトップへ

WebProfessional 新着記事