とほほのPromise入門
Promiseとは
Promise は、JavaScript や Node.js において、非同期処理のコールバック関数をエレガントに記述するための仕組みです。英語の promise は、「制約」、「保障」などの意味を持ちます。Promise は、Chrome 63, Firefox 58, Safari 11.1, Edge 18, Node.js 4.* から利用可能です。IE11 ではサポートされていません。
コールバック地獄問題
JavaScript や Node.js では、ブロックする(処理が終わるまで待ち合わせる)関数よりも、非同期関数(処理の完了を待たず、処理が完了した時点でコールバック関数が呼び出される)の方が多様されます。ここで、例えば、膨大な演算(実は単に元の数を2倍するだけ)を行う非同期関数 aFunc1() があるとします。下記は、100の2倍を求める非同期関数の使用例です。
// 引数を2倍にする非同期関数
function aFunc1(data, callback) {
setTimeout(() => {
callback(data * 2);
}, Math.random() * 1000);
}
function sample_callback() {
// 非同期関数を用いて100の2倍を求める
aFunc1(100, (value) => {
console.log(value); // => 200
});
}
単純に非同期関数を1回だけ呼び出すのであれば、上記で問題ありませんが、1回目で得られた値を用いて、aFunc1() を2度、3度呼び出そうとすると、下記の様な実装になります。
function sample_callback_hell() {
aFunc1(100, (data) => {
console.log(data); // => 200
aFunc1(data, (data) => {
console.log(data); // => 400
aFunc1(data, (data) => {
console.log(data); // => 800
});
});
});
}
呼び出す回数に比例してコールバックのネストが深くなります。これを、「コールバック地獄」と呼びます。
タイミング問題
非同期関数はまた、処理の順序を制御できないという問題も含みます。下記の例では、100の2倍、200の2倍、400の2倍を求めようとしたにも関わらず、処理結果は 200, 400, 800 だったり、800, 200, 400 など、結果処理が順不同となるという問題があります。
function sample_timing_problem() {
aFunc1(100, (data) => {
console.log(data); // => 200
});
aFunc1(200, (data) => {
console.log(data); // => 400
});
aFunc1(400, (data) => {
console.log(data); // => 800
});
}
Promiseによる実装(Promise)
これらの問題を解決するために考案されたのが Promise です。Promise は、約束、誓約、保証などの意味を持ちます。Promise は、待機(pending)、履行(fulfilled)、拒否(rejected)いずれかの状態を持つオブジェクトです。前述の非同期関数 aFunc1() を Promise を用いて書き直すと下記の様になります。処理を行う関数を引数とした Promise オブジェクトを返却するように修正します。
function aFunc2(data) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(data * 2);
}, Math.random() * 1000);
});
}
成功時の処理(resolve(), then())
Promise 実装側が履行関数(resolve())を呼び出した時、呼び出し側はこれを then() メソッドで受け取ることができます。
function sample_promise() {
aFunc2(100).then((data) => {
console.log(data); // => 200
});
}
さらに処理を継続するには、下記の様にします。
function sample_promise3() {
aFunc2(100).then((data) => {
console.log(data); // => 200
return aFunc2(data);
})
.then((data) => {
console.log(data); // => 400
return aFunc2(data);
})
.then((data) => {
console.log(data); // => 800
});
}
失敗時の処理(reject(), catch())
Promise のエラー処理について考察します。下記は、約 30% の確率でエラーとなる Promise 非同期関数です。
function aFunc3(data) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() < 0.30) {
reject(new Error('ERROR!'));
} else {
resolve(data * 2);
}
}, Math.random() * 1000);
});
}
.then() は第一引数に履行時のコールバック関数、第二引数に拒否時のコールバック関数を指定します。エラーを考慮した呼び出し元は下記の様になります。
function sample_reject() {
aFunc3(100).then(
(data) => { console.log(data); }, // 成功時(履行時)の処理
(e) => { console.log(e); } // 失敗時(拒否時)の処理
);
}
上記は、下記の様に記述することもできます。.catch(reject) は、.then(undefined, reject) と同じ意味を持ちます。Promise は一度エラーが発生すると、最初に reject 関数が指定されるまで、then 処理をスキップします。
function sample_catch() {
aFunc3(100).then((data) => {
console.log(data);
return aFunc3(data);
})
.then((data) => {
console.log(data);
return aFunc3(data);
})
.then((data) => {
console.log(data);
})
.catch((e) => {
console.log(e);
});
}
.catch() はまた、処理中に発生した throw をキャッチすることもできます。下記の例では、aFunc3() 内部で発生したエラーや、2番目の処理で発生した例外を .catch() が受け止めます。
function sample_catch_with_throw() {
aFunc3(100).then((data) => {
console.log(data);
return aFunc3(data);
})
.then((data) => {
console.log(data);
throw new Error('ERROR!!!');
})
.then((data) => {
console.log(data);
})
.catch((e) => {
console.log(e);
});
}
常時実行処理(finally())
.catch() の後ろに .then() を加えることで、履行時にも、拒否時にも常に実行される Finally のような処理を追加することができます。
function sample_finally() {
aFunc3(100).then((data) => {
console.log(data);
return aFunc3(data);
})
.then((data) => {
console.log(data);
return aFunc3(data);
})
.then((data) => {
console.log(data);
})
.catch((e) => {
console.log(e);
})
.then(() => {
console.log('*** Finally ***');
});
}
ES2018(ES9) では、.finally() がサポートされました。
function sample_finally2() {
aFunc3(100).then((data) => {
console.log(data);
return aFunc3(data);
})
.then((data) => {
console.log(data);
return aFunc3(data);
})
.then((data) => {
console.log(data);
})
.catch((e) => {
console.log("catch");
console.log(e);
})
.finally(() => {
console.log('*** Finally ***');
});
}
Promiseメソッド
すべてのタスクが完了したら(Promise.all())
Promise.all() は配列で指定されたすべての Promise タスクを待ち合わせ、すべてのタスクが完了した時点で .then() のコールバック関数を呼び出します。
function taskA() {
return new Promise((resolve) => {
console.log("taskA start.");
setTimeout(() => {
console.log("taskA end.");
resolve();
}, Math.random() * 3000);
});
}
function taskB() {
return new Promise((resolve) => {
console.log("taskB start.");
setTimeout(() => {
console.log("taskB end.");
resolve();
}, Math.random() * 3000);
});
}
function sample_all() {
p1 = taskA();
p2 = taskB();
Promise.all([p1, p2]).then(() => {
console.log("taskA and taskB are finished.");
});
}
いずれかのタスクが完了したら(Promise.race())
Promise.race() は配列で指定された Promise タスクを待ち合わせ、いずれかひとつのタスクが完了した時点で、.then() のコールバック関数を呼び出します。
function sample_race() {
p1 = taskA();
p2 = taskB();
Promise.race([p1, p2]).then(() => {
console.log("taskA or task B is finished.");
});
}
いずれかのタスクが完了したら(Promise.any())
ES2021 では Promise.any() が追加されました。Promise.race() と同様、いずれかのタスクの完了を待ちますが、race() がいずれかのタスクが履行(resolve)または拒否(reject)した時点で終了するのに対し、any() はいずれかのタスクが履行(resolve)した時のみ終了します。
function sample_race() {
p1 = taskA();
p2 = taskB();
Promise.any([p1, p2]).then(() => {
console.log("taskA or task B is finished.");
});
}
すべてのタスクが履行・拒否に関わらず完了したら(Promise.allSettled())
Promise.all() では、指定したタスクのいずれか一つがエラーになるとそこで待ち合わせを完了してしまいますが、ES2020 でサポートされた Promise.allSettled() を用いると、タスクがエラーとなっても、すべてのタスクが履行終了するか、エラー終了するまで、処理を待ち合わせることが可能となります。
p1 = Promise.resolve("OK1");
p2 = Promise.reject("NG2");
p3 = Promise.resolve("OK3");
Promise.allSettled([p1, p2, p3]).then(
resolveList => resolveList.forEach(res => console.log(res)),
rejectList => rejectList.forEach(rej => console.log(rej))
);
// => {status: "fulfilled", value: "OK1"}
// => {status: "rejected", reason: "NG2"}
// => {status: "fulfilled", value: "OK3"}
リゾルバ付きプロミス生成(Promise.withResolvers())
プロミスを返却する関数を定義する際、通常は下記の様に記述します。
async function request(url) {
const promise = new Promise(async (resolve, reject) => {
try {
const resp = await fetch(url);
const body = await resp.text();
resolve(body);
} catch (err) {
reject(err);
}
});
return promise;
}
ES2024 で追加された Promise.withResolvers() を用いることにより上記のネストをひとつ浅くすることができます。resolve や reject を new Promise(...) のスコープ外で使用でる点もメリットです。ES2024 でサポートされました。
async function request(url) {
const { promise, resolve, reject } = Promise.withResolvers();
try {
const resp = await fetch(url);
const body = await resp.text();
resolve(body);
} catch (err) {
reject(err);
}
return promise;
}
同期関数・非同期関数をPromiseでラッピングする(Promise.try())
条件によって Promise を返却したり実データを返却する関数をまとめて Promise に統合することができます。例えば、下記の getData() は引数 url に対応するデータがキャッシュにあれば実データを、無ければ Promise を返却します。呼び出し側も戻り値が Promise か否かで処理を振り分ける必要があります。
const cache = new Map();
function getData(url) {
if (cache.has(url)) {
return cache.get(url); // キャッシュにあれば実データを返却
}
return fetch(url) // 無ければ Promise を返却
.then(async (res) => {
const body = await res.text();
cache.set(url, body);
return body;
});
}
document.getElementById("btn").addEventListener("click", () => {
const url = document.getElementById("url").value;
const ret = getData(url);
if (ret instanceof Promise) {
ret.then((value) => { // Promise であれば非同期待ちを
console.log(value);
});
} else {
console.log(ret); // さもなくば実データを直に参照
}
});
この時、getData() を Promise.try() でラッピングしてやることで、キャッシュの有無に関わらず、戻り値を Promise として扱うことができるようになります。ES2025 で利用可能となりました。
document.getElementById("btn").addEventListener("click", () => {
const url = document.getElementById("url").value;
Promise.try(() => getData(url))
.then((value) => {
console.log(value);
});
});
非同期関数を同期関数っぽく呼び出す(async, await)
ES2017 では、Promise に加え、async, await がサポートされました。こちらも、Internet Explorer を除く大半のモダンブラウザで利用可能です。async と await を用いることで、Promise に対応した非同期関数を、同期関数の様に呼び出すことが可能となります。同期関数の様に呼び出したい非同期関数を呼び出す際に await をつけます。await を呼び出す関数には async をつける必要があります。
async function sample_async_await() {
var val = 100;
val = await aFunc2(val);
console.log(val); // 200
val = await aFunc2(val);
console.log(val); // 400
val = await aFunc2(val);
console.log(val); // 800
}
エラー処理に対応するコードは下記の様になります。
async function sample_async_await_with_catch() {
var val = 100;
try {
val = await aFunc3(val);
console.log(val);
val = await aFunc3(val);
console.log(val);
val = await aFunc3(val);
console.log(val);
} catch (e) {
console.log(e);
}
}
await を呼び出す関数には async をつける必要がありますが、ES2022 からモジュールモードの時には、トップレベルからの呼び出しのみは async 関数でラップしなくても await を呼び出せるようになりました。詳細は「トップレベル await」を参照してください。
繰り返し(for await ... of ...)
ES2018(ES9) では、非同期な反復可能オブジェクトに対して、for await (... of ...) でループを回せるようになりました。例えば、サーバからデータを1件ずつ読み込む非同期処理を、同期処理の様に for ループで記述することが可能となります。
var asyncIterableObject = {
[Symbol.asyncIterator]() {
return {
count: 0,
next() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
value: this.count,
done: (this.count < 5) ? false : true,
})
this.count++;
}, Math.random() * 1000);
});
}
};
}
};
async function for_await_of() {
for await (num of asyncIterableObject) {
console.log(num); // 0, 1, 2, 3, 4
}
};