JavaScriptで変数を宣言するとき、let・const・var の3つのキーワードが使えます。見た目は似ていますが、値を書き換えられるか、どこまでの範囲で使えるか(スコープ)、宣言前に触るとどうなるか、といった振る舞いが大きく異なります。この記事では、初心者〜中級者の方に向けて、3つの違いをコードで確かめながら整理し、実務でどう使い分ければよいかまでを解説します。
目次
3つのキーワードは何が違うのか
var は古くからある宣言方法で、いまでも動きますが、後述する「スコープの広さ」や「巻き上げ」の挙動が原因で、思わぬバグを生みやすいという弱点があります。そこで ES2015(ES6)で追加されたのが let と const です。この2つは var の弱点を解消するように設計されており、現在のJavaScriptではこちらを使うのが基本になっています。
まずは、それぞれの性格をざっくりつかむために、代表的な違いを一覧にしておきます。細かい意味はこのあと順番に説明していきます。
| 用語 | 意味 |
|---|---|
| スコープ | その変数が有効な(参照できる)範囲のこと |
| 再宣言 | 同じ名前の変数をもう一度宣言し直すこと |
| 再代入 | すでに宣言した変数に別の値を入れ直すこと |
| 巻き上げ(hoisting) | 宣言がそのスコープの先頭に引き上げられたように扱われる仕組み |
| TDZ(Temporal Dead Zone) | 宣言の行に到達するまで、その変数を参照できない区間のこと |
var の振る舞いと弱点
var は再宣言も再代入もできます。同じ名前でもう一度宣言してもエラーにならず、上書きされてしまう点に注意が必要です。
var count = 1;
var count = 2; // 再宣言してもエラーにならない
count = 3; // 再代入もできる
console.log(count); // 3
var のスコープは「関数スコープ」です。つまり、関数の中で宣言した変数はその関数全体で有効ですが、if や for のブロック({ })ではスコープが区切られません。次の例では、ブロックの中で宣言したつもりの message が、ブロックの外からも参照できてしまいます。
if (true) {
var message = "こんにちは";
}
console.log(message); // "こんにちは"(ブロックの外でも見える)
さらに var には「巻き上げ(hoisting)」という挙動があります。宣言だけがそのスコープの先頭へ引き上げられたように扱われるため、宣言より前で参照してもエラーにならず、値が入る前の状態である undefined が返ります。次のコードは一見エラーになりそうですが、実際には undefined が出力されます。
console.log(value); // undefined(エラーにはならない)
var value = 10;
console.log(value); // 10
上のコードは、内部的には次のように解釈されていると考えると分かりやすいです。宣言(var value;)だけが先頭に移動し、代入(value = 10;)は元の位置に残ります。
var value; // 宣言だけが先頭に引き上げられる
console.log(value); // undefined
value = 10; // 代入は元の位置のまま
console.log(value); // 10
加えて、var を関数の外(トップレベル)で宣言すると、ブラウザではグローバルオブジェクトである window のプロパティになります。意図せず既存のプロパティを上書きしてしまう危険があり、これも var を避ける理由のひとつです。
var userName = "taro";
console.log(window.userName); // "taro"(window のプロパティになる)
let はブロックスコープで再代入できる
let は再代入はできますが、同じスコープでの再宣言はできません。うっかり同じ名前を2回宣言してしまうと、その場でエラーになって気づけるため、var より安全です。
let count = 1;
count = 2; // 再代入はできる
console.log(count); // 2
let count = 3; // SyntaxError: Identifier 'count' has already been declared
let のスコープは「ブロックスコープ」です。{ } で囲まれた範囲の外からは参照できないため、変数の有効範囲が直感どおりになります。先ほどの var の例を let に置き換えると、ブロックの外ではエラーになります。
if (true) {
let message = "こんにちは";
console.log(message); // "こんにちは"(ブロック内では見える)
}
console.log(message); // ReferenceError: message is not defined
TDZ(Temporal Dead Zone)とは
let(および const)も、実は巻き上げ自体は起きています。ただし var と違い、宣言の行に到達するまでは変数を参照できません。この「スコープの先頭から宣言の行まで」の区間を TDZ(Temporal Dead Zone、一時的な死角)と呼びます。TDZ の中で変数に触れると、undefined が返るのではなく ReferenceError になります。
console.log(value); // ReferenceError: Cannot access 'value' before initialization
let value = 10;
var なら undefined が返って処理が続いてしまい、バグの原因が分かりにくくなります。let は宣言前のアクセスをエラーとして止めてくれるため、間違いに早く気づけるという利点があります。
const は再代入できない定数
const は let と同じくブロックスコープですが、再代入も再宣言もできません。そのため「後から値が変わらないもの」を表すのに使います。const は宣言と同時に値を入れる(初期化する)必要があり、値を入れずに宣言だけするとエラーになります。
const PI = 3.14;
console.log(PI); // 3.14
PI = 3.14159; // TypeError: Assignment to constant variable.
const TAX; // SyntaxError: Missing initializer in const declaration
オブジェクトや配列の中身は変更できる
const でよく誤解されるのが、「値を一切変更できない」というものです。正確には、const が禁止するのは変数への再代入だけです。オブジェクトや配列を const で宣言した場合、その変数が指し示す先(オブジェクトそのもの)は固定されますが、中身のプロパティや要素は変更できます。
const user = { name: "taro" };
user.name = "jiro"; // 中身の書き換えはOK
user.age = 20; // プロパティの追加もOK
console.log(user); // { name: "jiro", age: 20 }
const list = [1, 2, 3];
list.push(4); // 要素の追加もOK
console.log(list); // [1, 2, 3, 4]
// ただし、変数そのものへの再代入はエラー
user = { name: "hanako" }; // TypeError: Assignment to constant variable.
つまり const は「箱(変数)に入れる中身を差し替えられない」という意味であって、「中身のオブジェクトを絶対に変えられない」という意味ではありません。オブジェクト全体を固定したい場合は Object.freeze() など別の手段を使います。
スコープの3つの種類を整理する
ここまで「関数スコープ」「ブロックスコープ」という言葉が出てきました。スコープは大きく分けて次の3つで考えると整理しやすくなります。グローバルスコープはコードのどこからでも参照できる一番外側の範囲、関数スコープは関数の中だけで有効な範囲、ブロックスコープは { } の中だけで有効な範囲です。
const globalValue = "グローバル"; // どこからでも参照できる
function myFunc() {
const funcValue = "関数の中"; // この関数の中だけ
console.log(globalValue); // "グローバル"(外側は見える)
if (true) {
const blockValue = "ブロックの中"; // この if の中だけ
console.log(funcValue); // "関数の中"(外側は見える)
}
console.log(blockValue); // ReferenceError(ブロックの外からは見えない)
}
myFunc();
console.log(funcValue); // ReferenceError(関数の外からは見えない)
ポイントは、内側からは外側のスコープの変数を参照できますが、外側からは内側のスコープの変数を参照できない、という一方通行の関係です。let と const はブロックスコープなので、変数の有効範囲を最小限に保ちやすく、名前の衝突や意図しない参照を防げます。
for ループでの let と var の違い
スコープの違いが最もはっきり現れるのが、for ループとタイマー(setTimeout)を組み合わせたときです。まず var を使った例を見てみます。0・1・2と出力されそうですが、実際には3が3回出力されます。
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 100);
}
// 出力: 3, 3, 3
var は関数スコープなので、この i はループ全体で共有される「1つの変数」です。setTimeout に渡した関数は100ミリ秒後に実行されますが、そのときにはループがすでに終わっていて i は3になっています。3つの関数はどれも同じ i を見ているため、すべて3になるわけです。
これを let に変えると、期待どおり0・1・2が出力されます。
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 100);
}
// 出力: 0, 1, 2
let はブロックスコープで、しかも for ループでは繰り返しのたびに新しい i が作られるという特別な扱いになっています。そのため各 setTimeout の関数は、それぞれ異なる i(0、1、2)を覚えており、期待どおりの結果になります。この違いは実務でもつまずきやすいポイントで、let を使うだけで解決できるのは大きな利点です。
宣言方法の比較と使い分け
ここまで説明した3つの振る舞いを、あらためて一覧で比較します。
| var | let | const | |
|---|---|---|---|
| スコープ | 関数スコープ | ブロックスコープ | ブロックスコープ |
| 再宣言 | できる | できない | できない |
| 再代入 | できる | できる | できない |
| 宣言前の参照 | undefined(巻き上げ) | エラー(TDZ) | エラー(TDZ) |
| 初期化 | 任意 | 任意 | 必須 |
使い分けの方針はシンプルです。まず基本は const を使うと考えてください。値が変わらない前提で書けるとコードが読みやすくなり、うっかりした再代入も防げます。ループのカウンターのように後から値を書き換える必要があるものだけ let にします。var は関数スコープや巻き上げによる分かりにくいバグを招きやすいため、新しく書くコードでは基本的に使いません。
まとめ
var は関数スコープで再宣言・再代入ができ、巻き上げによって宣言前でも undefined として参照でき、トップレベルでは window のプロパティになります。let はブロックスコープで再代入は可能ですが再宣言はできず、宣言前の参照は TDZ によってエラーになります。const はブロックスコープで再代入・再宣言ができず宣言時の初期化が必須ですが、オブジェクトや配列の中身は変更できます。for ループと setTimeout の例で見たとおり、スコープの違いは実際の挙動に直結します。迷ったら const を基本に、書き換えるものだけ let、var は使わない、と覚えておけば安全です。