はじめに
Javascriptのハマりポイントを後輩に教えるために書きます。
変数定義
var, let, const(定数) の3種類ある。
可能な限り const を使いましょう。どうしても必要な時だけ let を使いましょう。
const の方が Typescript での型推論も正確に働いてくれます。
var は古い書き方です。変数のスコープが分かりにくいので使用しないでください。
// { から } がブロックになる
if (true) {
var x = 'var宣言'
}
console.log(x) // x が参照できちゃう。let or const だとエラーになってくれる。
スコープについて
let, const はブロックスコープです。つまりブロック内で定義した変数はそのブロック内でのみ有効です。
ブロックの外からブロック内の変数は参照できませんが、ブロック内からブロック外の変数は参照可能です。
ブロックはマジックミラーの部屋
のようなイメージを持つと良いです。中から外は見えるけど、外から中は見れない。そんなイメージです。
何も指定しなくてもコード動くんだけど?
指定しなかったらvar
として動作してしまいます。ちゃんと指定しましょう。
プリミティブ型とオブジェクト型の扱いについて
以下は全てプリミティブです。これ以外の配列、オブジェクトリテラル、関数などは全てオブジェクト型です(多分)
- 文字列
- 数値
- 論理値
- 長整数
- undefined
- シンボル
- null
だから何?
「再代入」と「変更」
プリミティブ型では値を変更するためには let で定義して再代入する必要があります。const で定義すると再代入ができないので変更できません。
オブジェクト型では const で定義すると(let と同じように)再代入はできませんが要素の変更はできます。
const s = 'string'
s = 3 // constだからダメ
const a = [1, 2, 3]
a = [4, 5, 6] // constだからダメ
// constでも要素の変更はできる
a.push(4) // [1, 2, 3, 4]
値の比較
プリミティブ型は直感的に分かりやすいですね。
const x = 0
const y = 0
if (x === y) {
console.log('x === y') // 実行される
}
オブジェクト型の比較は注意が必要です。以下のa
とb
は「別のオブジェクトを比較している」と判定され、false
になります。
const a = [1, 2, 3]
const b = [1, 2, 3]
if (a === b) {
console.log('a === b') // 実行されない
}
どうしてもtrue
にしたいんだけど?
オブジェクト自体をそのまま別の変数に代入して比較すればtrue
になります。
const a = [1, 2, 3]
const b = a
if (a === b) {
console.log('a === b') // 実行される
}
ただし、こんなことする機会はありません。バグが生まれやすくなるだけです。ぜひ以下を読んでください。
意図していないオブジェクト型の変更
const a = []
const b = a
b.push(100)
console.log('a:', a) // -> a: [100]
console.log('b:', b) // -> b: [100]
これは恐らく意図していない動作でしょう。a
とb
は異なる配列のように見えますが、同じ配列への参照を持ってしまっているからです。
b
に対して行なった変更は全てa
にも適用されます。逆もまた然りです。
このようなことはプリミティブ型では起こりません。
対策
先ほどのようなコピーはShallowCopy(浅いコピー)と呼ばれます。 <- 要確認
配列への参照をコピーしただけで、値自体は同じものを指している状態です。
const a = []
const b = a
DeepCopyを使いましょう。Javascriptではこう書きます。
const a = []
const b = [...a]
これで b
は a
と全く異なる(異なる参照をもつ)配列になりました。もう b
への変更が a
にも反映されることはありません。
もっと見つけにくい場合もある
誰かが配列をソートするすごい関数を作ってくれたとしましょう。ぱっと見よさそうですね。
// 配列をただソートして返すだけの関数
function mySuperSort(array) {
const sortedArray = array.sort()
return sortedArray
}
const data = [9, 8, 7, 6, 5, 4, 3, 2, 1]
const result = mySuperSort(data)
console.log(data) // -> [1, 2, 3, 4, 5, 6, 7, 8, 9]
何故か変数data
もソートされてしまっています。ちょっとsort()
のドキュメントを参照してみましょう。
- sort() メソッドは、配列の要素をその場でソートし、ソートされた同じ配列の参照を返します。
実は、data
と array
は同じ参照を持っています。つまり array.sort()
が実行した時点で data
もソートされてしまったということです。
結果的に、data
とresult
も全く同じオブジェクトを指してしまっている(同じ配列の参照を持っている)ことになります。
試しに比較してみると true を返します。
console.log(data === result) // -> true
これもDeepCopyを使えば対策できます。
function mySuperSort(array) {
const _array = [...array]
const sortedArray = _array.sort()
return sortedArray
}
値の比較
!=
と ==
は使用しないでください。必ず ===
か !==
を使ってください。
if (2 == "2"){
console.log('2 == "2"') // 実行される
}
if (2 === "2"){
console.log('2 === "2"') // 実行されない
}
関数の定義
Javascriptの関数の定義方法は3つあります。
以下の3つの関数は全く同じ動作をします。
// 通常の関数
function myFunc(x) {
return x
}
// アロー関数
const myArrowFunc = (x) => {
return x
}
// アロー関数定義 + return の省略
const myArrowFunc2 = (x) => x
繰り返し
配列
const items = ["a", "b", "c"]
// これは古い書き方
for (let i = 0; i < items.length; i++) {
console.log(i) // 0 -> 1 -> 2
console.log(items[i]) // "a" -> "b" -> "c"
}
// in は index を返す
for (const i in items)
console.log(i) // "0" -> "1" -> "2" 何故か文字列
// of は要素を返す
for (const item of items)
console.log(item) // "a" -> "b" -> "c"
// indexも要素も使いたい
for (const [i, item] of items.entries()) {
console.log(i) // 0 -> 1 -> 2
console.log(item) // "a" -> "b" -> "c"
}
オブジェクトリテラル
const obj = {
"k1": 1,
"k2": 2,
"k3": 3
}
for (const k of Object.keys(obj)) {
console.log(k) // "k1" -> "k2" -> "k3"
console.log(obj[k]) // 1 -> 2 -> 3
}
for (const v of Object.values(obj))
console.log(v) // 1 -> 2 -> 3
for (const [k, v] of Object.entries(obj)) {
console.log(k) // "k1" -> "k2" -> "k3"
console.log(v) // 1 -> 2 -> 3
}
同期処理と非同期処理
説明
皆さんは「プログラムはコードの行順に実行される」という認識を持っていると思います。それは同期処理と呼ばれるもので、処理中のコード行が完了してから次の行に進むものです。
非同期処理においては上から順に処理されるとは限らず、例えばコードの上の方で渡しておいた関数がしばらく経ってから実行されるようなパターンがあります。
抽象的に説明
イメージとしては、「この作業やっといてね。終わったら呼んで。俺は別の作業するから。」とお願いする感じです。
料理の注文で言うと、
- 食券を買う
- 店員に食券を渡し、料理を作り始める (関数の実行開始)
- 空いた席で好きに自分の時間を過ごす (次の行に進む)
- 料理ができたので店員が呼ぶ (コールバック関数の実行)
- 料理を取りに行って食べる (戻り値を受け取って処理を行う)
という流れです。(合ってるのかなこれ…?)
「同期処理と比べて何がいいの?」と思ったあなた、鋭いです。では料理の注文の例において、お客さんが100人いたとしましょう。
同期処理だと
- 1人目が食券を渡す
- 料理を作る
- 料理を渡す
- 2人目が食券を渡す
- 料理を作る
- …
となります。1つずつしか料理しないので、厨房がいくら広くても(CPU、メモリが高性能でも)無駄が多いです。
後ろの方に並んでいるお客さんは怒って帰るでしょう。「何故暇そうにしている店員や使っていないコンロを使わないんだ?待ってるんだよこっちは」と。
非同期であれば100人分の食券を一度に受け取って、早い順にお客さんへ料理が提供できます。厨房もフル稼働です。
例
ちょっと脱線しましたが、例を示します。
特に以下のような「応答を待つ」処理は非同期処理で実装されます。
- HTTPリクエスト
- ファイル入出力
- DBへのアクセス
- ユーザーからの入力を待つ(これに出会う頻度は少ないかも)
正確には
Python
だろうがC#
だろうが非同期処理の仕組みはあります。javascriptは「絶対に避けて通れない」という意味で異なると思います。
Promiseを使った非同期処理(非推奨)
これは Promise を使って非同期処理を実装したものです。
DBへのアクセス
-> HTTPリクエスト
-> ファイル出力
の順に処理を行います。
dummy.*
で始まる関数は説明のための仮想的な関数です。
function main() {
console.log('main() start')
// DBを読んで…
dummy.readDb().then((dbData) => {
console.log('readDb() completed.')
console.log(dbData)
// データが1件以上あればそれをサーバーに送って…
if (dbData.length > 0) {
dummy.fetch(dbData).then((httpCode) => {
console.log('fetch() completed.')
// 成功したならローカルファイルに書き出す
if (httpCode === 200) {
dummy.fileWrite(dbData).then((result) => {
console.log('fileWrite() completed.')
})
}
})
}
})
console.log('main() end')
}
console.log('promise.js start')
main()
console.log('promise.js end')
このコードを実行するとコンソールには以下の順で出力されます。
promise.js start
main() start
main() end
promise.js end
readDb() completed.
[
{ id: '1', email: 'a@a.com', name: 'bob' },
{ id: '2', email: 'b@b.com', name: 'mike' },
{ id: '3', email: 'c@c.com', name: 'richards' }
]
fetch() completed.
fileWrite() completed.
dummy.*
で始まる関数に引数として渡しているコールバック関数
(アロー関数のところです)が遅れて実行されています。
なんとなく分かりにくいコードであることが分かるでしょう。
特に変数 dbData
, httpCode
, result
の値を参照するためには変数があるブロックと同じか、それより内側のブロック出なければならないため非同期関数が入れ子に(ネスト)なってしまっています。
これが俗にいう「コールバック地獄」と呼ばれるものです。これはまだマシな方で、上のコードに例外処理や判定が追加されるともっと酷いことになります。
コールバック関数を使うことなく同期処理っぽく書けて、変数のスコープも分かりやすい書き方があればなぁ…ということで以下の書き方をしてください。
async, awaitを使った非同期処理
Promiseの諸々の使いにくさを解消するためにも、可能な限り async
, await
を使った実装にしてください。非同期処理を同期処理であるかのように記述できます。
まずは今までfunction
で定義していた関数をasync function
と記述する非同期関数に変えます。
await
は非同期関数の中でしか使えません!
そしてPromiseを返す関数、メソッドにawait
を付けて待機しましょう。コードは以下のようになります。
async function main() {
console.log('main() start')
// DBを読んで…
const dbData = await dummy.readDb()
console.log('readDb() completed.')
console.log(dbData)
// データが1件以上あればそれをサーバーに送って…
if (dbData.length === 0) return // -> 抜ける
const httpCode = await dummy.fetch(dbData)
console.log('fetch() completed.')
// 成功したならローカルファイルに書き出す
if (httpCode !== 200) return // -> 抜ける
const result = await dummy.fileWrite(dbData)
console.log('fileWrite() completed.')
console.log('main() end')
}
console.log('await-async.js start')
main()
console.log('await-async.js end')
変数 dbData
, httpCode
, result
が main
関数内ならどこでも参照できることにも注目してください。
実行してみます。main() end
が表示される位置が違うくらいで、ちゃんと非同期処理が実行されます。
await-async.js start
main() start
await-async.js end
readDb() completed.
[
{ id: '1', email: 'a@a.com', name: 'bob' },
{ id: '2', email: 'b@b.com', name: 'mike' },
{ id: '3', email: 'c@c.com', name: 'richards' }
]
fetch() completed.
fileWrite() completed.
main() end
Promise、よく分かりません…
最初は難しいです。私はここをみて一気に理解できました。おすすめです。
Promiseを返す関数、メソッドがどれなのか分からないんですけど…
VSCodeなどのIDEで関数、メソッドにカーソルを合わせてみてください。
試しに readDb()
にカーソルを合わせるとこんな感じのが表示されると思います。
(alias) readDb(): Promise<any>
この、「:
」より後ろの部分のが戻り値の型を表しており、これがPromiseであればawaitで待機できます。
おまけ
参考に、dummy.*
の実装はこんな感じです。
// DBの読み込みダミー。2秒待つ。
async function readDb() {
const dbData = [
{ id: '1', email: 'a@a.com', name: 'bob' },
{ id: '2', email: 'b@b.com', name: 'mike' },
{ id: '3', email: 'c@c.com', name: 'richards' },
]
await new Promise((resolve) => setTimeout(resolve, 2000));
return new Promise((resolve) => {
resolve(dbData)
});
}
// HTTPリクエストダミー。2秒待つ。
async function fetch(data) {
await new Promise((resolve) => setTimeout(resolve, 2000));
return new Promise((resolve) => {
resolve(200)
});
}
// fp.writeダミー。2秒待つ。
async function fileWrite() {
await new Promise((resolve) => setTimeout(resolve, 2000));
return new Promise((resolve) => {
resolve('fileWrite() success!')
});
}
module.exports = {
readDb,
fetch,
fileWrite,
}
その他補足など
分割代入とスプレッド演算子
よく使う便利な機能としてスプレッド構文 と分割代入があります。
どうしても説明が長くなるので一度調べてみてください。
Typescriptについて
できるだけTypescriptで実装してください。
javascriptで大規模なシステムを組むことは(多分)ほとんどありません。
特に新規開発でjavascriptを採用することは事情がない限りないでしょう。
.js
拡張子を.ts
にするだけで型推論の恩恵が受けられるほどTypescriptは素晴らしいです。