はじめに
いきなりですがクイズです。
下記のコードはどのような順番でconsoleに表示されるでしょうか?
const sushi = () => {
setTimeout(() => {
console.log('寿司');
});
};
sushi();
console.log('高い');
new Promise((resolve) => {
const pref = '銀座で';
return resolve(pref);
}).then((pref) => {
console.log(pref);
});
const tabeyo = () => {
console.log('食べよう');
};
tabeyo();
... わかりましたか?
見事正解された方は、きっとこの処理を正しい文法になるよう操り、高級寿司を食べに行けると思うので どうぞブラウザバックしてもろて結構です。
わからなかった、または不正解だったあなた!
この記事を読み終わるころにはきっと、銀座で高い寿司を食べていることでしょう。
この記事を書いたきっかけ
- そろそろ理解しとかなきゃと前々から思っていた
- JSのコードが期待通り動かない時、関数自体の問題の他、処理順に関することもまあまああった
- ここらへん勉強したらデバッグが前よりスムーズになったので共有したいと思った
以上が動機です。
この記事について
非同期処理と言えばPromise
とかasync/await
とかを想像すると思いますが、今回はその用法などには触れず、普通に処理順について調べたことをまとめてます。
が、この勉強してないと用法云々の前に使いこなせなかったので調べて正解でした。
そして非同期処理を勉強しようと蓋をあけてみたらかなりの深淵でビビりました。今回はさわりだけです。かなりざっくりです。
普段何となく使ってた人とかも読んでくれるといいかもしれません。
一緒に無事に寿司を食べましょう。
スレッド
ブラウザには、いろいろな スレッド というものがあります。
中でもJSの処理を司るスレッドは メインスレッド といいます。
(他にもいろいろありますが、本記事ではメインスレッドを中心に進めていきます。)
このメインスレッドとはなんなのかというと、JSの実行を一本の糸のように順番に処理していくところです。🪡
そしてメインスレッドはJSの処理を 一個ずつしか捌けません。
See the Pen メインスレッド占有と非同期処理 by 加藤菜摘 (@lreeyied-the-decoder) on CodePen.
このCode penを走らせた後、テキスト入力欄に文字を入力してみてください。
...おや。最初だけまったく反応しませんね。
これは、処理が終わるまで5秒間かかる関数を実行しているからです。
JSの処理を行うメインスレッドは、処理を1つずつしか捌けないため、上記のような処理を実行すると5秒間メインスレッドがその関数で 専有 されてしまい、他の処理を全く受け付けなくなってしまいます。
これはマウスイベントにかかわらず、スクロールなども全く効かなくなってしまいます。
このことを、ブロッキングといいます。
処理が終われば、メインスレッドは解放されるのでテキスト入力欄に文字を入力しはじめることができます。
それでは、以下のコードではどうでしょうか
See the Pen メインスレッドの占有 by 加藤菜摘 (@lreeyied-the-decoder) on CodePen.
Code penを走らせてすぐ、文字入力ができます。
しかし、setTimeout
関数で3秒間待った後、メインスレッドを専有する関数( occupation
)を実行しているので、5秒間操作を受け付けなくなります。
そして、処理が完了した5秒後、また文字入力ができるようになりますね。
< はにゃ?
JavaScriptってメインスレッドで順番に1つずつ処理されるんでしょ?
3秒数えながら文字入力も受け付けるって、明らかに同時に複数のことをやってない???
そうです。
メインスレッドは先述したように、ブラウザのレンダリングも司っています。3秒数えながらレンダリングもするっておかしいですよね。
実はsetTimeout
などの非同期型APIは、メインスレッドとは別のところで処理されているのです!
非同期API
いや別のところと言われましても。
という感じですよね。
そもそも、この世界にはいろーーんな環境があります。(ブラウザ環境とかランタイム環境とか)
JSが動くのは、その環境に埋め込まれた ECMAScript エンジンがあるからです。
そしてJSは ECMAScript の仕様に基づいて動作が定められているので、違う環境で実行しても動作は共通です。
一方APIは、ECMAScript とはほとんど関係なく
同じ機能を同じ名前で、それぞれの環境が独自に定義して提供してくれてるだけなんです。
(ただし実際には機能的にそれぞれ微妙〜〜に異なることがあります。)
APIには同期型と非同期型があり
setTimeout
は非同期型のAPIに区分されます。
なのでさっきの こいつ → の疑問をまとめると
setTimeout
の遅延処理はAPIを介して、環境がバックグラウンドで並列的に実行してくれているのです。
呼び出された瞬間、3秒数えといて〜〜って委任してるんですね。
だから、3秒数えるのと、テキスト入力を受け付ける、という2つのことを同時にできるわけです。
主に使う非同期型APIは
- setTimeout
- setInterval
- MutationObserver
- queueMicrotask
- Promise
- fetch
などなどじゃないでしょうか。
例えば上記のfetch
なんかは、引数にURLを指定してデータをとってくる時に使いますよね。
このfetch
はChromeブラウザ環境においては
Browserプロセス
というところのNetworkスレッド
でネットワーク接続やリクエストの処理を行っています。
だから、たとえ取得したいデータが超大容量で取得にめちゃめちゃ時間がかかったとしても
メインスレッドとは別のところで頑張ってもらってるので
データを取得できるまでの間でもブロッキングされることなく、ユーザー操作を受け付けてくれるということです。
(ちなみに、ChromeのメインスレッドはRendererプロセス
にあります。)
タスクキューとコールスタック
処理順の話に戻ります。
さっきJSがsetTimeout
などの非同期型APIを見つけたら、その処理を委任すると言いましたが
タスクキューとはその委任先のお仕事リストです。
お仕事リストは飲食店のオーダー表みたいなやつなので
先に注文されたものから先に処理されます。
この仕組みは 先入れ先出し という名前がついてます。
それではまず、冒頭のクイズに出した問題をちょこっとアレンジしたバージョンから見てみます。
console.log('高い');
const sushi = () => {
setTimeout(() => {
console.log('寿司');
});
};
sushi();
const tabeyo = () => {
console.log('食べよう');
};
tabeyo();
console.log('高い')
関数 sushi()
関数tabeyo()
この3つが順に並んでますね。
sushi関数は非同期型APIであるsetTimeout
を持っていますが、遅延時間は指定されてません。
普通記述順に処理されていくので
コンソールに表示されるのは
- 高い
- 寿司
- 食べよう
な気がしますが 違います。
ここで処理順を捌く登場人物を紹介します。
🧐 イベントループくんです!
イベントループくんのお仕事は
タスクキュー(お仕事リスト)を監視し、タスクが追加されたらコールスタックに渡してあげるのが仕事です。
いわば飲食店のホールスタッフです。
コールスタックには記述順に処理が入ってきます。
さっきのコードでいえば普通に
console.log('高い')
関数 sushi()
関数tabeyo()
の順なので
一番最初にコンソールに表示されるのは
高い
です。
次に関数sushi()
がくるのですが
非同期APIのsetTimeout
があるので
呼び出された瞬間一連の流れから切り離されます。
そしてsetTimeout
の中身の処理はタスクキューに渡されます。
その間、次の関数tabeyo()
がコールスタックに積まれ、コンソールには
食べよう
が表示されます。
イベントループくんはタスクキューに入ってきたconsole.log('寿司')
を
コールスタックが空になったのを見計らってコールスタックに渡し
最後に
寿司
がコンソールに表示されます。
かなりざっくりですが、こんな感じの流れになってます。
マクロタスクとマイクロタスク
実はタスクキューは一つじゃありません。
いろーーんなタスクキューがあります。
非同期型APIも、管理されるタスクキューは一つじゃありません。
マクロタスク と マイクロタスク で、別々に管理されるのです。
普段JSで実行順が期待通りにならないよー!!ってときは
この概念を理解していなかったのが原因でした。
ではマクロタスクとマイクロタスクにはどのような違いがあるのか?
それは、マイクロタスクの方が優先して処理されるという点にあります。
そしてマイクロタスクに入ったタスクが全て捌けるまで、マクロタスクは捌けません。
主な非同期型APIの振り分けはこちら。
マクロタスク | マイクロタスク |
---|---|
setTimeout |
queueMicroTask |
setInterval |
MutationObserver |
requestAnimationFrame |
Promise |
fetch |
今まで例に出してきたsetTimeout
は実はマクロタスクとして管理されるんですね。
では最初に出したクイズを振り返ってみましょう。
const sushi = () => {
setTimeout(() => {
console.log('寿司');
});
};
sushi();
console.log('高い');
new Promise((resolve) => {
const pref = '銀座で';
return resolve(pref);
}).then((pref) => {
console.log(pref);
});
const tabeyo = () => {
console.log('食べよう');
};
tabeyo();
高い
食べよう
の順で表示されるのは変わりません。
イベントループくんはどのタスクから優先的に捌くかの判断をしています。
setTimeout
はconsole.log('寿司')
をマクロタスクに
Promise
のメソッドチェーンのconsole.log(pref)
をマイクロタスクにぶちこみます。
イベントループくんはコールスタックにいた
高い
と
食べよう
が捌けたら
マイクロタスクに入っているconsole.log(pref)
から先にコールスタックに渡すので
記述順に関係なくコンソールに表示されるのは
銀座で
になります。
そして、イベントループくんはマイクロタスクに登録されたものが全部捌けないとマクロタスクを捌かないので
例えば
const sushi = () => {
setTimeout(() => {
console.log('寿司');
});
};
sushi();
console.log('高い');
new Promise((resolve) => {
const pref = '銀座で';
return resolve(pref);
}).then((pref) => {
console.log(pref);
}).then(() => { // 追加
console.log('たくさん');
});
const tabeyo = () => {
console.log('食べよう');
};
tabeyo();
上記のように、メソッドチェーンを追加したら、実行結果は
"高い"
"食べよう"
"銀座で"
"たくさん"
"寿司"
になります。
寿司
、一番最初に記述してるのに表示は一番最後になっちゃいましたね
ここまでくればもう高級寿司を食べに行けるはずです。
どんな方法で食べに行ってもいいです。
メソッドチェーンで順番に繋いで行ってもいいです。
みなさんの思い思いの方法で食べに行ってみてください。
const sushi = () => {
console.log('寿司');
};
new Promise((resolve) => {
const pref = '銀座で';
console.log(pref);
return resolve();
}).then(() => {
console.log('高い');
}).then(() => {
tabeyo();
})
const tabeyo = () => {
setTimeout(() => {
console.log('食べよう');
})
sushi();
};
"銀座で"
"高い"
"寿司"
"食べよう"
実践編
無事高級寿司を食べにいけたところで
この勉強をしたことで、私が実務で進化したところを紹介します。
こんな感じのカードがあるとします。
これはよくある、企業のイベントセミナー情報が記載されているリンク型のカードです。
このカードの中の情報はjsonで管理されており
指定のURLにアクセスしてjsonを取得しforEach()
でまわして
HTMLにinsertAdjacentHTML()
します。
所謂 json色つけ職人 です。
そしてカードが3枚以上になったらスライダーにします。(画像はスライダーになってませんがスライダーだと思ってください)
この要件でやることは
-
fetch
してURLにアクセスしjsonを取得 -
forEach
でまわして、カードの枚数分 HTMLにinsertAdjacentHTML()
する - なんらかのスライダーライブラリをつかって(SplideでもSwiperでも)スライダーにする
です。
とりあえず、一旦これ。
const eventSeminar = () => {
fetch('/json-seminar/') // jsonのURLでデータを取得
.then((responce) => {
return responce.json(); // json形式にして返す
})
.then((data) => {
const eventData = data.open_seminars;
appendEventCards(eventData); // htmlにつっこむ関数を発火
});
const appendEventCards = (eventData) => { // htmlにつっこむ関数本体
eventData.forEach((eventData) => {...});
};
};
const slider = () => { // スライダーを作る関数
new Splide('.my_slide').mount();
};
eventSeminar();
slider();
上記はスライダーライブラリ、Splide.jsを使用しております。
これだと、HTMLにインサートする前にスライダーをマウントしてしまうのでスライダーになりません。
虚無からスライダーを作ろうとしてます。
ならば、こう。
const eventSeminar = () => {
fetch('/json-seminar/')
.then((responce) => {
return responce.json();
})
.then((data) => {
const eventData = data.open_seminars;
appendEventCards(eventData);
});
const appendEventCards = (eventData) => {
eventData.forEach((eventData) => {...});
};
};
const slider = () => {
new Splide('.my_slide').mount();
};
eventSeminar();
setTimeout(() => { //フィーリング・セットタイムアウト
slider();
}, 300);
slider()
関数の実行を、setTimeout
で無理やり遅延させてます。
引数の値はバイブスです。こんなもんやろ、で入れてます。
これをフィーリング・セットタイムアウトと呼びます。(勝手に呼んでます)
実に良くない。
しかし、その場しのぎでこれやっちゃってる人いるんじゃないでしょうか。
進化後
const eventSeminar = () => {
fetch('/json-seminar/')
.then((responce) => {
return responce.json();
})
.then((data) => {
const eventData = data.open_seminars;
appendEventCards(eventData);
}).then(() => {
slider(); // ←これを追加
});
const appendEventCards = (eventData) => {
eventData.forEach((eventData) => {...});
};
};
const slider = () => {
new Splide('.my_slide').mount();
};
eventSeminar();
3つ目のメソッドチェーンを追加して、確実にHTMLにつっこんでからスライダーをマウントするようにしました。
これでフィーリング・セットタイムアウトから卒業です。
この例以外でも、処理順や仕組みをある程度理解できてから、期待通りの実行順で書けるようになりました。
勉強、大事。
まとめ
- JSはメインスレッドで処理される
- コールスタック上で処理が完了するまでは次の処理には進まない
- コールスタック(メインスレッド)が一定時間専有されることをブロッキングという
- 非同期処理にはマイクロタスクとマクロタスクがある
- マイクロタスクの処理が全て終わらないとマクロタスクの処理に移行しない
この記事が役に立ったと感じたら、ぜひ いいねと、コメントに🍣置いていってください。
最後までお付き合いいただきありがとうございました。
参考文献
イベントループとプロミスチェーンで学ぶJavaScriptの非同期処理
↑このweb本を読んでいただくと、本記事はいかに端折って説明しているかが良くわかると思います。かなり詳しく解説されているので、より知識を深めたい方は是非。
【JS】ガチで学びたい人のためのJavaScriptメカニズム
↑こちらはUdemyの有料講座ですが、かなり勉強になるので是非。