完全に状況を掌握した画像の遅延読み込みの実現

IE8の挙動について追記しました。
IE8は、img.complete は 画像読み込みでも true になりません(falseのままです)。 そのかわり、img.readyState が "complete" になります。

JavaScriptでの画像の動的/遅延読み込みといえば (new Image).src = URL; なんですが、
タイムアウトやエラーの状況を把握したい時もあったりします。GoogleMapライクなアプリを作ってるときとか。

今回ちょっと必要になったのでまずは調査から。

以下のコードで、存在しないファイル(missing.jpg)を読み込ませ、実行経路を確認してみます。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ja">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>JavaScript::loadImage test</title>
</head>
<body>
<div id="footprint"></div>

<script type="text/javascript">
var uu = window.uu = {
  /** 画像を非同期にロードする
   *
   * @param map map   { array:path, function:onLoad, function:onError,
   *                    number:delay = 50, number:timeout = 2000 }
   *
   *                  path:     画像のURLの配列です。絶対パスまたは相対パスで指定します。
   *                  onLoad:   ロード完了後にコールバックするメソッドを指定します。
   *                            絶対URLを引数にcallbackします。
   *                  onError:  エラー, 読込中断, タイムアウト時にコールバックするメソッドを指定します。
   *                            絶対URLを引数にcallbackします。
   *                  delay:    遅延時間の指定です。デフォルトは10msです。
   *                  timeout:  タイムアウト時間の指定です。デフォルトは2秒(2000ms)です。
   *                            0を指定するとタイムアウトしません。
   */
  loadImage: function(map) {
    uu.mix(map, { onLoad: uu.dummy, onError: uu.dummy, delay: 50, timeout: 2000 }, true);
    var images = uu.toArray(document.images);
    map.path.forEach(function(v) {
      uu["#loadImage"](images, v.match(RegExp("^(file|http|https):\/\/")) ? v : uu.getAbsolutePath(v),
                       map.onLoad, map.onError, map.delay, map.timeout);
    });
  },
  "#loadImage": function(images, abspath, onLoad, onError, delay, timeout) {
    for (var i = 0, sz = images.length; i < sz; ++i) {
      if (images[i].src === abspath && images[i].complete) {
        onLoad(abspath);
        return;
      }
    }
    var img = new Image(), tick = 0;
    img.onabort = img.onerror = function() {
      window.status += "e";
      if (img.complete) { window.status += "[+comp]"; }
    };
    img.onload  = function() {
      window.status += "l";
      if (img.complete) { window.status += "[+comp]"; }
    };
    img.src = abspath;
    if (timeout) {
      setTimeout(function() {
        window.status += ".";
        if (img.complete) { window.status += "[+comp2]"; }
        if (img.complete || (tick += delay) > timeout) {
          window.status += (img.complete) ? "C" : "T";
          if (img.width) { window.status += "[w:" + img.width + "]"; }
          return;
        }
        setTimeout(arguments.callee, delay);
      }, 0);
    }
  },
  mix: function(mapSrc, mapMixer, supplement /* = false */) {
    if (supplement) {
      for (var p in mapMixer) { (p in mapSrc) ? 0 : mapSrc[p] = mapMixer[p]; }
    } else {
      for (var p in mapMixer) { mapSrc[p] = mapMixer[p]; }
    }
    return mapSrc;
  },
  toArray: function(map) {
    var rv = new Array(map.length || 0), i = 0, sz = rv.length;
    for (; i < sz; ++i) { rv[i] = map[i]; }
    return rv;
  },
  getAbsolutePath: function(path) {
    var e = document.createElement("div");
    e.innerHTML = '<a href=\"' + path + '\" />';
    return e.firstChild.href;
  }
};
if (!Array.prototype.forEach) {
  Array.prototype.forEach = function(iter, bindThis /* = undefined */) {
    if (typeof iter !== "function") { throw TypeError(); }
    var i = 0, sz = this.length;
    for (; i < sz; ++i) { (i in this) && iter.call(bindThis, this[i], i, this); }
    return this;
  };
}

function imageLoaded(abs) {
  var e = document.getElementById("footprint");
  e.appendChild(document.createTextNode(abs));
}
function imageLoadError(abs) {
  window.status += "[" + abs + "]";
}
window.onload = function boot() {
  var map = {
    path: ["missing.jpg"],
    onLoad: imageLoaded,
    onError: imageLoadError
  };
  uu.loadImage(map);
}
</script>
</body>
</html>

読込失敗時に以下の挙動が確認できました。
また、1つのブラウザで複数の実行経路が存在することも確認しました。

ブラウザ実行経路読込失敗時の挙動
img.completeの
最終状態
img.onloadimg.onerror
Firefox2(2.0.013).[+comp2]Ctrue呼ばれない呼ばれない
Firefox3(Bata3)e[+comp].[+comp2]Ctrue呼ばれない呼ばれる
その時completeはtrue
Safari3(3.1)e[+comp].[+comp2]Ctrue呼ばれない呼ばれる
その時completeはtrue
Safari3(3.1).e[+comp].[+comp2]Ctrue呼ばれない呼ばれる
その時completeはtrue
Safari3(3.1).[+comp2]Ce[+comp]true呼ばれない呼ばれる
その時completeはtrue
Opera9(9.25)l..........Tfalse呼ばれる
その時completeはfalse
呼ばれない
Opera9(9.50Beta)e..........Tfalse呼ばれない呼ばれる
その時completeはfalse
IE6(6.0)e..........T[w:28]false呼ばれない呼ばれる
その時completeはfalse
IE7e..........T[w:28]false呼ばれない呼ばれる
その時completeはfalse
IE8未調査

次に、幅が160pxの存在するファイルを読み込ませた場合の挙動を調査しました。

ブラウザ実行経路読込成功時の挙動
img.completeの
最終状態
img.onloadimg.onerror
Firefox2(2.0.013)l[+comp][+comp2]C[w:160]true呼ばれる呼ばれない
Firefox2(2.0.013).[+comp2]C[w:160]l[+comp]true呼ばれる呼ばれない
Firefox3(Bata3)l[+comp].[+comp2]C[w:160]true呼ばれる呼ばれない
Firefox3(Bata3).[+comp]C[w:160]l[+comp]true呼ばれる呼ばれない
Safari3(3.1)l[+comp].[+comp2]C[w:160]true呼ばれる呼ばれない
Safari3(3.1).[+comp2]C[w:160]l[+comp]true呼ばれる呼ばれない
Safari3(3.1).l[+comp].[+comp2]C[w:160]true呼ばれる呼ばれない
Opera9(9.25)l[+comp].[+comp2]C[w:160]true呼ばれる呼ばれない
Opera9(9.50Beta)l[+comp].[+comp2]C[w:160]true呼ばれる呼ばれない
IE6(6.0)l.[+comp2]C[w:160]true呼ばれる呼ばれない
IE7l.[+comp2]C[w:160]true呼ばれる呼ばれない
IE8未調査

見事にバラバラですね(いつものことですが)。

結構厳しい気もしますが、めげずに実装してみます。

実装する機能は、

  • 読込失敗でonerror()を一度だけ発生させ、タイムアウトは発生させない。
  • 読込成功でonload()を一度だけ発生させる

です。

  "#loadImage": function(images, abspath, onLoad, onError, delay, timeout) {
    for (var i = 0, sz = images.length; i < sz; ++i) {
      if (images[i].src === abspath && images[i].complete) {
        onLoad(abspath);
        return;
      }
    }
    var img = new Image(), tick = 0;

    // ここから下を差し替え
    img.finish = false;
    img.onabort = img.onerror = function() {
      if (img.finish) { return; }
      img.finish = true;
      onError(abspath);
    };
    img.onload  = function() {
      img.finish = true;
      if (window.opera && !img.complete) {
        onError(abspath);
        return;
      }
      onLoad(abspath);
    };
    img.src = abspath;
    if (!img.finish && timeout) {
      setTimeout(function() {
        if (img.finish) { return; }
        if (img.complete) {
          img.finish = true;
          if (img.width) { return; }
          onError(abspath);
          return;
        }
        if ((tick += delay) > timeout) {
          img.finish = true;
          onError(abspath);
          return;
        }
        setTimeout(arguments.callee, delay);
      }, 0);
    }
  },

うまくやれました。

反省会:

  • 今回は、img.finish = false; を追加して流れをコントロールする方法をとった。
    • img.finishを使わない別解として、setTimeout()が返すタイマーIDを随所でclearTimeout()する方法も考えられたけど、finishでやれたので、clearTimeout()は試さなかった
  • onload内でOpera9.2系を意識したコードがどうしても必要だった。
  • 読込失敗時にwidthとheightが設定されるIEの挙動がユニーク