サイトに CSS・JS が効いていない時にミラーの CSS・JS ファイルを読み込んでフォールバックさせるスクリプトを作った

このブログ Corredor で、iPhone から閲覧している場合に時々発生していたのだが、ブログの CSS・JS ファイルが読み込めずにレイアウトが崩れていることがあった。

このブログの CSS と JS ファイルは、GitHub Pages でホスティングしているモノを「はてなブログ」管理画面の「設定」にて、head 要素内で読み込むようにしている。

<!-- はてなブログ管理画面の「設定」→「詳細設定」タブ→「head に要素を追加」欄に、以下のように記載している -->
<link rel="stylesheet" href="https://neos21.github.io/HatenaBlogs/dist/styles/Corredor.css">
<script src="https://neos21.github.io/HatenaBlogs/dist/scripts/Corredor.js"></script>

どういうワケか、iPhone Safari で見る時だけこのファイルが上手く読み込まれず、レイアウトが崩れることがあったので、試しにフォールバック処理を組み込んでみることにした。

目次

CSS・JS ファイルのフォールバック処理とは

ココでいう「フォールバック」とは、上述の HTML ソースで読み込ませようとした CSS ファイルや JS ファイルが読み込めなかった時に、別サイトにホスティングしている同じファイルを読み込ませる、ということだ。

当然ながら、CSS ファイル、JS ファイルを予めミラーサイトにホスティングしておく必要がある。

CSS・JS ファイルをホスティングするなら npm の CDN を使うのが手っ取り早い

CSS や JS のような静的なファイルをホスティングするなら、npm パッケージとして npm publish し、unpkg や jsdelivr のような CDN サービスを利用して読み込むのが良いだろう。

unpkg や jsdelivr に対しては特に登録の必要はない。ただ npm publish するだけで使えるようになるので、お手軽だ。

その他、サービス終了がアナウンスされているが、「Raw Git」やその類似サービスなど、GitHub 上にあるファイルに Content-Type を付けて返してくれるサービスも使えるだろう。GitHub Pages として配布するのとは少し違って、GitHub リポジトリの Raw ファイルを直接参照するっぽいので、フォールバックとして使えるだろう。

CSS のフォールバック処理

それではまず、CSS のフォールバック処理を用意してみる。

1. CSS 読み込みチェック用の HTML 要素を置く

まず、サイト内に CSS 読み込みチェック用の HTML 要素を置く。自分の場合は、以下のような空の要素をページの最下部など邪魔にならないところに置いておく。

<span id="n-check"></span>

2. CSS ファイルにて読み込みチェック用の要素にスタイルを当てる

次に、CSS ファイルで、読み込みチェック用の要素にスタイルを当てる。

/* CSS 読込が正常に行えているか確認するための検証用要素 */
#n-check {
  display: none;
  font-size: 0;
}

display: none は念のため、要素を非表示にするために指定。ココでは font-size: 0 と指定してある。

前述の link 要素を基に、onloadonerror 属性を追加する。

<link rel="stylesheet" href="https://neos21.github.io/HatenaBlogs/dist/styles/Corredor.css" onload="Neos21.styles(-1);" onerror="Neos21.styles(-1);">

コレで、この CSS ファイルが読み込まれた時、もしくは読み込みに失敗した時、いずれの場合でも、window.Neos21.styles() という関数が実行されることになる。この関数の中身はコレから実装する。

4. CSS 読み込みチェックおよびフォールバック処理用のスクリプトを実装する

CSS ファイルの読み込みチェックと、読み込めていなさそうだった時にミラーサイトから CSS ファイルを読み込み直す、一連のスクリプトを以下のように実装する。

/**
 * CSS・JS ファイルのフォールバック用グローバルオブジェクト
 */
Neos21 = {
  /**
   * link 要素または script 要素を head 要素に追加する
   * 
   * @param isLink true なら link 要素・false なら script 要素を生成する
   * @param nextIndex 添字
   * @param nextUrl 読み込むファイル URL
   */
  append: function(isLink, nextIndex, nextUrl) {
    var d = document;
    var s = 'setAttribute';
    var eventValue = 'Neos21.' + (isLink ? 'styles' : 'scripts') + '(' + nextIndex + ');';
    
    var elem = d.createElement(isLink ? 'link' : 'script');
    if(isLink) {
      elem[s]('rel', 'stylesheet');
    }
    elem[s](isLink ? 'href' : 'src', nextUrl);
    elem[s]('onload' , eventValue);
    elem[s]('onerror', eventValue);
    d.querySelector('head').appendChild(elem);
  },
  
  /**
   * CSS ファイルが読み込めているか検証し、必要に応じてフォールバック処理を行う
   * 
   * @param index フォールバック URL の添字
   */
  styles: function(index) {
    // 第1引数が number 型で指定されていなければ中止する
    if(typeof index !== 'number') {
      return;
    }
    
    var w = window;
    var d = document;
    
    // 検証用要素の読み込みを待つため再呼び出しして終了する
    if(!d.readyState || d.readyState === 'interactive') {
      w.addEventListener('load', function() {
        Neos21.styles(index);
      });
      return;
    }
    else if(d.readyState === 'loading') {
      d.addEventListener('DOMContentLoaded', function() {
        Neos21.styles(index);
      });
      return;
    }
    
    // フォールバック URL の定義
    var urls = [
      // unpkg ホスティングのミラーファイル
      'https://unpkg.com/@neos21/hatena-blogs@1.0.4/dist/styles/Corredor.css',
      // jsdelivr
      'https://cdn.jsdelivr.net/npm/@neos21/hatena-blogs@1.0.4/dist/styles/Corredor.css',
      // GitHack
      'https://raw.githack.com/Neos21/HatenaBlogs/master/dist/styles/Corredor.css',
      'https://rawcdn.githack.com/Neos21/HatenaBlogs/a9a8c7d78b940f1d90a8b07e40f04418f407c469/dist/styles/Corredor.css',
      // Raw GitHub
      'https://raw.githubusercontent.com/Neos21/HatenaBlogs/master/dist/styles/Corredor.css'
    ];
    
    // 検証用要素
    var check = d.getElementById('n-check');
    if(!check) {
      // 検証用要素なし・中止
      return;
    }
    
    // 検証用要素にスタイルが適用されているか確認する
    var checkStyle = parseInt(w.getComputedStyle(check).fontSize);
    if(checkStyle === 0) {
      // スタイル適用済なら正常・何もしない
      return;
    }
    
    // フォールバックが必要
    
    // 次の Index 値
    var nextIndex = index + 1;
    
    // フォールバック用 URL がなかったら対応不可・終了
    if(nextIndex >= urls.length) {
      return;
    }
    
    // link 要素を生成し挿入する
    Neos21.append(true, nextIndex, urls[nextIndex]);
  }
};

このフォールバック処理は window.Neos21 というグローバルオブジェクト内に styles() という関数で定義している。

最初のブロックでは引数のチェックをしている。引数 index は、このあと定義しているフォールバック用 CSS ファイルの URL を定義した配列の添字をズラすためのモノ。link 要素の onloadonerror 属性で Neos21.styles(-1) と指定したとおり、最初の CSS が読み込まれた時は -1 が指定されており、フォールバックが必要な場合はインクリメントして配列の 0 番目の URL からファイルを再読込しようとする。

次のブロックで、ページの読み込み状況を確認し、読み込みが終わっていなければ DOMContentLoaded なり load なりに自身を再呼び出しするイベントを追加して終了している。ページの読み込みが完了していない段階で実行すると、上手く CSS の読み込み状況が拾えないことがあるからだ。

そのあと、var urls でフォールバック用の URL を定義している。link 要素で直接指定した CSS ファイルと同じ内容のモノを、ミラーサイト (CDN) に置いておき、その URL を配列に控えておく。要はこれらの URL から CSS ファイルを順に読み込んでいけば、どれかしら正常に読み込めるんじゃね? という魂胆だ。

さて、CSS ファイルが正常に読み込めているかどうかの検証だが、window.getComputedStyle() という関数を使う。この関数は CSS が適用されて実際にどんなスタイルが当たっているのかを返してくれるモノで、この関数を使って検証用の HTML 要素 document.getElementById('n-check')font-size をチェックすればよかろう、というワケだ。結果は '0px' という文字列で返ってくるはずで、それを parseInt() すれば 0 が取れるはず。もし CSS ファイルが当たっていなければ、デフォルトスタイルで '16px' などの値が返ってくるはずなので、ココで読み込みチェックができる、ということ。必ずしも font-size 値で検証しないといけないワケではないが、display: none にしていても影響がなく、color のように期待値が分かりづらくなるプロパティを避けただけ。

検証用の関数に CSS が適用されていないようであれば、変数 urls から次のフォールバック用 URL を取得し、link 要素を生成して head 要素に埋め込んでいる。この時も onloadonerror 属性を付与しているので、続けざまにフォールバックが可能になっている。

このようなスクリプトを、link 要素より上にベタ書きする。

<!-- 先程のスクリプトを Uglify したもの -->
<script>
Neos21={append:function(e,t,s){var o=document,r="setAttribute",a="Neos21."+(e?"styles":"scripts")+"("+t+");",n=o.createElement(e?"link":"script");e&&n[r]("rel","stylesheet"),n[r](e?"href":"src",s),n[r]("onload",a),n[r]("onerror",a),o.querySelector("head").appendChild(n)},styles:function(e){if("number"==typeof e){var t=window,s=document;if(s.readyState&&"interactive"!==s.readyState)if("loading"!==s.readyState){var o=["https://unpkg.com/@neos21/hatena-blogs@1.0.4/dist/styles/Corredor.css","https://cdn.jsdelivr.net/npm/@neos21/hatena-blogs@1.0.4/dist/styles/Corredor.css","https://raw.githack.com/Neos21/HatenaBlogs/master/dist/styles/Corredor.css","https://rawcdn.githack.com/Neos21/HatenaBlogs/a9a8c7d78b940f1d90a8b07e40f04418f407c469/dist/styles/Corredor.css","https://raw.githubusercontent.com/Neos21/HatenaBlogs/master/dist/styles/Corredor.css"],r=s.getElementById("n-check");if(r)if(0!==parseInt(t.getComputedStyle(r).fontSize)){var a=e+1;o.length<=a||Neos21.append(!0,a,o[a])}}else s.addEventListener("DOMContentLoaded",function(){Neos21.styles(e)});else t.addEventListener("load",function(){Neos21.styles(e)})}}};
</script>

<!-- 最初に読み込ませる CSS ファイル -->
<link rel="stylesheet" href="https://neos21.github.io/HatenaBlogs/dist/styles/Corredor.css" onload="Neos21.styles(-1);" onerror="Neos21.styles(-1);">

フォールバック用スクリプトを外部ファイル読み込みにしてしまうと、そのスクリプトファイルが読み込めなかった時に対応しきれない。また、link 要素より先に書いておかないと、onloadonerror が先に発火してしまった時にグローバルオブジェクト window.Neos21 がまだ存在しなくてエラーになってしまう恐れがある。

以上で、CSS ファイルのフォールバック処理は完了だ。

JS ファイルのフォールバック処理

続いて JS ファイルも同様にフォールバック処理を用意していこう。

1. JS ファイルにて読み込みチェック用のグローバルオブジェクトを定義する

読み込む JS ファイル内で、グローバルオブジェクト window.Neos21 内に、JS ファイルが正常に読み込めていることを知らせるフラグ変数を用意する。

// JS ファイル内

// 本来の処理……省略……

// 検証用オブジェクト・プロパティを定義しておく
if(!window.Neos21) {
  window.Neos21 = {};
}

// スクリプトが読み込めたことを知らせるフラグ変数を設定する
window.Neos21.scriptLoaded = true;

2. 最初に読み込む JS ファイルの script 要素に onloadonerror 属性を設定する

前述の script 要素に、CSS ファイルの時に書いたような要領で onloadonerror 属性を付与する。

<script src="https://neos21.github.io/HatenaBlogs/dist/scripts/Corredor.js" onload="Neos21.scripts(-1);" onerror="Neos21.scripts(-1);"></script>

コレで、この JS ファイルが読み込めた時も、読み込めなかった時も、window.Neos21.scripts() 関数が実行される、というワケ。

3. JS 読み込みチェックおよびフォールバック処理用のスクリプトを実装する

JS ファイルの読み込みチェックも、CSS ファイルの場合と似たような関数を用意して実現する。

/**
 * CSS・JS ファイルのフォールバック用グローバルオブジェクト
 */
Neos21 = {
  /**
   * link 要素または script 要素を head 要素に追加する
   * 
   * @param isLink true なら link 要素・false なら script 要素を生成する
   * @param nextIndex 添字
   * @param nextUrl 読み込むファイル URL
   */
  append: function(isLink, nextIndex, nextUrl) {
    var d = document;
    var s = 'setAttribute';
    var eventValue = 'Neos21.' + (isLink ? 'styles' : 'scripts') + '(' + nextIndex + ');';
    
    var elem = d.createElement(isLink ? 'link' : 'script');
    if(isLink) {
      elem[s]('rel', 'stylesheet');
    }
    elem[s](isLink ? 'href' : 'src', nextUrl);
    elem[s]('onload' , eventValue);
    elem[s]('onerror', eventValue);
    d.querySelector('head').appendChild(elem);
  },
  
  /**
   * JS ファイルが読み込めているか検証し、必要に応じてフォールバック処理を行う
   * 
   * @param index フォールバック URL の添字
   */
  scripts: function(index) {
    // 第1引数が number 型で指定されていること
    if(typeof index !== 'number') {
      return;
    }
    
    // フォールバック URL の定義
    var urls = [
      // https://unpkg.com/@neos21/hatena-blogs/
      'https://unpkg.com/@neos21/hatena-blogs@1.0.4/dist/scripts/Corredor.js',
      // https://www.jsdelivr.com/package/npm/@neos21/hatena-blogs
      'https://cdn.jsdelivr.net/npm/@neos21/hatena-blogs@1.0.4/dist/scripts/Corredor.js',
      // http://raw.githack.com/
      'https://raw.githack.com/Neos21/HatenaBlogs/master/dist/scripts/Corredor.js',
      'https://rawcdn.githack.com/Neos21/HatenaBlogs/a9a8c7d78b940f1d90a8b07e40f04418f407c469/dist/scripts/Corredor.js',
      // Raw GitHub
      'https://raw.githubusercontent.com/Neos21/HatenaBlogs/master/dist/scripts/Corredor.js'
    ];
    
    // 検証用プロパティが存在しているか確認する
    if(Neos21.scriptLoaded) {
      // プロパティがあれば読込済・何もしない
      return;
    }
    
    // フォールバックが必要
    
    // 次の Index 値
    var nextIndex = index + 1;
    
    // フォールバック用 URL がなかったら対応不可・終了
    if(nextIndex >= urls.length) {
      return;
    }
    
    // script 要素を生成し挿入する
    Neos21.append(false, nextIndex, urls[nextIndex]);
  }
};

結局は window.Neos21.scriptLoadedtrue かどうかをチェックして、true でなければ別の URL から JS ファイルを読み込むよう、script 要素を生成して head 要素に追加しているだけ。

以上で JS ファイルもフォールバック処理させることができた。

最後にフォールバック用スクリプトの全量

最後に、掲載したフォールバック用スクリプトの全量を再掲載して終了する。

/**
 * CSS・JS ファイルのフォールバック用グローバルオブジェクト
 */
Neos21 = {
  /**
   * link 要素または script 要素を head 要素に追加する
   * 
   * @param isLink true なら link 要素・false なら script 要素を生成する
   * @param nextIndex 添字
   * @param nextUrl 読み込むファイル URL
   */
  append: function(isLink, nextIndex, nextUrl) {
    var d = document;
    var s = 'setAttribute';
    var eventValue = 'Neos21.' + (isLink ? 'styles' : 'scripts') + '(' + nextIndex + ');';
    
    var elem = d.createElement(isLink ? 'link' : 'script');
    if(isLink) {
      elem[s]('rel', 'stylesheet');
    }
    elem[s](isLink ? 'href' : 'src', nextUrl);
    elem[s]('onload' , eventValue);
    elem[s]('onerror', eventValue);
    d.querySelector('head').appendChild(elem);
  },
  
  /**
   * CSS ファイルが読み込めているか検証し、必要に応じてフォールバック処理を行う
   * 
   * @param index フォールバック URL の添字
   */
  styles: function(index) {
    // 第1引数が number 型で指定されていなければ中止する
    if(typeof index !== 'number') {
      return;
    }
    
    var w = window;
    var d = document;
    
    // 検証用要素の読み込みを待つため再呼び出しして終了する
    if(!d.readyState || d.readyState === 'interactive') {
      w.addEventListener('load', function() {
        Neos21.styles(index);
      });
      return;
    }
    else if(d.readyState === 'loading') {
      d.addEventListener('DOMContentLoaded', function() {
        Neos21.styles(index);
      });
      return;
    }
    
    // フォールバック URL の定義
    var urls = [
      // https://unpkg.com/@neos21/hatena-blogs/
      'https://unpkg.com/@neos21/hatena-blogs@1.0.4/dist/styles/Corredor.css',
      // https://www.jsdelivr.com/package/npm/@neos21/hatena-blogs
      'https://cdn.jsdelivr.net/npm/@neos21/hatena-blogs@1.0.4/dist/styles/Corredor.css',
      // http://raw.githack.com/
      'https://raw.githack.com/Neos21/HatenaBlogs/master/dist/styles/Corredor.css',
      'https://rawcdn.githack.com/Neos21/HatenaBlogs/a9a8c7d78b940f1d90a8b07e40f04418f407c469/dist/styles/Corredor.css',
      // Raw GitHub
      'https://raw.githubusercontent.com/Neos21/HatenaBlogs/master/dist/styles/Corredor.css'
    ];
    
    // 検証用要素
    var check = d.getElementById('n-check');
    if(!check) {
      // 検証用要素なし・中止
      return;
    }
    
    // 検証用要素にスタイルが適用されているか確認する
    var checkStyle = parseInt(w.getComputedStyle(check).fontSize);
    if(checkStyle === 0) {
      // スタイル適用済なら正常・何もしない
      return;
    }
    
    // フォールバックが必要
    
    // 次の Index 値
    var nextIndex = index + 1;
    
    // フォールバック用 URL がなかったら対応不可・終了
    if(nextIndex >= urls.length) {
      return;
    }
    
    // link 要素を生成し挿入する
    Neos21.append(true, nextIndex, urls[nextIndex]);
  },
  
  /**
   * JS ファイルが読み込めているか検証し、必要に応じてフォールバック処理を行う
   * 
   * @param index フォールバック URL の添字
   */
  scripts: function(index) {
    // 第1引数が number 型で指定されていること
    if(typeof index !== 'number') {
      return;
    }
    
    // フォールバック URL の定義
    var urls = [
      // https://unpkg.com/@neos21/hatena-blogs/
      'https://unpkg.com/@neos21/hatena-blogs@1.0.4/dist/scripts/Corredor.js',
      // https://www.jsdelivr.com/package/npm/@neos21/hatena-blogs
      'https://cdn.jsdelivr.net/npm/@neos21/hatena-blogs@1.0.4/dist/scripts/Corredor.js',
      // http://raw.githack.com/
      'https://raw.githack.com/Neos21/HatenaBlogs/master/dist/scripts/Corredor.js',
      'https://rawcdn.githack.com/Neos21/HatenaBlogs/a9a8c7d78b940f1d90a8b07e40f04418f407c469/dist/scripts/Corredor.js',
      // Raw GitHub
      'https://raw.githubusercontent.com/Neos21/HatenaBlogs/master/dist/scripts/Corredor.js'
    ];
    
    // 検証用プロパティが存在しているか確認する
    if(Neos21.scriptLoaded) {
      // プロパティがあれば読込済・何もしない
      return;
    }
    
    // フォールバックが必要
    
    // 次の Index 値
    var nextIndex = index + 1;
    
    // フォールバック用 URL がなかったら対応不可・終了
    if(nextIndex >= urls.length) {
      return;
    }
    
    // script 要素を生成し挿入する
    Neos21.append(false, nextIndex, urls[nextIndex]);
  }
};

もう少しコードとしては削れそうなところがあるが、とりあえずやりたいことはできたので良き良き。

改変して利用いただく場合は、グローバルオブジェクト名 Neos21 を任意のモノに変えていただいたり、フォールバック用 URL 定義を忘れずに変更していただくぐらいだろうか。検証用のプロパティや要素の設定もコード量を削れそうな感はあるので、短い名称を使うなどして検証できればいいかしら。