この章では、JavaScriptにおける非同期プログラミングの基礎を説明します。次の章のES6 Promiseの背景知識を提供します。
関数`f`が関数`g`を呼び出すとき、`g`は処理が完了した後にどこに戻るべきか(`f`の中)を知る必要があります。この情報は通常、スタック、つまり*コールスタック*によって管理されます。例を見てみましょう。
function
h
(
z
)
{
// Print stack trace
console
.
log
(
new
Error
().
stack
);
// (A)
}
function
g
(
y
)
{
h
(
y
+
1
);
// (B)
}
function
f
(
x
)
{
g
(
x
+
1
);
// (C)
}
f
(
3
);
// (D)
return
;
// (E)
最初に、上記のプログラムが開始されると、コールスタックは空です。D行の関数呼び出し`f(3)`の後、スタックには1つのエントリがあります。
C行の関数呼び出し`g(x + 1)`の後、スタックには2つのエントリがあります。
B行の関数呼び出し`h(y + 1)`の後、スタックには3つのエントリがあります。
A行に表示されているスタックトレースは、コールスタックがどのように見えるかを示しています。
Error
at h (stack_trace.js:2:17)
at g (stack_trace.js:6:5)
at f (stack_trace.js:9:5)
at <global> (stack_trace.js:11:1)
次に、各関数が終了し、そのたびにスタックから一番上のエントリが削除されます。関数`f`が完了すると、グローバルスコープに戻り、コールスタックは空になります。E行でreturnし、スタックは空になります。これはプログラムが終了したことを意味します。
簡略化すると、各ブラウザタブは単一のプロセス(*イベントループ*)で実行されます。このループは、*タスクキュー*を介して供給されるブラウザ関連の処理(いわゆる*タスク*)を実行します。タスクの例は次のとおりです。
項目2〜4は、ブラウザに組み込まれたエンジンを介してJavaScriptコードを実行するタスクです。コードが終了すると、それらは終了します。その後、キューからの次のタスクを実行できます。次の図(Philip Robertsのスライド[1]からインスピレーションを得ています)は、これらすべてのメカニズムがどのように接続されているかの概要を示しています。
イベントループは、それと並行して実行されている他のプロセス(タイマー、入力処理など)に囲まれています。これらのプロセスは、キューにタスクを追加することによって、イベントループと通信します。
ブラウザにはタイマーがあります。`setTimeout()`はタイマーを作成し、タイマーが起動するまで待機してから、タスクをキューに追加します。シグネチャは次のとおりです。
setTimeout
(
callback
,
ms
)
`ms`ミリ秒後、`callback`がタスクキューに追加されます。`ms`はコールバックがいつ*追加*されるかのみを指定し、実際にいつ実行されるかを指定するわけではないことに注意することが重要です。特にイベントループがブロックされている場合(この章の後半で示すように)、実行はずっと後になる可能性があります。
`ms`をゼロに設定した`setTimeout()`は、タスクキューにすぐに何かを追加するためによく使用される回避策です.ただし、一部のブラウザでは`ms`を最小値(Firefoxでは4 ms)未満にすることはできません。最小値未満の場合、`ms`は*その最小値に設定*されます。
ほとんどのDOM変更(特に再レイアウトを伴う変更)の場合、表示はすぐに更新されません。「レイアウトは16ミリ秒ごとにリフレッシュティックから発生します」(@bz_moz)そして、イベントループを介して実行する機会が与えられなければなりません。
ブラウザのレイアウトのリズムと衝突しないように、頻繁なDOM更新をブラウザと調整する方法があります。詳細については、`requestAnimationFrame()`のドキュメントを参照してください。
JavaScriptには、いわゆる実行完了セマンティクスがあります。次のタスクが実行される前に、現在のタスクは常に完了します。つまり、各タスクは現在のすべての状態を完全に制御し、同時変更について心配する必要はありません。
例を見てみましょう。
setTimeout
(
function
()
{
// (A)
console
.
log
(
'Second'
);
},
0
);
console
.
log
(
'First'
);
// (B)
A行で始まる関数はすぐにタスクキューに追加されますが、現在のコード(特にB行!)が完了した後にのみ実行されます。つまり、このコードの出力は常に次のようになります。
First
Second
見てきたように、各タブ(一部のブラウザではブラウザ全体)は単一のプロセスによって管理されます。ユーザーインターフェースと他のすべての計算の両方です。つまり、そのプロセスで長時間実行される計算を実行することで、ユーザーインターフェースをフリーズさせることができます。次のコードはそれを示しています。
<
a
id
=
"block"
href
=
""
>
Block for 5 seconds</
a
>
<
p
>
<
button
>
This is a button</
button
>
<
div
id
=
"statusMessage"
></
div
>
<
script
>
document
.
getElementById
(
'block'
)
.
addEventListener
(
'click'
,
onClick
);
function
onClick
(
event
)
{
event
.
preventDefault
();
setStatusMessage
(
'Blocking...'
);
// Call setTimeout(), so that browser has time to display
// status message
setTimeout
(
function
()
{
sleep
(
5000
);
setStatusMessage
(
'Done'
);
},
0
);
}
function
setStatusMessage
(
msg
)
{
document
.
getElementById
(
'statusMessage'
).
textContent
=
msg
;
}
function
sleep
(
milliseconds
)
{
var
start
=
Date
.
now
();
while
((
Date
.
now
()
-
start
)
<
milliseconds
);
}
</
script
>
冒頭のリンクをクリックするたびに、関数`onClick()`がトリガーされます。同期`sleep()`関数を使用して、イベントループを5秒間ブロックします。その間、ユーザーインターフェースは機能しません.たとえば、「単純なボタン」をクリックすることはできません。
イベントループのブロッキングは、次の2つの方法で回避できます。
まず、メインプロセスで長時間実行される計算を実行せず、別のプロセスに移動します。これは、Worker APIを介して実現できます。
第二に、長時間実行される計算(Workerプロセスでの独自のアルゴリズム、ネットワークリクエストなど)の結果を(同期的に)待機せず、イベントループを続行し、計算が完了したときに通知させます。実際、ブラウザでは選択肢がなく、この方法で処理する必要があります。たとえば、同期的にスリープする(以前に実装した`sleep()`のように)組み込みの方法はありません。代わりに、`setTimeout()`を使用すると、非同期的にスリープできます。
次のセクションでは、結果を非同期的に待機するための手法について説明します。
結果を非同期的に受信するための2つの一般的なパターンは、イベントとコールバックです。
結果を非同期的に受信するためのこのパターンでは、リクエストごとにオブジェクトを作成し、イベントハンドラーを登録します。1つは計算の成功用、もう1つはエラー処理用です。次のコードは、`XMLHttpRequest` APIでどのように機能するかを示しています。
var
req
=
new
XMLHttpRequest
();
req
.
open
(
'GET'
,
url
);
req
.
onload
=
function
()
{
if
(
req
.
status
==
200
)
{
processData
(
req
.
response
);
}
else
{
console
.
log
(
'ERROR'
,
req
.
statusText
);
}
};
req
.
onerror
=
function
()
{
console
.
log
(
'Network Error'
);
};
req
.
send
();
// Add request to task queue
最後の行は実際にはリクエストを実行せず、タスクキューに追加することに注意してください。したがって、`onload`と`onerror`を設定する前に、`open()`の直後にそのメソッドを呼び出すこともできます。JavaScriptの実行完了セマンティクスにより、動作は同じです.
ブラウザAPI IndexedDBには、少し独特なイベント処理スタイルがあります。
var
openRequest
=
indexedDB
.
open
(
'test'
,
1
);
openRequest
.
onsuccess
=
function
(
event
)
{
console
.
log
(
'Success!'
);
var
db
=
event
.
target
.
result
;
};
openRequest
.
onerror
=
function
(
error
)
{
console
.
log
(
error
);
};
最初にリクエストオブジェクトを作成し、結果が通知されるイベントリスナーを追加します。ただし、リクエストを明示的にキューに入れる必要はありません。それは`open()`によって行われます。現在のタスクが完了した後に実行されます。そのため、`open()`を呼び出した*後*にイベントハンドラーを登録できます。
マルチスレッドプログラミング言語に慣れている場合、このリクエスト処理スタイルは、競合状態が発生しやすいように奇妙に見えるかもしれません。しかし、実行完了のため、常に安全です。
このスタイルの非同期計算結果の処理は、結果を複数回受信する場合に適しています。ただし、結果が1つしかない場合は、冗長性が問題になります。そのユースケースでは、コールバックが一般的になっています。
コールバックを介して非同期結果を処理する場合、コールバック関数を非同期関数またはメソッド呼び出しに末尾パラメーターとして渡します。
Node.jsの例を次に示します。`fs.readFile()`への非同期呼び出しを介してテキストファイルの内容を読み取ります。
// Node.js
fs
.
readFile
(
'myfile.txt'
,
{
encoding
:
'utf8'
},
function
(
error
,
text
)
{
// (A)
if
(
error
)
{
// ...
}
console
.
log
(
text
);
});
`readFile()`が成功した場合、A行のコールバックはパラメーター`text`を介して結果を受け取ります。成功しなかった場合、コールバックは最初のパラメーターを介してエラー(多くの場合、`Error`のインスタンスまたはサブコンストラクター)を取得します。
従来の関数型プログラミングスタイルでは、同じコードは次のようになります。
// Functional
readFileFunctional
(
'myfile.txt'
,
{
encoding
:
'utf8'
},
function
(
text
)
{
// success
console
.
log
(
text
);
},
function
(
error
)
{
// failure
// ...
});
コールバックを使用するプログラミングスタイル(特に前述の関数型スタイル)は、次のステップ(*継続*)がパラメーターとして明示的に渡されるため、*継続渡しスタイル*(CPS)とも呼ばれます。これにより、呼び出された関数は、次に何が起こるか、いつ起こるかをより詳細に制御できます。
次のコードはCPSを示しています。
console
.
log
(
'A'
);
identity
(
'B'
,
function
step2
(
result2
)
{
console
.
log
(
result2
);
identity
(
'C'
,
function
step3
(
result3
)
{
console
.
log
(
result3
);
});
console
.
log
(
'D'
);
});
console
.
log
(
'E'
);
// Output: A E B D C
function
identity
(
input
,
callback
)
{
setTimeout
(
function
()
{
callback
(
input
);
},
0
);
}
各ステップについて、プログラムの制御フローはコールバック内で継続されます。これは、ネストされた関数につながり、*コールバック地獄*と呼ばれることもあります。ただし、JavaScriptの関数宣言は*巻き上げ*られる(定義はスコープの先頭で評価される)ため、ネストを回避できることがよくあります。つまり、先読みして、プログラムの後半で定義された関数を呼び出すことができます。次のコードは、巻き上げを使用して前の例をフラット化しています。
console
.
log
(
'A'
);
identity
(
'B'
,
step2
);
function
step2
(
result2
)
{
// The program continues here
console
.
log
(
result2
);
identity
(
'C'
,
step3
);
console
.
log
(
'D'
);
}
function
step3
(
result3
)
{
console
.
log
(
result3
);
}
console
.
log
(
'E'
);
通常のJavaScriptスタイルでは、次の方法でコードを合成します。
ライブラリAsync.jsは、Node.jsスタイルのコールバックを使用して、CPSで同様の操作を実行できるコンビネーターを提供します。次の例では、名前が配列に格納されている3つのファイルの内容を読み込むために使用されます。
var
async
=
require
(
'async'
);
var
fileNames
=
[
'foo.txt'
,
'bar.txt'
,
'baz.txt'
];
async
.
map
(
fileNames
,
function
(
fileName
,
callback
)
{
fs
.
readFile
(
fileName
,
{
encoding
:
'utf8'
},
callback
);
},
// Process the result
function
(
error
,
textArray
)
{
if
(
error
)
{
console
.
log
(
error
);
return
;
}
console
.
log
(
'TEXTS:\n'
+
textArray
.
join
(
'\n----\n'
));
});
コールバックを使用すると、CPS と呼ばれる、根本的に異なるプログラミングスタイルになります。CPS の主な利点は、その基本的なメカニズムが理解しやすいことです。しかし、欠点もあります。
Node.js スタイルのコールバックには、(関数型スタイルのコールバックと比較して)3 つの欠点があります。
if
文によって冗長性が増します。次の章では、Promise と ES6 Promise API について説明します。Promise は、コールバックよりも内部的には複雑です。その代わりに、Promise はいくつかの重要な利点をもたらし、前述のコールバックの欠点のほとんどを解消します。
[1] Philip Roberts による「Help, I’m stuck in an event-loop」(ビデオ)。
[2] HTML 仕様書の「イベントループ」。
[3] Axel Rauschmayer による「JavaScript における非同期プログラミングと継続渡しスタイル」。