はじめに
今回の記事では、多くの人が一度はつまずいたり、挫折した経験のある「非同期処理」について解説します。
「非同期処理」と聞いて、「うわ、無理…」「なんか苦手…」と感じる人も少なくないのではないでしょうか?
実は、僕自身もこの分野がめちゃくちゃ苦手でした。
プログラミングの処理は基本的に、上から下へ順番に実行されていきます。しかし、非同期処理では処理の途中で他の処理を実行し、非同期のタスクが完了したタイミングでその結果を使って画面を描画したり、データベースにアクセスしたりします。
たとえば画像表示サイトでは、ユーザー情報の取得を待たずに、先に画面操作ができるようになることがあります。
非同期処理は、まさにそういった仕組みを実現するために使われています。
でも正直に言うと、初めて非同期処理を学んだときは、本当に意味がわかりませんでした。
頭の中をミキサーでぐちゃぐちゃにされたような感覚で、「なんでこうなるの?」と何度も混乱しました。
それでも、何度もつまずきながら試行錯誤を繰り返し、ようやく非同期処理の考え方と仕組みが理解できるようになりました。
この記事では、そんな僕がついに「腹落ちした」非同期処理の本質について、できるだけわかりやすくまとめました。
また、この記事には簡単な練習問題も用意していますので、
読むにあたって、以下の2つの準備をおすすめします:
- 紙とペン、そして VS Code や Cursor などのエディターを用意しましょう
実際にコードを書きながら、一つひとつの動きを確認して進めてみてください。 - 紙に図を書いてみましょう
後ほど紹介する図を、自分の手で書いてみましょう。非同期処理のタスクがどこで管理され、どの順番で実行されるのかを可視化することで、理解度が一気に深まります。
この2つを実行すれば、記事を読み終えるころには、非同期処理への苦手意識がなくなっているはずです。
「騙されたと思って」ぜひ実践してみてください!
この記事を読むことで得られること
実際に手を動かしてコードを書いたり、処理順番を図にしたりすることで理解度倍増!
JavaScriptの難関、非同期処理について処理を視覚的に確認することができ、
非同期処理への苦手意識がなくなります。
目次
同期処理について
非同期処理について
-
メインスレッドから処理を一時的に切り離されて実行される
- メインスレッドはコールスタックで管理され、メインスレッドから切り離された非同期処理はタスクキューで管理されている
- 非同期処理ではメインスレッドから処理が切り離されているのでメインスレッドでの処理は継続される
-
タスクキュー(マクロタスク・マイクロタスク)で処理する順番を管理している(先入れ先出し)
- コールスタックとイベントループと連携している
- イベントループは定期的にコールスタックにコンテキストが積まれていないかを確認する仕組み
- コールスタックに処理が積まれていない場合にタスクキューからコールスタックに処理を移行し実行する
function sleep(ms) { const startTime = new Date(); while (new Date() - startTime ms); console.log('sleep done'); } const btn = document.querySelector('button'); btn.addEventListener('click', function(){ console.log('button clicked'); }); setTimeout(function (){ sleep(3000) ,2000})
-
setTimeout
は非同期処理でメインスレッドから切り離されている。関数実行(ページ読み込み)から2000(2秒間)経ってsleep(3000)
が実行される - メインスレッドから切り離されている時間(2秒間)はメインスレッドでの後続の処理が可能
- コールスタックとイベントループと連携している
代表的な非同期処理
非同期処理API
- setTimeout
- Promise
- queueMicrotask
etc…
UIイベント
etc…
NWイベント
- HTTPリクエストの送受信
- ソケット通信(WebSocketなど)
…etc
I/Oイベント
- ファイル読み書き(
fs.readFile
など) - データベースとの通信
- 標準入力や出力(ターミナルとのやりとり)
…etc
コールスタック、イベントループ、タスクキュー関係図
いろいろと用語が出てきて、「結局どう関係してるの?」と混乱しているかもしれません。
コールスタック?イベントループ?タスクキュー?
言葉だけだと関係性のイメージがつかみにくいですよね。
最初は難しく感じるかもしれませんが、図に描くことで処理の流れが目に見えるようになり、理解が一気に深まります。
※この段階では「ジョブキュー」と「タスクキュー」の違いは気にしなくてOKです。
どちらもまとめて「タスクキュー」として考えて進めていきましょう。
非同期処理コールバック
function a() {
setTimeout(function task1() {
console.log('task1 done');
});
console.log('fn a done');
}
function b() {
console.log('fn b done');
}
a();
b();
//コンソール出力
'fn a done'
'fn b done'
'task1 done'
練習問題
先程ののコードでは、a()を実行したあとにb()を実行していますが、
実行結果としては b() が task1 より先に表示されてしまいます。
では、「task1が完了したあとに関数bを実行する」には、どのようにコードを書き換えれば良いでしょうか?
先ほど紹介した関係図に書き込むと視覚的に理解できますよ!
回答
function a() {
setTimeout(function task1() {
console.log('task1 done');
b();
});
console.log('fn a done');
}
function b() {
console.log('fn b done');
}
a();
コードの流れ
関数a
を実行(コールスタックに関数a
の関数コンテキストとグローバルコンテキストが積まれる)
→ タスクキューに関数をtask1
を追加
→ コールスタックから関数a
の関数コンテキストが消滅
→ コールスタックからグローバルコンテキストが消滅
→ タスクキューから関数task1
がコールスタックに移行
→ 関数task1
が実行
→ 関数 b
を実行(コールスタックに関数b
の関数コンテキストが積まれる)
→ 関数b
が実行
上記のように非同期とコールバックの仕組みを使うことで関数の順序を変えることができる
コールバック地獄
function sleep(callback, val) {
setTimeout(function () {
console.log(val++)
callback(val)
}, 1000)
}
sleep(function (val) {
sleep(function (val) {
sleep(function (val) {
sleep(function (val) {
}, val)
}, val)
}, val)
}, 0)
コードの流れ
sleep
-
callback
とval
を引数に取る -
setTimeout
を使い1秒後、コンソールにval
を表示して表示した後にプラス1される - プラス1されて値が
callback
の引数に渡され処理される
sleep
使用側
-
sleep
の1回目は、引数0
を受け取り、1秒後にconsole.log(val++)
で0
を表示。その後、val
は1
にインクリメントされ、次のcallback
に1
が渡される。この時点では、渡された1
はまだコンソールには表示されない(次のsleep
内で表示される)。 -
sleep
の2回目は、前のコールバックから受け取ったval = 1
を引数にして呼び出され、1秒後にconsole.log(val++)
で1
を表示。その後、val
は2
になり、次のcallback
に2
が渡される。この時点でも、2
はまだ表示されない。
以下、繰り返し
出力結果
時間経過 | val |
console.log(val++) の出力 |
次に渡すval |
---|---|---|---|
0秒 | – | – | sleep(0) 開始 |
1秒後 | 0 | 0 |
1 |
2秒後 | 1 | 1 |
2 |
3秒後 | 2 | 2 |
3 |
4秒後 | 3 | 3 |
4 |
このコードは正常に動作しますが、コールバック関数のネストが深くなり、コードの可読性や保守性が著しく低下してしまいます。
このようにコールバックを連ねて非同期処理をつなげる方法は「コールバック地獄」と呼ばれ、推奨されません。
そこで、JavaScriptのPromiseやasync/awaitを使うことで、よりシンプルで読みやすい非同期処理の記述が可能になります。
次のセクションでは、同じ処理をPromiseとasync/awaitで書き直してみましょう。
Promise
- 非同期処理をより簡単に、可読性が上がるように書けるようにしたもの
コールバック地獄の解消
function sleep(val) {
return new Promise(function (resolve) {
setTimeout(function () {
console.log(val++);
resolve(val);
}, 1000);
})
};
sleep(0)
.then(val => sleep(val))
.then(val => sleep(val))
.then(val => sleep(val));
コードの流れ
sleep
関数
- 引数に
val
を受け取る -
new Promise
により Promise インスタンスを生成-
Promise
コンストラクタのコールバック関数は、resolve
とreject
という2つの引数を取る(今回はresolve
のみ使用)
-
-
setTimeout
を使って1秒後に以下の処理を行う:-
console.log(val++)
で現在のval
を表示し、その後val
をインクリメント -
resolve(val)
を呼び出し、次の.then()
に値を渡す
-
-
Promise
インスタンスを返すことで、.then()
で非同期処理をつなげることができる
sleep
使用側
-
sleep(0)
を最初に呼び出し、.then()
を使ってその後の処理をチェーンさせていく - 各
.then()
の中では次のsleep(val)
を呼び出して return することで、処理の流れが順番に続いていく -
.then()
に渡された関数は、前のresolve(val)
の結果を引数として受け取る -
.then()
に処理をつなげるには、常にPromise
を返す必要がある。-
resolve(val)
→.then(val => ...)
-
reject(err)
→.catch(err => ...)
に値が渡される
-
.then/.catch
で処理をつなげるために、必ず戻り値にPromise
のインスタンスを返す
-
resolve
→then
-
reject
→catch
async/await
Promise
をより直感的に書けるようにしたもの
// async 関数を変数に代入するパターン
const fetchData = async (url) => {
const response = await fetch(url);
// 後続の処理を書く
};
// 通常の関数宣言パターン
async function fetchData(url) {
const response = await fetch(url);
// 後続の処理を書く
}
コードの流れ
-
async
関数の前に
async
を付けることで、その関数を非同期関数(Async Function)として定義できる。-
async
関数は常にPromise
を返す(戻り値を自動的に Promise にラップする) -
Promise
が解決(resolve
)されるまで 処理を待機できる - 例:
return 1
と記述しても、実際はPromise.resolve(1)
を返す
-
-
await
await
は Promise の完了を待つ演算子。-
await
を使うと、Promise が解決されるまで 後続の処理を一時停止する -
async
関数の中でのみ使用可能-
async
でない関数内で使用すると エラーになる
-
- 一般的に、
async
関数とawait
は セットで使われる
-
async/awaitとPromiseの比較
function sleep(val) {
return new Promise(function (resolve) {
setTimeout(function () {
console.log(val++);
resolve(val);
}, 1000);
})
};
async function init (){
let val = await sleep(0);
val = await sleep(val)
val = await sleep(val)
val = await sleep(val)
// この return は実行されません(上の throw により中断される)
console.log(val)
}
init();
同じ処理を Promise チェーンで書いた場合
sleep(0)
.then(val => sleep(val))
.then(val => sleep(val))
.then(val => sleep(val));
- 上記の
async/await
の実装は、Promise セクションで紹介した処理と 同じ挙動 -
await
を使うとresolve(val)
の引数val
が、そのままinit
関数内のval
に代入される- 一方、Promise では
.then(val => ...)
を使って 明示的に受け渡す必要
- 一方、Promise では
- 両者を比較すると、
async/await
を使う方が よりシンプルで読みやすく、直感的に記述できる - なお、
await
を付けずに呼び出すと、戻り値は Promise オブジェクトになる
更に関数init
(async
関数)は Promise
を返しますのでthen
やcatch
で処理をつなげられる
//・・・上略(sleep関数)
async function init (){
let val = await sleep(0);
val = await sleep(val)
val = await sleep(val)
val = await sleep(val)
throw new Error ('not Data')
return val
}
init().then(function (data) {
console.log(data);
}).catch(function(e) {
console.error (e)
})
- 3回目の
await sleep(val)
の後、throw new Error ('not Data')
が実行されエラーを出力される - この場合
val
は返されず、エラーが発生してcatch
ブロックに処理が移る - このように、
async
関数内で発生した例外は 自動的に Promise の reject として扱われ、.catch()
で補足可能
try&catchで例外処理
コードの流れ
throwがある場合
try {
console.log("start")
throw new Error ("No Data")
console.log("task")
} catch (e) {
console.error(e)
} finally {
console.log("end")
}
-
try
ブロック内のconsole.log("start")
がまず実行される - 次に
throw new Error("No Data")
により例外が発生し、その時点でtry
ブロックの処理は中断される- よって、
console.log("task")
は実行されない
- よって、
- 発生したエラーは
catch (e)
に渡され、変数e
に格納される-
e.message
には"No Data"
が格納されており、console.error(e)
によってエラーオブジェクトが出力される
-
- 最後に
finally
ブロックが実行され、console.log("end")
によって"end"
が出力される-
finally
は、エラーの有無に関わらず必ず実行されるブロック
-
throwがない場合
try {
console.log("start");
console.log("task");
} catch (e) {
console.error(e);
} finally {
console.log("end");
}
-
try
ブロック内の処理はすべて正常に実行される-
console.log("start")
とconsole.log("task")
の両方が出力される
-
- エラーが発生しないため、
catch
ブロックはスキップされる - 最後に
finally
ブロックが実行され、console.log("end")
が出力される
try&cachを使った例外処理
async function fetchData() {
const res = await fetch("users.json")
if (res.ok) {
const json = await res.json()
if (json.length === 0) {
throw new Error('No Data')
}
return json
}
}
const userData = async function () {
try {
const users = await fetchData()
for (const user of users) {
console.log(`I'm ${user.name}(${user.age})`)
}
} catch (e) {
console.error(e)
} finally {
console.log("end")
}
}
userData();
コードの流れ
fetchData(async関数)
- この関数では、
users.json
というファイルからデータを取得しているが、本来は API やデータベースから取得する想定 - 非同期で
users.json
ファイルからデータを取得する - fetch によって返された Response オブジェクトの
ok
プロパティがtrue
の場合、レスポンスを.json()
メソッドで JavaScript の配列に変換する- 取得した配列が空(
json.length === 0
)だった場合-
throw new Error('No Data')
によってエラーを投げ、catch
ブロックに処理が移る
-
- 配列にデータがある場合
- 配列データ(
json
)を返す
- 配列データ(
- 取得した配列が空(
userData(async関数)
-
try
ブロック(正常にデータが取得できた場合)では-
fetchData()
の戻り値をusers
変数に格納 -
for...of
ループでusers
を順番に処理し、console.log(
I’m ${user.name}(${user.age}))
で各ユーザーの名前と年齢を出力
-
-
catch
ブロック(throw new Error()
によってエラーが投げられた場合)では- エラーオブジェクト
e
をconsole.error(e)
で表示
- エラーオブジェクト
-
finally
ブロックでは-
try
またはcatch
の処理が終了した後、必ずconsole.log("end")
を実行して"end"
を出力する
-
カスタムエラー
try&cachを使った例外処理のコードに下記を追加することでカスタムエラーの作成ができる
class NoDataError extends Error {
constructor(message) {
super(message)
this.name = NoDataError
}
}
コードの流れ
-
NoDataError
というクラスを、組み込みのError
クラスを継承して定義している - このクラスは、インスタンス化される際に
message
を引数として受け取る -
super(message)
は親クラス(ここではError
クラス)のコンストラクタを呼び出すもので、エラーメッセージの内容をError
オブジェクトとして正しく扱えるようにする -
this.name="NoDataError"
によって、エラーオブジェクトのname
プロパティが"NoDataError"
に設定され、エラー出力時にカスタムエラー名が表示されるようになる
使用例
throw new NoDataError('not Data')
const userData = async function () {
try {
const users = await fetchData()
for (const user of users) {
console.log(`I'm ${user.name}(${user.age})`)
}
} catch (e) {
if( e instanceof NoDataError) {
console.error(e)
} else {
console.error("error!!!")
}
} finally {
console.log("end")
}
}
-
Error
を継承したNoDataError('not Data')
でエラーを投げてcatch
ブロックに処理を移行する -
catch
ブロック内では、発生したエラーがどのクラス(インスタンス)から投げられたかを判定し、それに応じて処理を分岐している - エラーが
NoDataError
のインスタンスであれば、そのエラーオブジェクトをconsole.error(e)
で出力 -
NoDataError
以外のエラー(たとえばTypeError
やReferenceError
など)の場合は、"error!!!"
という文字列を出力する
マクロタスクとマイクロタスク
マイクロタスク
マクロタスクよりも優先して実行される
例: Promise
、queueMicrotask
など
マクロタスク
マイクロタスクがすべて完了してから実行される
例: setTimeout
、setInterval
など
- 処理の途中で新たにマイクロタスクが追加された場合でも、マイクロタスクがすべて完了するまでマクロタスクは実行されない
ここでもう一度先ほどの関係図を見るとマクロタスク(タスクキュー)、マイクロタスク(ジョブキュー)についてもイメージができると思います。
new Promise(function promise(resolve) {
console.log('promise');
setTimeout(function task1() {
console.log('task1');
resolve();
});
}).then(function job1() {
console.log('job1');
setTimeout(function task1() {
console.log('task2');
queueMicrotask(function job4() {
console.log('job4')
})
});
}).then(function job2() {
console.log('job2');
}).then(function job3() {
console.log('job3');
})
console.log('global end');
//コンソール出力
promise
global end
task1
job1
job2
job3
task2
job4
コードの流れ(イベントループとタスクの順序)
ここで同期タスク終了 → マクロタスクの処理へ移る
-
task1
実行 →console.log('task1')
→task1
-
resolve()
呼び出し → Promise 解決 →.then(job1)
登録 → マイクロタスク1
マイクロタスク処理開始
-
job1
実行 →console.log('job1')
→job1
-
setTimeout(task2)
登録 → マクロタスク2 -
.then(job2)
登録 → マイクロタスク2 -
.then(job3)
登録 → マイクロタスク3
マイクロタスク継続処理
-
job2
実行 →console.log('job2')
→job2
-
job3
実行 →console.log('job3')
→job3
ここでマイクロタスクが空 → 次のマクロタスク(task2
)へ移る
-
task2
実行 →console.log('task2')
→task2
-
queueMicrotask(job4)
登録 → マイクロタスク4
マクロタスク終了 → マイクロタスク実行
-
job4
実行 →console.log('job4')
→job4
練習問題-2
console.log('start');
setTimeout(() => {
console.log('timeout1');
Promise.resolve().then(() => {
console.log('micro1');
});
setTimeout(() => {
console.log('timeout2');
});
}, 0);
Promise.resolve().then(() => {
console.log('micro2');
return Promise.resolve().then(() => {
console.log('micro3');
});
}).then(() => {
console.log('micro4');
});
queueMicrotask(() => {
console.log('micro5');
});
console.log('end');
上記のコードがどのように動き、どの順番でコンソールに出力されるか考えてみて下さい。
答えが決まったらスクロールして下さい。
回答
//コンソール出力
start
end
micro5
micro2
micro3
micro4
timeout1
micro1
timeout2
コードの流れ(イベントループとタスクの順序)
-
console.log('start')
→ 同期 →start
-
setTimeout(..., 0)
登録 → マクロタスク1 -
Promise.resolve().then(...)
登録 → マイクロタスク1-
micro2
, さらに.then(() => micro3)
→ ネスト → マイクロタスク2
-
-
.then(() => micro4)
→ マイクロタスク3(micro3のあと) -
queueMicrotask(...)
登録 → マイクロタスク4(micro5
) -
console.log('end')
→ 同期 →end
ここで同期タスク終了 → マイクロタスクの処理へ移る
-
micro5
(queueMicrotask) →micro5
-
micro2
→micro2
-
micro3
(micro2
の中)→micro3
-
micro4
(micro3
のあと)→micro4
ここでマイクロタスクが空 → 次のマクロタスク(setTimeout)
-
timeout1
→timeout1
-
Promise.resolve().then(...)
→ マイクロタスク登録(micro1
) -
setTimeout(...)
→ マクロタスク登録(timeout2
)
マクロタスク内のマイクロタスク実行
micro1
timeout2
非同期の並列処理
-
Promise
を直列につなぐ処理は「Promiseチェーン」と呼ばれる -
一方、複数のPromiseを同時に実行するのが「並列処理」
Promise.all()
やPromise.race()
を使って複数の非同期処理を同時に扱えます。
Promise.all(反復可能オブジェクト)
function sleep(val) {
return new Promise(function (resolve,reject) {
setTimeout(function () {
console.log(val++);
resolve(val);
}, val * 1000);
});
}
Promise.all([sleep(1), sleep(2), sleep(3)])
.then(function(data) {
console.log(data);
})
//コンソール出力
1
2
3
(3) [2, 3, 4]
コードの流れ
sleep
関数
-
sleep(val)
はPromise
を返す関数 -
setTimeout
を使ってval
秒後に処理を実行する(例:sleep(2)
なら2秒後) -
console.log(val++)
によって現在の値を出力し、次に使うためにインクリメント -
resolve(val)
により、インクリメントされた値を次の.then()
に渡す
Promise.all()
-
Promise.all([...])
は、配列内のすべての Promise が「成功」するのを待ってから次の.then()
に進む - すべてが完了すると、各
resolve
の値が配列となって渡される
Promiseチェーンの戻り値にPromise.all([…])を渡した場合
sleep(1).then(function(val) {
return (Promise.all([sleep(2), sleep(3), sleep(4)]))
}).then(function(val) {
console.log(val)
return sleep(val[1]);
}).then(function(val) {
return sleep(val);
})
//コンソール出力
1
-------
2 ↑
3 並列
4 ↓
-------
(3) [3, 4, 5]
4
5
コードの流れ
-
sleep(1)
により 1秒後に1
を出力、resolve(val)
には 2 が渡る -
.then()
でPromise.all([sleep(2), sleep(3), sleep(4)])
を返す:- この3つの
sleep()
は並列でスタート - それぞれ
2s
,3s
,4s
後に2
,3
,4
を出力(インクリメントされるので[3, 4, 5]
を返す)
- この3つの
-
.then()
に渡されるval
は[3, 4, 5]
。その配列のval[1]
=4
を次のsleep()
に渡す -
sleep(4)
→ 4秒後に4
を出力、val++ →5
を返す -
sleep(5)
→ 5秒後に5
を出力
補足
-
Promise.all([...])
で並列処理- 配列の各要素は
Promise
を返す必要がある。 - すでに解決済みの値[1,2,3](同期処理)を渡しても内部で
Promise.resolve()
に包まれる(Promise.all([1, 2, 3])
はPromise.all([Promise.resolve(1), ...])
と同じ動作になる)ため.then()
は実行される。ただし、Promise.all()
の本来の目的は非同期処理の並列実行なので、通常は非同期関数のPromise
を渡す
- 配列の各要素は
-
sleep().then()
のようにつなぐと直列処理 - 複雑なフローでも
.then()
チェーンをうまく使えば、直列→並列→直列 のような処理が実現できる
Promise.race(反復可能オブジェクト)
//・・・sleep関数、ボタンイベントは省略
Promise.race([sleep(1),sleep(2),sleep(3)])
.then(function (data) {
console.log(data)
})
//コンソール出力
1
2
2
3
コードの流れ
Promise.race()
-
Promise.race([...])
は、配列内で一番最初に完了(解決または拒否)した Promise の結果に応じて、次の.then()
または.catch()
に進む - 一番早く完了した
Promise
の**結果(値またはエラー)**が、.then()
または.catch()
に渡される
Promise.allSettled(反復可能オブジェクト)
//shouldReject = false/trueでresolve/rejectを切り替え
function sleep(val, shouldReject = false) {
return new Promise(function (resolve, reject) {
setTimeout(function () {
console.log(val++);
if (shouldReject) {
resolve(val);
} else {
reject(val);
}
}, val * 1000);
});
}
Promise.allSettled([sleep(1),sleep(2),sleep(3)])
.then(function (data) {
console.log(data)
}).catch(function(e) {
console.error(e)
})
//コンソール出力
resolve(val)
1
2
3
(3) [{…}, {…}, {…}]
{status:'fulfilled', value:2}
{status:'fulfilled', value:2}
{status:'fulfilled', value:3}
reject(val)
1
2
3
(3) [{…}, {…}, {…}]
{status:'rejected', reason:2}
{status:'rejected', reason:2}
{status:'rejected', reason:3}
コードの流れ
Promise.allSettled()
-
Promise.allSettled([...])
は、配列内のすべての Promise が完了(成功・失敗どちらでも)するのを待ち、必ず次の.then()
に進む - すべての処理が終わると、結果は各 Promise の状態を表すオブジェクトの配列で返される
- 各オブジェクトは成功時、 { status: ‘fulfilled’, value: 結果 }、失敗時、{ status: ‘rejected’, reason: エラー内容 } の形になっているため、status プロパティを確認して成功・失敗を判別する
最後に
いかがだったでしょうか?
この記事を読む前よりも、非同期処理について少しでも理解が深まったと感じてもらえたら嬉しいです。
すぐ完璧に理解できなくても大丈夫。
非同期処理は一度でスッと理解できるものではありません。繰り返し手を動かしながら学習することで、必ず身についていきます。
実を言うと、僕自身も2年前まではPC操作すらままならず、最近まで非同期処理が苦手すぎて「なんとなく避けて」きました。
でも、何度もつまずきながらも少しずつ向き合っていくうちに、やっと「わかった!」と思える瞬間がやってきました。
非同期処理が分かるようになると、JavaScriptでの開発がぐっと楽しくなります。
「苦手だな…」と感じていたことが「おもしろい!」に変わる瞬間を、ぜひ体験してみてください。
もし、もっとJavaScriptの本質や仕組みを深く学びたいと思った方は、参考書籍や教材にチャレンジしてみるのもおすすめです。
(ただし、ただ、決して難易度は低くないです。焦らずじっくり取り組んでくださいね!)
参考教材
Views: 0