JavaScriptを学ぶうえで躓くポイントの一つである「非同期処理」
そして非同期処理で使用する「Promise」「async/await」について一緒に学びましょう!
1. 非同期処理とは
おそらく説明不要の方も多いと思いますが、一応非同期処理とは何か説明します。
非同期処理とはザックリ言うと 処理を並行して行う ことです。
実はJavaScriptは一度に一つの処理しか出来ません。これをシングルスレッドと言います。
逆に一度に複数の処理を行えることをマルチスレッドと言います。
コールスタックや実行コンテキストなどという難しい言葉で説明されることが多いのですが、
要はコードの上から順に処理を行い、その処理が終わったら次の処理を行うというイメージでOKです。
ではもしコードの1行目に物凄く重たい処理が書かれていたらどうなるでしょうか?
そうです。その処理の完了を待たないと次の処理は実行されません。
でもそれでは困りますよね?実際に重たい処理もあれば、この処理をしている間に他の処理を行いたいなんて日常茶飯事です。
その不便を解決してくれるのが非同期処理です!
「ということは、非同期処理はJavaScriptをマルチスレッドへと機能拡張するもの?」
いえ、違います。正確には
「非同期処理は特定の処理を切り分けて、その処理の完了を待たずに別の処理を並行して行うことができるということです。」
仕組みをザックリ説明すると、非同期の処理を受け取ったJavaScriptはその処理をブラウザやサーバーサイドに任せます。
そして自分は他の同期的な処理を行い、非同期の処理が終わったとき、涼しい顔をしてその結果を受け取るわけです。
いやー、JavaScriptずるいですね〜
でもそんなところが好きですw
では次から実際に同期処理と非同期処理を見てみましょう!
2. 同期処理と非同期処理
ちょっと語弊があるかもしれませんが、わかりやすさ重視で「うさぎとかめ」の童話に乗せて説明します。
ではまずは通常の処理、つまり同期処理の動きを見てみます。
const start = () => {
console.log('うさぎがゴール');
console.log('カメがゴール');
}
start();
// うさぎがゴール
// カメがゴール
まあ当然の結果ですね。
startという関数の中の処理が上から順に処理されて、
「うさぎ」「カメ」の順でゴールしています。
皆さんが内心思っている通り、やっぱりうさぎの方が早いわけです。(今回足の速さは関係ありませんが。)
では次は非同期処理です。
const start = () => {
setTimeout(() => {
console.log('うさぎがゴール');
}, 2000)
console.log('カメがゴール');
}
start();
// カメがゴール
// うさぎがゴール ※2秒後に出力
非同期処理代表のsetTimeout関数を使ってみました。
するとどうでしょうか?
同期処理だと「うさぎ」「カメ」の順でゴールしていたはずが、
「カメ」「うさぎ」の順でゴールしました!しかもうさぎはカメより2秒遅れです。
なるほど、童話の「うさぎとかめ」は非同期処理が働いていたのか。。。
冗談はさておき、
「setTimeoutで2000ms後って指定しているんだから当然だろ」
と思うかもしれませんが、これはとても大変なことです。
だってJavaScriptは上から順に一つずつしか処理が出来ないのですから、
非同期処理という仕組みがなかったら、2秒間うさぎがサボっていても、その間カメもスタートを待たなければなりません。
つまり「うさぎとかめ」の童話は成立しなくなるわけです(冗談です)。
非同期処理があるおかげで、カメはうさぎを出し抜いて先にゴール出来たわけなんですねー(冗談です)。
ただ、厳密に言うと、非同期処理でもスタートは「うさぎ」「カメ」の順番です。
今回うさぎは非同期関数内にあるので、JavaScriptは一旦うさぎが入った非同期関数の処理をブラウザまたはサーバーに任せ、カメを優先して 処理します ゴールさせます。そして2秒後に返ってきたうさぎを 処理する ゴールさせるわけです。※生き物に対して「処理する」という言葉は不適切だったので「ゴールさせる」に言い換えています。
このようにしてJavaScriptは処理を並行して行っているように見せかけることができます。
「見せかける」という点がJavaScriptのズルい点ですよね。
では次の処理の結果はわかりますか?
これがわかれば非同期処理について理解したと言っても良いと思います。
const func1 = () => {
setTimeout(() => {
console.log('task1 done');
func2();
});
console.log('func1 done');
}
const func2 = () => {
console.log('func2 done');
}
func1();
func2();
さあどうでしょうか?
func1の中でfunc2が実行されていますね。
その前にconsole.logで出力もあります。
でもその2つはsetTimeout関数で囲われています。
あれ?でも今回setTimeout関数に秒数が指定されていません。
つまり0秒というわけです。
ということは待つ必要がないから、、、
答えは
task1 done
func2 done
func1 done
func2 done
これだ!!!
おめでとうございます!!!!
あなたは今日この記事を見ていて良かった!!
残念ながら違います!
いや、その気持ち凄くわかります!
私も同じミスしました。
先に答えですが、
const func1 = () => {
setTimeout(() => {
console.log('task1 done');
func2();
});
console.log('func1 done');
}
const func2 = () => {
console.log('func2 done');
}
func1();
func2();
// func1 done
// func2 done
// task1 done
// func2 done
えぇ〜〜〜?!
setTimeoutは0秒じゃん!それなのになんで後で処理されるの??
と思いますよね?
実は一度非同期処理として扱われた処理は元の順番には戻りません。
また並び直しというわけです。
なので今回の処理の順番は、
func1内の「console.log('func1 done')」
func2内の「console.log('func2 done')」
func1内のsetTimeout内の「console.log('task1 done')」
func1内のsetTimeout内のfunc2内の「console.log('func2 done')」
となります。
これでもう大丈夫!
ここで間違えてて良かった!!
もちろん間違えなかった人も多いと思うので、間違えなかった人たちは「私は天才!」と自画自賛して下さいw
次は非同期処理に欠かせない「Promise」について見ていきます。
3. Promise 〜借りは返すと約束しよう〜
Promise(プロミス)、、、そんな会社があったような、なかったような。。。
日本語で「約束」ですね。
Promiseはオブジェクトであり、その名の通り、Promise内で書かれた処理が後で完了することを「約束」します。
「いまはまだ成功するか失敗するかはわからないけど、あとで必ずそのどちらかの結果を返します」と約束するのです。
たくさんの
「は?」
が聞こえてきます。。。
私もそうでした。
全然意味分からないですよね。
実際に処理を見たほうが早いかもしれません。
例えば、非同期処理で別のサイトからデータを取得してきたいとしましょう。
でもそのデータの取得が上手くいくかどうかわかりません。
通信障害や、取得先のサイトがメンテナンス中で通信出来ないかもしれないからです。
そんな時に「成功ならデータを表示させる」「失敗なら失敗した旨のメッセージを表示させる」という処理を書きたくなるはずです。
ここからは例題を通して見ていきましょう。
【例題1】
あるサイト「sample.com」からデータを取得してきて、取得できたら「(指定のurl)からデータ取得成功!」、失敗したら「失敗...」と出力させます。
※ただし、ダミーサイトなので、処理としては、setTimeoutの秒数をランダムな秒数で指定して、それが3秒未満であれば取得ができたとみなして「成功」、3秒以上であれば取得できなかったとみなして「失敗」とします。
まずはPromiseを使わずに行います。
const getData = (url) => {
const delayTime = Math.floor(Math.random() * 4000);
setTimeout(() => {
if(delayTime < 3000) {
console.log(`${url}からデータ取得成功!`)
} else {
console.log('失敗...')
}
}, delayTime)
}
getData('sample.com');
// delayTimeが3000(3秒)未満なら -> 'sample.comからデータ取得成功!'
// delayTimeが3000(3秒)以上なら -> '失敗...'
では次にPromiseを使って行います。
const getData = (url) => {
return new Promise((resolved, rejected) => {
const delayTime = Math.floor(Math.random() * 4000);
setTimeout(() => {
if(delayTime < 3000) {
resolved(`${url}からデータ取得成功!`)
} else {
rejected('失敗...')
}
}, delayTime)
});
}
getData('sample.com')
.then((res) => {
console.log(res);
})
.catch((e) => {
console.log(e);
})
// delayTimeが3000(3秒)未満なら -> 'sample.comからデータ取得成功!'
// delayTimeが3000(3秒)以上なら -> '失敗...'
え、、、?長くなってるじゃん!
と全力で突っ込みたい気持ちはわかります。
でもこれはあくまで一つの処理しかしていません。
これが「この処理が成功したら次、次の処理が成功したら次、その次の処理が成功したら次...」となった時に威力を発揮します。
次の例題を見てみましょう。
【例題2】
あるサイト「sample.com/page1」からデータを取得してきて、取得できたら「(指定のurl)からデータ取得成功!」、失敗したら「失敗...」と出力させます。また、成功だった場合、次は「saumple.com/page2」からデータを取得し、それも成功だった場合は「sample.com/page3」からデータを取得します。なお、失敗したらその時点でエラーメッセージを出して処理を終了させます。
※ただし、ダミーサイトなので、処理としては、setTimeoutの秒数をランダムな秒数で指定して、それが3秒未満であれば取得ができたとみなして「成功」、3秒以上であれば取得できなかったとみなして「失敗」とします。
さあこれも一旦Promiseを使わないで書いてみましょう。
const getData = (url, success, failure) => {
const delayTime = Math.floor(Math.random() * 4000);
setTimeout(() => {
if(delayTime < 3000) {
success(`${url}からデータ取得成功!`)
} else {
failure('失敗...')
}
}, delayTime)
}
getData('sample.com/page1',
(res) => {
console.log(res);
getData('sample.com/page2',
(res) => {
console.log(res);
getData('sample.com/page3',
(res) => {
console.log(res);
},
(e) => {
console.log(e);
}
);
},
(e) => {
console.log(e);
}
);
},
(e) => {
console.log(e);
}
);
どうですか?
これが有名なコールバック地獄です。
コールバック関数のネストがどんどん深くなっていますよね。。。
とても見づらいし、正直いま書いていて混乱しましたw
では次にPromiseを使ってみます。
const getData = (url) => {
return new Promise((resolved, rejected) => {
const delayTime = Math.floor(Math.random() * 4000);
setTimeout(() => {
if(delayTime < 3000) {
resolved(`${url}からデータ取得成功!`)
} else {
rejected('失敗...')
}
}, delayTime)
});
}
getData('sample.com/page1')
.then((res) => {
console.log(res);
return getData('sample.com/page2')
})
.then((res) => {
console.log(res);
return getData('sample.com/page3');
})
.then((res) => {
console.log(res);
})
.catch((e) => {
console.log(e);
})
どうでしょうか?
コード行数も若干ですが減っていますし、何より見やすいですよね!
「これが成功したらこれ、これが成功したらこれ」というのがコードからも読み取りやすくなっています。
また、エラー時の処理も最後に一度書くだけで良いというのも最高です!
これが更に処理が続いた場合どれほどの恩恵を受けるか、感の良い方ならわかるはず。
思わず叫びたくなりますよね。
Promiseありがとうーーーー!!!!
と、ここで冷静になり少し細かくみていきます。
最初にわかるのが、getData関数が受け取る引数が一つだけになっています。
successとfailureがなくなっています。
ではどこで成功と失敗を判定するのでしょうか?
それは新たに追加された「resolved(解決済)」と「rejected(拒否)」です。
実は他にも「pending(待機)」がありますが、ここでは割愛します。
成功 = resolved
失敗 = rejected
となります。
つまり成功すればresolvedを、失敗すればrejectedを返すわけです。
ここで最初に言った「いまはまだ成功するか失敗するかはわからないけど、あとで必ずそのどちらかの結果を返します」という約束を果たすことになります。
そして結果が成功であれば.thenのコールバック関数の引数として渡され、
失敗であれば.catchのコールバック関数の引数として渡されます。
また、処理が続く場合、.thenのコールバック関数内では次の処理を呼び、その結果をreturnで返す必要があります。
返された結果は次の.thenまたは.catchに渡されるという仕組みとなっているのです。
もう凄く考えられていて、コードも見やすく、Promise完璧ですね!
が、しかし!!!
世の中の天才さんたちは想像以上に横着でした。(良い意味です)
更に使いやすく、読みやすくしようと考えました。
そこで出てくるのが「async/await」です!
次は「async/await」を見ていきましょう!
4. async/await 〜私(await)はあなた(Promise)を待ち続けます〜
では先程の【例題2】で書いたPromiseで書いたコードをもう一度見てみましょう。
const getData = (url) => {
return new Promise((resolved, rejected) => {
const delayTime = Math.floor(Math.random() * 4000);
setTimeout(() => {
if(delayTime < 3000) {
resolved(`${url}からデータ取得成功!`)
} else {
rejected('失敗...')
}
}, delayTime)
});
}
getData('sample.com/page1')
.then((res) => {
console.log(res);
return getData('sample.com/page2')
})
.then((res) => {
console.log(res);
return getData('sample.com/page3');
})
.then((res) => {
console.log(res);
})
.catch((e) => {
console.log(e);
})
なんて非の打ち所がない読みやすいコードでしょうか!
これで良いじゃないか!と言いたくなりますが、
これをasync/awaitを使って書いたコードがコチラです。
const getData = (url) => {
return new Promise((resolved, rejected) => {
const delayTime = Math.floor(Math.random() * 4000);
setTimeout(() => {
if(delayTime < 3000) {
resolved(`${url}からデータ取得成功!`)
} else {
rejected('失敗...')
}
}, delayTime)
});
}
const getDataAsync = async () => {
try {
let res = await getData('sample.com/page1');
console.log(res);
res = await getData('sample.com/page2');
console.log(res);
res = await getData('sample.com/page3');
console.log(res);
} catch (e) {
console.log(e);
}
}
getDataAsync();
なんてことでしょうか!
更に見やすくなった気がします。
いや正直、毎回「.then」で繋ぐのダルいと思ってたんですよ!
それがまさか不要になるとは!!!
ちょっと細かく見ていきますね。
getData関数はPromiseの時となんら変わりません。
違うのは、新たに「getDataAsync関数」が作られ、何やら「async」という文字が入り、
中では見覚えのある「try/catch」文が書かれています。
catchは「e」を引数で受け取り、console.logで出力しています。
この「e」にはgetData関数内のrejectedが返ってきていそうですね。
tryの中では変数「res」が定義され、getData関数が呼ばれていますが、頭に「await」という文字がついています。
実はここがミソでして、
awaitはasync関数の中でしか定義出来ません。
asyncは「これは非同期関数ですよ」と宣言する関数で、awaitは「Promiseの返事が返ってくるまで処理を停止して待ち続けます」という関数です。つまりとても一途な関数なわけです。
そしてawaitの役割はそれだけではなく、必ずPromiseの結果を受け取って返すので、Promiseだけの処理と比べるとわかるのですが、「return」が必要ありません。
Promiseからの返事をそのまま変数に渡すことができるのです。(待ち続けた割にはあっさりと他の人に渡してしまう恐ろしい子)
この仕組みのおかげで連続する非同期の処理が簡潔に書くことが出来るわけです!
そしてこの処理を見て何か思いませんか?
そうです。とても同期処理と似た書き方になっているんです!
コールバックを何重にも入れ子にする必要も、.thenで繋ぐ必要もありません。
ただ、処理したい順番に書けば良いだけです。
もはや非同期処理の見やすさの究極系ですね。
もうみんなで叫びましょう!
頭の良い人達ありがとうーーー!!!!!
async/awaitサイコーーーー!!!!!
5. まとめ
いかがでしたか?
私自身とても非同期処理に苦手意識があり、正直避けて通れるなら避けて通りたいと思っていました。
でも、学ぶ内に先人たちの苦労やその努力を垣間見ることができ、いつの間にか苦手意識はなくなりました!
今となってはPromiseとasync/awaitの登場に感謝しています。
まだ実務で使ったことはありませんが、コールバック地獄を回避出来ると思うとそれだけで感謝です。
既存のコードだとコールバック地獄を見ることもあるそうですが、、、
非同期処理は私に「食わず嫌いはダメだよ」と教えてくれた気がします。
これを教訓に、これからは「ちょっと深入りしてみる」を大切にしようと思いました!