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

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

新規登録して質問してみよう
ただいま回答率
85.48%
JavaScript

JavaScriptは、プログラミング言語のひとつです。ネットスケープコミュニケーションズで開発されました。 開発当初はLiveScriptと呼ばれていましたが、業務提携していたサン・マイクロシステムズが開発したJavaが脚光を浴びていたことから、JavaScriptと改名されました。 動きのあるWebページを作ることを目的に開発されたもので、主要なWebブラウザのほとんどに搭載されています。

Q&A

3回答

7372閲覧

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

sounisi5011

総合スコア697

JavaScript

JavaScriptは、プログラミング言語のひとつです。ネットスケープコミュニケーションズで開発されました。 開発当初はLiveScriptと呼ばれていましたが、業務提携していたサン・マイクロシステムズが開発したJavaが脚光を浴びていたことから、JavaScriptと改名されました。 動きのあるWebページを作ることを目的に開発されたもので、主要なWebブラウザのほとんどに搭載されています。

0グッド

4クリップ

投稿2015/08/24 09:44

編集2015/09/16 17:15

最近、DOMオブジェクトとクロージャにより循環参照が起き、メモリリークが発生するという問題について知りました。
この問題が古いブラウザで発生し、動作に影響すると知ったため、昔書いたクロスブラウザ対応コードにこの問題が無いか検証したところ、以下の様な循環参照パターンとなっている事が分かりました。
(以下に提示するのは、簡易化したコードです)

JavaScript

1/** 2 * @param {Node} targetNode 3 */ 4function clickCaptcha(targetNode) { 5 targetNode.addEventListener('click', function () { 6 console.log(targetNode); 7 }, false); 8}

targetNodeが操作対象のDOMノードです。
これに追加するリスナーがクロージャになっており、targetNodeを参照しています。
このコードを循環参照が発生しないものに書き換えようと考えていますが、その方法が分かりません。

単純に考えるならば、以下のようにすれば解決すると思います。

JavaScript

1/** 2 * @param {Node} targetNode 3 */ 4var clickCaptcha = (function () { 5 // イベントのリスナーを外部に出すことでクロージャを回避 6 var clickListener = function () { 7 // addEventListenerにより設定したリスナーは、 8 // thisがイベントを追加した要素のDOMノード(=targetNode)となる 9 console.log(this); 10 }; 11 12 return function (targetNode) { 13 targetNode.addEventListener('click', clickListener, false); 14 }; 15}());

JavaScript

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

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

クロージャとメモリリークについてのコピペ - こんにちはこんにちはmonmonです!】の702で提示されたcreateLeakFreeClosure関数を使用し、以下のように書く方法もあります。

JavaScript

1function createLeakFreeClosure(closure) { 2 /** 3 * Note: 適切に動作するよう、いくつか修正 4 */ 5 var count = createLeakFreeClosure.count++; 6 createLeakFreeClosure[count] = closure; 7 closure = null; 8 return function () { 9 return createLeakFreeClosure[count].apply( 10 this, 11 Array.prototype.slice.call(arguments) 12 ); 13 }; 14} 15createLeakFreeClosure.count = 0; 16 17/** 18 * @param {Node} targetNode 19 */ 20function clickCaptcha(targetNode) { 21 targetNode.addEventListener('click', createLeakFreeClosure(function () { 22 console.log(targetNode); 23 }), false); 24}

しかし、createLeakFreeClosure関数は同ページの707でも指摘されているように、何度も実行した場合に引数に渡した関数が保持され続ける事になり、これはこれで問題となってしまいます。
私自身もこの関数が循環参照を回避できる理由が理解できていないため、下手に改良もできません。
よって、これも採用できません。

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

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

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

気になる質問をクリップする

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

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

バッドをするには、ログインかつ

こちらの条件を満たす必要があります。

ngyuki

2015/08/24 10:22

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

2015/08/24 16:49 編集

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

回答3

0

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

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


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

javascript

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

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


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

javascript

1function createEventHandler(id, func){ 2 return function(ev){ 3 ev = ev || window.event; 4 ev.target = ev.target || ev.srcElement; 5 ev.currentTarget = ev.currentTarget || document.getElementById(id); 6 7 ev.currentTarget._func_ = func; 8 var retval = ev.currentTarget._func_(ev); 9 ev.currentTarget._func_ = null; 10 11 ev.target = null; 12 ev.currentTarget = null; 13 return retval; 14 }; 15} 16 17function addEventListener(elem, name, func) 18{ 19 // 要素に ID が無ければならない、あるいは、無ければ ID をここで自動生成する 20 var handler = createEventHandler(elem.id, func); 21 22 if (elem.addEventListener) { 23 elem.addEventListener(name, handler, false); 24 } else if(elem.attachEvent) { 25 elem.attachEvent('on'+name, handler); 26 } 27} 28 29function clickListener(ev) 30{ 31 console.log(ev.target.tagName); // BUTTON 32 console.log(ev.currentTarget.tagName); // DIV 33 console.log(this.tagName); // DIV 34} 35 36function clickCaptcha(targetNode) { 37 addEventListener(targetNode, 'click', clickListener); 38} 39 40clickCaptcha(document.getElementById('hoge'));

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

投稿2015/08/25 00:22

ngyuki

総合スコア4514

バッドをするには、ログインかつ

こちらの条件を満たす必要があります。

sounisi5011

2015/08/25 14:47 編集

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);     }; }());
ngyuki

2015/08/25 17:11

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

0

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

addEventListenerではなくattachEventを使用した場合はthisが扱えませんし、Eventオブジェクトも信用できません。

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

JavaScript

1(function () { 2 function handleClick (event) { 3 var target = event.target || event.srcElement; // attachEvent では event.srcElement で target を参照できる 4 } 5 6 function initEvent () { 7 if (document.addEventLisetner) { 8 document.addEventLisetner('click', handleClick, false); 9 } else if (document.attachEvent) { 10 document.attachEvent('onclick', handleClick); 11 } 12 } 13}());

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

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

JavaScript

1(function () { 2 3 var getCurrentTarget; // event.currentTarget を返す関数 4 5 function createGetElement (element) { // 「要素ノードを返す関数」を生成する関数 6 return function getElement () { return element; }; 7 } 8 9 function handleClick (event) { 10 var currentTarget = event.currentTarget || getCurrentTarget(); 11 } 12 13 function initEvent () { 14 var currentTarget = document.getElementById('sample'); 15 16 getCurrentTarget = createGetElement(currentTarget); 17 18 if (currentTarget.addEventLisetner) { 19 currentTarget.addEventLisetner('click', handleClick, false); 20 } else if (currentTarget.attachEvent) { 21 currentTarget.attachEvent('onclick', handleClick); 22 } 23 } 24 25 initEvent(); 26}());

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


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

JavaScript

1(function () { 2 3 function handleClick (event) { 4 } 5 6 function handleDOMContentLoaded (event) { 7 } 8 9 function handleUnload (event) { // 全てのイベント登録を解除する 10 if (document.removeEventListener) { 11 document.removeEventListener('click', handleClick, false); 12 document.removeEventListener('DOMContentLoaded', handleDOMContentLoaded, false); 13 removeEventListener('unload', handleUnload, false); 14 } else if (document.detachEvent) { 15 document.detachEvent('onclick', handleClick); 16 detachEvent('onload', handleDOMContentLoaded); 17 detachEvent('onunload', handleUnload); 18 } 19 } 20 21 function initEvent () { 22 if (document.addEventListener) { 23 document.addEventListener('click', handleClick, false); 24 document.addEventListener('DOMContentLoaded', handleDOMContentLoaded, false); 25 addEventListener('unload', handleUnload, false); 26 } else if (document.attachEvent) { 27 document.attachEvent('onclick', handleClick); 28 attachEvent('onload', handleDOMContentLoaded); 29 attachEvent('onunload', handleUnload); 30 } 31 } 32 33 initEvent(); 34}());

投稿2015/08/24 11:12

編集2015/08/25 02:26
think49

総合スコア18162

バッドをするには、ログインかつ

こちらの条件を満たす必要があります。

sounisi5011

2015/08/24 16:30 編集

すみません。event.targetを、リスナーを設定した要素に対応するプロパティであると誤解していました。正しくはevent.currentTargetです。
sounisi5011

2015/08/25 14:44 編集

> どうしても `event.currentTarget` が必要と思える状況が出てきた場合は `event.currentTarget` を返す関数を定義して関数呼び出しして下さい。 このような感じでしょうか。 (下記コードでは、インデントが表示されるよう半角スペースをEN SPACE(U+2002)に置換しています) /**  * @param {Node} targetNode  */ var clickCaptcha = (function () {     // 循環参照対策のため、「要素を返す関数」を返す関数を定義     var createGetElement = function (element) {         return function () {             return element;         };     };     return function (targetNode) {         // 対象要素を返す関数を定義         var getTargetNode = createGetElement(targetNode);         targetNode.addEventListener('click', function () {             // リスナー内で「対象要素を返す関数」を実行し、             // それをローカル変数`targetNode`として定義             var targetNode = getTargetNode();             console.log(targetNode);         }, false);         // nullを代入し、参照を削除         targetNode = null;     }; }());
ngyuki

2015/08/24 23:50

その『「要素ノードを返す関数」を生成する関数』だと、「要素ノードを返す関数」が要素を束縛しているので、getTargetNode は要素を束縛しています。getTargetNode が存在するスコープの addEventListener の引数の function は getTargetNode を束縛するので、そのやり方では変わりません。
think49

2015/08/25 00:46

To: sounisi5011さん targetNode = null; があるので参照は切れていますが、良いコードとはいえません。 初めから targetNode を参照しないようにスコープを調節してください。 unload 時の処理を踏まえると、イベントリスナは全て名前付きで関数宣言する必要があります。 親記事に具体的なコードを追加しました。
sounisi5011

2015/08/25 14:45 編集

createGetElement関数によるevent.currentTarget取得コードの例ですが、これですと複数の要素にイベントを登録したい場合に変数getCurrentTargetが上書きされてしまい、正しく動作しないように思います。 この問題を解決するため、「handleClick関数を生成する関数」を定義し、その引数にgetCurrentTargetを指定する以下のコードを記述してみました。 これはどうなのでしょうか。 (下記コードでは、インデントが表示されるよう半角スペースをEN SPACE(U+2002)に置換しています) /**  * @param {Node} targetNode  */ var clickCaptcha = (function () {     var createGetElement = function (element) {         return function () { return element; };     };     var createHandleClick = function (getCurrentTarget) {         return function (event) {             var targetNode = event.currentTarget || getCurrentTarget();             console.log(targetNode);         };     };     /**      * @param {Node} targetNode      */     return function (targetNode) {         var getCurrentTarget = createGetElement(targetNode);         var handleClick = createHandleClick(getCurrentTarget);         if (targetNode.addEventLisetner) {             targetNode.addEventLisetner('click', handleClick, false);         } else if (targetNode.attachEvent) {             targetNode.attachEvent('onclick', handleClick);         }     }; }());
sounisi5011

2015/08/25 15:20

unloadイベント時に追加したイベントを削除する例ですが、documentオブジェクトにイベントを追加する事が前提のコードとなっています。 これをclickCaptcha関数のように、任意のDOM Nodeにイベントを追加できるよう変更を試みましたが、unloadイベント時に任意のDOM Nodeからイベントを削除する関係上、何かしらの変数で任意のDOM Nodeとリスナーに対する参照を保持し続ける必要があり、適切に設計できませんでした。 (focusイベントなどイベントバブリングが行われないものは、上位ノードのdocumentオブジェクトでキャッチできません。このため、任意のDOM Nodeにイベントを追加する必要があります) このような場合、どのようにすれば良いでしょうか。
ngyuki

2015/08/25 17:24

もう一度コメントしておきますと・・・     var createGetElement = function (element) {         return function () {             return element;         };     }; createGetElement が返す関数は element を参照しています(クロージャーだから)。     var getTargetNode = createGetElement(targetNode);  createGetElement の引数に targetNode を与えているので、createGetElement の戻り値である getTargetNode は targetNode を参照しています。     targetNode.addEventListener('click', function () { /* ... */}, false); ここの function は getTargetNode を参照しています(クロージャーだから)。     targetNode = null; targetNode に null をセットしても、getTargetNode が参照している targetNode は変わらず、この参照は切れていません(createGetElement の引数は共有渡しとか参照の値渡しとか呼ばれるやつだから)。
think49

2015/08/26 00: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)」が独立に形成され、循環参照しなくなるという認識でした。
ngyuki

2015/08/26 02:54

> click(DOM) -> handleClick(Script) -> DOMノード(DOM) 中間に js オブジェクトを幾つ挟んでも循環参照であることには変わりないです。なので、 > 「click(DOM) -> handleClick(Script) -> getCurrentTarget(Script)」と「getCurrentTarget(Script) -> currentTarget(DOM)」 でも循環参照してます。
think49

2015/08/30 00:35

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

0

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

JavaScript

1/** 2 * @param {Node} targetNode 3 */ 4function clickCaptcha(targetNode) { 5 var eventHandle = function () { 6 var targetNode = eventHandle.elem; 7 8 console.log(targetNode); 9 }; 10 // Add elem as a property of the handle fn to prevent a memory leak with IE non-native events 11 eventHandle.elem = targetNode; 12 13 // Bind the global event handler to the element 14 if (targetNode.addEventListener) { 15 targetNode.addEventListener('click', eventHandle, false); 16 } else if (targetNode.attachEvent) { 17 targetNode.attachEvent('onclick', eventHandle); 18 } 19 20 // Nullify elem to prevent memory leaks in IE 21 targetNode = null; 22}

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

投稿2015/09/16 17:17

編集2015/09/16 17:25
sounisi5011

総合スコア697

バッドをするには、ログインかつ

こちらの条件を満たす必要があります。

あなたの回答

tips

太字

斜体

打ち消し線

見出し

引用テキストの挿入

コードの挿入

リンクの挿入

リストの挿入

番号リストの挿入

表の挿入

水平線の挿入

プレビュー

まだベストアンサーが選ばれていません

会員登録して回答してみよう

アカウントをお持ちの方は

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

ただいまの回答率
85.48%

質問をまとめることで
思考を整理して素早く解決

テンプレート機能で
簡単に質問をまとめる

質問する

関連した質問