はじめに
最近のWebアプリでは、WebAPI を用いてクライアント側から JSON データを取得してそれらを描画する...
という流れが一般的になってきています。
WebAPIをクライアント側で使用するためには、JavaScript による非同期処理の実装は避けて通ることはできないでしょう。
今回、非同期処理の基本的な概念と実装方法を学習しましたので、その内容を記録しておきます。
※誤った記述等がありましたら、ご教授いただけると嬉しいです!
非同期処理とは
以下のような、引数で渡されたスクリプトを読み込む関数があったとする。
function loadScript(src) {
let script = document.createElement('script');
script.src = src;
document.head.append(script);
}
これは任意の JavaScript ファイルを読み込むための関数であり、呼び出されると <script src="...">
タグが<head></head>
タグ内に追加され、ファイルの読み込み処理が行われる。
// 呼び出し
loadScript('/my/script.js');
ファイルの読み込み処理はファイルサイズや通信状態によって時間がかかるため
読み込み処理のうしろに別の処理が控えていた場合、ファイル読み込みの完了を待たずに処理される。
loadScript('/my/script.js');
console.log('hogeeee!'); // loadScript関数 の完了を待たずに処理が開始する
このファイル読み込み処理のように、
呼び出されてもすぐに完了せず、同期的に処理が進行しないような処理を 非同期処理 と呼ぶ。
例えば、loadScript 関数で読み込まれるスクリプト内の関数を、後続処理で利用する場合は
スクリプトの読み込みが完了するまで待たなければならない。
スクリプトの読み込み中に呼び出すとエラーになってしまう。
loadScript('/my/script.js'); // myFunction() を含むスクリプトを読み込み
myFuction(); // スクリプトの読み込みが終わってないので、エラーが発生する。:「そんな関数は存在しません!」
これを解決するためには、loadScript に対して「読み込みが終わったら実行する関数( callback関数)」を導入する必要がある。
callback 関数
loadScript 関数の第2引数に callback関数 を追加する。
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
script.onload = () => callback(); // この行を追加
document.head.append(script);
}
※
onload
はHTMLのページやスクリプトファイル、画像ファイル等の読み込み完了後に
何らかの操作をしたいときに利用するイベントハンドラ。
ファイルの読み込みが完了したタイミングで実行したい処理を定義できる。
上記の例では、script.onload
に callback 関数を呼び出すための無名関数を設定しているので
スクリプトの読み込みの完了後に callback の処理が呼び出されるようになる。
以下は呼び出しのイメージ。
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
script.onload = () => callback(script); // callback関数 に script を渡す。
document.head.append(script);
}
loadScript('/my/script.js', script => {
// 受け取った script のパスを表示する。
alert(`${script.src} is loaded`);
});
エラー処理
上記の例ではスクリプト読み込み時に発生するエラーに対応できていない。
対応するには、callback 関数の引数にエラー情報を追加し、エラーを追跡する。
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
// 正常時
script.onload = () => callback(null, script);
// エラー発生時
script.onerror = () => callback(new Error(`Script load error for ${src}`));
document.head.append(script);
}
loadScript('/my/script.js', function(error, script) {
if (error) {
// エラー処理
} else {
// スクリプトの読み込みが成功
}
});
スクリプトの読み込みに成功した場合(onload)は callback(null, script)
が呼び出され、
失敗した場合(onerror)は callback(error)
が呼び出される。
callback関数の第1引数は error オブジェクトが渡される。エラーが発生したときのために予約しておき、
エラー発生時は error オブジェクトを、正常時は null
を渡すことでエラー発生を判定できる。
第2引数以降は正常時の結果データを得るための引数であり、任意の個数設定できる。
このことから、単一の callback関数はエラー報告と正常時の結果渡しの両方の用途で使用される。
これは コールバックベース の非同期プログラミングのスタイルであり、
上記のように callback関数の第1引数にエラーを設定するスタイルを エラーファーストなコールバック と呼ぶ。
callback 地獄
例えば /my/script.js
を読み込んだ後に、/my/script2.js
を読み込み、
その読み込み後に /my/script3.js
を読み込みたい場合、loadScript
の コールバック関数で再び loadScript
を呼び出すことで実現できる。
loadScript('/my/script.js', function(script) {
loadScript('/my/script2.js', function(script) {
loadScript('/my/script3.js', function(script) {
// 1 -> 2 -> 3 の順番で呼び出される。
});
})
});
これに先程のエラー処理を加えてみる。
loadScript('/my/script.js', function(error, script) {
if (error) {
handleError(error);
} else {
loadScript('/my/script2.js', function(error, script) {
if (error) {
handleError(error);
} else {
loadScript('/my/script3.js', function(error, script) {
if (error) {
handleError(error)
} else {
// ...
}
});
}
});
}
});
ネストが増え、複雑な見た目になってしまった。
上記は読み込むファイルも3ファイルのみなのでまだ良いが、これが4, 5, 6 ...と増えたときの事を考えると
コールバックが何重にも積み重なってしまい、コードの可読性・保守性が一気に低下して制御不能になる。
このことを コールバック地獄 と呼ぶ。
次のように全てのアクションを単一の関数によってステップ化することで、
ある程度この問題を軽減できる。
loadScript('/my/script1.js', step1);
function step1(error, script) {
if (error) {
handleError(error);
} else {
// ...
loadScript('/my/script2.js', step2);
}
}
function step2(error, script) {
if (error) {
handleError(error);
} else {
// ...
loadScript('/my/script3.js', step3);
}
}
function step3(error, script) {
if (error) {
handleError(error);
} else {
// ...
}
};
上記の書き方は一見整理されているように見えるが、コードがバラバラに分散され読みにくくなる。
また、step関数はこのコールバック地獄のためだけの関数であり、外部から使用されることもなく
名前空間を汚すだけである。
このような事象を回避するための方法として、promise や async/await などが用意されている。
まとめ
非同期処理とコールバック関数について理解を深めることができました。
最後に出てきた promise や async/await について、これから学習していきますので、
またこのような形で残しておきたいと思います。
実際のアプリを実装するにはもう少し掛かりそうです。。