JavaScriptの変数宣言、var、let、constの使い方とオブジェクト型のコピー、更新について


JavaScript には変数宣言方法が var, let, const の 3 種類存在します。変数宣言はプログラムの基礎、誰もが最初に使う機能です。JavaScript の場合、複雑でない動作、例えば表示を消したり何かを付け加えたりといった実装では変数自体に注意を向ける機会は少ないでしょう。

しかし、React などを使い、データを中心にフロントエンドを組み立て始めると、求められる変数宣言への知識はぐっと高くなります。特に、プリミティブでないデータ型のオブジェクトをガリガリ加工する段階が、JavaScript 開発の面白さをぐっと上げる場所であり、一種の壁を感じるタイミングでもあると個人的に思います。

JavaScript で開発を行うなら、変数宣言とそれに代入した値の振る舞いの違いを知ることは重要です。誰しも使ったことがある変数は、非常に基礎的な機能であり、何度も振り返ることになる難しい要素です。

自分自身何度も振り返るので、記事にまとめながら振り返ることにしました。

var は全環境、let と const はブラウザにより使用できる

元々 JavaScript の変数宣言は var のみでした。ECMA2015(ES6)で let と const が追加され、現在の主要ブラウザでは var、let、const の3種類が使用できます。特に制限のない開発では、var を使う理由がないなら let と const のみを使用します。var は直感的でなく分かりにくい振る舞いをしがちで、後で紹介するブロックスコープ以外でもコードの読みやすさという点で let と const が優れているからです。

console.log(sample) // undefined
var sample = 12;
var sample = [1,2,4,5];

console.log(sample2) // Error
let sample2 = 12;
var sample2 = 12; // Error
sample2 = [1,2,3,4,5];

const sample3 = 12;
sample3 = 13 // Error sample3には値を再代入できない

各変数宣言は同ファイル中に混ぜて使用することができます。上のサンプルを見てもらうと var だけが同じスコープ内で同様の変数名を再宣言ができています。また、var だけが変数宣言前に変数を宣言してもエラーにならず、値が undefined として扱われます。これは変数の巻き上げという機能です。

一方、let と const は同スコープ内での変数の再宣言はできません。

var のメリットはレガシーな環境であっても使えることです。let と const はレガシーなブラウザ(例えば IE10)では使うことができません。ただし、babel などトランスパイラを使用すれば開発するコードでは let と const、実際に読み込まれるコードは var というようにできます。

トランスパイラが使えない + レガシーブラウザの対応が必要な時は var。それ以外は let と const を使うという認識で問題ないはずです。

スコープ

スコープは変数や関数が使用できる範囲です。どこからでも参照できるグローバルスコープ、関数内で参照できるローカルスコープ、let と const が持つ内のブロックスコープがあります。

const sample = 12; // グローバル

function sampleFnc() {
  var test = sample; // 12
  let test2 = sample; // 12
}

function sampleFnc2() {
  const sample = 13; // グローバルスコープではないので再宣言可能
  let test2 = sample; // 13
}

if (true) {
  // ブロックスコープ
  const sample = 14;
  console.log(sample); // 14
}

console.log(sample); // 12 ブロックスコープ内の影響はない

var sample2 = 12;
if (true) {
  var sample2 = 15;
}
console.log(sample2); // 12

var はブロックスコープを持たないため、変数に再代入があると影響範囲が大きくなります。ブロックスコープの便利なところは、for などのループ処理中に意図しない変数の上書きが起きにくいことです。例として TypeScript で紹介されている for 文があります。ぱっとみると console に 1 から順番に数値が並びそうに思います。

Variable capturing quirks - TypeScript

for (var i = 0; i < 10; i++) {
  setTimeout(function() {
    console.log(i);
  }, 100 * i);
}

しかし、var は timeout の待ち時間中にどんどん上書きされ console で出力されるときには 10 となり、出力は全て 10 となります。var の部分を let にすればこの問題は解決します。

ブロックスコープを持つ let はそれぞれのループで別の変数が使えわれるため変数は上書きされず、 0,1,2…となります。var で解決する方法として IIFE(即時関数)が上記リンク先で紹介されていますので興味があれば参照してください。

この記事ではここから先 let と const に説明を絞り var は出てきません

let と const の使い分け

コード中で let を使う場合、どこかの処理で値が再代入されることを意味します。

const sampleFunc = () => {} // sampleFuncは関数
let sampleFunc = () => {} // 途中でなにかに変わる?

let sampleVal = "test"; // このあとのコード中で値が変更される
const SAMPLE_VAL = "test"; // 定数として使われている

let と const を正しく使い分ければコード中のヒントとなります。途中で値が再代入されるときのみ let を使用し、ほかは全て const を使います。

eslint を使用すれば不必要な let や変数にエラーを出すこともできます。慣れるまではツールにコードの書き方を教えてもらうのも手です。

JavaScript のデータ型

JavaScript のデータは大きく、プリミティブとオブジェクトに分類されます。

JavaScript のデータ型とデータ構造 - MDN

プリミティブに分類される、例えば文字列や数値はとても直感的な動作をします。一方でオブジェクトの振る舞いは注意して使用しないと思わぬ不具合を出してしまいます(後述)。

変数への値の入れ方

繰り返しになりますが変数宣言の let と const の違いは、let は値の再代入が可能で、const は再代入が不可で読み取り専用という点です。JavaScript の変数には型の指定はありません。よって、let で宣言すれば自由に型の違う値を再代入できます。

let sample = "test";
sample = 12;
sample = ["test", "test"];
sample = [1, "test", { test: 12 }, ["test", "test"]];

const sample2 = "12345"; // 再代入できないので "12345" 固定

const は再代入できませんので同スコープ内で文字列や数値、boolean といった プリミティブ なデータ型に分類される値は固定値として使用できます。

const FIRST_NAME ='太郎';
const LAST_NAME = '山田';
const AGE = 24;

しかし、Object(オブジェクト) や Array(配列)といったオブジェクトを同じように扱う使うことはできません。再代入はできませんが、プロパティを変更することが可能だからです

const user = {
  "first_name": "太郎",
  "last_name": "山田",
  "age": 24
}

// user = {"first_name"} 代入での変更はできない

console.log(user.last_name) // 山田

user.last_name = "佐藤" // プロパティの変更は可能
console.log(user.last_name) // 佐藤 


const userIdList = [1,2,3,4,5];
userIdList[0] = 24;
console.log(userIdList) // [24,2,3,4,5]

オブジェクト型の振る舞いについてもう少し掘り下げます。

オブジェクト型の値の比較、コピーについて

プログラム中で値の比較やコピーは良く行う処理です。例えば、同じ文字列かを比較するなら===や==で比較可能です。


const sample = "test";
if (sample === "test") {
  console.log("同じ値")
}

しかし、配列やオブジェクトはこのようにはいきません。

const sampleArray  = [1, 2, 3, 4, 5];
const sampleArray2 = [1, 2, 3, 4, 5];

if (sampleArray === sampleArray2) {
  console.log("同じ配列");
} else {
  console.log("違う配列");
}
// 違う配列と出力される

同じ配列のように思えますが JavaScript では違うものと判定されます。では、同じ配列とは何でしょうか。

const sampleArray  = [1, 2, 3, 4, 5];
const sampleArray2 = sampleArray;

if (sampleArray === sampleArray2) {
  console.log("同じ配列");
} else {
  console.log("違う配列");
}
// 同じ配列と出力される

このように元の配列を代入すれば同じ配列として扱われます。比較されているのは中の値ではなく、配列がメモリのどこに保存されているかだからです。

const arr1 = [];
const arr2 = [];

if (arr1 === arr2) {
  console.log("同じ配列");
} else {
  console.log("違う配列");
}
// 違う配列と出力される

空の配列を2つ宣言しました。直感的にはこれらは同じですが、別のメモリ空間にそれぞれの変数が格納されているので違う配列となります。一方で、同じ配列というのは完全に同じものです。よって

const arr1 = [];
const arr2 = arr1;

if (arr1 === arr2) {
  arr2.push(1) // arr2だけを [] => [1]にしているようにみえるが
}
console.log(arr1) // arr1とarr2は同じ配列なので[1]が出力される

このように同じ配列なので、arr2 が変われば arr1 も変わってしまいます。これは意図した動作でない限り不具合の要因で、コードが大きくなるほどデバッグが難しくなります。

const arr1 = [];
const arr2 = arr1;
const arr3 = [];

arr1,2,3 は全て空の配列ですが arr1 と arr2 は同じ配列で変更が共有され、arr3 はこれらとは別での配列です。例は全て配列で行いましたが、これはプリミティブなデータ型ではないオブジェクトでも同様です。

const obj1 = {
  "name": "tarou",
  "age": 25
};
const obj2 = obj1;

obj1.name = "hanako";
console.log(obj2.name) // hanako

オブジェクトをコピーする時は新しいオブジェクトを作る

現実的には、同じ配列をコピーしながらプロパティを共有して変更していく実装はレアなケースでしょう。オブジェクト型の難しいところは、そうしたくない状況で、意図せず同じオブジェクトをコピーしてしまうところにあります。

例えばユーザー情報のオブジェクトがあるときを考えます。

const user = {
  "name": "",
  "age": null,
}

const user1 = user;
user1.name = "satou"

const user2 = user;
user2.name = "suzuki";

ユーザー情報を追加する、あるいは変更するときに同じ配列を使ってしまうと全て同じオブジェクトになります。もし、コピーしたあとに別々のオブジェクトとして扱いたいなら、新しくオブジェクトを作成する関数を使用してオブジェクトをコピーします。

良く使用されるのが Object.assign や spread operator(スプレッド演算子) というオブジェクトの中身を展開する関数で、slice や concat といった関数も良く使われます。

const user = {
  "name": "",
  "age": null,
}

const user1 = Object.assign({}, user);
user1.name = "satou"

const user2 = {...user};
user2.name = "suzuki";

console.log(user, user1, user2) // 3種類のオブジェクト

user,user1,user2 は全て同じではないオブジェクトとなります。spread operator の良いところは、コピーしながら情報の一部だけ更新するときの表記が分かりやすいところです。

const user2 = {...user};
user2.name = "suzuki";
user2.age = 32;

// user2と同じ更新内容
const user3 = {
  ...user,
  "name": "suzuki",
  "age": 32
}

このように spread operator で新しいオブジェクトを作成しながらコピーすることで、意図しないデータの変更を阻止しやすくなります。実際にどのような関数を使うかやデータの整形については、Redux のドキュメントが特に参考になります。また、React や Redux のコード例をみればオブジェクト型のコピーは必ずこのように新しいオブジェクトを返す関数が使われているはずです。

ここまででも慣れが必要な内容ですがまだ注意することがあります。それは、これらの新しいオブジェクトを返す関数でも、完全に新規のオブジェクトを返すのではなくshallow copyしたオブジェクトを作成する点です。

shallow copy について

先程の例はデータ階層が浅く問題ありませんでした。user 情報の中に住所があり、オブジェクト型がネストしているケースを考えます。新しい user のオブジェクトをを作成して address してみます。


const userData = {
  "id": 1,
  "name": "yamada",
  "age": 29,
  "address": {
    "pref": "Tokyo",
    "city": ""
  }
}

const updateUserData = {...userData}
updateUserData.name = "sato"

console.log(userData.name, updateUserData.name) ]
// yamada, sato

updateUserData.address.pref = "Osaka"
console.log(userData.address.pref, updateUserData.address.pref)
// Osaka Osaka

このように 名前は別々ですが住所は同時に変更されてしまいました。shallow copy されたオブジェクトはネストした部分は同じオブジェクトのままです、よって変更が共有されます。上記の状況で新しいオブジェクトを作成しながら情報を更新するには、ネストしたオブジェクトで更に spread operator を使用します。

const userData = {
  "id": 1,
  "name": "yamada",
  "age": 29,
  "address": {
    "pref": "Tokyo",
    "city": ""
  }
}

const updateUserData = {
  ...userData,
  "address": {
    ...userData.address,
  }
}
updateUserData.name = "sato"
updateUserData.address.pref = "Osaka"

// もしくは1回で
const updateUserData2 = {
  ...userData,
  "name": "sato",
  "address": {
    ...userData.address,
    "pref": "Osaka"
  }
}
console.log(userData.address.pref, updateUserData.address.pref);
// Tokyo Osaka

このように新しいオブジェクトを作るのは簡単なようで難しいです。もちろん、コピーせずにオブジェクトを普通に新規作成して書き直す手もあります。しかし、それはデータ変更に弱くなり、データ更新時にどこが変更したのかも分かりづらくなります。データが増える毎に型情報なしでは要素の確認だけで大変です。

例えば、userData にメール、電話、ニックネーム、sns…と増えていき、データに親子関係が出てくる状況は用意に想定できます。spread operator などでデータをコピーし、必要な部分だけ書き換えるのは効率的で分かりやすいコードになります。他の要素が増減しても下記のコードでは name 以外が変更されないことがすぐわかります。


const updateUserData = {
  ...userData,
  "name": "suzuki"
}

オブジェクト型は非常に便利ですが、複雑なデータ構造になるとデータ更新時に意図しない動作を起こしやすいデメリットもあります。これを防ぐために Immutable.js や Normalizr といった様々な工夫ができるライブラリも存在します。

let と const

変数には様々なデータが入ります。なので、変数の理解は、変数に入るデータのことも理解してようやく理解かなと考えたりします。

今回は紹介しませんでしたが変数に関数を代入する機会も多く、関数も含めて変数の理解とするなら変数を理解するのは本当に最後の最後で、そんな日が果たして訪れるのか。それはそれで別の内容だろうと考えを変えるのかは不明です。

自分の習熟度が上がり、また振り返る時がきたら記事を書き直すか追記します。

参考