この記事はフラー Advent Calendar 2019の 1 日目の記事です。
今年も JavaScript (TypeSctipt) をたくさん書きました。
JavaScript には ECMAScript という標準仕様がありますが,近年では TC39 によって毎年新しい機能が追加されています。議論のプロセスは常にオープンになっているので誰でも閲覧できます *。
そして今回は JavaScript の”Promise”にフォーカスして近況を解説します。
Promise で非同期プログラミング
Promise は ES2015 (ES6)で正式に導入されましたが,それ以前にも JavaScript プログラミングの非同期処理の中心にいました。
まずは Promise について簡単に復習しましょう。コンストラクタで Promise を生成する一般的な形式の例を以下に示します。
const promise = new Promise((resolve, reject) => {
doSomething(x => {
if (x) {
// 成功
resolve(x);
} else {
reject(new Error("失敗😢"));
}
});
});
promise
.then(result => { ... })
.catch(error => { ... });
doSomething は何らかの非同期処理を行い,コールバックによって結果を得る関数です。無事に結果が得られたときは resolve() を使います。もしも結果が得られなかったときは reject()を使います。
Promise には”pending”, “fulfilled”, “rejected” の 3 つの状態が存在します。先ほどの例で,resolve() か reject() を呼びだす前の状態は pending 状態です。呼び出したあとは fulfilled か rejected のいずれかの状態になります(これを Settled と呼びます)。
Promise は必ず 3 つのうちいずれか状態をとりますが,Settled にならない場合もあります。例えば,以下の Promise は永久に pending 状態です。
const p = new Promise(() => {});
.finally()
ES2018 では Promise.prototype.finally() が導入されています。
promise
.then(result => { ... })
.catch(error => { ... })
.finally(() => {
// 後始末をここに書く
});
.finally()のコールバック関数は,fulfilled か rejected かに関わらず実行されます。
複数の Promise を操る Promise.all と Promise.race
ES2015 では, 複数の Promise を操作する API Promise.all と Promise.race の 2 つが導入されました。
Promise.all:全ての Promise が fulfilled となった結果Promise.race:いずれかの Promise のうち最初に Settled となった結果
Promise.all は複数の非同期処理を同時にスタートして,全てが完了したその結果を扱いたいときに使います。
const p1 = new Promise((resolve, reject) => {
setTimeout(() => resolve("成功😄"), 100);
});
const p2 = new Promise((resolve, reject) => {
setTimeout(() => resolve("成功😁"), 200);
});
const p3 = new Promise((resolve, reject) => {
setTimeout(() => resolve("成功😎"), 300);
});
Promise.all([p1, p2, p3])
.then(result => console.log(result)) // ["成功😄", "成功😁", "成功😎"]
.catch(e => console.log(e.message));
Promise.allSettled と Promise.any
Promise.all は,Promise が 1 つでも失敗して rejected になったら,その結果は rejected になってしまいます。
const p1 = new Promise((resolve, reject) => {
setTimeout(() => resolve("成功😄"), 100);
});
const p2 = new Promise((resolve, reject) => {
setTimeout(() => resolve("成功😁"), 200);
});
const p3 = new Promise((resolve, reject) => {
setTimeout(() => reject(new Error("失敗😭")), 300);
});
Promise.all([p1, p2, p3])
.then(result => console.log(result))
.catch(e => console.log(e.message)); // "失敗😭"
そこで新たに 2 つの API が提案され,導入されようとしています。
Promise.allSettled:全ての Promise が Settled となった結果Promise.any:いずれかの Promise が最初に fulfilled となった結果。全ての Promise が rejected の場合にのみ,rejected 状態になる。
先ほどの例を Promise.allSettled を使って書くと以下のようになります。
const p1 = new Promise((resolve, reject) => {
setTimeout(() => resolve("成功😄"), 100);
});
const p2 = new Promise((resolve, reject) => {
setTimeout(() => resolve("成功😁"), 200);
});
const p3 = new Promise((resolve, reject) => {
setTimeout(() => reject(new Error("失敗😭")), 300);
});
Promise.allSettled([p1, p2, p3])
.then(result => console.log(result.map(r => r.value))) // ["成功😄", "成功😁", undefined]
.catch(e => console.log(e.message));
async / await
ES2017 で追加された Async 関数を使うと,Promise を更に扱いやすくなります。Async 関数は以下のように async キーワードがついた関数です。
async function makePizza() {
const ingredients = await prepareIngredients(); // 材料を用意します
const pizza = await bakePizza(); // ピザを焼きます
return pizza;
}
Async 関数の中では,await オペレータを使用できます。 await は Promise が Settled になるまで待機し,結果が得られたら処理を再開します。もしもその Promise が fulfilled となった場合,await はその値を返します。もしも rejected となった場合はそのエラーを throw します(try-catch 構文でハンドリングできます)。
Async 関数の結果は常に Promise です。したがって以下の 2 つの Async 関数は同じ値(”🍕“の Promise)を返します。
async function makePizza1() {
return "🍕";
}
async function makePizza2() {
return Promise.resolve("🍕");
}
const pizza1 = await makePizza1();
const pizza2 = await makePizza2();
console.log(pizza1 === pizza2); // true
await は Promise ではない値も受け付けます。
const burger1 = await "🍔";
const burger2 = await Promise.resolve("🍔");
console.log(burger1 === burger2); // true
コールバック関数と await
まず最初に以下のヘルパー関数を定義します。
async function delayEcho(x, ms) {
return new Promise(resolve =>
setTimeout(() => {
resolve(x);
}, ms)
);
}
引数 x で指定した値を引数ms で指定した分だけ待って resolve する Async 関数です。
["🍔", "🍕", "🍣"]という配列をこの関数と.map() で処理したいとき,以下のように書きたくなるかもしれませんが,.map() に渡している関数に async キーワードが無いためシンタックスエラーです。
async function delayedFoods() {
const foods = ["🍔", "🍕", "🍣"];
return foods.map(f => await delayEcho(f, 100)); // シンタックスエラー 🙅♀️
}
const x = await delayedFoods();
console.log(x);
コードバック関数にも async キーワードをつけるとシンタックスエラーは無くなりますが,先に述べたとおり Async 関数の返り値は Promise なので,正しく動作するには,Promise.all を使って以下のように書きます。
async function delayedFoods() {
const foods = ["🍔", "🍕", "🍣"];
return Promise.all(foods.map(async f => await delayEcho(f, 100)));
}
const x = await delayedFoods();
console.log(x); // ["🍔", "🍕", "🍣"]
実はこの例は, delayEcho の結果を await する必要はなく以下のように書くとよりシンプルです。
async function delayedFoods() {
const foods = ["🍔", "🍕", "🍣"];
return Promise.all(foods.map(f => delayEcho(f, 100)));
}
Async イテレーション
最後に,ES2018 で追加された Async イテレーションをみていきます。
async function* collectFoods() {
await new Promise(resolve => setTimeout(() => resolve(), 1000));
yield delayEcho("🌭", 100);
yield delayEcho("🍜", 100);
yield "🍖";
}
collectFoods は Async ジェネレータ関数です。await に加えて yield を使うことができます。collectFoodsは Async iterator を作成します。Async iterator は .next() を呼び出すと結果が Promise で返ってきます。
const asyncIter = collectFoods();
console.log(await asyncIter.next()); // {value: "🌭", done: false}
console.log(await asyncIter.next()); // {value: "🍜", done: false}
console.log(await asyncIter.next()); // {value: "🍖", done: false}
console.log(await asyncIter.next()); // {value: undefined, done: true}
さらに,Async Iterator のループは for await...of 構文を使うことができます。
for await (const food of collectFoods()) {
console.log(food);
// "🌭"
// "🍜"
// "🍖"
}
ちなみに for await...of 構文は Async では無い通常の iterator でも正常に動作します。
for await (const food of ['🍎', '🍊', Promise.resolve('🍋')]) {
console.log(food);
// 🍎
// 🍊
// 🍋
}
おわりに
今年は,僭越ながら周囲の人に JavaScript を教える機会をいただくことが多い 1 年でした。特に Promise について質問されることが多かったので,この記事を書こうと思いつきました。来年も元気にやっていきますのでよろしくお願いします 👋