コールバック
コールバックを利用する非同期プログラミングは、JavaScriptにおける非同期プログラミングの実装パターンとして最も基本的なものです。このパターンでは、非同期に処理を行う関数に引数として渡したコールバックが処理完了時に実行されます。
まずは簡単に"setTimeout()"を使用したコールバックを実行してみます。
setTimeout(() => console.log('1秒通過しました'), 1000);
console.log('setTimeout()を実行しました。');
>>>
setTimeout()を実行しました。
1秒通過しました
実行すると、「setTimeout()」の処理を待たずに「console.log()」が実行されている。
コールバックは自動機のインターフェースとして用いられるが、コールバックは必ず非同期処理というわけではない。
const array1 = [0, 1, 2, 3, 4]
const array2 = array1.map(v => {
console.log(`${v}を変換します。`)
return v * 10
});
console.log(array2)
>>>
0を変換します。
1を変換します。
2を変換します。
3を変換します。
4を変換します。
[ 0, 10, 20, 30, 40 ]
エラーハンドリング
JavaScriptではエラーハンドリングのためにtry...catch構文が用意されています。
try...catchではコールバックの中で発生したエラーをハンドリングできない。
const parseJSONAsync = (json, next) => {
try{
setTimeout(() => next(JSON.parse(json)), 1000);
} catch (err) {
console.log('エラーをキャッチしました。', err);
next({})
}
}
parseJSONAsync('json', result => console.log('parse結果', result ))
>>>
SyntaxError: Unexpected token j in JSON at position 0
Node.jsは通常、エラーがイベントループまで到達するとprocessオブジェクトからuncaughtExceptionいベントが発行され、アプリケーションが停止します。uncaughtExceptionを補足し、これを握りつぶしてアプリケーションの停止を食い止めることも可能ですが、この結果アプリケーションの整合性が保証されない状態になるため推奨されません。
process.on('uncaughtException', err => process.exit(1))
コールバックの中では起こりうるアプリケーションエラーを適切にキャッチし、それをイベントループまで到達させることなく呼び出し元に返すことが重要です。
const parseJSONAsync = (json, next) => {
setTimeout(() => {
try{
next(null, JSON.parse(json));
} catch (err) {
next(err)
}
}, 1000)
}
parseJSONAsync('json', (err, result) =>
console.log('parse結果', err, result)
)
>>>
parse結果 SyntaxError: Unexpected token j in JSON at position 0
同期と非同期を混ぜると危険
同じ文字列に対してJSONAsync()を実行した結果は常に同じになるはずですから、この結果をキャッシュして使い回してみます。
const cache = {}
const parseJSONAsyncWithCache = (json, callback) => {
const cached = cache[json]
if(cached) {
callback(cached.err, cached.result)
return
}
parseJSONAsync(json, (err, result) => {
cache[json] = {err, result}
callback(err, result)
})
}
parseJSONAsyncWithCache(
'{"message": "Hello", "to": "world"}',
(err, result) => {
console.log('1回目の結果', err, result)
parseJSONAsyncWithCache(
'{"message": "Hello", "to": "world"}',
(err, result) => console.log('2回目の結果', err, result)
)
console.log('2回目の呼び出し完了')
}
)
console.log('1回目の呼び出し完了')
>>>
1回目の呼び出し完了
1回目の結果 null { message: 'Hello', to: 'world' }
2回目の結果 null { message: 'Hello', to: 'world' }
2回目の呼び出し完了
この実装は、JavaScriptのよく知られたアンチパターンです。ここでの問題は、parseJSONAsyncWithCache()が状況によってコールバックを同期的に実行したり非同期的に実行したりすることです。
コールバックの呼び出しが同期的か非同期かで一貫性がないと、APIの挙動が予期しづらくなってしまいます。今回の例では1回目の呼び出しでは"console.log('1回目...')"の後にコールバック関数が実行されているのに対して、2回目はその順番が逆になっています。状況によってこうした順番が前後するのは、複雑で原因の特定が困難な不具合の原因になりかねない。
これを書き直すと次のようになる。
const cache2 = {}
const parseJSONAsyncWithCache = (json, callback) => {
const cached = cache2[json]
if(cached) {
// キャッシュに値が存在する場合でも、非同期的にコールバックを実行する
setTimeout(() => callback(cached.err, cached.result), 0)
return
}
parseJSONAsync(json, (err, result) => {
cache2[json] = {err, result}
callback(err, result)
})
}
parseJSONAsyncWithCache(
'{"message": "Hello", "to": "world"}',
(err, result) => {
console.log('1回目の結果', err, result)
parseJSONAsyncWithCache(
'{"message": "Hello", "to": "world"}',
(err, result) => console.log('2回目の結果', err, result)
)
console.log('2回目の呼び出し完了')
}
)
console.log('1回目の呼び出し完了')
>>>
1回目の呼び出し完了
1回目の結果 null { message: 'Hello', to: 'world' }
2回目の呼び出し完了
2回目の結果 null { message: 'Hello', to: 'world' }
これで1回目も2回目も一貫性が貯めたれるようになりました。
非同期的に行っている"setTimeout"を"process.nextTick()"に置き換えても問題ありません。
process.nextTick()はsetTimeout()よりもはやくコールバックを実行するため、すぐに値を返したいという要求により適している。
ただし、process.nextTick()はブラウザ環境のJavaScriptには存在しないAPIのため、ブラウザ上でも動かすコードを書いている場合には使えない。
ブラウザと共有して使用する場合には、Web標準に由来するグローバルメソッドのqueueMicrotask()を利用するとよい。
コールバックヘル
複数の非同期処理を逐次的に実行する場合、非同期処理のコールバックの中で次の非同期処理を実行する必要があります、これを繰り返した結果、コードのネストが深く可読性や保守性が損なわれる状態になります。この状態をコールバックヘルと呼びます。これはJavaScriptの最もよく知られたアンチパターンです。
asyncFunc1(input, (err, result) => {
if(err){
...
}
asyncFunc2(input, (err, result) => {
if(err){
...
}
asyncFunc3(input, (err, result) => {
if(err) {
...
}
})
})
})
解決策としては、コードを分割することです。
function first(arg, callback) {
asyncFunc1(input, (err, result) => {
if(err){
...
};
second(result, callback);
});
}
function second(arg, callback) {
asyncFunc2(input, (err, result) => {
if(err){
...
};
third(result, callback);
});
}
function third(arg, callback) {
...
}