console.log の落とし穴:console.log にはオブジェクトの参照が渡るので気を付けよう


TL; DR

console.log()に大きなオブジェクトや配列を渡すと、console.log()を呼び出した時点での値ではなく、コンソールでそれをクリック展開した時点での値が表示される。

もう少し詳しく

console.log()に大きなオブジェクトを渡すとコンソール上で省略されて表示されますが、その省略表示を展開すると、console.log()が呼び出された時点の値ではなく、展開したその時点で評価した値が表示されます

つまり例えばあるオブジェクトに操作を施す前後でconsole.log()を取っても、実行終了後のコンソールには操作を施した後のオブジェクトしか表示されないということです。

要はオブジェクトへの参照を保持しているような挙動です(参考)。

(以下の例ではわかりやすさのため常に省略表示されるconsole.dirを使っています)

obj.aをに値を代入する前後でログを取っているのに、代入後の値しか取れていません。

let obj = {
  a: 'foo'
}

console.dir(obj) // => {a: 'bar'}
obj.a = "bar"
console.dir(obj) // => {a: 'bar'}

確認した限りではChrome, Firefox, Edge, IE11の2019年5月現在の最新バージョンで再現できました。

Chrome74

FireFox66

ちなみにChromeでは、横のiアイコンをホバーすると「値はたったいま評価されたものだよ」と教えてくれます。

なぜ呼び出した時点の値を教えてくれないのか

このStack Overflowでは、

  • 各時点でのオブジェクトを保持しておくとすると、メモリや計算量的にヤバいから
  • 参照ではなく文字列として保持しておくとすると、循環参照があった場合にマズいから

などの理由が指摘されていました。

ではどうすればいいか

① コピーを渡す
let obj = {
  a: 'foo'
}

console.dir({...obj}) // => {a: 'foo'}
obj.a = "bar"
console.dir(obj) // => {a: 'bar'}

スプレッド構文で(シャロー)コピーを作って渡しているため、obj.aは後の変更の影響を受けません。

ただしコピーは1段階の深さで行われるため、objがネストしている場合ネストされた部分は変更の影響を受けてしまいます。
その場合は何らかのライブラリのディープコピー用関数を用いるしかなさそうです。

② 一度文字列に変換する

MDNにあった解決法です。

let obj = {
  a: 'foo'
}

console.log(JSON.parse(JSON.stringify(obj)));
obj.a = "bar"
console.log(JSON.parse(JSON.stringify(obj)));

ただし文字列にする関係上、循環参照があった場合はエラーになります

③ ブレークポイントを使う

欲しい所で止めれば当然その時点の値が取れます。

④ プロパティの値を渡す

追跡したいプロパティaの値がプリミティブ型ならconsole.log(obj.a)のようにやるのが簡単ですね。

⑤ オブジェクトを変化させない

オブジェクトがイミュータブルであればいつconsole.logを取っても同じ値が表示されます!

他にも良い方法があれば是非教えてください。

まとめ

console.log()で大きなオブジェクトや配列のデバッグをするときは、参照が渡る挙動をすることを忘れないようにしましょう(私は忘れて混乱することがありました)。