JavaScript : 複雑なbindのリーディング

月花です。

今回は、複雑なJavaScriptを見つけて、リーディングに時間がかかったので、その仕組みを紹介したいと思います。
単純な修正なのに、2時間くらいかけて読んで30分くらいかけて修正することになったので、次修正するとき忘れないように書いておきます。

バインド時にイベントハンドラの動的生成をして、event や this をさらにその先へ引き回す、というコードです。
主に以下の内容についての解説です。

  • argumentsオブジェクト
  • apply()
  • .on()
$(function() {
    $(document).on("click", ".targetA", returnFunc(hoge));
    $(document).on("click", ".targetB", returnFunc(fuga));
});

var returnFunc = function () {
    var func = arguments[0];
    
    return function applyImpl () {
        return func.apply(this, new Array(2, 3));
    };
};

var hoge = function(foo, bar){
    result = foo + bar;
    alert(result);
}
var fuga = function(foo, bar){
    result = foo * bar;
    alert(result);
}

上記のコードはどのように動くだろうか。

See the Pen GBbNgN by gekka (@gekka9) on CodePen.

というように動作する。

今回はこのコードを読んでいく。
説明のため、各所に番号を振ったものを以下に掲載する

$(function() {
    $(document).on("click", ".targetA", returnFunc(hoge)); // [1] 関数を収めた変数を引数として渡す
    $(document).on("click", ".targetB", returnFunc(fuga));
});
var returnFunc = function () {
    var func = arguments[0]; // [2] argumentsによる引数の受け取り
    
    return function handler () {
        return func.apply(this, new Array(2, 3)); // [3] apply()による関数の呼び出し
    };
};

var hoge = function(foo, bar){
    result = foo + bar;
    alert(result);
}
var fuga = function(foo, bar){
    result = foo * bar;
    alert(result);
}

目的

今回のコードリーディングの目的は、このクリックイベントの処理内で新たに event と this を使った追加の処理を挿入することである。
ざっくり読んでもわからなかったので、深く読んでみることになった。

もくじ

  • [1] 関数を収めた変数を引数として渡す
  • [2] argumentsによる引数の受け取り
  • [3] apply()による関数の呼び出し

[1] 関数を収めた変数を引数として渡す

$(function() {
    $(document).on("click", ".targetA", returnFunc(hoge)); // [1] 関数を収めた変数を引数として渡す
});

.on() の引数は以下のようになる。

第1引数 イベント種別 今回はclickなのでクリックしたときに発火する。
第2引数 セレクタ(オプション) 今回は targetA というクラスを指定する。※今回はイベントデリゲートの話はしません。
第3引数 データ (オプション) イベントハンドラにデータを渡すことができる。今回は省略されている。
第4引数 ハンドラ イベントハンドラを設定する。

したがって、ここで行っているのは、targetA というクラスを持つ要素に処理をバインドする処理である。

コード中のイベントハンドラとして記述している部分に着目すると、何やら関数を呼び出しているようだ。
この関数の定義を読んでみよう。

var returnFunc = function () {
    var func = arguments[0]; // ← ここはあとで解説する
    
    return function handler () {
        return func.apply(this, new Array(2, 3)); // ← ここもあとで解説する
    };
};

着目してほしいのは4行目のreturn文で、この関数は関数を返すものであることがわかる。
この関数によって返却されてきた関数こそがハンドラとしてバインドされることになる。
最終的に、バインドされるのは handler() 。

つまり、このクリックイベントを取得したい場合や、クリックされた要素自体を $(this) として掴むには、ここを以下のように修正すればよいことになる。

$(function() {
    $(document).on("click", ".targetA", returnFunc(hoge));
});
var returnFunc = function () {
    var func = arguments[0];
    
    return function handler (event) { // ← イベントを引数として取得
        piyo = $(this); // ← クリックされた要素自体の取得
        return func.apply(this, new Array(2, 3)); 
    };
};

なんと、$(this) が関数を生成する関数の、しかもreturn文の中に入ってしまった
これは良くない。なにか他の書き方ができないか、もっと読んでみよう。

ちなみに、よくある簡素な書き方では、

$(function() {
    $(document).on("click", ".targetA",function(event){
        piyo = $(this);
    });
});

というように、ネストの中で記述することができる。

[2] argumentsによる引数の受け取り

$(function() {
    $(document).on("click", ".targetA", returnFunc(hoge));
});
var returnFunc = function () {
    var func = arguments[0]; // [2] argumentsによる引数の受け取り
    
    return function handler () {
        return func.apply(this, new Array(2, 3)); // ← ここはあとで解説する
    };
};

これは関数を定義して変数に収める記述だが、よく見ると関数定義に引数がないにも関わらず呼び出し時に変数を渡している。
そしてこれは、渡された変数をちゃんと使っている。

argumentsオブジェクト

JavaScriptの関数では、argumentsというローカル変数が提供される。
この中には様々なデータが入っているが、単純に連番の添字を見た場合には引数が入っている。
argumentsの中には定義した引数の数に関わらず(たとえゼロでも)呼びだれたときに渡されたものをそのまま保持している。
なのでたとえば、

for (  var i = 0;  i < arguments.length;  i++  ) {
    console.log ( arguments[ i ] );
}

というようにループすることができる。
おそらくこれが本来の使い方で、決して引数を定義する必要がないわけではない

arguments の中には他にも、呼び出し元関数を収めた arguments.caller や 呼び出し先関数(つまり arguments がローカル変数として所属する関数自身)を収めた arguments.callee などがあった
現在は caller は削除、 callee は非推奨となっている。
callee は無名関数での再帰処理に使うことができた。本来名前がないから再帰ができない無名関数だが、arguments.callee にはきちんと収まるため、arguments.callee を擬似的な識別子として扱えることを利用できた。

しかし、この arguments はスマートな処理追加の解法とは関係なさそうだ。

[3] apply()による関数の呼び出し

$(function() {
    $(document).on("click", ".targetA", returnFunc(hoge));
    $(document).on("click", ".targetB", returnFunc(fuga));
});
var returnFunc = function () {
    var func = arguments[0];
    
    return function handler () {
        return func.apply(this, new Array(2, 3)); // [3] apply()による関数の呼び出し
    };
};

var hoge = function(foo, bar){
    result = foo + bar;
    alert(result);
}
var fuga = function(foo, bar){
    result = foo * bar;
    alert(result);
}

JavaScript での関数の呼び出しは、 単純に関数名に引数を渡すように書くことで呼び出すが、それよりアドバンスな呼び出しをする関数が提供されている。

apply(thisArg, argsArray)

apply関数は、JavaScriptの関数オブジェクトが持っている関数なので、関数なら基本的に使用できる。

$(function() {
    $(document).on("click", ".targetA", returnFunc(hoge)); // hoge() を渡している
});
var returnFunc = function () {
    var func = arguments[0]; // hoge() を受け取っている
    
    return function handler () {
        return func.apply(this, new Array(2, 3)); // hoge() をapply() を使って呼び出す
    };
};

// このコードでの呼び出し先関数 hoge() の定義
var hoge = function(foo, bar){
    result = foo + bar;
    alert(result);
}
第1引数 this ここで渡したオブジェクトが呼び出し先の関数内で this を使って参照するオブジェクトとなる。
第2引数 引数の配列 呼び出し先関数の引数を収めた配列

これは、たとえばすでに配列になっているものを分解して引数として渡す手間を省いたり、既存の関数を非破壊的に上書きすることに使われる。

今回の記述を追いかけてみると、[1] の章で書いたとおり、handler() 関数はハンドラなので、 this にはクリックされた要素自体が入っていることが想定されるものである。
なので、handler() にとっての this をapply() を使って呼び出し先に渡してやることで、呼び出し先にとっての this が handler() にとっての this と同じものになる。
第2引数として渡した配列長2の配列はそのまま呼び出し先関数の第1引数・第2引数となり、キモとなる演算(ここでは加算や乗算)が行われる。

これらを踏まえると、

$(function() {
    $(document).on("click", ".targetA", returnFunc(hoge));
});
var returnFunc = function () {
    var func = arguments[0];
    
    return function handler (event) { 
        piyo = $(this); // ← これと
        return func.apply(this, new Array(2, 3)); 
    };
};
var hoge = function(foo, bar){
    piyo = $(this);// ← これ
    result = foo + bar;
    alert(result);
}

このコメントアウトした2行の $(this) は等価になる。
なので、[1] の章 で書いたように $(this) を handler() の中で使うように書くよりもむしろ handler() では書かずに、呼び出し先変数で書くべきだろう。

しかし、この修正では残念ながら event を呼び出し先変数に渡すことはできなかった。
正確には、 apply() の引数として追加してやればよいが、そのためには既存の関数に引数の定義を追加しなくてはならない。
解決したのは this を使う処理部分だけだが、 event を使った処理は大したことない上に仮に呼ばれすぎでも問題はない処理なので、これで良いこととしてしまった。

まとめ

結局このコードはなんだったのか

$(function() {
    // returnFunc() に hoge() や fuga() という関数を渡す
    // returnFunc() は渡された引数を使ってハンドラを動的に生成し、返す
    // 返ってきた関数はハンドラとしてバインドされ
    $(document).on("click", ".targetA", returnFunc(hoge));
    $(document).on("click", ".targetB", returnFunc(fuga));
});

// ハンドラを作成して返す関数の定義
var returnFunc = function () {
    // argumentsによる引数(中身は関数)の受け取り
    var func = arguments[0]; 
    
    // ハンドラ関数を生成して返却
    return function handler () {
        // apply() を使って呼び出すことで、呼び出し先に this を引き回す
        return func.apply(this, new Array(2, 3));
    };
};

// キモとなる演算を行う関数の定義
var hoge = function(foo, bar){
    result = foo + bar;
    alert(result);
}
var fuga = function(foo, bar){
    result = foo * bar;
    alert(result);
}

とこのように、バインド時にイベントハンドラの動的生成をして event や this をさらにその先へ引き回す、というコードであることがわかった。

結局私がしたい修正は、 event や this を使って追加の処理をしたかっただけだったので、[3] の章のように呼び出し先関数を修正することで対応した。

コードリーディングを終えて

確かに動的にハンドラを生成するためだったり重複コードをまとめるのにはこの手法は有効であると感じたが、読むのに時間がかかってしまった。

なかでも this の引き回しは修正の難易度が高かった。
最初に修正したのは handler() の中だったが、異なる呼び出し先をこれから呼ぼうという場所で書くは憚られ、結果として呼び出し先変数でも書けることがわかったので良かったが、気づいていなければかき混ぜてしまう修正になりかねなかった。

確かにスマートな書き方だがメンテナンスが楽じゃないという点では微妙で、私がPMならレビューで弾く、そんなコードだった。