JavaScriptのPromiseについて。
同期処理 / 非同期処理
同期処理
JSはシングルスレッドで、ひとつ処理が終了したら次の処理を実行、という形で、通常は同期処理がおこなわれる。
var i = 0;
function doSomething1() {
console.log(i);
}
function doSomething2() {
i += 1;
console.log(i);
}
doSomething1(); // 0
doSomething2(); // 1
同期処理では、1つの処理が詰まると次の処理が行われない。
(例: APIのレスポンス待機, 複雑な計算 etc.)
そのため、非同期処理ももちろん用意されている。
非同期処理
1つの処理の終了を持たないまま別の処理に進める方法。
例えば、setTimeout
, addEventListener
, jQueryの$.ajax
などが挙げられる。
function doSomething1() {
console.log("hoge");
}
function getUrl(url) {
$.ajax({
url: url
}).done(function() {
console.log("success");
}).fail(function() {
console.error("error");
});
}
getUrl("https://example.com"); // 非同期
doSomething1(); // 同期
従来の非同期処理の課題
例えば、API1にリクエストしてアイテムの一覧を取得した後、API2で先頭のアイテムの詳細情報を取得、という処理を書くとする。関数にコールバックを渡す形式にする。
function getFirstItem(callback) {
getUrl("/items", function(err, items) {
if (err) {
return callback(err);
}
getUrl("/items/" + items[0].id, function(err, detail) {
if (err) {
return callback(err);
}
callback(null, detail);
})
});
}
getFirstItem(function(err, item) {
if (err) {
console.error(e);
return
}
successProcess(item);
});
また、1番目のアイテムを取得した後に2番目も取得しようとすると、例えば以下のようになる。
function getSecondItem(callback) {
getUrl("/items", function(err, items) {
if (err) {
return callback(err);
}
getUrl("/items/" + items[0].id, function(err, detail1) {
if (err) {
return callback(err);
}
callback(null, detail1);
getUrl("/items/" + items[1].id, function(err, detail2) {
if (err) {
return callback(err);
}
callback(null, detail2);
}
})
});
}
さらに、
- 他の全てのアイテムの詳細を逐次取得していく直列処理
- 各アイテムの取得のリクエストを同時に行い、全てレスポンスが返ってきてから描画、といった並列処理
などを行うとなったらどうするか?なかなか辛そう。
このように、従来の非同期処理には、
- 処理が多段階になると、コールバックのネストがどんどん深くなり、書きづらい / 読みづらい
- コールバックの設定の仕方も様々
- 非同期処理の関数にコールバック関数を引数として渡す
- 非同期処理を行うオブジェクトにコールバック関数を登録
- etc.
といった課題があった。そこで登場したのがPromise。
Promise
- 非同期処理を統一的に扱う方法を定義した、 オブジェクト
- 非同期処理を行う関数はPromiseを戻り値として返し、呼び出し側はPromiseにコールバック関数を登録
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
- ES2015で導入、ブラウザ対応状況
Promiseを使うと、上記の処理は以下の様な感じになって短く書けるようになる。
function getFirstItem() {
return getUrl("/items").then((items) => {
return getUrl("/items/" + items[0].id);
});
}
getFirstItem().then((item) => {
successProcess(item);
}).catch((e) => {
console.error(e);
});
Promiseの使い方
- Promiseオブジェクトを、
Promise
コンストラクタで生成 - コンストラクタの引数に、
resolve
とreject
という2つの関数を渡す
const promise = new Promise((resolve, reject) => {
if ('処理が成功') {
resolve(1);
} else {
reject(new Error('error'));
}
});
こうして生成したPromiseオブジェクトは、then
とセットで使う。
promise.then((value) => {
console.log(value); // 1
}, (error) => {
console.error("error:", error.message);
});
Promiseの使い方の基本は以下。
-
resolve()
を呼ぶと、「処理が成功した状態 (fullfilled)」になる- 引数に値を受け取れ、
then
のコールバックに渡せる
- 引数に値を受け取れ、
-
reject()
を呼ぶと、「処理が失敗した状態 (rejected)」になる-
resolve
と同様に引数に値を渡せる -
Error
オブジェクトを渡すのが通例
-
- Promiseオブジェクトは
then()
メソッドを持ち、処理が成功 or 失敗した際の処理をコールバックとして渡せる- 処理が成功だと第1引数の関数を実行
- 失敗だと第2引数の関数を実行 ※省略可
-
then()
が返すのもPromise
Promiseの状態
上述したとおり、Promiseには処理に応じた「状態」があり、次の3つのうちの1つになる
- pending: 初期状態、まだ処理が成功も失敗もしていない
- fullfilled: 処理が成功し、完了した状態
- rejected: 処理が失敗した状態
基本的に、Promiseオブジェクトはpendingの状態で生成され、関連付けた処理の実行結果に応じて、fullfilled あるいは rejectedの状態に変化する。この状態の変更は一方通行で、pendingに戻ることはない。
そして、fullfilled もしくは rejected への状態変更が起こった時に実行する関数を登録できるのがthen()
。
使ってみる例
指定時間後に何らかの処理を実行する関数wait
を実装する。
function wait(time) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(`wait: ${time}`);
}, time);
});
}
wait(3000).then((value) => {
console.log(value);
return `finish ${value}`;
}).then((value) => {
console.log(value);
}).then((value) => {
console.log(value);
});
setTimeout
という非同期アクションの実行結果に応じた処理が行われる(上記は処理成功が前提になっているが)。wait
は未来のある時点でPromiseを返す。Promiseを利用することで、同期処理と同じように値を返せるようになった。
コードを実行すると、約3,000ms後に"wait: 3000"が出力され、そのあとすぐに"finish wait: 3000"と"undefined"が出力される。
$ node -v
v6.3.1
$ node wait.js
wait: 3000
finish wait: 3000
undefined
then
のメソッドチェーン
上記の例では、3つのthen()
を繋げている。先述したように、then()
が返すのもPromiseオブジェクトであるためこのようなことが可能、何個でもチェーンできる。
-
then
の中でreturn <値>
することは、resolve(<値>)
と等しい - 何も
return
しないときは、resolve()
と同じ
このようなルールがある。
catch
アイテムの一覧 => 詳細を取得する非同期処理をもう一度。
function getUrl(url) {
return new Promise((resolve, reject) => {
let xhr = XMLHttpRequest();
xhr.open("GET", url);
xhr.onload = () => {
if (xhr.status === 200) {
resolve(xhr.response);
} else {
reject(new Error(xhr.statusText));
}
};
xhr.onerror = () => {
reject(new Error(xhr.statusText));
};
xhr.send();
});
}
function getFirstItem() {
return getUrl("/items").then((items) => {
return getUrl("/items/" + items[0].id);
});
}
getFirstItem().then((item) => {
successProcess(item);
}).catch((e) => {
console.error(e);
});
getUrl()
でPromiseを返し、通信の成功/失敗で処理を分岐させる。
getFirstItem()
で、2つのAPIエンドポイントに順次リクエストし、最後まで処理が完了したらsuccessProcess()
、失敗したらエラーを出力。
ここでcatch((e) => {})
を利用しているが、これはthen(null, (e) => {});
と同義。
then()
のチェーンの最後に付けておけば、一連の処理中にreject()
で明示的に処理失敗を宣言した場合だけでなく、予想外のエラーも拾ってくれる。
これまで書いてきたが、Promiseが統一的なインターフェースを提供してくれるおかげで、コードの見通しがだいぶ良くなる。ただし、これだけで終わりではなく、非同期処理の並列実行/直列実行も簡単に実装できるようになる。
並列処理
APIを多数コールし、全てのレスポンスが返った後に処理を行う、という場合はPromise.all()
を使うと楽に書ける。
function task(value, ms) {
console.log(`${value} called`);
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(value);
console.log(`${value} fullfilled`);
}, ms);
});
}
function taskA() { return task("A", 3000); }
function taskB() { return task("B", 2000); }
function taskC() { return task("C", 1000); }
Promise.all([taskA(), taskB(), taskC()]).then((values) => {
console.log(values);
console.info('all fullfilled');
}).catch((error) => {
console.error(error);
});
コードを実行すると、以下の出力に。
$ node parallel.js
A called
B called
C called
C fullfilled
B fullfilled
A fullfilled
[ 'A', 'B', 'C' ]
all fullfilled
taskA, B, Cの処理は配列のインデックス順にほぼ同時に開始され、task()
の最初のconsole.log
が実行される。その後処理はsetTimeout
に到達し、コールバック実行までの時間が最も短いtaskC
から順にresolve()
され、それと同時にログに出力が行われる。つまり、コードの実行から約1,000ms後にC、2,000ms後にB, 3,000ms後にAのPromiseがfullfilledになる。
Promise.all()
は
-
all()
の引数の配列内のPromiseの状態が全て fullfilled になったら、all()
自体も fullfilled - 1つでも rejected になると、
all()
も rejected
というルールになっており、上記の例ではいずれの処理も成功しているため、all()
が返すPromiseも fullfilled になる。結果、taskA
が終了した約3,000ms後に、catch()
ではなくthen()
の引数のコールバック関数が実行される。コールバック関数の引数(values
)には、並列処理したそれぞれのPromiseがresolve()
で返した値が配列として入っているのが分かる。
all()
は全てのPromiseを一斉に実行し、全てが fullfilled になるのを待ってから処理を行ってくれるため、冒頭に書いたような「APIを多数コールし、全てのレスポンスが返った後に処理を行う」ケースなどで非常に有用である。
※1つでも rejected になれば、all()
もすぐ rejected となり、catch()
側の処理に入る
直列処理
非同期処理が成功したら次の非同期処理...といった直列 (逐次) 実行はどうするか。
then()
をチェーンさせる
taskA()
.then(taskB)
.then(taskC)
.catch(...);
数が増えると少しめんどいし、見難くなってくる。
Array.prototype.reduce()
を使う
調べるとよく出てくるやり方。
const tasks = [taskA, taskB, taskC].reduce((promise, task) => (
promise.then(task)
), Promise.resolve());
tasks.catch(...);
reduce()
を使ってチェーンを繋げていく方式。
チェーンの先頭になるPromise.resolve()
はStaticメソッドで、コンストラクタを利用せずにPromiseオブジェクトを生成でき、その状態は初めから fullfilled である。(同様にPromise.reject()
も存在する)
これでも良さげだが、さらに良いやり方があるらしい。Generatorというのを利用する。
Generatorを使った直列処理
Generatorとは
- こちらもES2015で導入された概念
- 関数の任意の場所で処理を中断/再開できる仕組みを提供
- 非同期処理を同期処理のように書けるようになる
ここはまだちょっと理解が怪しい
Generatorの利用例
Generatorを使った簡単なコードを書いてみる。
ジェネレータ関数は、function
と関数名の間に*
を付けて定義する。また、ここで欠かせないのがyield
。
function *generator() {
yield 1;
yield 2;
return 3;
}
const g = generator();
console.log(g);
console.log(g.next());
console.log(g.next());
console.log(g.next());
コードの実行結果は以下
$ node generator.js
{}
{ value: 1, done: false }
{ value: 2, done: false }
{ value: 3, done: true }
ジェネレータ関数は実行するとイテレータを返し、next
メソッドというのを持っている。これを実行すると、generator()
内の最初のyield
まで実行され、yield
式の結果をvalue
として返す。またnext
を実行すると次のyield
まで実行される。return
あるいは実行するyield
が無くなると、イテレータのdone
がtrue
になる。
簡単な例で示したが、このようにGeneratorを利用することで、関数内の処理の中断・再開を行える。
非同期処理の直列処理
Generatorを利用したPromiseの直列処理も簡単に書ける。ここではco
というライブラリを利用する。
const co = require('co');
co(function *() {
yield taskA();
yield taskB();
yield taskC();
});
同期処理のようにしか見えないが、それぞれのtask
がPromiseを返すまで処理中断 => 返したら再開、という挙動になり、結果として非同期処理が逐次処理される。これはすごい。。。
次の世代のECMAScriptでは、async
/await
という機能で、このGeneratorが標準搭載されるそう。
まとめ
- 非同期処理が複雑になればなるほど、Promiseが威力を発揮する
- 統一的な記法になるので、慣れてしまえば書きやすい・読みやすい
- Promiseはただのクラスなので、ポリフィルライブラリを読み込むだけでも利用可能
- Generatorは新しい概念だが、逐次処理などで有用
- もうちょい学習が必要かな。。