JavaScriptのPromiseの使い方
以前は、今まで避けて通ってきたJavaScriptの配列の値渡しと参照渡しの違いについて学びましたが、今回は同様に他で代替ができるので今まで利用を避けてきたJavaScriptのPromiseについて学習します。
このブログではhilight.jsでページ内のコードに色をつけているのですが、最終的にこれに使う.jsファイルや.cssファイルを遅延読み込みしてみます。
遅延読み込みはasyncやdeferを使っても実現できますが、これだとページの初期表示と切り離しができません。Promiseを使って、ボタンクリックやページスクロールを契機にシーケンシャルにスクリプトを読み込んでみたいと思います。
Promise
PromiseはJavaScriptの非同期用のオブジェクトです。
Promiseではオブジェクトを作成する際に関数を渡します。この関数はオブジェクトを作成した際に非同期に実行されます。戻り値とは別にステータスを持ち、それらは、初期状態を意味する「待機 (pending)」、実行に成功した「(fulfilled)」、失敗した「拒否 (rejected)」です。
オブジェクトに渡す関数では成功時に呼び出す関数(resolve)と失敗時に呼び出す関数(reject)を受け取ることができ、ステータスはそのどちらかが呼び出されるまでは「待機」になっています。
「resolve」「reject」関数は、与えられた引数で状態が「成功」または「失敗」となったPromiseオブジェクトを返します。
「then」メソッドはPromiseのステータスが「待機」から変わった際に実行されます。このメソッドに成功と失敗時それぞれの場合に実行する関数を引数に渡します。関数はどちらも省略できます。省略されたり関数ではなかった場合は、成功時は受け取った値を返す関数、失敗時は受け取ったエラーをスローする関数に置き換えられます。関数を指定した場合、Promise以外の値を返すと戻り値は解決済みのPromiseに変換されます。また、戻り値に「待機」状態の新たなPromiseを返すことでPromiseを連鎖させることができます。
このような挙動になっているのでPromiseは数珠繋ぎが途切れた場所で、最終的にはPromise以外の値または、例外に置き換わるようになっています。
コールバックを使う方法
先にどのようなコードを実現したいかというのをPromiseなしで書いておきます。配列内にCDNから読み込ませたいライブラリをを順番通りにURLを配置して関数に渡します。関数はscript要素のonloadイベントにより再帰的に呼び出されます。存在しないアドレスを指定するとエラーとなるのを考慮していなかったり、タイムアウトの設定もしていません。
const strUrls=['https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js','https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js'];
const elHead = document.getElementsByTagName('head')[0];
const loadScripts = async (strUrls,intIndex) => {
if(intIndex < strUrls.length) {
let script = document.createElement('script');
script.src=strUrls[intIndex];
script.async="async";
script.onload=()=>{
console.log(strUrls[intIndex]+" loaded");
loadScripts(strUrls,intIndex+1);
};
elHead.appendChild(script);
} else {
console.log('all libs loaded');
}
}
loadScripts(strUrls,0);
Promiseで記述
先のコードをPromiseで書くと次のようになります。Promiseで書いた場合は、タイムアウトを指定しないと長く(永遠?)待ち続けてしまったので、タイムアウトも設定しました。
resolveに新しいPromiseを設定することで連鎖させています。Promiseを受ける変数の中は、未解決の時と解決済みの時で値が変わります。未解決ならPromiseそのもの、解決済みならPromiseのresolveの引数に持たせた値となります。
const strUrls=['https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js','https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js'];
const elHead = document.getElementsByTagName('head')[0];
const loadJs = (intIndex) => {
if (intIndex < strUrls.length) {
return new Promise((resolve, reject) => {
//存在しないアドレスを指定した場合にonloadイベントが起きずに処理を待ち続けるのでタイムアウトを設定しています。
let timeoutId = setTimeout(() => { reject(strUrl + 'の読み込みでタイムアウトになりました') }, 30 * 1000);
let script = document.createElement('script');
script.async = "async";
script.src = strUrls[intIndex];
script.onload = (e) => {
clearTimeout(timeoutId);
/* 挙動を確認しやすいようにタイムアウトを入れています。 */
setTimeout(()=>{
console.log(strUrls[intIndex]+" loaded");
/* Promiseは生成時に実行を開始するのでここでインスタンスを作る必要があります */
resolve(loadJs(intIndex+1));
},3000);
}
elHead.appendChild(script);
})
.catch(err=>{console.log(err)});
} else {
//console.log('all libs loaded');
return Promise.resolve('all libs loaded');
}
}
//console.log(loadJs(0)); /* すぐに受け取ると未解決のPromiseが返ります */
(async() => {
console.log(await loadJs(0)); /* 連鎖の終了まで待つと、文字列(all libs loaded)が返ります */
})();
最後の(async()の部分の解説をしておきます。これはPromiseの完了を待つと中身はPromise以外のものになるという事を紹介したくて記載しています。awaitは非同期処理を待ち受ける(同期処理に変える)キーワードです。これは、非同期関数からしか使えないので、asyncの無名関数を定義して即時実行しています。
CSSも含める
Promiseの話は以上になりますが、先のhilight.jsは、JSと一緒にCSSも読み込みする必要がありましたので、コードにCSSを含める方法も紹介します。
通常の、CSSの遅延(非同期)読み込みはQuiita:「CSSを非同期ロードする最も簡単な方法」で紹介されているThe Simplest Way to Loa CSS Asynchronouslyの翻訳にあるように、次のようにするそうです。
<link rel="preload" href="/path/to/my.css" as="style">
<!-- または -->
<link rel="stylesheet" href="/path/to/my.css" media="print" onload="this.media='all'">
詳しくは参考サイトの方を読んでいただければと思いますが、preloadにより優先度を最高位にするか、メディアクエリを一旦print(印刷用)にしてonload時に元に戻す仕組みにすることにより、CSS読み込み時のレンダリングブロックを防ぐことができるそうです。
初期状態でheadに存在しなものを後からpreloadとして挿入するのは、なんとなく不具合が起きそうなので、media=printの方法を使うことにします。
CSS対応コード
CSSの遅延読み込みにも対応するコードは次のようになります。こちらはコードの紹介なので、わかりやすいようにコールバック式でコードを紹介します。
let strUrls=[
"https://unpkg.com/@highlightjs/cdn-assets@10.5.0/highlight.min.js",
"https://unpkg.com/@highlightjs/cdn-assets@10.5.0/styles/arduino-light.min.css"
];
const elHead = document.getElementsByTagName('head')[0];
const loadScripts = async (strUrls,intIndex) => {
if(intIndex < strUrls.length) {
let strSlice3 = strUrls[intIndex].slice(-3).toLowerCase();//最後の3字を判定のために取得
if(strSlice3==='.js') {
let script = document.createElement('script');
script.src=strUrls[intIndex];
script.async="async";
script.onload=()=>{
console.log(strUrls[intIndex]+" loaded");
loadScripts(strUrls,intIndex+1);
};
elHead.appendChild(script);
} else if(strSlice3==='css') {
let link = document.createElement('link');
link.rel="stylesheet";
link.href=strUrls[intIndex];
link.media="print";
link.onload=()=>{
console.log(strUrls[intIndex]+" loaded");
link.media="all";
loadScripts(strUrls,intIndex+1);
}
elHead.appendChild(link);
}
}
}
loadScripts(strUrls,0);
参考にさせていただきましたサイトの皆様ありがとうございました。