ページ読込み直後にimgタグの画像を(擬似的に)高速に置き換える

Yuichi Yabu
Yuichi Yabu’s blog
12 min readDec 7, 2019

こちらは、PLAID AdventCalendar 2019 7日目の記事です。

11/1からプレイドにジョインしました。これまでの技術スタックは主にScala, Play Framework, Python, AWSでバックエンド専門、たまにAngularでWebフロントエンドも、という感じでしたが、プレイドではNode, JavaScript, TypeScript, Vue.js, GCPとなっていて全然違う技術スタックに若干とまどいつつ、新しい挑戦と環境を楽しんでいるところです。

この記事では、そんなプレイドでのWebフロントエンドの実装で、ある要求があって、それをWebフロントエンド初心者の僕がどのように解決していったかを順を追って説明していきます。

要求

Webページ読込み直後に特定のimgタグの画像 (特定のsrc属性の値) をできるだけ速く別の画像に置き換えて欲しい。条件として、置き換え前の画像は見えないようにして欲しい。

果たしてこんなニッチな要求がどれくらいあるのか(つまりこの記事がどれくらいの人に役に立つのか)不明ですが、僕には必要だったのでやってみました。

簡単なページと単純な実装で試してみる

なにはともあれ、まずは検証用の簡単な画像ありのWebページをローカルに用意します。

検証用ページ

検証用ページはラベルと画像が置いてあるだけの単純なページです。このページに画像置き換えのJavaScriptファイルを読み込ませます。ちなみに置き換え前の画像が赤で、置き換え後の画像が青です。与えられる情報は置き換え前後の画像のURLだけとします。

もっとも簡単な実装として単に該当のURLのimgタグをquerySelectorで探し、その要素のsrc属性を書き換えるというものがあると思います。

const beforeImgSrc = './img/before.png';
const afterImgSrc = './img/after.png';
const beforeImg =
document.querySelector(`img[src="${beforeImgSrc}"]`);
beforeImg.setAttribute('src', afterImgSrc);

このJSファイルをbodyの最後に入れておけば、書き換わるはずです。試してみます。

置き換わりました。置き換え前の画像も見えてません。何度かリロードを試しましたが、大丈夫でした。完璧です。終わりです。

って、そんな簡単な話ではありません。このコードではローカルの画像を取りにいってますが、実際はインターネット上から画像を取ってくる(通信コストがある)でしょうし、書き換えの前処理が必要な場合もあるでしょう。ですので、そのような処理を想定して、setTimeout関数で書き換え前に少しだけ遅延を入れてみます。

const beforeImgSrc = './img/before.png';
const afterImgSrc = './img/after.png';
// 擬似的な遅延
setTimeout(() => {
const beforeImg =
document.querySelector(`img[src="${beforeImgSrc}"]`);
beforeImg.setAttribute('src', afterImgSrc);
}, 20);

その結果はこちらです。

チラ見がわかりやすい用スロー再生

想定どおり、遅延を入れた分、リロード時に置き換え前の画像がチラ見してますね。このような状況でも置き換え前の画像が見えないようにどうにかしたいです。どうしたらいいでしょうか。

CSSで頑張ってみる

正直、上記のコードは遅延込みで最速で置き換えていると思うので、JSでこれ以上速くする方法が僕には思いつきませんでした。ですが、置き換えを高速にしなくても、見てる側が置き換え前の画像に気づかなければいいだけなので、そっち方面でなんとか出来ないかなと考えました。その結果思いついたのが、CSSで置き換え前の画像を見えなくするというものです。

実際の処理はこんな感じです。

  1. JSを<head>内に移動
  2. document.styleSheetsに該当の画像のopacityを0にするCSSを挿入
  3. DOMContentLoadedのイベントのListenterに画像を置換え処理をセット
  4. 画像置換え処理の最後に2. で挿入したCSSを削除する

JSをhead内に移動したのは、ページロード前にCSS差し込みを行いたかったからです(もしかしたらロード直後にCSS差し込みでもいけるかもしれません)。これで、大丈夫な気がします。さっそくやってみます。

const beforeImgSrc = './img/before.png';
const afterImgSrc = './img/after.png';

const css = `
img[src="${beforeImgSrc}"] {
opacity: 0;
}`;
// 該当画像のopacityを0にするCSSを差し込む
document.styleSheets.item(0).insertRule(css, 0);

document.addEventListener('DOMContentLoaded', () => {
// 擬似的な遅延
setTimeout(() => {
const beforeImg =
document.querySelector(`img[src="${beforeImgSrc}"]`);
beforeImg.setAttribute('src', afterImgSrc);

// 挿入したcssを削除
document.styleSheets.item(0).deleteRule(0);
}, 10);
});

置き換え前の画像が見えなくなりましたね。代わりにopacityを0にしている分、チラチラしますがそもそも何度もリロードするというのは通常利用ではあまり考えられないので許容します。置き換え前の画像が見えるよりはUXが良さそうです。

その他もろもろの問題をどうするか

これで終わりでもいいのですが、いくつか考えるべき問題があります。

  1. もしCSS差込後、かつ置換え前にバグって止まったら、画像がなにも表示されないまま?
  2. 管理下にない別のJSが非同期でCSSを差し込んだらどうするのか
  3. 該当の画像がページ読込後に別のJSから挿入される場合はどうするのか

などがありそうです。

  1. の問題に関しては、opacityを0にするCSSをopacity 0から1に変化するアニメーションに変えることで対処できそうです。こうしておけば、置き換え処理がコケてもアニメーションで置き換え前の画像が表示されるはずです(前提として何も表示されないくらいなら置き換え前の画像が表示される方がベターというのがあります)。
  2. の問題に関しては、先程のサンプルコードではCSSを削除するときindexを決め打ちで0にしていましたが、この問題が発生するとindexがずれるため意図していないCSS削除をしてしまうということです。この対処方法は、1. とも関係しますが、アニメーション名をランダムな文字列にしておいて、その文字列でCSSを検索することで対処できそうです。
  3. の問題に関しては、今回は対処しません。やるとしたら、例えば MutationObserver を使ってDOMの変更を検知し、変更箇所に該当画像があるか検索し、置き換え処理を走らせるとか出来るかもしれません(MutationObserverに詳しくないので用途として適切かどうかは分かりません)。

以上のような改善を行った結果がこちらです。

const beforeImgSrc = './img/before.png';
const afterImgSrc = './img/after.png';
// 2.の対策のためのランダムなアニメーション名
const animationName = `fadein_${Math.random().toString().substring(2)}`;
// 1.の対策のためのアニメーションCSS
const keyFrameCSS = `
@keyframes ${animationName} {
from {
opacity:0;
}
to {
opacity:1;
}
}`;

const animationCSS = `
img[src="${beforeImgSrc}"] {
animation: ${animationName} .5s step-end;
}`;

document.styleSheets.item(0).insertRule(keyFrameCSS, 0);
document.styleSheets.item(0).insertRule(animationCSS, 0);

document.addEventListener('DOMContentLoaded', () => {
// 擬似的な遅延
setTimeout(() => {
const beforeImg =
document.querySelector(`img[src="${beforeImgSrc}"]`);
// afterImgSrcの画像を取ってくるのが遅かったときのため前画像は見えなくしておく
beforeImg.removeAttribute('src');
// 画像を差し替えるとaltと整合性が取れないかもしれないので消しておく
beforeImg.removeAttribute('alt');
beforeImg.setAttribute('src', afterImgSrc);

// 書き換え後にCSSルールの削除を行う
// 非同期で別のjsからルールが挿入されることを想定し、cssTextを検索して削除する
const styleSheets = document.styleSheets.item(0);
for (let i = styleSheets.rules.length-1; i >= 0; i--) {
const rule = styleSheets.rules[i];
if (rule.cssText.indexOf(animationName) >= 0) {
styleSheets.deleteRule(i);
}
}
}, 10);
});

細かな点ですが、例えば置換え後の画像がサイズが大きくてダウンロードに時間がかかった場合、opacityのアニメーションが先に効いて前画像が見えてしまうかもしれません。そのため、一旦前画像のsrcを削除しています。
また、画像を差し替えるとimgタグのalt属性は画像との整合性が取れなくなるかもしれないので、とりあえず消しておくということも場合によっては必要かもしれません。

では1.の問題が発生した場合を試してみます。setTimeout()のコールバックの先頭で例外を投げます。

document.addEventListener('DOMContentLoaded', () => {
// 擬似的な遅延
setTimeout(() => {
// 置換え処理の直前で例外
throw 'err';
// 置換え処理が続く
}, 10);
});

こうすれば、CSSが差し込まれたあと、画像の置換えが発生することなく例外で止まります。

置換え前に例外を発生させた

このように、例外が発生してもアニメーションのおかげで、少し遅延はありますが、前の画像が表示されています。これで安心ですね。

以上で、imgタグ画像の擬似的な高速置換え処理の完成です。要件は満たしつつ、ある程度のトラブルにも対応できるようなコードになっているかと思います。ただし、置換え後のURLが404だった場合は?とかCSSのルール削除が後ろからだと効率悪くない?とか改善点はあると思っています。

まとめ

正直、まだWebフロントエンド初心者なので、この方法がいい方法なのかは分かりませんが、要求は満たしていたのでよしとしました。タイトルにもあった「(擬似的に)高速」の ”擬似的に” というのは置換え前の画像を透明にしてお茶を濁している部分のことです。

これまで、主にバックエンド開発をしてきたので、フロントエンド開発は知らないことも多く楽しめています。転職では割りと自分の技術スタックに寄った職を探すかと思いますが、全然違うところに飛び込んでみるのも悪くないなと思っているところです。

それでは。

--

--