iOS版Safariに、Function.prototype.bindがない件

前回の記事で、「bindすげぇ!」と書くくらい、JavaScriptのbindメソッドに酔興し、引数が必要なメソッド/関数をEventListener系メソッドの引数にバシバシ割り当てるなんてことをしているのだが、それで実装したとある機能が、iOSSafariで動いてくれなかったので、いったい何が起きているのだろうかと調べたことを報告します。まぁ結論から言えば、タイトルの通りiOSSafariには"Function.prototype.bind"がない所為なんですが。

'undefined' は関数じゃねぇから!

実装した機能が動いてくれないということは、何かしらのエラーが出ている可能性がある。というわけで、iOSシミュレーターを起動し、SafariデバッグコンソールをONにして、該当のページを開いてみた。

すると、そこには"TypeError: 'undefined' is not a function"の文字。ただ、エラー行の行番号は表示されないので、エラーが起きているだろう箇所にアタリをつけて、削除→再実行を繰り返すというなんとも力技なデバッグの末、どうもbindを使ってる箇所がエラー吐いてるなぁ、というところまで行き着く。
正直なところ、まさかbindが実装されてないなんて思ってもみなかったので、いろいろ試行錯誤したのだが、ふと思い立って実行した下記の式に、みごとにbindが実装されてないことに気づいた。

var func = function(){};
console.log(Object.getOwnPropertyNames(func));
//Mac-Safari: ["arguments", "caller", "length", "name", "prototype"]
//iOS-Safari : arguments, callee, caller, length, name, prototype

console.log(Object.getOwnPropertyNames(func.__proto__));
//Mac-Safari: ["name", "length", "toString", "apply", "call", "bind", "constructor"]
//iOS-Safari: name, length, toString, apply, call, constructor

よりによってなぜbindだけ…

パンが無ければケーキを食べればいいじゃない

ふざけんな! パンが無ければパンを作るのがエンジニアだ!(違
というわけで、bindがなければbindを作ればいいのである。

bindの動作を確認する

bindは、任意のFunctionオブジェクトのthis参照先を任意のオブジェクトに変更した、新しいFunctionオブジェクトを生成する関数である。日本語で言うとなんか妙にややこしいが、ソースコードを読めばきっと分かるに違いない。

//コメントはPCでの動作
var a = {
	x: 1
};
a.func = function(){
	console.log("output : " + this.x);
	console.log(arguments);
}

a.func();
//output : 1
//[]

a.func("test", "test2");
//outoput : 1
//["test", "test2"]

var b = {
	x: 2
}

var bfunc = a.func.bind(b, "test3", "test4", "test5");
//何も起きない

bfunc();
//output : 2
//["test3", "test4", "test5"]

a.func.apply(b, ["test6", "test7"]);
//output : 2
//["test6", "test7"]
a.func.call(b, "test8", "test9");
//output : 2
//["test8", "test9"]

a.funcは、その中でthis.xを参照しているが、これはaオブジェクトのxプロパティを参照しているため、a.func()の評価結果は"output : 1"となっている。
一方bオブジェクトだが、これはプロパティとしてxしか持っておらず、b.func()と呼んでもエラーが起きるだけである。しかし、bindメソッドを使って、そのメソッド(a.func)内でthisが参照する先をbオブジェクトにすることで、まるでbオブジェクトがaオブジェクトと同等(?)で、b.funcを持っているように動作させることができる。
しかも、bindの第2引数以降にそのFunctionオブジェクトに与える引数を指定することもできる。上記でも、bfunc関数自体は引数を伴わず評価しているが、a.funcにbindの第2引数以降を与えて実行したのと同じ結果を吐いているのがわかると思う。これにより、EventListener系メソッドの第2引数のように、引数を伴った関数の評価が無理な場合でも、予め引数を指定した新しいFunctionオブジェクトを作るとことで、引数を伴った関数の評価が可能になる。
ちなみに、a.func.bind(b)を一度var bfuncに代入しているのは、"a.func.bind(b)を評価しても、a.funcが指し示すFunctionオブジェクトが評価されるわけではない”ためである。一度別の変数に代入しておき、あとからbfunc()のように呼ぶことで、評価させることができる。つまり「bオブジェクトをthisの参照先とした新しいFunctionオブジェクト」を作っているのである。
代入せず、そのまま評価するにはapplyもしくはcallメソッドを使えばいい。

bindをどこに実装するべきか

さて、上記のbindの動作を確認した上で、自前でbindメソッドを作ろう。
bindメソッドは、すべてのFunctionオブジェクトにおいて利用可能にしたいので、Function.prototypeオブジェクトに追加することにする。"var func = function(){};"のようにして作ったオブジェクトはすべてFunction.prototypeから派生したオブジェクトである。そのため、func.__proto__とFunction.prototypeとは同じオブジェクトを参照している。つまり、Function.prototypeに新しいプロパティを宣言すると、すべてのメソッド/関数でそのプロパティが参照可能となる。

var a = {};
a.func = function(){
};

var b = {};
b.func = function(){
};

console.log(a === b); // false
console.log(a.func === b.func); // false
console.log(a.func.__proto__ === b.func.__proto__); // true
console.log(a.func.__proto__ === Function.prototype); // true

console.log(a.func.x); // undefined
Function.prototype.x = 1;
console.log(a.func.x); //1
bindを実装す

すでにbindが実装されている環境に再実装してもしかたがないので、まずその有無を判定し、もし実装されていなければbindを実装するようにする。
bindを実装する上で重要なのは、「Functionオブジェクトを返す」という点である。この時点でそのFunctionオブジェクト自体を評価してはいけない。そこで、 "return function(){...};"という形で締める必要がある。また、このreturnされたFunctionオブジェクトが評価された際に、その元のFunctionオブジェクトのthis参照を別のオブジェクトにして評価する。式が現れた時点で評価するには、applyメソッドもしくはcallメソッドを使えば良い。ただ、bindメソッドに与えられる引数の数が不明なので、callメソッドのように引数を1つずつ指定することができないため、argumentsプロパティを配列化してapplyで実行するようにしている。*1

if(!Function.prototype.hasOwnProperty("bind")){
	Function.prototype.bind = function(){
		var func = this;
		var t = arguments[0];
		var len = arguments.length;
		var newargary = [];
		for(var i = 1; i < len; i++){
			newargary.push(arguments[i]);
		}
		return function(){
			return func.apply(t, newargary);
		};
	}
}

実動

実際に動かしてみたが、特に問題なく動いているように見える。もし、何かこれでは問題が起きそう/起きた等の意見があったら、ぜひご連絡いただきたい。
Function.prototype.bind at JavaScript for MobileSafari · GitHub

*1:applyとcallとの違いは、前者が「引数を配列として渡す」のに対し、後者が「引数を引数として渡す」点である。