Javascriptで非同期処理を行う手法を自分の中で整理するために、それぞれの概要をざっくりまとめた。
コールバックによる非同期処理
Javascriptで非同期処理を行う際には下記のようなコールバック関数を用いた手法が頻繁に用いられている。
setTimeout関数やIO系の関数で用いられているので、誰しもが使ったことのある手法だと思う。
http.get('http://www.aaa.bb.co.jp', function(response) {
...
})
コールバックの問題点
コールバック関数を用いた非同期処理の実現はシンプルで分かり易いので、単純な処理の場合には非常に使い易いが、少し処理が複雑になってくると途端に見通しが悪くなるという問題点がある。
例えば、ファイルa.txt、b.txt、c.txtを非同期に読み込んで何か処理を行う場合、下記のようなコードを書くことになる。
read('a.txt', function(a) {
read('b.txt', function(b) {
read('c.txt', function(c) {
// doSomething
})
})
});
処理が増えるたびにネストも1段増えていき、どんどん見通しが悪くなる。これにエラー処理を加えたり、aの結果によってはbを読み込まないといった条件などを加えるケースなどはもう想像するだけで面倒くさくなる。
Promise
Promiseとは
コールバックによる問題点を解決して、非同期処理をいい感じに扱えるようにしてくれる存在。
正直言葉では説明し辛いので実例を見てもらった方が早いと思う。
Promiseで嬉しいこと
非同期処理を同期的に書ける
下記のようなコールバックによる非同期処理をPromiseで書くと、
doA(function(a){
doB(a, function(b){
doC(b, function(c){
done(c);
});
});
});
こんな感じになる。
doA().then(doB).then(doC).then(done);
非同期処理が終わると、thenに設定した関数が呼び出され、またそれが終わると次のthenに設定した関数が呼び出される...といった形で、非同期処理をチェーンさせて書くことができる。
コードも短くなり、実行順序も同期処理のように直感的で非常にわかり易い。
処理が増えても、ネストは深くなることはないので、複雑になり辛い。
エラー処理がシンプルに書ける
コールバックを使った非同期処理でよくある、第二引数にエラー処理を受け取るタイプの関数を考えてみると、下記のようになる。
doA(function(a){
doB(a, function(b){
doC(b, function(c){
done(c);
}, function(err) {
// Handle error
});
}, function(err) {
// Handle error
});
}, function(err) {
// Handle error
});
こんなにシンプルな例なのに、もうどこでどのエラーを処理すればいいのかわからなくなってくる。
これもPromiseを使って書くと、超シンプルに記述できる。
doA().then(doB).then(doC).then(done).catch(function(err) {
// Handle error
});
Promise、すごい。
Promiseの少し使いづらい点
直列処理がすっきり書けない
例えば、ファイル名の配列を渡すと、ファイルを全て順番に読み込んで結果を返してくれる関数を考える。
同期処理で書くと以下のようになる。
function readFilesSync(fileNames) {
return fileNames.map(function(fileName) {
return readSync(fileName);
});
}
が、これをPromiseで実装しようと思うと、一手間必要となる。
例えば、Array.prototype.reduceを使って書くことができるが直感的とは言いがたい。
function readFiles(fileNames) {
return fileNames.reduce(function(sequence, fileName) {
return sequence.then(function(results) {
return read(fileName).then(function(result) {
results.push(result);
return results;
});
});
}, Promise.resolve([]));
}
上の処理は、ファイルを読み込む->結果をresultsに放り込む->ファイルを読み込む->...といった処理をthenで繋げ、全部処理し終わったらresultsを返すといった流れになっている。
直列処理ではなく、並列処理で問題無い場合はPromise.allを使って以下のように書ける。
function readFiles(fileNames) {
return Promise.all(fileNames.map(function(fileName) {
return read(fileName);
}));
}
Generator
Generatorとは
関数の処理を途中で止めたり再開したりすることができる仕組み。
pythonのyieldとかコルーチンとか知っている人には理解し易いかも。
Generatorで嬉しいこと
すっごくざっくり例を書くとこんな感じ。
coとかfunction *()とかyieldとか何って感じだが、ひとまず雰囲気だけ感じてもらえればと。
後ろの方でもう少し詳しく説明します。
co(function *() {
var a = yield read('a.txt');
var b = yield read('b.txt');
var c = yield read('c.txt');
// do something with a, b, c
})();
上記を実行すると、ファイルを読み込み処理は非同期で行われるものの、実行順序としてはaを読み込んで、それが終わったらbを読み込んで...と上から下に素直に処理が流れていき、まるで同期処理のように扱うことができる。
わかり易い。便利。
もう少し詳しく
function *() で宣言された関数は generator function と呼ばれる。generator functionは実行を一時中断することのできる関数で、 yield 文に到達するとそこで実行が止まる。
例えば、
function *() {
yield 1;
yield 2;
yield 3;
}
を実行すると、初回実行時にはyield 1;で停止し、次に実行するとyield 2まで実行し、さらに実行するとyield 3まで実行が進む、といった流れである。
generator functionの実行を進めるには、 iterator を用いる。iteratorとは、generator functionを実行した時に得られるもので、以下のように使う。
var generator = function *() {
yield 1;
yield 2;
yield 3;
};
var iterator = generator();
// iterator.next()を呼ぶと、実行が進む
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
generator functionは、yield文で値を返すことができ、next関数の戻り値として値が返されるが、逆にnext関数を通じてgeneratorに値を渡すこともできる。
var generator = function *() {
console.log(yield 1);
console.log(yield 2); // 10
console.log(yield 3); // 20
};
var iterator = generator();
// iterator.next()を呼ぶと、実行が進む
console.log(iterator.next());
console.log(iterator.next(10));
console.log(iterator.next(20));
初回実行時に、nextに引数として渡した値は、generator functionのyieldの戻り値として得られる。
coとは?
以上が基本的なGeneratorだが、coはそれを使いやすくしてくれたライブラリである。
非同期処理がES7のasync/await構文並に同期っぽく書けるようになったり、PromiseとGeneratorが共存できるようにしてくれたり、色々してくれる。
詳しくは、他の方が分かり易く解説しているページ(generatorとJavaScriptの非同期処理)などを見ていただければと。