質問をすることでしか得られない、回答やアドバイスがある。

15分調べてもわからないことは、質問しよう!

ただいまの
回答率

90.12%

DOMとクロージャによる循環参照問題の解決方法

受付中

回答 3

投稿 編集

  • 評価
  • クリップ 4
  • VIEW 4,552

sounisi5011

score 711

最近、DOMオブジェクトとクロージャにより循環参照が起き、メモリリークが発生するという問題について知りました。
この問題が古いブラウザで発生し、動作に影響すると知ったため、昔書いたクロスブラウザ対応コードにこの問題が無いか検証したところ、以下の様な循環参照パターンとなっている事が分かりました。
(以下に提示するのは、簡易化したコードです)
/**
 * @param {Node} targetNode
 */
function clickCaptcha(targetNode) {
    targetNode.addEventListener('click', function () {
        console.log(targetNode);
    }, false);
}
targetNodeが操作対象のDOMノードです。
これに追加するリスナーがクロージャになっており、targetNodeを参照しています。
このコードを循環参照が発生しないものに書き換えようと考えていますが、その方法が分かりません。

単純に考えるならば、以下のようにすれば解決すると思います。
/**
 * @param {Node} targetNode
 */
var clickCaptcha = (function () {
    // イベントのリスナーを外部に出すことでクロージャを回避
    var clickListener = function () {
        // addEventListenerにより設定したリスナーは、
        // thisがイベントを追加した要素のDOMノード(=targetNode)となる
        console.log(this);
    };

    return function (targetNode) {
        targetNode.addEventListener('click', clickListener, false);
    };
}());
/**
 * @param {Node} targetNode
 */
var clickCaptcha = (function () {
    // イベントのリスナーを外部に出すことでクロージャを回避
    var clickListener = function (e) {
        // EventオブジェクトのcurrentTargetプロパティを利用
        console.log(e.currentTarget);
    };

    return function (targetNode) {
        targetNode.addEventListener('click', clickListener, false);
    };
}());
しかし、クロスブラウザ対応用のコードであるため、addEventListenerではなくattachEventを使用した場合はthisが扱えませんし、Eventオブジェクトも信用できません。
このため、この2つの解決方法は採用できず、変数targetNodeを他の方法でリスナーから参照する必要があります。

クロージャとメモリリークについてのコピペ - こんにちはこんにちはmonmonです!】の702で提示されたcreateLeakFreeClosure関数を使用し、以下のように書く方法もあります。
function createLeakFreeClosure(closure) {
    /**
     * Note: 適切に動作するよう、いくつか修正
     */
    var count = createLeakFreeClosure.count++;
    createLeakFreeClosure[count] = closure;
    closure = null;
    return function () {
        return createLeakFreeClosure[count].apply(
            this,
            Array.prototype.slice.call(arguments)
        );
    };
}
createLeakFreeClosure.count = 0;

/**
 * @param {Node} targetNode
 */
function clickCaptcha(targetNode) {
    targetNode.addEventListener('click', createLeakFreeClosure(function () {
        console.log(targetNode);
    }), false);
}
しかし、createLeakFreeClosure関数は同ページの707でも指摘されているように、何度も実行した場合に引数に渡した関数が保持され続ける事になり、これはこれで問題となってしまいます。
私自身もこの関数が循環参照を回避できる理由が理解できていないため、下手に改良もできません。
よって、これも採用できません。

冒頭のコードを、循環参照が発生せず、また変数targetNodeを参照できるようにするには、どのようにすれば良いでしょうか。

循環参照について理解できていないため、動作の違うサンプルを例示されても修正して活用できません。
このため、コメントにて私の書いたコードを例示し、何度も指導していただく形になってしまいます。

可能であれば、「第一引数に受けたDOM Nodeにclickイベントを定義」する、質問本文冒頭のコードと同じ動作をする循環参照を解決したコードを例示していただければ有難いです。
  • 気になる質問をクリップする

    クリップした質問は、後からいつでもマイページで確認できます。

    またクリップした質問に回答があった際、通知やメールを受け取ることができます。

    クリップを取り消します

  • 良い質問の評価を上げる

    以下のような質問は評価を上げましょう

    • 質問内容が明確
    • 自分も答えを知りたい
    • 質問者以外のユーザにも役立つ

    評価が高い質問は、TOPページの「注目」タブのフィードに表示されやすくなります。

    質問の評価を上げたことを取り消します

  • 評価を下げられる数の上限に達しました

    評価を下げることができません

    • 1日5回まで評価を下げられます
    • 1日に1ユーザに対して2回まで評価を下げられます

    質問の評価を下げる

    teratailでは下記のような質問を「具体的に困っていることがない質問」、「サイトポリシーに違反する質問」と定義し、推奨していません。

    • プログラミングに関係のない質問
    • やってほしいことだけを記載した丸投げの質問
    • 問題・課題が含まれていない質問
    • 意図的に内容が抹消された質問
    • 広告と受け取られるような投稿

    評価が下がると、TOPページの「アクティブ」「注目」タブのフィードに表示されにくくなります。

    質問の評価を下げたことを取り消します

    この機能は開放されていません

    評価を下げる条件を満たしてません

    評価を下げる理由を選択してください

    詳細な説明はこちら

    上記に当てはまらず、質問内容が明確になっていない質問には「情報の追加・修正依頼」機能からコメントをしてください。

    質問の評価を下げる機能の利用条件

    この機能を利用するためには、以下の事項を行う必要があります。

質問への追記・修正、ベストアンサー選択の依頼

  • ngyuki

    2015/08/24 19:22

    Event オブジェクトが信用出来ない、というのはどういう理由で? window.event だったり、event.srcElement だったりするという理由ですか?

    キャンセル

  • sounisi5011

    2015/08/25 01:48 編集

    文中のe.targetはe.currentTargetの誤りでした。event.currentTargetはattachEventのEventオブジェクトに代替のプロパティが存在しません。またaddEventListenerのPolyfillを設計する場合には、逆にEventオブジェクトを補う必要があります。このような場合も想定したため、Eventオブジェクトに頼る実装は可能な限り避けたいと考えています。

    キャンセル

回答 3

+1

まず、質問者さんが必要としているのは event.targetevent.currentTarget (this と等価) のどちらなのでしょうか?
解決法として挙げられている2つのコードが event.target だったり、this だったりするので判別できませんでした。

addEventListenerではなくattachEventを使用した場合はthisが扱えませんし、Eventオブジェクトも信用できません。 
attachEvent では this を使えませんが、event オブジェクトは DOM Events と互換性がないだけで信用できないわけではありません。
event.target を利用した場合のメモリリーク回避パターンは次の通りです。

(function () {
  function handleClick (event) {
    var target = event.target || event.srcElement;  // attachEvent では event.srcElement で target を参照できる
  }
  
  function initEvent () {
    if (document.addEventLisetner) {
      document.addEventLisetner('click', handleClick, false);
    } else if (document.attachEvent) {
      document.attachEvent('onclick', handleClick);
    }
  }
}());

attachEvent では event.currentTarget に代わるプロパティが存在しない為、parentNode を辿って参照する工夫は必要になります。
また、複数ノードに同じイベントを割り当てる処理をたまにみかけますが、event.target を利用すれば上位ノードでイベント定義するだけで済む場合が結構あります。

大抵はこれで何とかなりますが、どうしても event.currentTarget が必要と思える状況が出てきた場合は event.currentTarget を返す関数を定義して関数呼び出しして下さい。

(function () {

  var getCurrentTarget;  // event.currentTarget を返す関数

  function createGetElement (element) {   // 「要素ノードを返す関数」を生成する関数
    return function getElement () { return element; };
  }
  
  function handleClick (event) {
    var currentTarget = event.currentTarget || getCurrentTarget();
  }

  function initEvent () {
    var currentTarget = document.getElementById('sample');

    getCurrentTarget = createGetElement(currentTarget);

    if (currentTarget.addEventLisetner) {
      currentTarget.addEventLisetner('click', handleClick, false);
    } else if (currentTarget.attachEvent) {
      currentTarget.attachEvent('onclick', handleClick);
    }
  }

  initEvent();
}());

あとは、「event.currentTarget をグローバル変数にする」という方法もないわけではありませんが、お勧めしません。


最後に window.onunload 時に全てのイベントハンドラ(イベントリスナ)を detachEvent (removeEventListener) を実行する事をお勧めします。
この処理を入れておくと循環参照が発生していたとしても window.onunload のタイミングで順参照が切れる為、メモリリークしません。

(function () {

  function handleClick (event) {
  }

  function handleDOMContentLoaded (event) {
  }

  function handleUnload (event) {   // 全てのイベント登録を解除する
    if (document.removeEventListener) {
      document.removeEventListener('click', handleClick, false);
      document.removeEventListener('DOMContentLoaded', handleDOMContentLoaded, false);
      removeEventListener('unload', handleUnload, false);
    } else if (document.detachEvent) {
      document.detachEvent('onclick', handleClick);
      detachEvent('onload', handleDOMContentLoaded);
      detachEvent('onunload', handleUnload);
    }
  }

  function initEvent () {
    if (document.addEventListener) {
      document.addEventListener('click', handleClick, false);
      document.addEventListener('DOMContentLoaded', handleDOMContentLoaded, false);
      addEventListener('unload', handleUnload, false);
    } else if (document.attachEvent) {
      document.attachEvent('onclick', handleClick);
      attachEvent('onload', handleDOMContentLoaded);
      attachEvent('onunload', handleUnload);
    }
  }

  initEvent();
}());

投稿

編集

  • 回答の評価を上げる

    以下のような回答は評価を上げましょう

    • 正しい回答
    • わかりやすい回答
    • ためになる回答

    評価が高い回答ほどページの上位に表示されます。

  • 回答の評価を下げる

    下記のような回答は推奨されていません。

    • 間違っている回答
    • 質問の回答になっていない投稿
    • スパムや攻撃的な表現を用いた投稿

    評価を下げる際はその理由を明確に伝え、適切な回答に修正してもらいましょう。

  • 2015/08/26 09:26

    To: sounisi5011さん
    複数の要素を扱いたい場合は getCurrentTarget を配列化して handleClick を要素の数だけ用意してください。
    DOMとScriptの循環参照回避パターンは基本的に汎用性は犠牲になります。
    汎用性を求める場合は event.currentTarget, event.type, listener, useCapture をキャッシュして実行する仕組みが必要であり、jQuery も同様のコードになっていたはずです。
    私も以前書いた事がありますが、event のプロパティの互換性をとったり、attachEvent で実行順を保証したり、といろいろ実装したらかなり長いコードになりました。
    https://gist.github.com/think49/882821
    いろいろ考えて書いている内は楽しいですが、それに伴う労力を考えると割り切りも必要なのかもしれません。
    原理的には可能なはずなので、もし、よろしければ私の書いたコードを読み解いて fork してみて下さい。

    To: ngyukiさん
    createGetElement で返す getCurrentTarget は currentTarget への参照を保持しますが、関数呼び出しされるまで参照できません。
    メモリリークパターンの循環参照は「click(DOM) -> handleClick(Script) -> DOMノード(DOM)」で形成されるものですが、currentTarget となる要素ノードは handleClick のスコープ上に存在しない為、「click(DOM) -> handleClick(Script) -> getCurrentTarget(Script)」と「getCurrentTarget(Script) -> currentTarget(DOM)」が独立に形成され、循環参照しなくなるという認識でした。

    キャンセル

  • 2015/08/26 11:54

    > click(DOM) -> handleClick(Script) -> DOMノード(DOM)

    中間に js オブジェクトを幾つ挟んでも循環参照であることには変わりないです。なので、

    > 「click(DOM) -> handleClick(Script) -> getCurrentTarget(Script)」と「getCurrentTarget(Script) -> currentTarget(DOM)」

    でも循環参照してます。

    キャンセル

  • 2015/08/30 09:35

    返信遅くなりましてすみません。
    今では検証手段がないので真偽を確かめることは出来ませんが、ngyukiさんの言われる内容が正しいのであれば、汎用的な対策としては unload 時に detachEvent する以外に方法はなさそうですね…。

    キャンセル

+1

文中のe.targetはe.currentTargetの誤りでした。event.currentTargetはattachEventのEventオブジェクトに代替のプロパティが存在しません。

あ、そういうことですか・・
↓のような回答を書こうとしていたのですがそういう意味ではないということですね・・


単に clickCaptcha で次のように target を取り出せば良い、というわけではないのでしょうか?

var clickListener = function (e) {
    e = e || window.event;
    e.target = e.target || e.srcElement;
};

昔はよくこのようなコードを書いていた気がします。
ただ、addEventListener のレシーバとイベントオブジェクトの target は意味が異なるので注意です。


おそらくキレイな解決方法はなく、要素の ID を振るなどの泥臭い方法しか無いと思います。
例えば次のような風にです。

function createEventHandler(id, func){
    return function(ev){
        ev = ev || window.event;
        ev.target = ev.target || ev.srcElement;
        ev.currentTarget = ev.currentTarget || document.getElementById(id);

        ev.currentTarget._func_ = func;
        var retval = ev.currentTarget._func_(ev);
        ev.currentTarget._func_ = null;

        ev.target = null;
        ev.currentTarget = null;
        return retval;
    };
}

function addEventListener(elem, name, func)
{
    // 要素に ID が無ければならない、あるいは、無ければ ID をここで自動生成する
    var handler = createEventHandler(elem.id, func);

    if (elem.addEventListener) {
        elem.addEventListener(name, handler, false);
    } else if(elem.attachEvent) {
        elem.attachEvent('on'+name, handler);
    }
}

function clickListener(ev)
{
    console.log(ev.target.tagName);         // BUTTON
    console.log(ev.currentTarget.tagName);  // DIV
    console.log(this.tagName);              // DIV
}

function clickCaptcha(targetNode) {
    addEventListener(targetNode, 'click', clickListener);
}

clickCaptcha(document.getElementById('hoge'));

この例だと、createEventHandler の中のイベントハンドラであるクロージャーは要素を束縛していません。
代わりに id を束縛していて、ハンドラの中で id を元に要素を取り出して色々使ってます。

投稿

  • 回答の評価を上げる

    以下のような回答は評価を上げましょう

    • 正しい回答
    • わかりやすい回答
    • ためになる回答

    評価が高い回答ほどページの上位に表示されます。

  • 回答の評価を下げる

    下記のような回答は推奨されていません。

    • 間違っている回答
    • 質問の回答になっていない投稿
    • スパムや攻撃的な表現を用いた投稿

    評価を下げる際はその理由を明確に伝え、適切な回答に修正してもらいましょう。

  • 2015/08/25 23:42 編集

    IDの自動生成コードを追加したコードです。
    このような感じでしょうか?
    (下記コードでは、インデントが表示されるよう半角スペースをEN SPACE(U+2002)に置換しています)

    /**
     * @param {Node} targetNode
     */
    var clickCaptcha = (function () {
        /**
         * @return {string}
         * @see http://stackoverflow.com/questions/3231459#19842865
         */
        function createRandomId() {
            var id;
            do {
                id = 'x' + Math.random().toString(16).slice(2);
            } while (document.getElementById(id));
            return id;
        }

        function createEventHandler(id, listener) {
            return function (ev) {
                ev = ev || window.event;
                ev.target = ev.target || ev.srcElement;
                ev.currentTarget = ev.currentTarget || document.getElementById(id);

                var retval = listener.call(ev.currentTarget, ev);

                ev.target = null;
                ev.currentTarget = null;

                return retval;
            };
        }

        function addEventListener(target, type, listener) {
            var id = target.id || (target.id = createRandomId());
            var handler = createEventHandler(id, listener);

            if (target.addEventListener) {
                target.addEventListener(type, handler, false);
            } else if (target.attachEvent) {
                target.attachEvent('on' + type, handler);
            }
        }

        function clickListener(event) {
            console.log(event.currentTarget);
        }

        /**
         * @param {Node} targetNode
         */
        return function (targetNode) {
            addEventListener(targetNode, 'click', clickListener);
        };
    }());

    キャンセル

  • 2015/08/26 02:11

    そのような感じです。

    ちなみにページのアンロード時に DOM のオブジェクトを絡めた循環参照があるとメモリリークする問題は、かなり昔に解決されていたと思います(IE7 だったかな?)。

    キャンセル

0

jQuery 1.11.3の実装を見たところ、以下のようにしてメモリリークを回避しているようです。
(4306~4417行目)

/**
 * @param {Node} targetNode
 */
function clickCaptcha(targetNode) {
    var eventHandle = function () {
        var targetNode = eventHandle.elem;

        console.log(targetNode);
    };
    // Add elem as a property of the handle fn to prevent a memory leak with IE non-native events
    eventHandle.elem = targetNode;

    // Bind the global event handler to the element
    if (targetNode.addEventListener) {
        targetNode.addEventListener('click', eventHandle, false);
    } else if (targetNode.attachEvent) {
        targetNode.attachEvent('onclick', eventHandle);
    }

    // Nullify elem to prevent memory leaks in IE
    targetNode = null;
}

ただ、この処理が循環参照の解決策となるのかどうかについては確証が持てません…

投稿

編集

  • 回答の評価を上げる

    以下のような回答は評価を上げましょう

    • 正しい回答
    • わかりやすい回答
    • ためになる回答

    評価が高い回答ほどページの上位に表示されます。

  • 回答の評価を下げる

    下記のような回答は推奨されていません。

    • 間違っている回答
    • 質問の回答になっていない投稿
    • スパムや攻撃的な表現を用いた投稿

    評価を下げる際はその理由を明確に伝え、適切な回答に修正してもらいましょう。

15分調べてもわからないことは、teratailで質問しよう!

  • ただいまの回答率 90.12%
  • 質問をまとめることで、思考を整理して素早く解決
  • テンプレート機能で、簡単に質問をまとめられる

同じタグがついた質問を見る