😱

var の落とし穴:なぜモダンJavaScriptで避けるべきか

2024/08/15に公開

はじめに

みなさん、こんにちは。私は業界歴 1 年半、JavaScript 歴も同じく 1 年半の hiro です。

正直なところ、最初はvarについて「なんとなく使わない方がいいらしい」という漠然とした認識しかありませんでした。ES6 以降のモダンな JavaScript しか触れてこなかった私にとって、varはどこか遠い存在で、その問題点を深く理解する機会がありませんでした。

しかし、先輩エンジニアの方々とコードレビューを重ねる中で、varの使用に対して厳しい指摘を何度か目にしました。「なぜvarはそれほど問題なのか?」「letconstとどう違うの?」そんな疑問が徐々に膨らんでいきました。

この記事は、そんな疑問を解消するために、自分自身の学びをまとめたものです。varが非推奨とされる理由を、特にスコープの観点から掘り下げていきます。また、letconstがどのようにこれらの問題を解決するのかについても触れていきます。

私と同じように、モダン JavaScript しか知らない方々にとって、この記事がvarの問題点を理解し、より良いコーディング習慣を身につけるきっかけになれば幸いです。

コードの実行環境

HTML ファイルで js ファイルを読み込むというシンプルな構成でコードを実行しています。
Node.js で動かすのも考えましたが、インストールしていない方もいらっしゃるという想定なので Node.js は使用しない方針で進めます。

まずは結論から。なぜ非推奨なのか。

最後まで読む時間がない方はこの章だけでも覚えておいて下さい。

  • グローバルスコープや関数スコープを作成し、ブロックスコープを無視する
  • 変数の巻き上げ(ホイスティング)によって予期せぬ動作を引き起こす
  • 同名の変数を再宣言できてしまう

この 3 点がlet,constとの明確な違いであり、非推奨とされる所以だと私は確信しています。
それぞれ実際のコードを基に解説していきます。

解説の前に、そもそもスコープとは何か

早速「関数スコープ」「グローバルスコープ」「ブロックスコープ」というワードが出てきましたね。
今回はスコープの観点からvarが非推奨とされる理由を解説するのでまずはスコープとは何か、押さえておきましょう。
MDN には以下のように記載されています。

実行の現在のコンテキスト。値 と式が「見える」、または参照できる文脈。変数や他の式が "現在のスコープ内にない" 場合、使用できません。スコープを階層構造で階層化して、子スコープから親スコープにアクセスできるようにすることもできますが、その逆はできません。

https://developer.mozilla.org/ja/docs/Glossary/Scope

要は、スコープとは 「実行中のコードから値と式が参照できる範囲」 です。
もう少しかみ砕いた表現をすると、スコープは 「どこで変数や関数にアクセスできるかを決定するルール」 です。スコープの範囲によって、プログラムの異なる部分で同じ名前の変数を安全に使うことができます。

グローバルスコープについて

main.js
let a = 0;
const b = 0;
var c = 0;
function d() {}
debugger; // 実行を一時停止するもの

let,const,varによる変数宣言と、処理を行わない関数宣言を記述したサンプルコードです。
debuggerはブラウザの検証ツールを開いている状態で、その行に達した時点でコードの実行を一時停止させるために使用します。

検証ツールの「Scope」というタブにはdebuggerによって処理が止まっている行から見えるスコープが表示されています。

これはdebuggerが実行された行(つまり処理が一時停止している行)から見える変数がスクリプトスコープとグローバルスコープにそれぞれ存在していて、

  • let,constで定義した変数はスクリプトスコープのプロパティになる。
  • varで定義した変数、関数宣言はグローバルスコープに属し、windowオブジェクトのプロパティになる。

ということを表しています。
そのため、varで定義した変数c,関数dwindow.cwindow.dとして参照することが可能になります。

表にすると以下のようになります。

Script Global(Window オブジェクト)
let var
const function

これがいわゆる「グローバルスコープ」です。
一般的には使い勝手が変わらないため「スクリプトスコープ」も「グローバルスコープ」として扱われますが、上記で示したような明確な違いがあるので注意が必要です。

関数スコープについて

関数スコープとは、その名の通り関数宣言に囲まれた{}の中のスコープのことです。

main.js
function a() {
  // この中で参照できるスコープ
}

ブロックスコープについて

ブロックスコープとは、{}で囲まれた範囲内のスコープを指します。関数スコープとの違いは、関数宣言が不要な点です。基本的には、ブロック内で定義された変数はそのブロック内でのみ有効で、外からはアクセスできません。

ただし、ブロックスコープを有効にするには条件があります。それは、変数の宣言にletまたはconstを使用することです。varで宣言された変数や関数宣言は、ブロックスコープを無視する特殊な動作をします。

実際にコードを書いて確認してみます。

main.js
{
  let a = 1;
  const b = 2;
  var c = 3;
  debugger;
}


コード実行後、 「ブロック」 という項目にletconstで宣言されたabが追加されました。一方、varで宣言されたcは、ブロックスコープではなくグローバルスコープに追加されます。これは、varがブロックスコープを生成しないためです。

main.js
{
  var c = 3;

  function d() {
    console.log("hello, world");
  }
}
console.log(c);  // 3
d();  // hello, world

varで宣言された変数cや関数宣言で定義した関数dは、ブロックの外部からもアクセス可能です。これは、varや関数宣言がブロックスコープを無視する挙動を持つためです。

このように、ブロックスコープを使用するためには、必ずletconstを使用する必要があり、varでの変数の定義、関数宣言はブロックスコープを無視するという特徴があります。

グローバルスコープや関数スコープを作成し、ブロックスコープを無視する

お待たせしました。
それでは実際のコードを使用してvarによる変数の定義が非推奨になる理由を見ていきます。
まずは 「グローバルスコープや関数スコープを作成し、ブロックスコープを無視する」 という理由です。

先ほども解説しましたがブロックスコープを作成するには、 letもしくはconstで変数を定義する という条件があります。
早速「ブロックスコープを無視する」という挙動を確認しましょう。

main.js
{
  var x = 3;
  console.log(x);  // 3
}
x = 100;
console.log(x);  // 100

このコードでは、ブロック内で定義した x がブロックの外からも参照でき、さらに値を変更することができてしまいます。
これは変数のスコープが不明確になり、意図しない変数の使用や上書きを引き起こす可能性があります。

次に、letconst)を使用した場合の挙動を見てみましょう

main.js
{
  let x = 5;
  console.log(x); // 5
}
console.log(x); // ReferenceError: x is not defined

let で定義された変数xは、そのブロック内でのみ有効です。ブロックの外からxにアクセスしようとするとReferenceErrorが発生し、変数のスコープが明確に制限されていることがわかります。
このように、let を使用することで変数のスコープがより明確になり、意図しない変数の使用や上書きを効果的に防ぐことができます。

変数の巻き上げ(ホイスティング)によって予期せぬ動作を引き起こす

ホイスティングとは、変数や関数の宣言が、そのスコープの先頭に移動されたかのように振る舞う JavaScript の特性です。
この特性は、コードの意図とは異なる動作を引き起こすことがあるため、特に初心者にとって注意が必要です。

次の例を見てみましょう。

main.js
console.log(x); // undefined
var x = 5;
console.log(x); // 5

このコードでは、var で宣言された変数 x が、実際の宣言よりも前に参照されていますが、undefined が返ってきます。
これは、JavaScript が内部的に var x; をスコープの先頭に移動させているからです。この「巻き上げ」によって、変数 x が初期化される前に参照されてしまい、undefined となるのです。

一方、letconst で宣言された変数は、このような巻き上げの影響を受けません。次のコードを見てみましょう。

main.js
console.log(x); // ReferenceError: x is not defined
let x = 5;
console.log(x); // エラーが発生しているため実行されない

このコードでは、let で宣言された変数 x を宣言前に参照しようとすると、ReferenceError: x is not defined というエラーが発生します。
これは、letconst がブロックスコープを持ち、巻き上げが発生しないためです。この挙動によって、コードがより予測可能で安全になります。

以上がホイスティングによって予期せぬ動作を引き起こす例です。

理解が早い方は、下記コードのundefinedが返ってくる部分とTypeErrorが返ってくる部分の違いについて疑問に思うかもしれません。

console.log(x); // undefined
var x = 5;

b(); // TypeError: b is not a function
var b = function () {
  console.log("b is called!");
};

その辺についても軽くまとめてみたので時間がある方は以下のアコーディオンを開いてみて下さい。

変数参照と関数呼び出しにおける undefined と TypeError の違い
  • 変数参照

    console.log(x); // undefined
    var x = 5;
    

    この場合、var x の宣言は巻き上げられますが、初期化(= 5)は元の位置に残ります。そのため、console.log(x)の時点では x は宣言されていますが、値は割り当てられていないので undefined になります。

  • 関数呼び出し

    b(); // TypeError: b is not a function
    var b = function () {
      console.log("b is called!");
    };
    

    この場合、変数 b の宣言は巻き上げられますが、関数の割り当ては巻き上げられません。そのため、b()を呼び出した時点では、bundefined であり、関数ではありません。
    undefined な値を関数として呼び出そうとしているため、b is not a functionというエラーが発生します。
    もし単に console.log(b)とした場合は undefined が表示されます。

    この挙動は関数宣言と関数式の違いを理解することが重要です。

    • 関数宣言(function b() {...})は全体が巻き上げられます。
    • 関数式(var b = function() {...})では変数宣言のみが巻き上げられ、関数の割り当ては元の位置に残ります。

同名の変数を再宣言できてしまう

varの最も問題となる特徴の 1 つは、同じスコープ内で同名の変数を何度でも再宣言できてしまうことです。
これは、コードの可読性を損ない、予期せぬバグを引き起こす可能性があります。

実際にコードで、この問題を具体的に見てみましょう。

main.js
var x = 10;
console.log(x); // 出力: 10

var x = 20; // 同名の変数を再宣言
console.log(x); // 出力: 20

// 同じスコープ内で何度でも再宣言が可能
var x = 30;
console.log(x); // 出力: 30

このコードでは、同じスコープ内で x という変数が 3 回宣言されています。各宣言で x の値が上書きされ、最後に宣言された値が使用されます。
これの何が問題かというと、

  • 意図せずに既存の変数を上書きしてしまう
  • どの宣言が実際に使用されているのかを追跡するのが困難になる
  • 変数の生存範囲が予想以上に広くなり、意図しない箇所で変数が使用される可能性がある
  • リファクタリングの際に変更の影響範囲を正確に把握するのが難しくなる
    etc...

のようにあげればキリがないほど問題があります。

一方、letconst を使用すると、このような再宣言は許可されません。

main.js
let y = 10;
console.log(y); // 出力: 10

let y = 20; // SyntaxError: Identifier 'y' has already been declared

const z = 30;
console.log(z); // 出力: 30

const z = 40; // SyntaxError: Identifier 'z' has already been declared

letconst を使用すると、同名の変数を再宣言しようとした時点でSyntaxErrorが発生します。これにより、変数の重複宣言を防ぎ、コードの意図をより明確にできます。

まとめ

この記事を通じて、varの問題点と、なぜモダン JavaScript で避けるべきかを見てきました。主な理由は以下の 3 点です

  1. グローバルスコープや関数スコープを作成し、ブロックスコープを無視する
  2. 変数の巻き上げ(ホイスティング)によって予期せぬ動作を引き起こす
  3. 同名の変数を再宣言できてしまう

これらの問題は、コードの予測可能性を低下させ、バグの原因となる可能性があります。一方、letconstを使用することで、これらの問題を回避し、より安全で読みやすいコードを書くことができます。

モダン JavaScript では、varの使用を避け、代わりに

  • 再代入が必要な変数にはlet
  • 定数や再代入が不要な変数にはconst

を使用することを強く推奨します。これにより、変数のスコープがより明確になり、予期せぬ動作やバグを防ぐことができます。

私自身、この記事を書くことでvarの問題点をより深く理解することができました。皆さんも、日々のコーディングでletconstを適切に使い分け、より安全で保守性の高い JavaScript コードを書いていってください。

Discussion