画像の一覧を XML ファイルに書いておき、プログラムで読み込んで順に並べる、という作り方があります。画像を差し替えるときに XML だけを書き換えればよいので便利ですが、よく起きるのが「XML に書いた順番に並ばず、表示するたびに順番が変わる」という問題です。原因は、画像の読み込みが非同期で、終わる順番が決まっていないことにあります。ここでは、Flash の ActionScript 3 で起きていた例を入り口に、今の JavaScript での解決策を説明します。

ActionScript 3 で起きていた問題

Flash でギャラリーを作るときは、XML から画像の URL を取り出し、画像ごとに Loader で読み込む書き方がよく使われました。読み込みが終わったときの処理 (Event.COMPLETE) で画面に追加すると、次のようになります。

// 読み込みの終わった順に並んでしまう例
var images:XMLList = xml.image;
var count:int = 0;

for (var i:int = 0; i < images.length(); i++) {
    var loader:Loader = new Loader();
    loader.contentLoaderInfo.addEventListener(Event.COMPLETE, onComplete);
    loader.load(new URLRequest(String(images[i].@src)));
}

function onComplete(event:Event):void {
    var content:DisplayObject = LoaderInfo(event.target).content;
    content.x = count * 110; // 終わった順に、左から置いていく
    addChild(content);
    count++;
}

読み込みは XML の順に始まりますが、終わる順番は、画像の大きさやネットワークの状態で変わります。小さい画像が先に終われば、その画像が左に来ます。手元では毎回同じ順に並んでいても、公開すると順番が入れ替わる、ということも起こります。

当時の解決策は、読み込みを始める前に、Loader を XML の順番どおりに画面へ並べておくことでした。Loader そのものが表示用の部品なので、中身の読み込みが後から終わっても、位置は変わりません。

for (var i:int = 0; i < images.length(); i++) {
    var loader:Loader = new Loader();
    loader.x = i * 110; // XML の順番で位置を決める
    addChild(loader);   // 読み込みの前に、枠として並べておく
    loader.load(new URLRequest(String(images[i].@src)));
}

なお、Flash Player は 2020 年末でサポートが終わり、今のブラウザーでは動きません。同じことは JavaScript で作ります。

JavaScript で XML を読む

次のような XML を用意したとします。

<?xml version="1.0" encoding="UTF-8"?>
<gallery>
  <image src="images/01.jpg" alt="春の公園"/>
  <image src="images/02.jpg" alt="夏の海"/>
  <image src="images/03.jpg" alt="秋の山"/>
</gallery>

fetch() で文字列として読み込み、DOMParser で XML の文書に変換します。XML の形式が正しくないと、例外にはならず、parsererror という要素を含む文書が返るので、それを確かめます。

// XML を読み込んで、画像の情報の配列を返す
async function loadGalleryXml(url) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error("XML を読み込めません: " + response.status);
  }
  const text = await response.text();
  const doc = new DOMParser().parseFromString(text, "application/xml");
  if (doc.querySelector("parsererror")) {
    throw new Error("XML の形式が正しくありません");
  }
  return [...doc.querySelectorAll("image")].map((node) => ({
    src: node.getAttribute("src"),
    alt: node.getAttribute("alt") ?? "",
  }));
}

方法 1: 先に枠を順番どおりに作る

以下の例は、type="module" のスクリプトとして読み込み、関数の外で await を使っています。

いちばん簡単なのは、ActionScript 3 のときと同じく、読み込みを待たずに img 要素を XML の順番どおりにページへ追加してしまう方法です。要素の並びは追加した順に決まるので、画像の読み込みがどの順に終わっても、表示の順番は変わりません。

const gallery = document.querySelector("#gallery");
const items = await loadGalleryXml("gallery.xml");

for (const item of items) {
  const img = document.createElement("img");
  img.src = item.src;
  img.alt = item.alt;
  img.loading = "lazy"; // 画面に近づいてから読み込む
  gallery.append(img);
}

画像の幅と高さがわかっているなら、width と height も指定しておくと、読み込みの途中でレイアウトがずれません。値は src や alt のプロパティに入れ、XML の文字を innerHTML で HTML に埋め込むことはしません。XML の中に思わぬ文字が入っていても、HTML として解釈されずに済みます。

方法 2: すべてそろってから並べる (Promise.all)

すべての画像の読み込みが終わってから、一度に表示したい場合もあります。画像の読み込みを Promise にして、Promise.all() で待ちます。Promise.all() の結果は、終わった順ではなく、渡した配列の順に並ぶので、順番が保たれます。

// 画像 1 枚の読み込みを Promise にする
function loadImage(src, alt) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve(img);
    img.onerror = () => reject(new Error("画像を読み込めません: " + src));
    img.alt = alt;
    img.src = src;
  });
}

const gallery = document.querySelector("#gallery");
const items = await loadGalleryXml("gallery.xml");
const results = await Promise.allSettled(
  items.map((item) => loadImage(item.src, item.alt))
);
for (const result of results) {
  if (result.status === "fulfilled") {
    gallery.append(result.value); // XML の順番のまま追加される
  }
}

ここでは Promise.all() の代わりに Promise.allSettled() を使っています。Promise.all() は 1 枚でも失敗すると全体が失敗になりますが、Promise.allSettled() なら、読み込めた画像だけを順番どおりに並べられます。ただし、この方法ではいちばん遅い画像を待つことになるので、枚数が多いときは方法 1 のほうが早く表示を始められます。

非同期処理の待ち方そのものについては、「処理の終わりを待ってから次へ ── コールバックから async/await まで」で詳しく説明しています。

まとめ

  • 非同期の読み込みは、始めた順に終わるとは限らない。終わった順に並べると順番が崩れる
  • 先に枠 (ActionScript 3 の Loader、JavaScript の img 要素) を順番どおりに並べておけば、読み込みの順番に関係なく並ぶ
  • すべてそろってから並べるなら、Promise.all() や Promise.allSettled() を使う。結果は渡した順に並ぶ
  • XML は fetch() と DOMParser で読み、parsererror で形式の誤りを確かめる

参考