1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

JSの非同期処理[Promise/async/await]と仲良くなる

Last updated at Posted at 2022-08-24

javascriptの非同期処理(Promise/async/await)仲良く出来ていますか?
自分は、結構苦戦していました。
非同期処理は、プログラミングをみっちり構造的に理解している人ほど、罠に陥りやすいんじゃないかと思っています。
特に、構造化プログラミングを信奉していてフローチャート的に考える習慣のある人は、たちまち破綻して地獄を見ます。
javascriptの場合、外部とのやり取りをしていない場合は比較的平穏にコーディングを進められるんですが、外部とのやり取り、つまりajaxリクエスト・DBへのアクセスetcを扱うようになると非同期の世界へ一気に引き摺り込まれます。
自分の場合、ajax処理なんかは従来のjsのコールバック処理で何とこなすことが出来ていたのですが、nodejsでDBやファイルを扱うようになってからハマるようになって来ました。

始終jsを使う仕事なら非同期処理などは身体に叩き込まれるんでしょうけど、使ったり使わなかったりして、その度に知識がリセットされるのも悔しいし無駄なので、自分の忘備録として用意しておきます。
場合によっては他人様にも役に立つかも知れません。
ただし、技術的に厳密なものではないので、すでに知見のある人は他の資料を見た方が良いです。

非同期処理を最速で理解するには?

jsの非同期処理の基本はPromiseオブジェクトであり、 Promiseを表に出さず同期処理のように見せかけるための書き方ができるのがasync/awaitキーワードです。
そのため
async/await
だけ覚えれば話は簡単なんじゃないか?と思ったこともありました。
しかし、async/awaitを使えばそれだけで同期言語的に書けるかというとそうではなく、予想外の挙動に悩まされるケースが出て来ます。
この際Promiseに対する知識がないとデバッグもままならないため、やはりPromiseから理解するしかないというのが結論です。

ではPromiseって?

元々の意味は 「約束」 ですよね?
そしてjsのPromiseオブジェクトとは何かというと、 「約束手形」 のような意味合いを持ちます。
例えば、あなたが太郎さんに仕事を頼んだとします。太郎さんはデザイナーです。

「最高にイカしたWebデザインを制作してください・・・」

みたいに頼むのですが、太郎さんだって瞬時に納品できるわけがないですよね?
かと言って、出来上がるまでに黙ったままだと頼んだ方も「本当に作ってくれるのか?」不安になります。

// デザインを作る(作らせる)関数
function makeDesign(specs){
    //略
    return design
}

const design = makeDesign(specs)
// これだと不安

そこで、太郎さんは依頼を受けたら 「約束手形」 を先に発行します。
別にそこには大したことは書いてなくて、 「現在絶賛制作中、しばしお待ちを」 くらいなものです。
(具体的には、Promiseオブジェクトというものを生成してreturnします。)

// デザインを作る(作らせる)関数
function makeDesign(specs){
    return new Promise( // とりあえず約束手形発行
        (resolve)=>{ 
            //略 デザインを作る過程
            resolve(design) // デザインを納品
        }
    )
}

const design = makeDesign(specs)
// まずは約束手形を即座に受け取る

この方が幾分安心できます。
では、実際の「デザインを描く仕事」はどこで行われるのでしょうか?
Promiseオブジェクト内のコールバック関数内の処理がそれに当たります。
最後に、resolve()に戻り値をセットします。
resolvePromise「解決」 するための関数ですが、このケースでの 「解決」「デザインを納品する」 という行為にあたります。「約束を果たす」わけですね。

では、依頼者が 「約束手形」 を受け取った後はどうすれば良いんでしょうか?最終的には納品物が手に入らなければ意味がありません。

そのためのPromiseのメソッドが.then()です。
モノが出来上がった時に、受け取る側の動作をthen()内に記述することが出来るのです!

design.then(data=>{
    // 納品物をチェックして
    // サーバにアップロード
    // もちろん報酬をきっかり支払い!
    // みたいな
})

この際の無名関数の引数dataが納品されて来たデータ(=resolve()の引数)になりますので、このdata(名前は何でも良いのですが)を使って好きに後処理すれば良いわけです。
良く出来てますよねえ。
さらに良いことに、色んな人に色んな作業を 「同時に」 頼むことも出来るんです!

例えば、今度は次郎さんがいるとします。次郎さんにはプログラムを書いてもらうことになりました。

// デザインを作る(作らせる)関数
function makeDesign(){
    return new Promise( // とりあえず約束手形発行
        (resolve)=>{ 
            //略 デザインを作る過程
            resolve(data) // デザインを納品
        }
    )
}

// プログラムを作る(作らせる)関数
function makeProgram(){
    return new Promise( // とりあえず約束手形発行
        (resolve)=>{ 
            //略 プログラムを作る過程
            resolve(data) // プログラムを納品
        }
    )
}

const design = makeDesign()
const program = makeProgram()
// まずは約束手形を即座に受け取る

design.then(data=>{
    // サイズ調整して
    // ブログにアップロード
    // もちろん報酬をきっかり支払い!
    // みたいな
})

program.then(data=>{
    // 動作を検証して
    // サーバにアップロード
    // もちろん報酬をきっかり支払い!
    // みたいな
})

じゃ、この場合、 太郎さんと次郎さんはどういう順番で仕事をしてくれるんですかね?
太郎さんがデザインを作り終わるのを待ってから、次郎さんが取り掛かる?
いや違うんです。太郎さんも次郎さんも 同時に掛かります 。早く終わった方から納品です。
効率的ですよね!

ここまで聞いて、まだ非同期処理と聞いていやーな感じがしますか?
むしろ 「もっと仲良くしたい!」 って思い始めてませんか??
何でも仕事はスマートに、早く終わった方が気分良いですよね!

では、上記のコードを、実際に動くものに置き換えます。
この際、呼ばれる関数の処理内容は時間がかかるものをシミュレートしたいので、jsのSetTimeout()を使って、何秒後にレスポンスを返す形にします。
そして太郎さんの仕事は1秒、次郎さんは2秒で終わるものとします。

// デザインを作る(作らせる)関数
function makeDesign(){
    return new Promise( // とりあえず約束手形発行
		resolve=>{
			setTimeout(function() {
			    resolve("はい、こちらデザインです")
			}, 1000); // 制作時間1秒
		}
	)
}

// プログラムを作る(作らせる)関数
function makeProgram(){
    return new Promise( // とりあえず約束手形発行
		resolve=>{
			setTimeout(function() {
			    resolve("はい、こちらプログラムです")
			}, 2000); // 制作時間2秒
		}
	)
}


const design = makeDesign() // 太郎さんにデザインを依頼する
const program = makeProgram() // 次郎さんにプログラムを依頼する
// まずは約束手形を即座に受け取る

design.then(data=>{
    console.log(data + " デザイン受け取った!")
})

program.then(data=>{
    console.log(data + " プログラム受け取った!")
})

さあ、これを実行すると(実行環境はChromeのコンソールでも何でも良いのですが、
https://paiza.io/ja
とか便利なのでお試しください)

はい、こちらデザインです デザイン受け取った!
はい、こちらプログラムです プログラム受け取った!

となるはず。
しかも、太郎さんにも次郎さんにも 同時発注 しているので、2秒後 には太郎さんのデザインも、次郎さんのプログラムも受け取れています。1 + 2 = 3秒後、ではないんですね。
ここが非同期処理のウマイ所です。

いや、しかし!
太郎さんにデザインを作ってもらって、それを次郎さんに渡したいケースもありますよね!
デザインを作る→→→(デザインデータ)→→→プログラムを作る
こういう流れです。
非同期処理の困る点は、こうした当たり前の順次動作(同期処理)が却って面倒臭い所にあります。

まず考えられるのが、.then()の中に次の動作を含めるという方法です。

design.then(designData=>{
    console.log(designData + " デザイン受け取った!")
    const program = makeProgram(designData) // 次郎さんにプログラムを依頼する
    program.then(programData=>{
        console.log(programData + " プログラム受け取った!")
    })
})

しかしこれはご覧の通り、キモいです。
可読性最悪ですね。これだったら、Promiseが登場する前から使われていたコールバック関数を使う書き方の方がはるかにマシです。
実はthen()はメソッドチェーンに対応しているので、以下のような書き方も出来ます。

design
    .then(data=>{
        console.log(data + " デザイン受け取った!")
        const program = makeProgram(data) // 次郎さんにプログラムを依頼する
        return program
    .then(data=>{
        console.log(data + " プログラム受け取った!")
    })
})

だいぶ良い感じになってきました。
.then()をつなげる事によって、連続動作をしている様子がコードからイメージしやすいです。
この際、then()ブロック内でreturnされた戻り値が次のthen()ブロックに伝達される、という点がポイントになります。
こうして次々とデータをバトンタッチすることが出来るわけですね!

ちなみに、Promiseには、複数の処理を走らせて出揃った時点で次の処理に移る
Promise.all()
や、複数の処理を走らせていずれかの処理がゴールインした時に次の処理に移る
Promise.race()
などのテクニックもありますが、async/awaitの説明に移りたいので割愛します。

async / await

さて、これまでの知識だけでも、jsによる非同期処理・同期処理を使いこなすが出来ます。
ただ、Promiseオブジェクトという概念自体が掴みづらく、コードの可読性を下げてしまっているのが欠点です。
そこで、非同期処理を、PHPやpythonなどと同じように同期的に書けるようにしたasync/await構文を使う事にしましょう。

ここに、ある「普通の」関数があったとします。

function myFunc(){
    // 略(時間のかかる処理)
    return "done!"
}

処理内容は省略していますが、「そこそこ時間のかかる処理」が来るものと考えてください。
次に、関数定義の前にasyncキーワードを付け加えます。

async function myFunc(){
    // 略(時間のかかる処理)
    return "done!"
}

キーワードasyncが付いただけなので、見た目はさほど変わりませんが、実は関数の仕様が大きく変わります。
具体的には、asyncの方のmyFunc()は、"done!"という 文字列をreturnしているのにかかわらず、戻り値はPromiseオブジェクト になります。
上記コードをコンソールなどで実行してみると、

Promise {<fulfilled>: 'done!'}

というような反応が得られると思います。
実はこのコードは

function myFunc(){
    return new Promise( // とりあえず約束手形発行
		resolve=>{
            // 略(時間のかかる処理)
			resolve("done!")
		}
	)
}

と同義です。
処理内容はasyncを付けた方と従来とまったく同じなのですが、コード上ではPromiseオブジェクトが現れず、隠蔽されています。一種の簡略構文と思ってもらって(多分)OKです。

なので、この関数を使いたい時は、受け取ったPromiseオブジェクトの解決後の動作を設定しておく必要があります。
そのための1つ目の方法が、Promise.then()を使う方法、そして2つめの方法が、
await
キーワードを使う方法になります。
awaitキーワードを使用すると、このような呼び出し方が可能になります。

const r = await myFunc()
console.log(r)

このawaitを外してみるとどのような動作になるでしょうか?
おそらくrがundefinedとして出力されると思います。
通常の呼び出し方だと「約束手形」が発行されるだけなので、期待していた"done!"が出力されません。
awaitを付けて、解決されるまで 「待つ」 ことで、戻り値である"done!"を捕まえることが出来るのです。
つまり、このコードは以下と同義です。

const r  = myFunc()
r.then(data=>{console.log(data)})

やはりasyncに対してはawaitを使った方がスッキリしますよね!

しかし、async/awaitを使うにあたって、大変重要なルールがあります。

awaitはasync関数の中でしか使えない

というものです。

Chromeのコンソールなどでは、特別に直での(グローバルスコープでの)awaitの使用は認められますが(Top Level Await)、それ以外の場合では、async関数の中に書かれていないawaitはエラーになります。

awaitによる呼び出しは、(時間のかかる)動作を同期的に実行する場合に使われるので、その呼び出しをラップする関数も同じくawaitによって呼び出される前提になります。
awaitによって呼び出されるのはasync関数だけなので、
awaitはasync関数の中でしか使えない
というルールは至極当然ということになります。
(ただ、このルールを撤廃した方が良いんじゃないかという議論もあるようです)

このルール通りに記述すると以下のようになります。

async function myFunc(){
    // 略(時間のかかる処理)
    return "done!"
}

async function main(){
    const r = await myFunc()
    console.log(r)
}

main() //実行

そして、「時間のかかる処理」をシミュレートするために、先ほどのsetTimeout()による方法に替わり、「ただ時間を消費する関数」doSomething()を用意します。
なぜこうするのかというと、setTimeout()自体が非同期処理で戻り値を得るのが難しく、説明用としては無駄なコードが増えてしまうためです。

// msecミリ秒間何もしない関数
function doSomething(msec) {
  var start = new Date();
  while (new Date() - start < msec);
}

※上は説明用のコードです。CPUを酷使するので、 本番には使わないでください
まとめると以下になります。

// msecミリ秒間何もしない関数
function doSomething(msec) {
  var start = new Date();
  while (new Date() - start < msec);
}

async function myFunc(){
    doSomething(1000)
    return "done!"
}

async function main(){
    const r = await myFunc()
    console.log(r)
}

main() //実行

 実行すると、1秒後に"done!"と出るはずです。
そして、最後のmain()にはawaitをつける必要はありません。
asyncを呼ぶときは必ずしもawaitを付ける必要はなく、動作上後続処理が必要かどうかで判断します。
実行することだけが目的であればawaitは不要です。(そもそもTop Level Awaitが認められている実行環境でないとエラーになる)
以下の記事が参考になるかも知れません:
https://qiita.com/jun1s/items/4ec151b1c2562f6b0f26

以上を踏まえ、太郎さんと次郎さんのコードに当てはめると、以下のようになります。
(データの引き継ぎは簡略のため無しにしてあります)

// msecミリ秒間何もしない関数
function doSomething(msec) {
  var start = new Date();
  while (new Date() - start < msec);
}

// デザインを作る(作らせる)関数
async function makeDesign(){
    doSomething(1000)
    return "はい、こちらデザインです"
}

// プログラムを作る(作らせる)関数
async function makeProgram(){
    doSomething(2000)
    return "はい、こちらプログラムです"
}

async function main(){
    const design = await makeDesign()
    console.log(design + " デザイン受け取った!")
    const program = await makeProgram()
    console.log(program + " プログラム受け取った!")
}

実行してみると、

はい、こちらデザインです デザイン受け取った!
はい、こちらプログラムです プログラム受け取った!

ということでPromiseを使った場合と同じ動作であることが確認できると思います。
そしてコードも直感的に流れを理解することが出来、少ない手数でコーディングをすることが出来ます。
さらに、スコープがネストする事なく、非常に理解しやすいコードになっていることが見て取れると思います。

ここまで理解できれば、大分非同期処理についての苦手意識が軽くなっているんじゃないでしょうか?ちょっと大変ですが、この部分が理解できないと、APIをガンガン活用したり、流行のVueやReact、あるいはnodeJsを使いこなすのは難しいので、ぜひ乗り越えてください( ^ω^ )

その他注意点

  • 無名関数の中でawaitを使う場合も、親関数(つまりはその無名関数)がasyncでないといけません。この場合は、引数部の前にasyncキーワードを付けます
  • forEach()メソッドを使用する時も、上記の状況になります。通常のforループにすると、このような問題は発生しません。
  • 何も考えずに やたらとasync/awaitを付けまくるのはハマる原因になるので禁物 です。例えば、上記 doSomething()なども「時間がかかる処理だからasync付けとけ」などと考えてしまうと挙動がおかしくなります。
  • Promiseの解決の際にresolve関数を渡しましたが、その時にreject関数も合わせて定義して渡し、.thenだけでなく.catchブロックを作って 「失敗時の処理」 を設けておくことが推奨されています。この事についてはいずれ補足したいと思います。
1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?