Promiseは既に広く定着し、今では、非同期処理を行うライブラリのほとんど全てが、Promiseオブジェクトを返す仕様で実装されています。
本記事では、Promise基本文法を丁寧に説明し、複雑な非同期処理の制御をPromise+thenチェーンで記述できるようになるための基礎として説明します。
なお、そもそもJavaScriptの非同期処理って何?Promiseってどこから出てきたの?という方は、以下の記事を参考になさってください。
2024年2月24日 JavaScriptのコールバック関数と非同期処理を丁寧に理解する
Promiseの基本形
Promise はJavaScriptに用意されたオブジェクトで、オブジェクト作成時に、非同期処理に紐づけます。
Promise オブジェクトは、本来はいつ実行され、いつ終わるかも分からない非同期処理の進行状況に対応した3つの状態を、自身の状態として保持することができます 。
Promiseの状態3つ
・pending:初期状態
・fulfilled:非同期処理が正常に完了した(成功)
・rejected:処理中に何らかのエラーが起こり正常終了しなかった(失敗)
擬似コードを示します。以下のPromise
オブジェクトの状態は、async_processes()
の進行状況に応じて、pendingからfulfilledまたはrejectedに遷移していきます。
{"code":"const async_processes = () => {\n setTimeout(() => {\n console.log('Hello World.');\n }, 2000);\n}\n\/\/ \u8ffd\u8de1\u3057\u305f\u3044\u51e6\u7406\u3092\u30b3\u30fc\u30eb\u30d0\u30c3\u30af\u306b\u3001Promise\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u3092\u4f5c\u6210\nnew Promise(async_processes);\n\/\/ \u30b3\u30f3\u30bd\u30fc\u30eb\u51fa\u529b\n\/\/ 2\u79d2\u5f8c Hello, World\u304c\u8868\u793a\u3055\u308c\u308b","language":"javascript","id":1,"filename":""}
Promiseオブジェクトの状態遷移。 非同期処理の進行状況に応じて状態遷移する
Promiseオブジェクトがfulfilledになった時、非同期処理が確実に終了したことが保証 されます。
実際には、Promise作成時に渡すコールバックは、特殊な引数resolve, rejectを取る関数として実装します。resolve, rejectは、以下のような関数です。
非同期処理が成功した際に実行する関数resolve()
非同期処理が失敗した際に実行する関数reject()
百聞は一見にしかず、コードを見て理解しましょう。以下は、readFile ライブラリで非同期的にテキストファイルを読むコード例です。
. ├── readTxt.js └── foo.txt
(foo.txt)
this is sample content of text file.
{"filename":"readTxt.js","code":"import fs from 'node:fs';\nnew Promise((resolve, reject) => {\n fs.readFile('.\/foo.txt', 'utf8', (error, data) => {\n if (error){\n \/\/ \u30a8\u30e9\u30fc\u304c\u8d77\u3053\u3063\u305f\u6642\u306breject()\u3092\u547c\u3076\n reject();\n }\n console.log(data);\n \/\/ \u6b63\u5e38\u7d42\u4e86\u3057\u305f\u6642\u306bresolve()\u3092\u547c\u3076\n resolve();\n })\n});\n\/\/ \u30b3\u30f3\u30bd\u30fc\u30eb\u51fa\u529b\n\/\/ this is sample content of text file.","language":"javascript","id":1}
Promiseは、resolve()
が呼ばれた時点でfulfilledに、reject()
が呼ばれた時点でrejectedに状態遷移していきます。
resolve()またはreject()が呼ばれた時に状態遷移
resolve()
は正常終了時に、reject()
はエラー発生に対応させて、例外処理として呼び出します。
Promiseの基本形は以上の通りです。
コマリブル
構文が複雑だけど、まだ何の役に立つのかわかんないぞ。
シバセンセー
次節から、Promiseを使うメリットが分かるように説明していくよ。
まとめ
Promiseの状態にはpending, fulfilled, rejectedがある
非同期処理をPromiseでラップし、Promiseの状態で非同期処理の進行状況を追う
処理が完了したらresolve()、エラーが起こったらreject()を呼ぶ
then(), catch(), finally()
非同期処理の進行状況を(エラーが発生したかどうかも含めて)、Promiseの状態という形で追跡できるようになりました。
Promiseオブジェクトには、Promiseの状態を利用して非同期処理の実行順序の制御を行う、then() , catch() , finally() メソッドが用意されており、それぞれ下記の用途で使用します。
then()
:Promiseがfulfilledになった後、実行する処理を記述
catch()
:Promiseがrejectedになった後、実行する処理を記述
finally()
:Promiseの状態に関わらす、最終的に実行する処理を記述
Promiseの状態遷移と制御フロー
コード例を見てみましょう。前節で挙げた例に、then(), catch(), finally()を使ってみます。
テキストファイルの読み込みを行い、正常にファイルの読み込みが出来たらファイル内容をコンソールに表示、エラーが発生したらエラーメッセージーをコンソールに表示します。
{"filename":"readTxt.js","code":"import fs from 'node:fs';\nnew Promise((resolve, reject) => {\n fs.readFile('.\/foo.txt', 'utf8', (error, data) => {\n if (error){\n const message = 'Error Occurred while opening text file.';\n reject(message)\n }else{\n resolve(data);\n }\n })\n}).then((data) => {\n \/\/ fulfilled\u306e\u6642\u5b9f\u884c\u3055\u308c\u308b\n console.log('text:', data);\n}).catch((message) => {\n \/\/ rejected\u306e\u6642\u5b9f\u884c\u3055\u308c\u308b\n console.log('ERROR:', message);\n}).finally(() => {\n \/\/ Promise\u306e\u72b6\u614b\u306b\u95a2\u4fc2\u306a\u304f\u4e00\u756a\u6700\u5f8c\u306b\u5b9f\u884c\u3055\u308c\u308b\n console.log('done');\n});","language":"javascript","id":1}
コンソール出力は、以下のようになるはずです。
{"code":"\/\/ \u6b63\u5e38\u7d42\u4e86\uff08fulfilled\uff09\u306e\u6642\n\/\/ text: this is sample content of text file.\n\/\/ done.\n\/\/ \u30a8\u30e9\u30fc\u767a\u751f\u6642\uff08rejected\uff09\u306e\u6642\n\/\/ ERROR: Error Occurred while opening text file.\n\/\/ done.","language":"javascript","id":1,"filename":""}
Promiseがfulfilled, rejectedになった後、それぞれthen(), catch()メソッドに渡したコールバック関数が実行されます。
そして、resolve()に指定した値はthen()のコールバック関数へ、reject()に指定した値はcatch()のコールバック関数へ渡すことが出来ます。
上の例だとresolve(data)としてテキストファイルの内容をthen()ブロックの中に渡し、reject(message)としてエラーメッセージをcatch()ブロックの中に渡していますね。
Promiseを使うメリット1:thenチェーン
ここから、複数の非同期処理を逐次実行するthenチェーン で、Promiseのメリットを確認しましょう。
thenブロックは何個も繋げて書くことができ、この書き方は、thenチェーンと呼ばれます。
{"code":"new Promise((resolve) => {\n setTimeout(() => {\n console.log('Hello, World.');\n resolve();\n }, 2000);\n}).then(() => {\n return new Promise((resolve) => {\n setTimeout(() => {\n console.log('Hello, JavaScript.');\n resolve();\n }, 1000);\n })\n}).then(() => {\n console.log(\"Let's use Promise chaining.\");\n})","language":"javascript","id":1,"filename":""}
↑のコードのコンソール出力は、次のようになります。
// (2秒待つ)
Hello, World.
// (1秒待つ)
Hello, JavaScript.
Let's use Promise chaining.
フローを1つ1つ丁寧に追ってみましょう。
Promiseが作成される
①のPromiseに紐付いた非同期処理が実行される
resolve()が実行され、①のPromiseがfulfilledになる
1つ目のthen()ブロックのコールバック関数が実行される
then()ブロックのコールバックは、Promiseオブジェクトを返す関数
新しいPromiseが作成される
⑥のPromiseに紐付いた非同期処理が実行される
resolve()が実行され、⑥のPromiseがfulfilledになる
2つ目のthen()ブロックのコールバック関数が実行される
.then()メソッドに渡すコールバックを、Promiseを返す関数として実装する。
新しくできたPromiseオブジェクトに、さらに.then()メソッドを使って連鎖させることができる。
thenチェーンを使うことで、3つ以上の非同期処理を逐次実行しても、ネストを深くせずに記述することができるようになります。
以下は、3つのテキストファイルを逐次読み込むコードを、thenチェーンで実装した場合と、Promise登場以前のコールバック型で実装した場合の比較です。
Promise + thenチェーン コールバック型(ES2015以前)
{"filename":"readTextFiles.js","code":"import fs from 'node:fs';\nnew Promise((resolve) => {\n fs.readFile('.\/foo.json', (error1, data1) => {\n resolve(data1);\n })\n}).then((data1) => {\n console.log(data1);\n return new Promise((resolve) => {\n fs.readFile('.\/bar.json', (error2, data2) => {\n resolve(data2);\n })\n });\n}).then((data2) => {\n console.log(data2);\n return new Promise((resolve) => {\n fs.readFile('.\/baz.json', (error3, data3) => {\n resolve(data3);\n })\n });\n}).then((data3) => {\n console.log(data3);\n console.log(\"3 json files loaded.\");\n})","language":"javascript","id":1}
発展
上記のコードは、テキストファイルの内容を取得するという非同期処理を3回繰り返していますね。
同じ処理を別の関数として切り出して、thenチェーンを使うと、さらに綺麗に書くことができます。
{"filename":"readTextFiles.js","code":"import fs from 'node:fs';\nfunction cat_file(filepath){\n return new Promise((resolve, reject) => {\n fs.readFile(filepath, (error, data) => {\n if (error){\n reject('Error!');\n }else{\n resolve(data);\n }\n })\n });\n}\n\/\/ \u5b9f\u884c\ncat_file('.\/foo.json')\n.then((data1) => {\n console.log(data1);\n cat_file('.\/bar.json')\n})\n.then((data2) => {\n console.log(data2);\n cat_file('.\/baz.json');\n})\n.then((data3) => {\n console.log(data3);\n console.log('3 json files loaded.')\n});","language":"javascript","id":1}
16〜28行目が、処理を実行している部分です。ここまで整理すると、コールバック型との差は歴然 ですね。
{"filename":"readTextFiles.js","code":"import fs from 'node:fs';\nfs.readFile('.\/foo.json', 'utf8', (error1, data1) => {\n console.log(data1);\n fs.readFile('.\/bar.json', 'utf8', (error2, data2) => {\n console.log(data2);\n fs.readFile('.\/baz.json', 'utf8', (error3, data3) => {\n console.log(data3);\n console.log(\"3 json files loaded.\");\n })\n })\n});","language":"javascript","id":1}
上記のコードは、本記事冒頭で言及した参考記事に掲載した悪いコード例 です。
非同期処理の数が増えると無限にネストが深くなってしまうこのアンチパターンは、俗にコールバック地獄などと呼ばれます。
(ファイル構成は以下を想定)
. ├── readTextFiles.js ├── foo.txt ├── bar.txt └── baz.txt
ダテネコ
実務ではもっと複雑な処理を書くから、コールバック型じゃあ厳しいぜ。
Promiseのメリット2:柔軟で強力な例外処理
thenチェーンで、非同期処理が何個あっても順番を決めて逐次処理できることがわかりました。
最後に、Promiseを使用する大きなメリットとして、柔軟な例外処理 が 書けること を紹介します。
Promiseで追跡している非同期処理の実行中に起こったエラーは、(thenチェーンで繋がっていても)、最後のcatch()ブロックで全て捉えることが出来ます。
{"code":"\/\/ then\u30c1\u30a7\u30fc\u30f3\u306e\u64ec\u4f3c\u30b3\u30fc\u30c9\nnew Promise((resolve, reject) => {\n \/\/ \u975e\u540c\u671f\u51e6\u74061\n if (...){\n reject('Error in Process 1!');\n }\n resolve();\n}).then((msg) => {\n \/\/ \u975e\u540c\u671f\u51e6\u74062\n return new Promise((resolve, reject) => {\n if (...){\n reject('Error in Process 2!');\n }\n resolve();\n })\n}).then((msg) => {\n \/\/ \u975e\u540c\u671f\u51e6\u74063\n return new Promise((resolve, reject) => {\n if (...){\n reject('Error in Process 3!');\n }\n })\n}).then(() => {\n \/\/ \u5168\u90e8\u6b63\u5e38\u7d42\u4e86\n console.log('All Processes finished');\n}).catch((error) => {\n \/\/ \u4f8b\u5916\u51e6\u7406\uff08\u30a8\u30e9\u30fc\u767a\u751f\u6642\uff09\n console.log('ERROR MESSAGE:', error);\n});","language":"javascript","id":1,"filename":""}
thenチェーンで繋がった非同期処理の制御フロー
then(), catch(), finally()の項 で説明したように、catch()メソッドにはreject()から値を渡すことができる ので、どこでエラーが起きたかを特定できるような情報を渡しておけば、デバッグの役に立ちます。
エラーメッセージで、どこのブロックでエラーが起こったか判断できる
resolve()とthen()、reject()とcatch()の組み合わせは見た目よりずっと強力です。resolve()もreject()も、任意の場所で呼び出すことができるからです。
何度も紹介しているテキストファイル読み込みの例を少し深掘りします。ファイルが壊れていて開けなかったら当然エラーが起こりますが、例えば他にも、
テキストファイルの中身が空だった場合、処理を中断してエラーとして処理したい
テキストファイルのサイズが大きすぎたらその時点で処理を中断して、エラーとして処理したい
といった独自の例外処理を追加したい場合もあるでしょう。そして、実務上はどの場合が起こったのか、簡単に判別できるようにしておかなければなりません。
次のコードは、これら全ての場合をエラーメッセージから判別できるようにした実装例です。
{"filename":"readTextFiles.js","code":"import fs from 'node:fs';\nfunction get_text(filepath){\n return new Promise((resolve, reject) => {\n fs.readFile(filepath, 'utf8', (error, data) => {\n \n if (error){\n reject('Error occurred while opening ' + filepath);\n }else{ \n \/\/ \u30c6\u30ad\u30b9\u30c8\u3092\u884c\u3054\u3068\u306b\u5206\u5272\n let lines = data.split('\\n');\n \/\/ \u4f55\u3082\u66f8\u304d\u8fbc\u307e\u308c\u3066\u3044\u306a\u304b\u3063\u305f\u6642\n if (lines[0] === ''){\n reject(filepath + ' is Empty.');\n }\n \/\/ 10\u4e07\u884c\u4ee5\u4e0a\u3042\u3063\u305f\u3089\u4e2d\u65ad\u3059\u308b\n if (lines.length > 100000){\n reject(filepath + ' is too large.');\n }\n \n resolve(data);\n}\n\/\/ \u51e6\u7406\u3092\u5b9f\u884c\nget_text('.\/foo.txt')\n.then(() => get_text('.\/bar.txt'))\n.then(() => get_text('.\/baz.txt'))\n.catch((error_message) => {\n console.log(error_message);\n})","language":"javascript","id":1}
下に、上記コードのフロー図(ファイルオープン時のエラーは省略)を掲載します。
どのファイルを処理している間にエラーが起こったか
独自の仕様も含めて、エラーの種類は何か
がエラーメッセージから判定できます。
各種エラーに対応した制御フローとエラーメッセージ
複雑な非同期処理の制御に対しても、どこでどんなエラーが起こったか細かく(柔軟に)判定できるということは、プロダクトの開発時でもメンテナンス時でも大きなメリットとなります。
まとめ
最後にPromiseの基本文法についての要点をまとめておきます。お疲れ様でした。
Promiseの基本文法と使い方 まとめ
非同期処理の進行状況を、Promiseの状態という形で追跡できる
Promiseの状態で分岐する、then(), catch(), finally()メソッドを使う
thenチェーンで、複数の非同期処理を(ネストせずに)逐次実行できる
resolve()とreject()は任意の場所で実行でき、複雑な例外処理も可能
参考リンク