JavaScriptのクロージャはメモリーリークをちゃんと理解して使おう

はじめに

前にブログで書いた記事なのですが、せっかくなのでQiitaにも投稿します。

脱初級者の壁として君臨しているクロージャ。クロージャの使い方はわかったけど、いろんな記事を見るとクロージャは問題点もあるみたい。それに、そもそもクロージャの使い所がいまいちわかんないと思ってクロージャに再度立ち向かおうと思った次第です。同じような悩みを抱えているデザイナーさん、コーダーさん、フロントエンドエンジニアさんの参考になれば嬉しいです。

クロージャとは

とりあえずおさらい & 補足をします。

よく見かけるクロージャの見本がこちら。

function closure(initVal){
  var count = initVal;

  var innerFunc = function() {
    return ++count;
  }
  return innerFunc;
}

var myClosure = closure(100);
myClosure(); // 101
myClosure(); // 102
myClosure(); // 103

ここで簡単にクロージャについて説明します。ちなみに、最近読んだ本で何となくJavaScriptを書いていた人が一歩先に進むための本が説明としてわかりやすかったので、そちらを引用させていただきながら。
まずクロージャとは

ローカル変数を参照している、関数の中に定義している関数

ということらしいです。なので今回の場合だとinnerFunc関数がクロージャに該当しますね。では、なぜ、myClosure()が呼び出されるたびに結果が増えていくかというのがわかるとクロージャがどんなものなのかわかってきます。

まず、通常の関数の中に定義されているローカル変数は、関数の処理が終わった時点で破棄されます。しかし、先ほどのコードだとmyClosureがローカル変数countを参照し続けています。そのことによって結果が増えていきます。では、なぜこのようなことが起きるのかというと

  1. closure関数ではローカル変数countを参照している関数innerFuncが返却されている
  2. innerFuncそのものはmyClosureに格納される
  3. myClosureはグローバル変数なため、グローバルオブジェクトが存在し続ける限り解放されることがない
  4. なので、ローカル変数countも破棄されない
  5. countは破棄されないので、closure呼び出し時に代入された値が保持される
  6. よってcountは加算されていく

こんな仕組みで動いているのがクロージャです。スコープチェーンと破棄されるタイミングさえ掴めれば理解できそうです。
ざっとクロージャとはどんなものかおさらいできたところで、今回の本題に入りたいと思います。

クロージャの問題点

最初に結論言ってしまうとメモリーリークを引き起こすのが問題なんです。

メモリーリークとは?

メモリーリークとはアプリケーションが占有したメモリ領域が何らかの理由で解放されないまま残り続けてしまう現象のことです。JavaScriptの場合だと、JavaScriptで使用されなくなったオブジェクトへの参照が、何らかの理由での残り続けてしまうことで、オブジェクトがメモリ上に残り続けてしまうことで発生します。

JavaScriptはGC(ガベージコレクション)と言う、プログラムが動的に確保したメモリ領域の内、不要になった領域を自動的に開放する機能を採用しているので、JavaScriptの書き手がメモリの確保・開放を意識することなくメモリ領域から解放されます。しかしオブジェクトの参照が残っていると回収できず、メモリ上に残ってしまいます。

メモリの解放がほとんどされずそこにさらに何かしらの操作をしたりすれば、さらにメモリを圧迫してしまいます。そうするとどんどんブラウザの動作が重くなってしまいますよね。さらにメモリ消費が大きいWebアプリではGCが頻発し、パフォーマンス劣化に繋がってしまいます。

メモリーリークを引き起こしてみる

では、実際にメモリーリークを引き起こしてみたいと思います。

this.handleClickがクロージャになっていて、start_buttonをクリックすると発動するようになっています。
はじめに言っておくとこのクロージャは循環参照と言うのがおきてしまっていて参照が破棄されません。詳しくは後ほど説明します。

var leak;
var Leaker = function(a){
  this.a = a;
  var self = this;
  this.b = []

  this.handleClick = function(){
    self.b.push(new Array(100000).join(self.a));
  };
  var el = document.getElementById("start_button");
  el.addEventListener("click", this.handleClick);
};

var init = function(){
  leak = new Leaker("fugafuga");
};
var teardown = function(){
  leak = null;
};

init()

$("#destroy_button").click(function(){
  teardown()
});
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Title</title>
</head>
<body>
<h1>index.html</h1>
<button type="button" id="start_button">start_button</button>
<button type="button" id="destroy_button">destroy_button</button>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="leak.js"></script>
</body>
</html>

Google ChromeのDevToolsを使ってメモリーリークを調べる

DevToolsを表示するとPerformanceタブがあるのでそちらの画面にします。

01-1.jpg

この機能を使って現在のページのメモリの使用量を調べます。ちなみに、画面上部に「ゴミ箱ボタン」があると思うんですが、これを押すと強制的にGCを発生させてメモリ解放ができます。

  1. 「⚫️」のRecordボタンを押して録画開始
  2. 最初にゴミ箱ボタン押す
  3. start_buttonを押しまくる
  4. destroy_buttonを押して参照をきる
  5. もう一回ゴミ箱ボタンをおす
  6. stopボタンを押してRecord停止させる

02.jpg

Memoryボタンを押すと中段あたりに折れ線グラフみたいのが表示されます。何色かあると思うんですが、今回みるのは青い線のみです。
ちなみに、時系列的には、左から右に流れています。記録開始後、はじめにゴミ箱ボタンを押したので、メモリの使用量が下の方(最低限のメモリ使用状況)になっているのが確認できると思います。

行った操作を思い出しながら青い線をみてもらいたいんですが、start_buttonを押すごとに階段状でメモリの使用量が増えているのがわかりますね。
さらに、2回目のゴミ箱ボタンを押してもメモリの使用量が初期の状態に戻っていないことがわかります。メモリーリークがおきていそうな箇所が発見できました。

今回は、関数が一つしかないので特定しやすいんですけど、実際のJSコードではそんなことほとんどありませんし、いくつも関数が実行している場合も考えられるので、メモリーリークが起きていそうな箇所で使っている関数で参照が残ってしまっているのを探し当てる必要があります。

スナップショットで該当する関数を探す

該当する関数を見つけます。と言っても今回のサンプルだと「Leaker」関数が該当なので、「Leaker」が該当となればOKですよね。
まずは、DevTools上部のメニューでMemoryタブに移動します。次に、「Take Heap SnapShot」を選択してください。んで、一応ページのリロードをしておきましょう。

今回の計測は何をするのかというと、ページレンダー時、start_buttonを押した時、destroy_buttonを押した時を計測してdestroy_button押下後に「Leaker」の参照があるのかどうかを確認します。「Leaker」の参照がある場合はここでメモリーリークがおきています。

では、次の手順をやってみてください。

  1. Take Snapshotボタンおす
  2. start_buttonおす
  3. Recordボタンおす
  4. destroy_buttonおす
  5. Recordボタンおす

03.jpg

Snapshotが3つできていますね。ページレンダー時、start_buttonを押した時、destroy_buttonを押した時の状態がそれぞれ記録されています。それでは、「Leaker関数」があるかどうかを探します。
Snapshot1から順番にClass filterでLeakerを検索してみましょう。

Snapshot1(ページレンダー時)はありますね。
Snapshot2(start_buttonを押した時)はありますね。

ここまでは「leaker関数」は使用しているのであってOKです。

Snapshot3(destroy_buttonを押した時)これも存在してしまっています。

leak = null;

で、参照をきっているはずなのに参照が残ってしまっています。
これでこの部分でメモリーリークしているのがわかりましたね。

コードを書き換える

次に、メモリーリークがおきないように修正をして上記の計測をやり直します。

var leak;
var Leaker = function(a){
  this.a = a;
  var self = this;
  this.b = []

  this.handleClick = function(){
    self.b.push(new Array(100000).join(self.a));
  };
  var el = document.getElementById("start_button");
  el.addEventListener("click", this.handleClick);
};

Leaker.prototype.dispose = function(){
  var el = document.getElementById("start_button");
  el.removeEventListener("click", this.handleClick);
};

var init = function(){
  leak = new Leaker("fugafuga");
};

var teardown = function(){
  leak = null;
};

init()

$("#destroy_button").click(function(){
  leak.dispose()
  teardown()
});

$("#start_button").click(function(){
  leak.handleClick()
});

04.jpg

Performanceタブではゴミ箱ボタンを押すと初期の状態までメモリ使用量が減少しているのが確認できます。
スナップショットの計測もやってみてくださいね。Snapshot3(destroy_buttonを押した時)にLeakerがなくなっているはずです。

メモリーリークの温床になりやすいクロージャ

ここまででクロージャはメモリーリークの温床になりやすいことがわかりました。
では、実際にクロージャを使う場合にどういったことに気をつけるべきなのでしょうか。

  • 循環参照によるメモリーリークに気をつける
  • 必要が無い場合はクロージャを使わない

この2点に気をつけるといいのかなと思います。特に、「循環参照によるメモリーリークに気をつける」は気づかないうちになってしまっているケースもあるので特に気をつけなければいけません。

循環参照によるメモリーリークに気をつける

さっきのサンプルコードが実は循環参照でメモリーリークがおきてしまっていました。

どう循環参照しているかというと。

  • DOMがhandleClickを参照。
  • handleClickはクロージャなので、Leakerの「環境」を参照。
  • その「環境」は el変数を通してDOMを参照。
var leak;
var Leaker = function(a){
  this.a = a;
  var self = this;
  this.b = []

  this.handleClick = function(){
    self.b.push(new Array(100000).join(self.a));
  };
  var el = document.getElementById("start_button");
  el.addEventListener("click", this.handleClick);
};

var init = function(){
  leak = new Leaker("fugafuga");
};

var teardown = function(){
  leak = null;
};

init()

$("#destroy_button").click(function(){
  teardown()
});

先ほどはremoveEventListenerで参照をきっていました

Leaker.prototype.dispose = function(){
  var el = document.getElementById("start_button");
  el.removeEventListener("click", this.handleClick);
};

循環参照になっていなければOKだから下記のやり方でもメモリーリークはおきません。

var leak;
var Leaker = function(a){
  this.a = a;
  var self = this;
  this.b = []

  this.handleClick = function(){
    self.b.push(new Array(100000).join(self.a));
  };
};

var init = function(){
  leak = new Leaker("fugafuga");
};

var teardown = function(){
  leak = null;
};

init()

$("#destroy_button").click(function(){
  teardown()
});

$("#start_button").click(function(){
  leak.handleClick()
});

必要が無い場合はクロージャを使わない

下記の場合、そもそもクロージャを使う必要性もないですね。このクラス定義では、メソッドもプロパティになっているのがわかります。余計なメモリを使っているのがわかりますね。

var MyFunc = function(text) {
    this.text = text;
    this.log = function() {
      console.log(this.text + 'hoge')
    }
  };
  var myFunc = new MyFunc('hoge')
  myFunc.log()
  console.log(myFunc) // MyFunc {text: "hoge", log: ƒ}

クロージャを使わずにprototypeで書いた方が良さそうです。クロージャも使わず同じことができてこちらの場合であればメソッドはインスタンスのプロパティではありませんので、メモリの節約になっています。

var MyFunc = function(text) {
    this.text = text;
  };
  MyFunc.prototype.log = function() {
    console.log(this.text + 'hoge')
  };

  var myFunc = new MyFunc('hoge')
  myFunc.log()
  console.log(myFunc) // MyFunc {text: "hoge"}

クロージャの使い所とは

メモリーリークの温床になってしまうクロージャというのがわかってきました。では、実際クロージャってどんなケースで使えばいいのでしょうか?

状態を覚えておきたい時

よくあるのが、

jQuery(function($){
  var isClicked = false;
  $('.btn').click(function(){
    if (isClicked) {
      console.log('クリック済みです');
    }
    isClicked = true;
  });
});

こんな感じなのですね。クリックしたかどうかを覚えておく時に使ったりします。コーポレートサイトなんかでjQueryを使っている時なんかはいいのかもしれません。ただ、特にメモリーリークがおきやすいSPAだと、Reactなどを使っているケースがほとんどだと思います。その場合State管理しているので別段クロージャを使わなくていいですね。

private プロパティの定義

Javascriptでは、「プライベートメンバ」という機能がありません。全てのメンバは常にパブリックになってしまいます。下記のprototypeの例だと、nameが外部から操作されてしまっているのがわかります。

mitsuruog/clean-code-javascript: Clean Code concepts adapted for JavaScript

const Employee = function(name) {
  this.name = name;
};

Employee.prototype.getName = function() {
  return this.name;
};

const employee = new Employee('John Doe');
console.log('Employee name:' + employee.getName()); // Employee name: John Doe
delete employee.name;
console.log('Employee name:' + employee.getName()); // Employee name: undefined

クロージャのテクニックを使って、private プロパティのようなものを作ることができます。下記の場合だと、delete employee.name;で操作できていないことがわかりますね。

function makeEmployee(name) {
  return {
    getName: function() {
      return name;
    }
  };
}

const employee = makeEmployee('John Doe');
console.log('Employee name:' + employee.getName()); // Employee name: John Doe
delete employee.name;
console.log('Employee name:' + employee.getName()); // Employee name: John Doe

矛盾が発生している

先ほどの「必要が無い場合はクロージャを使わない」ではメモリの節約からprototypeを使うとしましたが、private プロパティを作りたいなら、逆にクロージャを使うのがいいです。確かにクロージャを使えばプライベートメンバを作れるのですが、PrototypeベースというJavaScriptの利点をなくしてしまう他、インスタンス化する度にメソッドを定義するためメモリも余計に使ってしまいます。これを回避するために、this._nameのようにして紳士協定でprivate プロパティを作る方法があります。

const Employee = function(name) {
  this._name = name;
};

Employee.prototype.getName = function() {
  return this._name;
};

ES6ではWeakMapを使ってprivate プロパティを作れる

Classをインスタンス化する際、そのインスタンス(this)をWeakMapにsetすればWeakMap.get(this)でメンバにアクセスできるようになります。

参考
- 【JavaScript】privateなプロパティやメソッドを定義する | Web活

var Func = (function() {
  var privates = new WeakMap();

  function Func() {
    privates.set(this, {});

    privates.get(this).prop = 1;
  }

  Func.prototype.method = function() {
    console.log('******************')
    console.log(privates.get(this).prop)
    console.log('******************')
  };

  return Func;
})();

let p = new Func();
p.method();

まとめ

今回クロージャについて再度調べてみました。クロージャのデメリットとメリットについてまとめてみました。
正直、ES6以前ではprivateプロパティを作るという点でいいんですが、どうしてもメモリ使用量とのトレードオフになってしまいますね。紳士協定に寄る対応策もありますがクロージャの使い所を間違えないようにしたいですね。んで、もしクロージャを使っているのであればメモリーの使用についてはしっかりと計測をしてメモリーリークがおきていないか把握したいところです。

ただ、ES6環境であればWeakMapでprivateプロパティを作れるのは大きいですね。

参考